2、单细胞数据格式:AnnData 结构详解与常用属性归纳总结

做单细胞分析,天天跟 AnnData 打交道。说实话,我刚接触 scanpy 那会儿,对着这个 .h5ad 文件也是一脸懵——里面到底装了啥?X 矩阵、obs、var……这些名词翻来覆去出现,但你真的清楚它们怎么协作吗?

AnnData(Annotated Data)是 scanpy 和整个 Python 单细胞生态的核心数据容器。它把表达矩阵、细胞元数据、基因元数据、降维结果、差异分析结果统统塞进一个对象里。说白了,就是一个“带注释的数据框”的升级版。

我个人习惯把 AnnData 想象成一个三层抽屉的收纳盒:

  • 主抽屉(X):原始或归一化后的表达矩阵
  • 左侧抽屉(obs):细胞层面的注释,类似 Excel 的行标注
  • 右侧抽屉(var):基因层面的注释,类似 Excel 的列标注
  • 底层暗格(uns、obsm、varm 等):分析结果、降维坐标、自定义数据

下面的 SVG 图能帮你一眼看穿这个结构:

AnnData 核心结构 X 表达矩阵 cells × genes 稀疏/稠密矩阵 obs 细胞元数据 n_obs × m var 基因元数据 n_var × p obsm 降维坐标等 varm 基因变量矩阵 obsp varp uns 非结构化数据 行 = 细胞,列 = 基因

小技巧: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_namespd.Index细胞 ID 列表['AAACCCAAGAAA-1', ...]
adata.var_namespd.Index基因名称列表['ENSG000001...', 'GNB2L1']
adata.rawAnnData 子对象存储原始表达(未经标准化)在做 log 变换前备份
adata.layersdict存储多个表达矩阵(如 counts、normalized)adata.layers['counts'] = ...
adata.obspdict细胞-细胞距离/邻接矩阵(稀疏)adata.obsp['distances']
adata.varpdict基因-基因关系矩阵基因互作网络等

我的经验:做整合分析时,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 里有哪些降维坐标——全都一目了然。