1、AnnData 概述:单细胞转录组数据标准格式

大家好,我是老陈。搞单细胞数据分析这些年,我踩过不少坑。今天咱们聊聊 AnnData——这个在单细胞领域绕不开的数据结构。

说白了,AnnData 就是一个专门为单细胞组学数据设计的容器。为什么需要它?你想想看,一个典型的单细胞实验会产出数万个细胞、两万多个基因的表达矩阵,再加上降维坐标、聚类结果、细胞注释……这些数据七零八落地放着,不出三天你自己都找不着北。

AnnData 就是来解决这个问题的。它把所有相关数据组织在一起,让你能像操作一个“智能文件夹”一样管理分析流程。

1.1 为什么是 AnnData?

2018 年左右我刚开始转做单细胞时,大家的通用做法是把表达矩阵放在 CSV 里,元数据单独放一个 Excel,降维坐标又存另一个文件。结果呢?做一次整合分析,光是对齐样本就花了半天。

AnnData 的出现改变了这个局面。它由 Alex Wolf 和 Fabian Theis 团队在开发 Scanpy 时设计,核心目标就三点:

  • 统一:所有数据放一个对象,不再东一个文件西一个文件
  • 高效:底层基于 NumPy 和稀疏矩阵,几万个细胞也跑得动
  • 可扩展:随时往对象里加新的计算结果,不破坏原有结构

我个人习惯是:任何一个新项目,第一步就是把原始数据读成 AnnData。这样一来,后续无论做质控、标准化还是降维聚类,数据都不会跑偏。

💡 我的习惯:刚接触时别被 AnnData 的层级结构吓到。你只需要记住一个核心公式——AnnData = 表达矩阵 + 细胞信息 + 基因信息 + 中间结果。剩下的都是锦上添花。

1.2 AnnData 与 Scanpy 生态

AnnData 是 Scanpy 的“灵魂”,但并不绑死在 Scanpy 上。我现在同时用 Scanpy 做预处理、用 scVI 做批次校正、用 CellRank 做细胞命运分析——它们都读写 AnnData 对象。

整个生态大概长这样:

# 核心库
anndata        # 数据容器
scanpy         # 分析框架(预处理、聚类、可视化)

# 扩展库
scvelo         # RNA 速率分析
scvi-tools     # 深度学习模型(批次校正、注释)
cellrank       # 细胞命运推断
squidpy        # 空间转录组分析

你看,底层统一用 AnnData。这就好比 iPhone 和 App Store 的关系——AnnData 是操作系统,各种工具是上面的应用。

⚠️ 避坑:我曾经在同一个项目里混用了两个不同版本的 AnnData 对象(一个 0.7,一个 0.8),结果在合并 obs 时踩了属性不兼容的坑。建议全团队统一版本,用 pip install anndata==0.8.0 锁定。

1.3 AnnData 对象核心结构

好,现在咱们拆开 AnnData 看看里面到底有什么。我画了一张结构图,你一看就明白:

AnnData 对象核心结构 AnnData 对象 X:表达矩阵 obs:细胞 var:基因 obsm:细胞层面扩展 varm:基因层面扩展 uns:非结构化数据 💡 双向索引 adata[细胞名, 基因名] 直接切片 🔗 生态兼容 Scanpy / scVI / CellRank 通用

结构图看明白了?我挑几个重点展开讲。

1.3.1 X —— 表达矩阵

这是 AnnData 的核心,行是细胞,列是基因,存储表达值(通常是 UMI 计数)。

在我早期做 scRNA-seq 时,有个教训一直记忆犹新:千万注意 X 的数据类型。默认读进来是 CSR 稀疏矩阵(十亿级的零值存成稠密矩阵会爆内存)。

import anndata as ad
import numpy as np

# 创建一个最小 AnnData 对象
X = np.array([
    [3, 0, 1, 8],
    [0, 4, 0, 2],
    [7, 1, 5, 0]
])
cells = ['cell_A', 'cell_B', 'cell_C']
genes = ['gene_1', 'gene_2', 'gene_3', 'gene_4']

# 推荐显式指定 dtype
adata = ad.AnnData(X=X.astype(np.float32), obs=cells, var=genes)

# 检查矩阵形状
print(f"细胞数: {adata.n_obs}, 基因数: {adata.n_vars}")

