2、单细胞数据格式:AnnData 结构详解与常用属性归纳总结
做单细胞分析,天天跟 AnnData 打交道。说实话,我刚接触 scanpy 那会儿,对着这个 .h5ad 文件也是一脸懵——里面到底装了啥?X 矩阵、obs、var……这些名词翻来覆去出现,但你真的清楚它们怎么协作吗?
AnnData(Annotated Data)是 scanpy 和整个 Python 单细胞生态的核心数据容器。它把表达矩阵、细胞元数据、基因元数据、降维结果、差异分析结果统统塞进一个对象里。说白了,就是一个“带注释的数据框”的升级版。
我个人习惯把 AnnData 想象成一个三层抽屉的收纳盒:
- 主抽屉(X):原始或归一化后的表达矩阵
- 左侧抽屉(obs):细胞层面的注释,类似 Excel 的行标注
- 右侧抽屉(var):基因层面的注释,类似 Excel 的列标注
- 底层暗格(uns、obsm、varm 等):分析结果、降维坐标、自定义数据
下面的 SVG 图能帮你一眼看穿这个结构:
小技巧:AnnData 文件后缀通常是 .h5ad,本质是 HDF5 格式。用 adata = sc.read_h5ad('file.h5ad') 读完,adata 就是一个结构体。我在项目中经常用 print(adata) 快速看一眼整体概况——输出里会直接告诉你行数和列数,比查文档快得多。
2.1 核心属性一:X——表达矩阵的心脏
adata.X 是 AnnData 里最最重要的部分。它存储的是细胞×基因的表达量矩阵,行数等于细胞数,列数等于基因数。数据类型可以是稠密的 numpy 数组,更常见的是稀疏矩阵(scipy.sparse)。为什么稀疏?因为单细胞数据里大部分基因表达为零,用稀疏矩阵能省下 90% 以上的内存。
我曾经遇到一个项目,10 万细胞 × 3 万基因,直接用稠密矩阵内存直接爆掉——128G RAM 都扛不住。后来换用 .X = X.tocsr() 转成 CSR 格式,瞬间降到了 15G。
# 创建 AnnData 的常见方式
import scanpy as sc
import numpy as np
# 假设自己构造一个 1000 cells × 2000 genes 的矩阵
counts = np.random.poisson(lam=1, size=(1000, 2000)).astype(np.float32)
adata = sc.AnnData(X=counts)
print(adata)
# AnnData object with n_obs × n_vars = 1000 × 2000
注意:不是所有表达量都放在 X 里。如果你做了 sc.pp.normalize_total,会默认修改 adata.X;如果你用 sc.pp.log1p 也会改。想保留 raw 数据?建议用 adata.raw 另存一份。
2.2 核心属性二:obs 和 var——细胞与基因的“身份证”
adata.obs 是 pandas DataFrame,索引是细胞 barcode 或细胞 ID。每一列就是一个细胞层面的注释:样本来源、批次、聚类簇、细胞类型等。
adata.var 同理,索引是基因名称或 ENSEMBL ID。每一列是基因的注释:是否高变基因、平均表达量、方差等。
# 添加细胞元数据
adata.obs['sample'] = ['sample1'] * 500 + ['sample2'] * 500
adata.obs['n_counts'] = np.sum(adata.X, axis=1)
# 添加基因元数据
adata.var['highly_variable'] = np.random.choice([True, False], size=2000, p=[0.1, 0.9])
# 查看前3行
print(adata.obs.head())
print(adata.var.head())
避坑指南:adata.obs 的索引长度必须等于 adata.X.shape[0],adata.var 的索引长度必须等于 adata.X.shape[1]。我早期写代码时犯过错误:用 .reset_index() 后直接赋值,导致索引错位,后续画图全乱套。一定用 adata.obs_names 和 adata.var_names 来对齐。
2.3 核心属性三:obsm 和 varm——多维降维与矩阵扩展
adata.obsm 用来存储细胞级别的“额外矩阵”,最常见的用途就是 PCA、UMAP、t-SNE 的坐标。
# 存储降维结果(仿 PCA)
pca_coords = np.random.randn(1000, 50)
adata.obsm['X_pca'] = pca_coords
# 存 UMAP
umap_coords = np.random.randn(1000, 2)
adata.obsm['X_umap'] = umap_coords
# 也能存其他自定义矩阵,比如每个细胞的基因集打分
adata.obsm['gene_scores'] = np.random.randn(1000, 10)
adata.varm 则对应基因级别的“额外矩阵”,比如 PCA 的载荷矩阵(loading)、差异表达分析的 fold change 矩阵等。不常用,但遇到时知道在哪找。
2.4 核心属性四:uns——一切非结构化数据的“垃圾桶”
adata.uns 是一个 Python 字典,什么都能往里塞。scanpy 的很多分析结果都存这里,比如 adata.uns['log1p'] 里放着做了 log 变换的基础参数;adata.uns['neighbors'] 里有邻居图的信息;聚类结果的颜色映射也藏在 adata.uns['cluster_colors'] 里。
我喜欢用 list(adata.uns.keys()) 快速查看里面都有哪些东西。有一次调试差异分析结果,发现 rank_genes_groups 的 p_val 怎么都取不到,后来才意识到它被存在 adata.uns['rank_genes_groups']['pvals'] 里,不是直接挂载在 obs 上。
# 手动往 uns 里存点东西
adata.uns['analysis_date'] = '2025-01-15'
adata.uns['notes'] = {'preprocessing': 'standard', 'norm_method': 'CPM'}
2.5 其他常用属性速查
| 属性 | 类型 | 用途 | 典型例子 |
|---|---|---|---|
adata.obs_names | pd.Index | 细胞 ID 列表 | ['AAACCCAAGAAA-1', ...] |
adata.var_names | pd.Index | 基因名称列表 | ['ENSG000001...', 'GNB2L1'] |
adata.raw | AnnData 子对象 | 存储原始表达(未经标准化) | 在做 log 变换前备份 |
adata.layers | dict | 存储多个表达矩阵(如 counts、normalized) | adata.layers['counts'] = ... |
adata.obsp | dict | 细胞-细胞距离/邻接矩阵(稀疏) | adata.obsp['distances'] |
adata.varp | dict | 基因-基因关系矩阵 | 基因互作网络等 |
我的经验:做整合分析时,adata.layers 特别有用。比如你用 Harmony 整合后,想保留原始的 counts 矩阵用于差异分析,可以这样做:
adata.layers['counts'] = adata.X.copy() # 备份原始表达
sc.pp.normalize_total(adata, target_sum=1e4) # 修改 adata.X
sc.pp.log1p(adata) # 再修改 adata.X
# 后续差异分析时用 adata.layers['counts'] 作为 raw 数据
2.6 实战:快速浏览一个真实的 AnnData 对象
拿到别人的 .h5ad 文件后,我通常这样快速建立认知:
adata = sc.read_h5ad('pbmc3k_processed.h5ad')
print(adata)
# 输出示例:
# AnnData object with n_obs × n_vars = 2638 × 1838
# obs: 'n_genes', 'percent_mito', 'louvain'
# var: 'n_cells', 'highly_variable'
# uns: 'louvain', 'louvain_colors', 'neighbors', 'pca', 'umap'
# obsm: 'X_pca', 'X_umap'
# varm: 'PCs'
# obsp: 'distances', 'connectivities'
# 进一步查看 obs 列
print(adata.obs.columns.tolist())
# ['n_genes', 'percent_mito', 'louvain']
你看,一行 print(adata) 就揭示了全部结构。obs 里有哪些列,var 里有哪些列,uns 里存了什么结果,obsm 里有哪些降维坐标——全都一目了然。