先说我自己的判断:做单细胞转录组数据分析,真正决定你图好不好看的,不是你用的是 t-SNE 还是 UMAP,而是数据预处理和参数调得对不对。但怎么调,又不完全能脱离方法本身说清楚。所以这篇把单细胞数据降维与可视化里最常用的三种思路——t-SNE、UMAP 和自编码器——放在一起,从原理、代码到坑,系统对照一遍。如果你手里正有一批单细胞表达矩阵,想画出一张能说服自己的分群图,也想知道自编码器这种“神经网络降维”到底有没有必要,这篇文章会比较对胃口。
我们处理的很多问题其实都发生在“高维表达矩阵”到“二维散点图”的压缩过程中。t-SNE 是过去十来年最主流的细胞群可视化工具,UMAP 近几年强势上场,自编码器则代表另一条更“重”但更灵活的路线。它们的目的从表面上看似相同,内部逻辑差别很大。先把这个底层差异讲透,再上真实代码,后面你会更容易判断“为什么同样的数据,三种方法画出来不一样”。
1. 先说清楚:单细胞数据为什么非降维不可
1.1 表达矩阵的真实面貌
单细胞转录组测序拿到手之后,最常见的形式是一个稀疏矩阵:行是细胞,列是基因。人类基因组注释到的蛋白编码基因差不多两万个,你测一个 10x 样本,少则几千个细胞,多则几万甚至几十万。每个细胞里真正能够稳定检测到的基因一般也就几千个,所以这个矩阵里大量位置是 0。即使做了标准化和 log 变换,数据的有效维度依然很高,直接在所有基因上算距离或跑聚类,不仅慢,还容易被无关基因和噪音冲淡信号。
很多刚接触单细胞数据的朋友会有一个问题:表达矩阵都已经能用 PCA 了,为什么还要 t-SNE、UMAP 甚至自编码器?答案是 PCA 是线性降维,它假设数据的主要变化方向能用“基因的线性组合”概括。单细胞数据里存在大量 dropout 现象,一个基因在某个细胞里没测到不代表它真没表达;生物学上还普遍存在不同细胞状态连续变化、发育轨迹非线性延伸的情况。单靠 PCA,经常会把本来连续的细胞群切得很别扭,或者把少数类群压缩进一片看不出结构的云里。
1.2 降维的目的不只是画图好看
我们在 scRNA-seq 分析中反复降维,核心目的有四个:一是去掉技术噪音,让有效生物学信号凸显出来;二是把数据压缩到能承载“近邻关系”的维度,再跑 KNN 图聚类;三是做二维/三维投影,方便肉眼观察是否存在若干候选细胞群;四是给轨迹推断、差异表达等下游任务提供一个更稳定的数据入口。
这里必须强调,聚类和可视化是两个动作。现在的标准流程通常是:先做 PCA,用前十几或几十个主成分构建 KNN 图,然后在图上做 Leiden/RNA 聚类,最后把聚类结果放在 t-SNE 或 UMAP 的二维坐标上展示。所以 t-SNE 和 UMAP 更多是“展示层”,它们让细胞按聚类关系在低维空间里形成一团一团的视觉结构;而自编码器则可以承担“特征提取层”,它学习的是一个把高维基因表达压缩到低维瓶颈向量的非线性映射,压缩得到的特征还可以继续走 KNN 图聚类或 UMAP 可视化。
1.3 线性、非线性、神经网络降维的选择
PCA 属于线性方法,t-SNE 和 UMAP 属于非线性流形学习方法,自编码器则是用神经网络逼近非线性映射,本质上也是一种非线性降维。打个不一定严谨但好懂的比方:PCA 像从高处俯视一座山,把三维景观直接压成一张二维平面图,方向是固定的;t-SNE/UMAP 更像在山上架了许多悬浮索道,让相邻的点更愿意靠近自己附近的点;自编码器则像一个勤快的测绘员,先学会把整座山浓缩成一张草图,再根据草图把山还原出来。
理解这个比喻之后,接下来我们看每种方法的具体思路。
2. 三种主力降维方法各自的“脑回路”
2.1 t-SNE:维护邻居之间的概率关系
t-SNE 的全称是 t-distributed Stochastic Neighbor Embedding。它的基本想法是:在高维空间中,把“细胞 A 和细胞 B 是不是邻居”转化成一个概率值;然后在低维空间中,也想办法给每个细胞一套二维坐标,让低维空间里的邻居概率尽量还原高维空间里的邻居概率。
它用对称化的条件概率定义高维相似度,计算时会根据 perplexity 参数给每个细胞自动分配一个方差。低维空间则使用 t 分布而不是正态分布,t 分布有更厚的尾巴,允许低维空间中相对较远的点仍然有可忍受的概率值,这样能缓解所谓的“拥挤问题”——也就是高维空间里大量彼此距离差不多的点,投影到二维后会被挤成一堆。
t-SNE 的代价函数采用了 KL 散度。这个方法有个很著名的特点:它对“本来应该靠近却没靠近”的点惩罚很大,对“本来不近却被分到了一起”的点惩罚相对较小。换句话说,t-SNE 非常愿意保留局部近邻结构,但全局结构不一定可靠。所以你在 t-SNE 图上看到的“聚成一团”的细胞群,往往比较紧,但不同群之间的距离远近,并不能严谨说明它们之间的相似程度。
实践里还要注意:t-SNE 每次跑都可能得到不同的全局布局;直接在高维稀疏表达矩阵上跑 t-SNE 很慢而且不稳定。我们一般会先用 PCA 把数据压缩到 15~50 维,再在这个结果上跑 t-SNE。还有一点,t-SNE 的目标是最小化二维布局与原始近邻关系的差异,因此基本上只为可视化服务,很少有人直接拿 t-SNE 坐标做下游定量分析。
2.2 UMAP:在流形上构建模糊图
UMAP 全称是 Uniform Manifold Approximation and Projection,它名字里自带“流形”这个词。流形这个概念听起来玄,但可以理解为:高维数据虽然在两万个基因空间里分布,但真正有意义的点很可能分布在一个较低维度的连续“曲面”附近。UMAP 假设数据服从流形分布,并且大致均匀,只是当前观测的采样密度不均匀。
具体做法上,UMAP 会先在原始高维空间里为每个点找 k 个邻居,用局部距离为每个点确定一个特征尺度,并据此构造一个加权的 k 近邻图。然后在低维空间里,它希望低维图与高维图尽量相似,优化目标是交叉熵。这个设计中,局部邻居关系和全局结构都被纳入了目标函数,因此 UMAP 比 t-SNE 更容易保留一些广域结构,比如不同细胞群之间的相对位置。
实际体验上,UMAP 有一个非常明显的好处:速度快、内存友好。同样几万个细胞,UMAP 比 t-SNE 快很多,这也是它逐渐取代 t-SNE 成为单细胞主图的重要原因。但速度与稳定不是免费的,UMAP 对邻居数、最小距离等参数更敏感,不同的参数组合会把同一份数据切成完全不同的“视觉故事”。后面我会专门讲参数调整。
2.3 自编码器:先压缩,再重建
自编码器是一种神经网络结构,由编码器 Encoder 和解码器 Decoder 两部分组成。输入是高维表达向量,编码器把它压成一个低维向量,也就是瓶颈特征;解码器从瓶颈特征尝试还原原始输入。如果输入是两组数量级不同的特征,比如基因表达量还带着批次、测序深度等变量,那常见做法是先做标准化再进入网络。模型训练的目标是让输出接近输入,理想情况下,瓶颈层就保存了能够重建整个表达谱的关键信息。
单细胞领域会用到的自编码器变体不少。最基础的是全连接自编码器。因为基因表达矩阵非常稀疏,大量基因在绝大多数细胞中不表达,普通全连接网络容易过度关注那些“高表达驱动重建”的基因,忽略与细胞身份密切相关的低表达 marker,因此我们通常会给瓶颈层或隐藏层加稀疏约束,形成稀疏自编码器。稀疏性本质上是让大部分神经元对大多数样本保持抑制,只对一小部分激活,这能避免网络把表达矩阵中的 dropout 噪音当成重要结构记住。
如果你处理的对象不是表达矩阵而是单细胞图像,比如高内涵筛选、空间组学里的局部图像块,那么卷积自编码器会更合适,时间序列或体积数据则可能用到 3D 卷积自编码器。这篇文章我们主要讨论普通 scRNA-seq 表达矩阵,所以后续代码以全连接稀疏自编码器为主。自编码器还有一类带条件输入的变体,像 scVI 那样把样本来源、批次信息作为额外变量输入,可以实现在压缩特征里把批次效应解释掉,这是普通 t-SNE 和 UMAP 做不到的。
2.4 核心差异一张表看完
| 对照维度 | t-SNE | UMAP | 自编码器 |
|---|---|---|---|
| 基本原理 | 高维/低维邻居概率对齐 | 高维/低维模糊图逼近 | 编码后解码重建 |
| 是否线性 | 非线性 | 非线性 | 非线性,取决于网络 |
| 全局结构保真度 | 较弱,偏局部 | 相对较好 | 取决于瓶颈维度和训练 |
| 输出维度 | 通常 2D/3D | 任意维度 | 瓶颈层任意维度 |
| 是否天然适合流形 | 是,但偏向局部流形 | 是,兼顾全局 | 是,但需要调网络 |
| 大批量计算速度 | 慢 | 快 | 有 GPU 时快 |
| 可解释性 | 低 | 低 | 中等,可看瓶颈基因权重 |
| 典型用途 | 可视化 | 可视化、中间降维 | 特征提取、去批次、建模 |
| 随机性 | 较高 | 较低但存在 | 较高,取决于随机种子 |
这张表的意思很清楚:t-SNE 适合“看局部结构”,UMAP 适合“快速看整体并做展示”,自编码器则更多用于“为下游模型生成低维特征”。它们不完全是替代关系,经常是先后配合的关系。
3. 在同一套 PBMC 数据上的实战流程
3.1 数据预处理与 PCA 基线构建
纸上谈兵没有用,我们用一套公开的 PBMC 单细胞数据来跑一遍。数据量不大,适合演示。实际项目中的分析步骤会更多,但核心流程是一样的。
首先做好预处理。这个阶段不能省,具体顺序通常是:细胞质控过滤、基因过滤、库大小归一化、log 变换、高变基因筛选、标准化、PCA。
import scanpy as sc import numpy as np import torch import torch.nn as nn # 加载示例数据,PBMC 3k adata = sc.datasets.pbmc3k() adata.var_names_make_unique() # 基本质控:每个细胞至少检测到 200 个基因,每个基因至少在 3 个细胞中出现 sc.pp.filter_cells(adata, min_genes=200) sc.pp.filter_genes(adata, min_cells=3) # 线粒体高比例通常提示细胞状态不佳 adata.var['mt'] = adata.var_names.str.startswith('MT-') sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=False, inplace=True) adata = adata[adata.obs.pct_counts_mt < 20, :].copy() # 库大小归一化到 10000,再做 log1p 变换 sc.pp.normalize_total(adata, target_sum=1e4) sc.pp.log1p(adata) # 这里把全基因表达备份到 raw,后续给 marker 基因上色用 adata.raw = adata # 挑选高变基因 sc.pp.highly_variable_genes(adata, n_top_genes=2000) adata = adata[:, adata.var.highly_variable].copy() # 标准化:缩放每个基因的表达量 sc.pp.scale(adata, max_value=10) # PCA sc.tl.pca(adata, svd_solver='arpack', n_comps=50) sc.pl.pca_variance_ratio(adata, n_pcs=50)处理完后可以用pca_variance_ratio画碎石图,观察前多少个主成分贡献了主要方差。PBMC 3k 这种数据,通常前 10~20 个 PC 已经够用。我们这里选前 15 个 PC 作为后续邻居图的输入。
需要提醒一点:adata.raw = adata这句是在高变基因筛选之前做的,这样后面无论从高变基因子集还是从全基因表达中取 marker 基因,Scanpy 都能快速找到。预处理阶段的“标准化”尤其关键,漏掉 scale,PCA 和后续距离计算会被高表达基因主导。
3.2 用 Scanpy 一次跑出 t-SNE 与 UMAP
预处理完成后,先构建邻居图,然后跑聚类。这一步决定你后面的图上颜色怎么划分。
# 邻域图:在 PCA 前 15 维上找邻居 sc.pp.neighbors(adata, n_neighbors=15, n_pcs=15) # Leiden 聚类 sc.tl.leiden(adata, resolution=0.5, key_added='leiden_0.5') # UMAP 可视化 sc.tl.umap(adata, min_dist=0.5, random_state=42) sc.pl.umap(adata, color=['leiden_0.5', 'CD3D', 'MS4A1', 'LYZ'], frameon=False) # t-SNE 可视化 sc.tl.tsne(adata, n_pcs=15, perplexity=30, random_state=42) sc.pl.tsne(adata, color=['leiden_0.5', 'CD3D', 'MS4A1', 'LYZ'], frameon=False)你自己跑时会发现:UMAP 跑的很快,几分钟内能出结果;t-SNE 稍慢,但 3k 细胞也不会太离谱。UMAP 的图通常各个细胞群之间有清晰的“通路”或“隔断”,t-SNE 的团块更紧凑,局部边界更清晰。
加 marker 基因上色非常关键。CD3D是 T 细胞 marker,MS4A1是 B 细胞 marker,LYZ在单核细胞里比较突出。如果只看聚类色块,你只能说“我分出了几个群”,但只有 marker 表达图上色后,你才能说“这个群大概率是 T 细胞,那个群是 B 细胞”。这一步很多人会跳过,然后注释细胞类型时就容易翻车。
3.3 用 PyTorch 实现一个稀疏自编码器
自编码器部分我们用 PyTorch 从零写一个足够用的稀疏自编码器。输入是高变基因标准化后的矩阵,瓶颈维度设 16,相当于自编码器学习到的压缩表示。
X = adata.raw[:, adata.var_names].X # 上面的写法并不严谨,我们直接使用当前 adata.X 即可 X = adata.X.astype(np.float32) if hasattr(X, 'toarray'): X = X.toarray() class SparseAutoencoder(nn.Module): def __init__(self, n_input, n_hidden=256, n_latent=16, sparsity_lambda=1e-3): super().__init__() self.sparsity_lambda = sparsity_lambda self.encoder = nn.Sequential( nn.Linear(n_input, n_hidden), nn.BatchNorm1d(n_hidden), nn.ReLU(), nn.Linear(n_hidden, n_latent) ) self.decoder = nn.Sequential( nn.Linear(n_latent, n_hidden), nn.BatchNorm1d(n_hidden), nn.ReLU(), nn.Linear(n_hidden, n_input) ) def forward(self, x): z = self.encoder(x) return self.decoder(z), z model = SparseAutoencoder(n_input=X.shape[1], n_hidden=256, n_latent=16) X_t = torch.tensor(X, dtype=torch.float32) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss() def train_model(n_epochs=30, batch_size=256, seed=42): torch.manual_seed(seed) n = X_t.shape[0] for epoch in range(n_epochs): model.train() perm = torch.randperm(n) total_loss = 0.0 for i in range(0, n, batch_size): idx = perm[i:i + batch_size] batch = X_t[idx] x_hat, z = model(batch) rec_loss = loss_fn(x_hat, batch) l2 = sum(p.pow(2).sum() for p in model.parameters()) sparse_loss = model.sparsity_lambda * z.abs().mean() loss = rec_loss + 1e-5 * l2 + sparse_loss optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * len(idx) if (epoch + 1) % 10 == 0: print(f"epoch {epoch + 1}/{n_epochs}, avg_loss={total_loss / n:.4f}") train_model(n_epochs=30, batch_size=256) model.eval() with torch.no_grad(): codes = model.encoder(X_t).numpy()代码里我加了两项正则:一个是常见的 L2 权值衰减,另一个是 L1 稀疏惩罚。L1 惩罚会让编码器输出层的激活值更稀疏,不至于让每个细胞都用全部 16 维特征编码。单细胞矩阵里大量零值和 dropout,如果完全不做稀疏约束,自编码器很容易学会“把所有细胞都压成同一个巨大模态”,然后再靠解码器硬撑还原。
接下来把自编码器得到的瓶颈特征作为 UMAP 的输入。为了不破坏原始数据的 neighbor 图和 UMAP 结果,建议复制一份 adata 再操作。
adata_ae = adata.copy() adata_ae.obsm['X_ae'] = codes sc.pp.neighbors(adata_ae, n_neighbors=15, use_rep='X_ae') sc.tl.umap(adata_ae, min_dist=0.5, random_state=42) sc.pl.umap(adata_ae, color=['leiden_0.5', 'CD3D', 'MS4A1', 'LYZ'], frameon=False)这里需要注意,adata_ae.copy()保留了之前的 Leiden 聚类标签,所以图上颜色可以直接沿用。对比 PCA 输入得到的 UMAP 和 AE 瓶颈特征得到的 UMAP,你会发现两者大概率能分出相似的细胞群,但细胞的边界、紧凑程度以及一些稀有细胞群的“地位”可能不同。如果训练不稳定或 epoch 太少,AE 版本可能会表现得非常混乱,这也是它比 t-SNE/UMAP 更难用的原因之一。
3.4 三种方法结果怎么对比才有意义
拿到三张图之后,先别急着下结论。最直接的做法是把同一套已知 marker 基因分别放在三张图上,看 marker 的表达模式是否一致。比如 T 细胞 marker 是否都集中在同一个区域。如果 t-SNE 上 B 细胞和单核细胞靠得很近而 UMAP 上它们隔得较远,不要立刻说 UMAP 更准,应该回到 PCA 结果和 marker 表达上去查。
我实际跑 pbmc3k 的经验是:PCA + UMAP 给出的整体格局最“干净”,t-SNE 的群内更紧凑但群之间的空间感觉会被放大,AE + UMAP 在瓶颈维度设置合理时能给出类似结构,但没有深度学习调参经验的话,它不会比 PCA + UMAP 自动更好。所以如果你是第一次做单细胞分析,我建议先把 PCA + UMAP + Leiden 跑顺,自编码器当作进阶工具使用。
4. 参数调优:把图调到能发文章的关键细节
4.1 t-SNE 的困惑度到底在调什么
t-SNE 有三个参数特别值得关注:perplexity、learning rate 和迭代次数。
perplexity 可以粗略理解为“每个点认为多少个其他点是它的邻居”。比较推荐的范围是 5~50,并不是越大越好。perplexity 太小,图中容易出现散碎的小点团;perplexity 太大,强制的邻居范围变大,可能把本来独立的细胞群强行拉在一起。数据量越大,通常可以稍微调大 perplexity,但也要结合细胞类型复杂程度。
在 Scanpy 的sc.tl.tsne里,一般通过perplexity参数直接控制,数据规模大时还可能用到early_exaggeration和learning_rate。如果你用的是 sklearn 底层接口,early_exaggeration 控制初始阶段簇与簇之间的推开力度,调试时如果发现所有细胞都挤成一团,可以适当增大这个值。真实单细胞数据中,t-SNE 坐标点分布非常依赖随机初始化,所以实验报告里建议固定random_state,否则换一个种子可能得到左右翻转甚至局部结构变化的图。
4.2 UMAP 的 n_neighbors 和 min_dist
UMAP 最影响观感的是两个上游参数:n_neighbors和min_dist。第一个在sc.pp.neighbors里出现,第二个在sc.tl.umap里出现。
n_neighbors控制 UMAP 构建高维图时考虑多少个邻近点。数值小,比如 5,会更关注局部结构,适合寻找容易漏掉的稀有群,但代价是整体容易碎成很多小岛;数值大,比如 50,更能反映连续分化或全局关系,但可能把小群彻底淹没。单细胞数据一般从 10~30 起步,我用 PBMC 3k 时常用 15;遇到细胞数很多的数据,可以加大到 20~50。
min_dist则控制低维空间中点与点之间允许的最小间距。min_dist越小,聚类团之间的边界越清楚,细胞点会像星云一样彼此推开,但可能导致一个真实连续的分化过程被拆成好几段;min_dist越大,整体越松,细胞群更容易连成片,适合展示连续轨迹。很多人习惯把min_dist调到 0.1 以下让细胞群边界更干净,但如果你研究的对象是发育分化轨迹,可能需要把 min_dist 调到 0.3 以上,避免把 stage 之间的过渡细胞硬生生劈开。
4.3 自编码器:瓶颈维度、归一化与训练轮数
自编码器参数弹性很大。瓶颈维度决定了最终压缩特征的容量:太小可能丢失关键信息,太大又可能只是把 PCA 结果换了个非线性表达。表达矩阵输入为高变基因时,我会优先试 8、16、32 三个维度。比如 2000 个高变基因压到 16 维,相当于每个维度要承载很多基因的信息,模型压力较大;如果下游只做可视化,16 维通常够用,如果还要做分类或轨迹推断,可能 32 维更稳。
训练前要对输入做标准化,这一点很容易被忽略。自编码器用 MSE 作为重建损失,如果不同基因的表达量水平差好几档,网络会把大部分容量用于还原高表达基因,细胞类型特异的低表达信息会被当作噪声忽略。代码里我们沿用sc.pp.scale之后的矩阵,就是对每个基因做了均值 0、方差 1 的标准化,这样重建损失对不同基因是公平的。
训练轮数也不能盲目求多。单层瓶颈网络在几千个细胞的小数据上很容易过拟合。一个比较土但有效的办法是:固定随机种子,分别用 20、40、80 个 epoch 训练,比较瓶颈特征跑出来的 UMAP 是否稳定。如果 40 和 80 轮结果差异很大,说明模型对初始化太敏感,最好加大数据量或增强正则化。
4.4 如何客观衡量降维效果,而不是只靠眼睛
眼睛看久了会自我说服,所以最好搭配一些量化指标。常用的是 trustworthiness、连续性和剪影系数。trustworthiness 衡量的是原空间中的近邻在低维空间中是否仍然是近邻,值越接近 1 越好;连续性则反过来,看低维空间中的近邻在原空间中是否也近。这两个指标比单纯看散点图靠感觉要客观得多。
不过要注意,这些指标只衡量“几何关系的保持程度”,不代表生物学上更准。单细胞分析里最硬的标准还是 marker 基因验证。你可以把候选 marker 的表达值映射到二维图上,看是否形成清晰的“颜色梯度”或“局部亮斑”;也可以计算每个 Leiden 群相对于其他群的差异表达,取 top 基因去注释。一个降维布局如果连已知 marker 都无法对齐,无论几何指标多高都说明特征提取环节可能出了问题。
5. 高频问题与避坑现场记录
5.1 每次运行结果不一致,是不是代码写错了
不是。t-SNE、UMAP、自编码器都依赖随机初始化或随机采样,结果存在一定随机性。t-SNE 对随机种子尤其敏感,换一个随机种子可能出现全局布局旋转、翻转,甚至局部群组关系变化。UMAP 的稳定性相对好一些,但也不是完全确定。自编码器因为涉及神经网络权重初始化、batch 采样顺序,天然有随机性。
建议从一开始就固定随机种子。在 Scanpy 里设置sc.settings.seed = 42,在 PyTorch 里调用torch.manual_seed(42),有 GPU 时还要设置torch.cuda.manual_seed_all(42)。如果固定种子后多次运行结果仍然有明显差别,那说明数据本身的信号可能不强,或者参数位于敏感区域,这时候要回头检查预处理和离群细胞,而不是反复换随机种子硬找“好看”的图。
5.2 UMAP 图全是“连体婴儿”,群边界模糊怎么办
这种问题最常见的原因不是 UMAP 而是输入。如果高维距离空间里本来没有清晰的边界,UMAP 不可能强行造出边界。你先检查 Leiden 聚类结果里是否存在一个巨大的群,比如 90% 的细胞都被分到一起。如果是,大概率是细胞质控没过关,或者 PCA 维数选得过高,把技术噪音也当成信号。
排除了输入问题后,可以试着调小min_dist,比如从 0.5 调到 0.1 或 0.05,细胞群之间会更“炸开”。也可以稍微减小n_neighbors,让算法更关注局部连接。如果 t-SNE 图上能看到边界而 UMAP 看不到,大概率是 UMAP 的全局结构建模把边界抹掉了,并不是 UMAP 比 t-SNE 差,只是两者侧重不同。
反过来,如果 UMAP 图碎成太多小岛,而且每个小岛只有寥寥几个细胞,一般不是稀有细胞群,而是过拟合噪音。这时可以增大n_neighbors,或先降 PCA 到更少的维度。
5.3 预处理的坑:不要跳过 scale,也不要忽略数据版本
很多刚上手的同学会直接拿原始 count 矩去跑 UMAP,结果图里所有细胞被几个超高水平基因主导,分群完全不是生物学表现。还有同学会在高变基因筛选前做 scale,导致后续高变基因选择受缩放影响,这也是不太合理的顺序。标准顺序是先归一化、log、高变基因筛选,再 scale,然后 PCA。
如果用的是 10x 的 Cell Ranger 流程,表达矩阵已经过初步处理,但不同版本之间基因命名和重复基因存在差异。跑分析前记得用adata.var_names_make_unique()处理重复基因名,否则后面 marker 上色会找错基因。此外,adata.raw不能乱覆盖,它一旦被删掉,很多扫描raw的函数会报错,画 marker gene 散点图会非常痛苦。
5.4 什么时候不该用自编码器
相比 PCA 加 UMAP,自编码器并不是银弹。如果你的目标就是出一张细胞分群主图,PCA + UMAP 基本足够,没有必要引入神经网络。自编码器的优势在于能学习非线性特征、把批次变量或测序深度作为条件输入、并生成用于下游预测或聚类的高维抽象特征。
如果你的样本量只有几百个细胞,自编码器很容易过拟合;如果你没有 GPU 或者不熟悉反向传播训练,排查训练异常会非常耗时;如果你很在意结果可复现并可能被审稿人要求解释“为什么用这个架构”,那自编码器的门槛会明显提升。普通单细胞分析报告里,自编码器不是必选项,PCA 仍然是默认基线。
如果决定用自编码器,记得把稀疏约束加上。没有稀疏或噪声正则的自编码器可能会学习到“把每个细胞都近似编码成均质向量”的捷径。那种简化版在二维 UMAP 上往往表现为一个巨大的细胞团,完全看不出离散群,这种结果我也遇到过几次,基本都是输入没标准化、正则太弱或训练轮数太少这三类原因。
5.5 下游聚类和细胞注释,别被降维图带偏
降维图上的“视觉距离”不等于“细胞相似度”。在单细胞分析流程里,更稳的做法是先用 PCA 或自编码器瓶颈特征构建 KNN 图,然后用 Leiden 聚类。聚类的分辨率决定你看到的群数量,resolution 越大,分群越多。分群结束后,再把群标签映射到 t-SNE 或 UMAP 图上做展示。
很多新手容易反过来:觉得 UMAP 图上某两坨分得开,就直接把颜色标签手动指定成两个细胞类型。这样非常危险,因为 UMAP 的布局会受参数影响,同一批数据不同参数下可能把两个群画得更开或更近。科学上的判断依据应该是 marker 基因表达、差异表达统计和已知细胞类型注释,而不是纯几何位置关系。
我在实际项目中会先跑 PCA + Leiden,再用rank_genes_groups找每个群的标记基因。只有标记基因表达模式与文献已知细胞类型一致时,我才会把“群号”翻译成“细胞类型”。可视化只是让这个判断过程更直观,不能反过来主导判断。
6. 选型快查表与我的使用习惯
6.1 快速决策表
| 场景 | 推荐组合 | 理由 |
|---|---|---|
| 快速看整体细胞群结构 | PCA + UMAP | 速度快、局部与全局平衡 |
| 投稿时需要更紧凑的细胞群展示 | PCA + t-SNE | 细胞群视觉更紧,侧面辅助审稿 |
| 大样本(十万级以上) | PCA + UMAP | t-SNE 内存和耗时都偏高 |
| 探索稀有细胞群 | PCA + UMAP,调低 n_neighbors | 增强局部敏感度 |
| 发育轨迹/连续状态展示 | PCA + UMAP,调高 min_dist | 避免过度离散化 |
| 非线性低维特征用于免疫分型或预测 | 自编码器或 scVI | 能获得抽象特征向量 |
| 多批次数据整合后可视化 | 条件自编码器类工具,或校正后 PCA+UMAP | 显式建模批次来源 |
| 严格可解释、低门槛分析 | PCA + UMAP | 默认基线,容易排查 |
自编码器在单细胞领域更适合作为“特征提取前置”,而不是直接替代 PCA 画图。条件自编码器像 scVI 通常还会输出一个校正后的表达矩阵或潜在表示,再连接 UMAP/Leiden 使用,这种流程在批次效应明显的多样本项目中更可靠。
6.2 我个人在实际项目中的使用习惯
跑过大量单细胞数据之后,我的固定流程大致是这样:先按标准流程做质控和预处理,PCA 保留前 15~30 个主成分;用 PCA 构建邻居图,跑 Leiden;先出 UMAP 看整体格局,再跑一个 t-SNE 用来交叉验证那些只在 UMAP 上看起来离群的小簇。如果时间允许,我会把 AE 得到的瓶颈特征也投影一次,看它能否提供额外可解释信号,但不会用它作为唯一结论。
一个值得分享的小技巧是:不要只看聚类标签着色的图,一定要把若干关键 marker 同时放在 UMAP 上检查。比如免疫细胞,T、B、NK、单核这几类的 marker 表达模式如果与聚类群对不上,多半是聚类参数或数据质控出了问题。把颜色阈值、图例范围和布局列统一后,再去比较 t-SNE 和 UMAP,你会少走很多弯路。还有一点,降维图和聚类结果要保持 seed 固定,否则后期复现结果时会浪费大量时间。
最后说句实在话,方法学工具没有绝对的高低之分。t-SNE 适合精细展示,UMAP 适合整体快速把握,自编码器适合在理解神经网络原理的前提下做更有弹性的特征学习。先用公认流程把基础结果做扎实,再根据具体问题决定要不要引入深度学习,这条路对绝大多数人来说更稳也更省力。