# 稀疏存储检查(大项目必备)
print(f"X 是否为稀疏矩阵: {adata.isbacked}")
🔧 工程师笔记:如果你的数据集超过 10 万个细胞,强烈建议用 adata = ad.AnnData(X, backed='r') 打开只读内存映射模式。我第一次处理 50 万细胞的 10X 数据时忘了这茬,直接 OOM 崩溃。血泪教训。

1.3.2 obs 和 var —— 行/列元数据

obs 存细胞信息,var 存基因信息。说白了就是一个有索引的 Pandas DataFrame。

# 添加细胞元数据
adata.obs['batch'] = ['batch1', 'batch1', 'batch2']
adata.obs['n_genes_by_counts'] = [3, 2, 3]  # 每个细胞检测到的基因数

# 添加基因元数据
adata.var['highly_variable'] = [True, False, True, False]
adata.var['mean_counts'] = [3.33, 1.67, 2.00, 3.33]

# 查看
print(adata.obs.head())
print(adata.var.head())

注意:obs 索引名必须和 X 的行名一一对应,var 同理。我以前手滑把索引顺序弄反过,结果画 UMAP 时发现细胞标签全乱了——排查了两天才定位到问题。

⚠️ 避坑:千万不要直接用 adata.obs['new_col'] = list,长度和顺序必须严格匹配。我建议用 adata.obs['new_col'] = pd.Series(index=adata.obs_names, data=...) 来确保索引对齐。

1.3.3 obsm 和 varm —— 嵌入空间与基因扩展

obsm 存细胞层面的多维结果,比如 PCA、UMAP、Harmony 校正后的坐标。这些数据是“附加”的,不直接参与计算,但可视化时离不开它们。

# 模拟 PCA 结果 (10 个主成分)
pca_result = np.random.rand(adata.n_obs, 10)
adata.obsm['X_pca'] = pca_result

# 模拟 UMAP 结果 (2 维)
umap_result = np.random.rand(adata.n_obs, 2)
adata.obsm['X_umap'] = umap_result

# 查看
print("obsm 的键:", list(adata.obsm.keys()))
print("PCA 矩阵形状:", adata.obsm['X_pca'].shape)

varm 则相反,存储基因层面的“载荷”或“权重”,比如 PCA 的 loading。

# 模拟 PCA loading (基因 x 主成分)
pca_loading = np.random.rand(adata.n_vars, 10)
adata.varm['PCs'] = pca_loading

print("varm 中 PCs 形状:", adata.varm['PCs'].shape)
属性 维度 描述 典型示例
obsm n_obs × k 细胞嵌入/降维结果 PCA、UMAP、Harmony
varm n_vars × k 基因特征/载荷 PCA loading、基因权重

1.3.4 uns —— 非结构化数据区

这是 AnnData 的“杂物间”。聚类参数、配色方案、差异基因列表……任何没法塞进前几个属性的东西,都往这里扔。

# 存储聚类参数
adata.uns['leiden_params'] = {'resolution': 1.0, 'n_neighbors': 15}

# 存储绘图配色
adata.uns['cluster_colors'] = {
    'cluster_1': '#1f77b4',
    'cluster_2': '#ff7f0e'
}

# 存储差异基因排序
adata.uns['rank_genes_groups'] = {
    'names': ['gene_1', 'gene_3']
}

print("uns 中所有键:", list(adata.uns.keys()))
💡 个人经验:大胆用 uns 存中间结果,但一定要写清楚注释。我见过有人在 uns 里塞了一个巨大的嵌套字典,一个月后自己都看不懂了。建议每次赋值都加上一个 'description' 键记录做了什么。

1.4 一图胜千言:AnnData 的“四层”数据模型

最后给你一个速查表,这是我在带新人时反复用的。把它贴在你的显示器边上:

AnnData 对象 = {
    X      : (n_obs, n_vars)  → 表达矩阵(必须)
    obs    : (n_obs,  m)      → 细胞级元数据(Pandas DataFrame)
    var    : (n_vars, p)      → 基因级元数据(Pandas DataFrame)
    obsm   : (n_obs,  k)      → 细胞级"高维"扩展(PCA、UMAP)
    varm   : (n_vars, q)      → 基因级"高维"扩展(PCA loading)
    uns    : dict             → 剩余一切(参数、配色、注释)
}

嗯,这一章就聊到这儿。AnnData 的概念看似多,但你只要抓住“行是细胞、列是基因、中间是表达值”这个主线,后面的内容就顺了。