单细胞转录组细胞类型鉴定与轨迹分析全流程实现详解¶
基于 bioSkills 技能库(561 个生物信息学技能)的案例 2 实现
涉及技能:7 个 bio-single-cell-* 技能 | 场景:肿瘤组织 10x 单细胞数据
场景假设¶
研究者从肿瘤组织中获取 10x Genomics 单细胞转录组数据(多个患者样本,不同批次),目标是:
- 鉴定组织中存在的细胞亚群(细胞类型注释)
- 推断细胞分化轨迹(如肿瘤干细胞 → 分化的肿瘤细胞)
- 分析细胞间的通讯关系
数据特征:droplet 平台捕获,含空液滴和环境 RNA 污染,跨样本批次效应明显。
第一步:数据读取与质控¶
技能:bio-single-cell-data-io + bio-single-cell-preprocessing
工具:Scanpy (Python) / Seurat (R)
1.1 数据读取¶
import scanpy as sc
# 读取 10x 原始矩阵(未过滤),保留空液滴用于环境RNA估计
adata = sc.read_10x_h5('raw_feature_bc_matrix.h5')
adata.var_names_make_unique()
关键点:读取的是原始(未过滤)液滴矩阵,因为空液滴调取(EmptyDrops)和环境 RNA 去除需要原始计数。
1.2 环境 RNA 去除(SoupX)¶
library(SoupX)
sc_ <- load10X('cellranger_outs/') # 需要 raw + filtered 两个矩阵
sc_ <- autoEstCont(sc_) # 估计污染比例 rho
counts_adj <- adjustCounts(sc_, roundToInt = TRUE) # 输出非整数,取整给NB模型
关键点:环境 RNA 去除必须在质控之前做,因为它需要 soup 估计(一旦 subet 到细胞,soup 估计就丢失了)。对于固体肿瘤和血液样本尤其重要(血红蛋白、白蛋白等高表达分泌基因会泄漏到每个 cluster)。
1.3 质控过滤¶
核心原则:不要用固定阈值(如 mito<20%),而要用 MAD(中位数绝对偏差)自适应阈值,且逐样本计算(不同批次深度不同)。
import scanpy as sc
import numpy as np
from scipy.stats import median_abs_deviation
adata.var['mt'] = adata.var_names.str.startswith('MT-') # 线粒体基因
adata.var['ribo'] = adata.var_names.str.startswith(('RPS', 'RPL'))
adata.var['hb'] = adata.var_names.str.contains(r'^HB[ABDEGMQZ]\d*(?!\w)') # 血红蛋白亚基
sc.pp.calculate_qc_metrics(adata, qc_vars=['mt', 'ribo', 'hb'],
percent_top=[20], log1p=True, inplace=True)
def is_outlier(adata, metric, nmads):
M = adata.obs[metric]
return (M < np.median(M) - nmads * median_abs_deviation(M)) | \
(np.median(M) + nmads * median_abs_deviation(M) < M)
adata.obs['outlier'] = (is_outlier(adata, 'log1p_total_counts', 5) |
is_outlier(adata, 'log1p_n_genes_by_counts', 5) |
is_outlier(adata, 'pct_counts_in_top_20_genes', 5))
adata.obs['mt_outlier'] = is_outlier(adata, 'pct_counts_mt', 3) | (adata.obs['pct_counts_mt'] > 8)
adata = adata[~(adata.obs['outlier'] | adata.obs['mt_outlier'])].copy()
sc.pp.filter_genes(adata, min_cells=3) # 去掉在<3个细胞中出现的基因
关键注意点:
| 指标 | 参考值 | 说明 |
|---|---|---|
min_genes |
200 | 低于此多为空液滴/碎片 |
log1p_total_counts |
5 MAD | 在对数尺度过滤(深度右偏) |
pct_counts_in_top_20_genes |
5 MAD | 高值标记低复杂度/垂死细胞 |
pct_counts_mt |
3 MAD + 硬 >8% | 组织依赖:心肌/肝细胞/肌肉天然 20-40% 且健康;细胞核 ~0-2% |
min_cells(基因) |
3 | 去掉稀有基因 |
特别警示:心脏/肝脏/肌肉的实质细胞天然高线粒体(20-40%),固定阈值会整群删掉健康细胞。且高线粒体可能是凋亡、真实代谢活动、解离应激或技术污染的混合,必须组织自适应处理。
1.4 解离应激处理¶
高温解离会诱导 FOS/JUN/JUNB/EGR1/HSPA1A/B 等即时早期基因表达,制造一个虚假的”活化/应激”cluster。处理方式:先给每个细胞打分解离模块,再从 HVG/聚类中排除这些基因。
1.5 双细胞检测(可选但重要)¶
在归一化之前、基于原始计数、逐样本进行(双细胞会形成虚假的中间 cluster)。
二、归一化与高变基因¶
技能:bio-single-cell-preprocessing
工具:Scanpy (Python)
2.1 归一化¶
# 先保存原始计数(HVG 和 doublet 检测需要)
adata.layers['counts'] = adata.X.copy()
# shifted-log 归一化(默认),sc.pp.normalize_total 将每个细胞缩放到中位数
sc.pp.normalize_total(adata) # target_sum=None 用中位数
sc.pp.log1p(adata) # 自然对数(1+x)
归一化方法选择:
| 方法 | 模型/假设 | 何时用 | 何时失效 |
|---|---|---|---|
| shifted-log (CP10k) | 常数总 mRNA | 通用默认 | 组成差异大的细胞类型(浆细胞、增殖细胞) |
| scran deconvolution | pooled size factors | 低深度、高 dropout | R-only,需预聚类 |
| sctransform v1/v2 | NB 正则回归 | Seurat 深度去除 | 慢,v1 过拟合 |
| Pearson residuals | 解析残差 | UMI 计数 | 实验性 |
核心洞察:Ahlmann-Eltze & Huber (2023) 发现 plain shifted-log + PCA 性能与 sctransform 相当甚至更好,所以 shifted-log 是可辩护的默认选择。
2.2 高变基因选择¶
# seurat_v3 flavor 需要原始计数,必须在 layer 中
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor='seurat_v3', layer='counts')
大坑:seurat_v3 flavor 吃原始计数,seurat flavor 吃对数归一化数据。喂错输入会静默运行但产出垃圾 HVG。
2.3 缩放与回归(可选,日益不推荐)¶
现代 best practice 不建议作为默认做回归掉 total_counts 和 pct_counts_mt——这些协变量与真实的细胞身份和状态混杂(增殖细胞确实有更多 RNA)。用批次整合(Harmony/scVI)处理不需要变异,而不是线性回归。
三、批次整合¶
技能:bio-single-cell-batch-integration
工具:Harmony (Python/R) / scVI (Python)
import scanpy as sc
# Harmony 整合
sc.external.pp.harmony_integrate(adata, key='sample') # 以样本为批次键
adata.obsm['X_pca_harmony'] = adata.obsm['X_pca_harmony']
整合 vs 回归:整合(Harmony/scVI)替代线性回归来处理不需要的变异,两者在 DE 分析中的定位不同——整合后的数据不能直接喂给 DE(会双重校正),只能用于聚类和可视化。
四、聚类分析¶
技能:bio-single-cell-clustering
工具:Scanpy (Python)
4.1 PCA 降维¶
sc.tl.pca(adata, n_comps=50, svd_solver='arpack')
sc.pl.pca_variance_ratio(adata, n_pcs=50, log=True) # 肘图选择 n_pcs
关键:n_pcs 对结果的影响远大于 n_neighbors。太少会塌缩真实结构,太多会引入技术噪声。用肘图判断,一般 30-50。
4.2 构建邻居图和聚类¶
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=30)
# 固定后端,保证可重复
sc.tl.leiden(adata, resolution=0.5, flavor='igraph', n_iterations=2, directed=False)
sc.tl.umap(adata, min_dist=0.3)
sc.pl.umap(adata, color=['leiden', 'CD3D', 'MS4A1', 'CD14'])
Leiden vs Louvain:
- Leiden 是当前默认(保证连通社区),Louvain 可能返回内部不连通社区(Traag 2019)。
- Scanpy 1.10-1.12 中 sc.tl.leiden 默认后端将迁移,必须显式固定 flavor='igraph'。
4.3 分辨率扫描与验证¶
关键原则:分辨率不是”真相旋钮”,聚类不是”发现”。过度聚类是默认失败模式——任何均匀的 blob 都能被二分,更高分辨率总能再分。
# 扫描分辨率,观察细胞流动
for res in [0.2, 0.4, 0.6, 0.8, 1.0]:
sc.tl.leiden(adata, resolution=res, key_added=f'leiden_r{res}',
flavor='igraph', n_iterations=2, directed=False)
print(res, adata.obs[f'leiden_r{res}'].nunique(), 'clusters')
# R中用clustree看跨分辨率细胞流动
# clustree(seurat_obj, prefix = 'RNA_snn_res.')
聚类是假设,不是发现。必须用 marker、稳定性、显著性检验验证。不要在 UMAP 坐标上聚类——UMAP 距离是人为目标下的伪影,不是度量。
五、marker 基因检测与细胞注释¶
输入:bio-single-cell-markers-annotation
工具:Scanpy (Python) / Seurat (R)
5.1 Marker 检测¶
核心:marker 检测是描述性排名,不是推断!两种被混淆为”DE”的问题必须区分:
- marker 检测(”cluster X 中哪些基因高?”)→ 排名/注释任务,Wilcoxon 可接受
- 条件 DE(”treated vs control 中哪些基因变?”)→ 必须用 pseudobulk(伪bulk)
import scanpy as sc
# 注意:scanpy 默认是 t-test,必须显式用 wilcoxon
sc.tl.rank_genes_groups(adata, groupby='leiden', method='wilcoxon',
pts=True, corr_method='benjamini-hochberg')
markers = sc.get.rank_genes_groups_df(adata, group=None)
# 用效应量和表达比例过滤,不用 p 值
specific = markers[(markers['logfoldchanges'] > 1) &
(markers['pct_nz_group'] > 0.5) &
(markers['pct_nz_reference'] < 0.25)]
print(specific.groupby('group').head(10)[['group', 'names', 'logfoldchanges', 'pct_nz_group', 'pct_nz_reference']])
为什么不用 p 值:cluster 被定义为最大化组间分离,所以在其上测试 marker 是双重暗示(double-dipping),p 值无效(type-I error 随分辨率升高趋近 1)。用效应量和表达比例过滤。
5.2 基因 signature 打分¶
t_cell_panel = ['CD3D', 'CD3E', 'CD4', 'CD8A', 'CD8B']
sc.tl.score_genes(adata, gene_list=t_cell_panel,
ctrl_size=50, n_bins=25, score_name='T_cell_score')
5.3 手动注释¶
cluster_labels = {'0': 'CD4 T', '1': 'CD14 Mono', '2': 'B', '3': 'CD8 T',
'4': 'NK', '5': 'FCGR3A Mono'}
adata.obs['cell_type'] = adata.obs['leiden'].map(cluster_labels).fillna('Unassigned')
5.4 条件 DE(pseudobulk,正确做法)¶
# 按 sample x cell_type 聚合原始计数(不是归一化值)
cell_type = adata[adata.obs['cell_type'] == 'CD14 Mono']
pseudobulk = sc.get.aggregate(cell_type, by='sample', func='sum')
counts_df = pseudobulk.layers['sum']
# 之后喂给 DESeq2/edgeR/limma-voom,见 deseq2-basics
关键:条件 DE 必须以样本/供者为重复单位,把细胞当重复是伪重复,虚假阳性增加数量级(Squair 2021)。聚合原始计数(sum),绝不能在归一化/校正数据上做 DE。
5.5 常见 PBMC marker(需在数据集中验证)¶
| 细胞类型 | Markers |
|---|---|
| CD4 T | CD3D, CD4, IL7R |
| CD8 T | CD3D, CD8A, CD8B |
| B | MS4A1, CD79A, CD19 |
| CD14 Mono | CD14, LYZ, S100A8 |
| DC | FCER1A, CST3 |
marker 是条件陈述:血液中的 marker 在肿瘤中可能广泛表达,”vs rest” 依赖 “rest” 是什么。移植的 marker 面板必须重新验证。
六、轨迹推断¶
输入:bio-single-cell-trajectory-inference
工具:Scanpy + Palantir + CellRank (Python) / Slingshot/Monocle3 (R)
6.1 决定拓扑优先(PAGA)¶
核心原则:连续谱的存在是生物判断,要在排序之前决定。任何方法都会在离散细胞类型或噪声上输出数字;算法不测试连续谱是否存在。
import scanpy as sc
sc.pp.neighbors(adata, n_neighbors=15, use_rep='X_pca')
sc.tl.leiden(adata, resolution=1.0, flavor='igraph', n_iterations=2, directed=False)
sc.tl.paga(adata, groups='leiden')
sc.pl.paga(adata, threshold=0.03, color='leiden') # 修剪低连通边
sc.tl.umap(adata, init_pos='paga') # 全局拓扑保留
threshold 是关键判断:无存活边的孤立 cluster 是离散细胞类型,不是轨迹分支,不能被强行拉入一个排序。
6.2 方法选择(拓扑优先)¶
| 方法 | 模型/假设 | 使用场景 | 失效时 |
|---|---|---|---|
| PAGA | cluster 图连通性 | 判断连续谱是否存在(强制第一步) | 只给连通性不给定方向 |
| Slingshot | 簇中心 MST + 主曲线 | 已知树/分支拓扑 | 依赖输入聚类 |
| Monocle3 | 反向图嵌入主图 | 树/环形/断连拓扑 | 在 UMAP 空间学习,继承畸变 |
| DPT | 可逆扩散随机游走 | 线性/简单分支 | 根敏感 |
| Palantir | 有向马尔可夫链 | 分支命运 | 根敏感 |
| CellRank 2 | 不可逆马尔可夫链 + GPCCA | 有向命运映射 | kernel 质量依赖 |
拓扑选择原则:线性 → Slingshot/DPT;分叉 → Slingshot/Palantir/CellRank2;树状(>2分支)→ Slingshot/Monocle3;环形 → PAGA;未知/断连 → 先 PAGA。
6.3 伪时间(Diffusion Pseudotime)¶
sc.tl.diffmap(adata, n_comps=15)
# 根细胞用已知 marker 锚定,不在 UMAP 上用眼睛选
adata.uns['iroot'] = np.flatnonzero(adata.obs['cell_type'] == 'HSC')[0]
sc.tl.dpt(adata, n_dcs=10, n_branchings=0) # n_branchings=0 -> 纯伪时间
核心原则:伪时间是几何,不是时钟。等距伪时间间隔不表示等距真实时间间隔。根细胞的选择会翻转每个基因的趋势——锚定在正交证据(已知 marker、真实采样时间、速度、干性得分),永远不在 UMAP 上靠眼睛选。
6.4 命运概率(Palantir)¶
import palantir
dm_res = palantir.utils.run_diffusion_maps(adata, n_components=5)
ms_data = palantir.utils.determine_multiscale_space(dm_res)
pr_res = palantir.core.run_palantir(ms_data, early_cell='HSC_cell_id',
terminal_states=None, num_waypoints=1200)
# pr_res.pseudotime, pr_res.entropy, pr_res.branch_probs
关键:在分叉附近用命运概率,不要硬分配分支标签。多能祖细胞的命运真的未确定,硬分配在生物学上错误。
6.5 CellRank 2 有向命运映射¶
import cellrank as cr
pk = cr.kernels.PseudotimeKernel(adata, time_key='dpt_pseudotime').compute_transition_matrix()
ck = cr.kernels.ConnectivityKernel(adata).compute_transition_matrix()
combined = 0.8 * pk + 0.2 * ck # 权重是研究者选择,需扫描
g = cr.estimators.GPCCA(combined)
g.compute_macrostates(n_states=10, cluster_key='leiden')
g.predict_terminal_states(method='stability')
g.predict_initial_states(n_states=1)
g.compute_fate_probabilities()
g.compute_lineage_drivers()
6.6 RNA Velocity(可选,谨慎)¶
import scvelo as scv
scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=2000)
scv.pp.moments(adata, n_pcs=30, n_neighbors=30)
scv.tl.recover_dynamics(adata)
scv.tl.velocity(adata, mode='dynamical') # 默认是 'stochastic',显式传 'dynamical'
scv.tl.velocity_graph(adata)
scv.tl.velocity_confidence(adata) # 先检查置信度
警告:velocity 在成熟/终末/非分裂系统(成年神经元、稳态组织)中不可靠,箭头可能反向;干净 2D 流图可能制造出高维场缺乏的连贯性幻觉。不要直接消费原始箭头,要喂给 CellRank 作为内核,用 marker 验证。
七、细胞通讯分析¶
输入:bio-single-cell-cell-communication
工具:LIANA (Python) + CellChat (R) + NicheNet (R)
7.1 共识推断(默认)¶
核心原则:每个配受体输出都是共表达代理,不是信号证据。方法间不一致是因为估计的数量不同,不是噪声。默认用共识(LIANA rank_aggregate)。
import liana as li
import scanpy as sc
adata = sc.read_h5ad('adata_annotated.h5ad')
li.mt.rank_aggregate(adata, groupby='cell_type', resource_name='consensus',
expr_prop=0.1, use_raw=False, n_perms=1000, verbose=True)
res = adata.uns['liana_res']
# 输出 magnitude_rank 和 specificity_rank(不是单一 liana_rank)
robust = res[(res['specificity_rank'] < 0.05) & (res['magnitude_rank'] < 0.05)]
7.2 资源敏感性检查¶
from liana.method import cellphonedb
for resource in ['consensus', 'cellphonedb', 'cellchatdb']:
cellphonedb(adata, groupby='cell_type', resource_name=resource,
expr_prop=0.1, use_raw=False, key_added=f'cpdb_{resource}', verbose=False)
# 比较 adata.uns['cpdb_*'] 的 top 对——跨资源存活的才可靠
7.3 CellChat 通路级概率¶
library(CellChat)
cellchat <- createCellChat(object = seurat_obj, group.by = 'cell_type')
cellchat@DB <- CellChatDB.human
cellchat <- subsetData(cellchat)
cellchat <- identifyOverExpressedGenes(cellchat)
cellchat <- identifyOverExpressedInteractions(cellchat)
cellchat <- computeCommunProb(cellchat, type = 'triMean') # 保守 trim 均值
cellchat <- filterCommunication(cellchat, min.cells = 10)
cellchat <- computeCommunProbPathway(cellchat)
cellchat <- aggregateNet(cellchat)
cellchat <- netAnalysis_computeCentrality(cellchat, slot.name = 'netP')
# netVisual_aggregate(signaling='WNT'), netVisual_bubble(), netAnalysis_signalingRole_heatmap()
7.4 NicheNet 下游配体活性(R)¶
library(nichenetr)
geneset_oi <- FindMarkers(seurat_obj, ident.1 = 'activated_T', ident.2 = 'naive_T') %>%
filter(p_val_adj < 0.05, avg_log2FC > 0.5) %>% rownames()
background <- get_expressed_genes('T_cell', seurat_obj, pct = 0.10)
expressed_ligands <- intersect(unique(lr_network$from),
get_expressed_genes(c('Macrophage', 'Dendritic'), seurat_obj, 0.10))
expressed_receptors <- intersect(unique(lr_network$to), background)
potential_ligands <- lr_network %>%
filter(from %in% expressed_ligands, to %in% expressed_receptors) %>%
pull(from) %>% unique()
ligand_activities <- predict_ligand_activities(
geneset = geneset_target, background_expressed_genes = background,
ligand_target_matrix = ligand_target_matrix, potential_ligands = potential_ligands)
7.5 通讯伪信号(confounds)¶
| 混杂因素 | 如何制造虚假交互 | 缓解 |
|---|---|---|
| Ambient RNA | 高表达分泌基因泄漏到每个 cluster | 先 SoupX 去污染 |
| 细胞丰度 | 大 cluster 给更紧凑 null → 成为网络枢纽 | 下采样,检查 counts vs 大小 |
| 测序深度 | 深度差异混淆技术与生物 | 整合计数,先归一化深度 |
| 解离应激 | FOS/JUN/HSP 是真实配体 | 打分应激模块 |
八、条件 DE(pseudobulk)¶
如果要检验肿瘤 vs 正常之间的差异,正确做法是:
1. 按 sample x cell_type 聚合原始计数(sc.get.aggregate(..., func='sum'))
2. 喂给 DESeq2/edgeR/limma-voom(样本=生物重复单位)
3. 绝不在归一化/校正数据上跑 DE(双重校正)
全流程总结¶
原始 10x 矩阵
│
├─ Step 1: 数据读取 + 环境RNA去除(SoupX)+ 质控(MAD自适应, 逐样本)
│
├─ Step 2: 归一化(shifted-log)+ 高变基因选择(seurat_v3需raw counts)
│
├─ Step 3: 批次整合(Harmony/scVI,整合后不用于DE)
│
├─ Step 4: 聚类(PCA elbow → kNN图 → Leiden,固定后端,分辨率扫描)
│
├─ Step 5: Marker检测(Wilcoxon, 双态校正感知)+ 手动注释(marker面板)
│
├─ Step 6: 轨迹推断(PAGA拓扑 → Slingshot/DPT/Palantir/CellRank2,伪时间)
│
└─ Step 7: 细胞通讯(LIANA共识 + CellChat通路 + NicheNet活性)
涉及技能一览¶
| 步骤 | 技能名 | 工具 | 核心作用 |
|---|---|---|---|
| 1 | bio-single-cell-data-io |
Scanpy | 数据读取 |
| 1-2 | bio-single-cell-preprocessing |
Scanpy/Seurat | 质控、归一化、HVG |
| 3 | bio-single-cell-batch-integration |
Harmony/scVI | 批次整合 |
| 4 | bio-single-cell-clustering |
Scanpy/Seurat | 降维+聚类 |
| 5 | bio-single-cell-markers-annotation |
Scanpy/Seurat | Marker+注释 |
| 6 | bio-single-cell-trajectory-inference |
PAGA/Slingshot/Monocle3/CellRank | 轨迹+伪时间 |
| 7 | bio-single-cell-cell-intercommunication |
LIANA/CellChat/NicheNet | 细胞通讯 |