2. 创建 AnnData 对象:从 CSV/MTX/H5AD 文件读取、手动构造与质量控制

说实话,做单细胞数据分析这几年,我每次拿到新数据的第一件事,就是把它装进 AnnData 对象里。你想想看,后面所有的降维、聚类、差异分析,全都依赖这个容器搭得稳不稳。今天我们就来把创建 AnnData 这件事彻底讲透。

2.1 AnnData 对象的基本结构

AnnData 说白了就是一个数据仓库。它把表达矩阵、细胞元数据、基因元数据、降维结果、邻接图全整合在一起。我个人习惯用一个公式来记:

AnnData = X(表达矩阵)+ obs(细胞注释)+ var(基因注释)+ uns(非结构化数据)+ obsm/varm(降维结果)+ obsp/varp(邻接图)

核心组件一览:

属性 存储内容 维度要求
.X 表达矩阵(细胞×基因) n_obs × n_vars
.obs 细胞级别的元数据(批次、样本、细胞类型等) n_obs × m
.var 基因级别的元数据(基因名、染色体位置等) n_vars × p
.uns 非结构化数据(配色方案、参数记录等) 无固定维度
.obsm 细胞的多维降维结果(PCA、UMAP 坐标等) n_obs × d
.varm 基因的多维降维结果(基因在 PCA 上的载荷等) n_vars × d
.obsp 细胞间的邻接关系(KNN 图、距离矩阵) n_obs × n_obs
.varp 基因间的邻接关系(共表达网络等) n_vars × n_vars
小贴士:刚上手时只需要填满 .X、.obs、.var 这三位一体,其他的后面慢慢加。

2.2 从 CSV 文件读取——最接地气的方式

实验室里经常有人给我一个 Excel 转出来的 CSV 文件,行是细胞、列是基因。这种场景下我一般这么干:

import pandas as pd
import anndata as ad

# 用 pandas 先把数据读进来,顺便做个体检
counts_df = pd.read_csv('expression_matrix.csv', index_col=0)
metadata_df = pd.read_csv('cell_metadata.csv', index_col=0)

# 检查行名是否对应
print("表达矩阵行数:", counts_df.shape[0])
print("元数据行数:", metadata_df.shape[0])

# 创建 AnnData 对象
adata = ad.AnnData(X=counts_df.values, obs=metadata_df, var=pd.DataFrame(index=counts_df.columns))
print(adata)

这里有个坑——adata.obs_names 和 adata.var_names 的一致性。我曾经在项目里因为 CSV 文件的索引列有空格没去掉,导致后面过滤细胞时 obs 和 var 对不上,排查了大半天。所以养成习惯:读进来后先跑一句 adata.obs_names_make_unique()。

2.3 从 Market Matrix (MTX) 文件读取——10X 数据的标准姿势

做单细胞测序的人对 MTX 格式再熟悉不过了。每次拿到 Cell Ranger 的输出,我都是这样直接读取:

import scanpy as sc

# 一行搞定,scanpy 自动识别 .mtx.gz 后缀
adata = sc.read_10x_mtx('filtered_feature_bc_matrix/', var_names='gene_symbols')
print(adata)

如果你手头是 matrix.mtx + barcodes.tsv + features.tsv 三件套,直接用 sc.read_10x_mtx 最省事。但要注意:

  • var_names 参数:设成 'gene_symbols' 会直接拿基因名当变量名;如果设成 'gene_ids',那就用 ENSEMBL ID。我个人偏好基因名,好记。
  • gzipped:10X 默认文件都是 .gz 压缩的,scanpy 自动处理。
  • cache:如果数据量很大(比如 50 万细胞以上),建议加个 cache=True,第二次加载会快很多。
注意: 如果 MTX 文件里的基因名包含 .1 这样的后缀(比如 ENSG00000123456.1),记得用 pandas 清洗一下,不然后面画热图时名字会很难看。

2.4 从 H5AD 文件读取——最利落的存取方式

H5AD 是 AnnData 的原生二进制格式,读写最快,且能完整保留 .uns、.obsm 等所有属性。我在项目中经常用它来做中间结果缓存:

# 直接加载
adata = ad.read_h5ad('processed_data.h5ad')
print(adata)

# 也可以筛选加载,省内存
adata = ad.read_h5ad('big_data.h5ad', backed='r')
print(adata.obs.head())

