20260910 113051 单细胞转录组细胞类型鉴定与轨迹分析全流程实现详解

20260910_113051_单细胞转录组细胞类型鉴定与轨迹分析全流程实现详解.md

单细胞转录组细胞类型鉴定与轨迹分析全流程实现详解

基于 bioSkills 技能库(561 个生物信息学技能)的案例 2 实现
涉及技能:7 个 bio-single-cell-* 技能 | 场景:肿瘤组织 10x 单细胞数据


场景假设

研究者从肿瘤组织中获取 10x Genomics 单细胞转录组数据(多个患者样本,不同批次),目标是:

  1. 鉴定组织中存在的细胞亚群(细胞类型注释)
  2. 推断细胞分化轨迹(如肿瘤干细胞 → 分化的肿瘤细胞)
  3. 分析细胞间的通讯关系

数据特征: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 细胞通讯
返回列表
永久链接 引用此文章使用:20260910_113051_单细胞转录组细胞类型鉴定与轨迹分析全流程实现详解.md