backed='r' 是内存紧张时的救命稻草。它不会把完整矩阵读进内存,而是一个一个切片去取。不过坏处是写操作有限——你只能追加 .obs 或 .var 列,不能直接改 .X。

读取方式 适用场景 速度 内存占用
sc.read_10x_mtx Cell Ranger 原始输出 中等(依赖文件大小) 高
ad.read_h5ad(全量) 中间结果、预过滤数据 快 高
ad.read_h5ad(backed) 超大文件、低内存环境 慢(I/O 瓶颈) 低
pd.read_csv + ad.AnnData 实验室小批量数据、CSV 交接 需要手动构造 高

2.5 手动构造示例数据——调试时最常用

有时候算法写了一半,想快速验证流程对不对,这时候手动造一个小的 AnnData 最方便:

import numpy as np
import anndata as ad

# 造一个 10 个细胞 × 5 个基因的小矩阵
X = np.array([
    [1, 0, 3, 0, 0],
    [0, 2, 0, 4, 0],
    [1, 1, 1, 1, 1],
    [0, 0, 5, 0, 0],
    [3, 0, 0, 0, 2],
    [0, 0, 0, 0, 0],
    [2, 2, 0, 0, 0],
    [0, 0, 0, 3, 0],
    [1, 0, 0, 0, 0],
    [0, 0, 0, 0, 1]
])

obs = pd.DataFrame({
    'batch': ['A', 'A', 'A', 'B', 'B', 'B', 'C', 'C', 'C', 'C'],
    'n_counts': X.sum(axis=1)
}, index=[f'cell_{i}' for i in range(10)])

var = pd.DataFrame({
    'gene_symbol': ['GAPDH', 'ACTB', 'TP53', 'EGFR', 'MYC']
}, index=[f'gene_{i}' for i in range(5)])

adata = ad.AnnData(X=X, obs=obs, var=var)
print(adata)

输出长这样:

AnnData object with n_obs × n_vars = 10 × 5
    obs: 'batch', 'n_counts'
    var: 'gene_symbol'

这招我在单元测试里用得特别多。造几个零值多、有全零行/列的小矩阵,专门用来测过滤函数是否正常工作。

2.6 数据质量控制基础——别让脏数据毁了下游分析

创建好 AnnData 对象之后,我从来不会直接往下跑 PCA。先给数据做个体检再说。我的 QC 三件套:

  • 线粒体基因占比:高比例意味着细胞膜破损,是死细胞或受损细胞的标志。
  • 总 UMI 计数:太低的可能是空滴或双细胞碎片,太高的可能是双细胞。
  • 检测到的基因数:太少说明细胞活性差,太多也可能是双细胞。

快速计算线粒体占比:

# 假设基因名是 'mt-' 开头表示线粒体基因
adata.var['mt'] = adata.var_names.str.startswith('MT-')
sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=False, inplace=True)

# 看看分布
print(adata.obs[['n_genes_by_counts', 'total_counts', 'pct_counts_mt']].describe())

我会根据这个分布设定一个经验阈值:

# 过滤细胞
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)

# 手动过滤线粒体占比 > 20% 的细胞
adata = adata[adata.obs.pct_counts_mt < 20, :].copy()
print(f"过滤后细胞数: {adata.n_obs}")
核心思路:QCs 不是一刀切。每个数据集都有自己合适的阈值。我的经验是,先看直方图,再定阈值,而不是直接套某个模板。

我曾经有个项目,就是因为没有事先滤掉线粒体高表达细胞,导致后续的降维聚类结果完全偏离了生物学意义——那些死细胞自己聚成了一团,还特别稳定。嗯,从那以后 QC 再也没跳过。

下面这张图帮你理清 AnnData 的创建与质控流程:

AnnData 创建 & 质量控制流程 CSV 文件 MTX 三件套 H5AD 文件 AnnData 对象 .X .obs .var .uns QC 指标计算 总UMI · 基因数 · 线粒体占比 阈值过滤 min_genes min_cells pct_mt 清洁的 AnnData 对象 可用于降维、聚类、差异分析 手工构造示例数据 记住:先创建,后 QC,再分析——顺序不能乱。手动构造的数据适合快速验证流程。

好了,整个流程其实就三步:读进来 → 做体检 → 滤脏数据。你可以根据数据来源选不同的读取方式,但 QC 这步千万别偷懒。

我的习惯:每次创建 AnnData 后先存一份原始的 .h5ad 备份。万一下游过滤做过了头,还能从干净版本重新来过。