3. scVI 模型架构:编码器与解码器设计

我个人习惯把 scVI 看作一个两端粗中间细的“沙漏”。

基因表达数据进来,动辄两万个基因,维度很高。模型先把它们压缩到一个低维的“瓶颈”——这就是潜在空间。然后再从这个瓶颈里把数据还原回去,同时还要把批次信息剥离干净。

编码器干的是降维和去噪的活。解码器则是生成和校正的主力。这两部分加起来,才是 scVI 能打的核心。

3.1 架构总览:从高维到低维再到生成

先看一张总图,把整体流程走一遍。

scVI 编码器–解码器核心流程 输入 X 批次标签 s 编码器 φ μ_z σ²_z 潜在变量 z 解码器 θ π, μ, θ (ZINB 参数) 重构 X' 编码器 φ 将 X 与批次标签 s 映射为潜在均值与方差;从该分布采样得到 z; 解码器 θ 将 z 与 s 组合,重构出 ZINB 分布参数,最终还原 X'。

说白了,编码器负责“看懂”数据,解码器负责“复述”出来。但这里的复述不是原样照搬,而是需要考虑到批次效应的影响。

我的经验:实际上,解码器不仅重构表达量,它还在学一个生成过程。如果你把批次标签去掉,解码器就不知道该怎么还原——这正是scVI能够校正批次的根本原因。

3.2 编码器:从表达量到潜在空间

编码器的输入有两样东西:原始基因表达矩阵 X 和批次标签 s。输出是潜在变量 z 的均值和方差。

3.2.1 输入层设计

X 的维度是基因数 G,通常几千到两万出头。我见过一些新手直接拿全基因跑,结果模型训练慢得让人崩溃。原因很简单——网络输入层太大,参数太多。

我个人习惯的做法是:先做一波高变基因筛选,保留 2000-5000 个。这不会损失太多信息,因为大部分基因在单细胞数据里都是零或者低表达。

3.2.2 隐藏层与非线性变换

编码器的隐藏层用的是全连接网络加 BatchNorm 和 Dropout。典型配置长这样:

nn.Linear(n_input, n_hidden)
nn.BatchNorm1d(n_hidden)
nn.ReLU()
nn.Dropout(p=0.1)
nn.Linear(n_hidden, n_latent * 2)   # 同时输出均值和对数方差

你看,最后一层直接输出两倍于潜在维度的值——一半是均值 μ_z,一半是对数方差 log(σ²_z)。为什么用对数方差?因为方差必须为正,取对数之后就不需要额外加约束了。

注意:如果你把 Dropout 设得太大,比如 0.5 以上,编码器可能会“学不进去”。我踩过这个坑——模型输出的潜在空间完全坍缩成了一个点。后来我改成 0.1-0.2,效果就好了很多。

3.2.3 重参数化技巧

这一步是训练能跑通的关键。简单说,我们不直接从 N(μ_z, σ²_z) 采样——因为采样操作不可导。我们换了个方式:

epsilon = torch.randn_like(sigma)
z = mu + sigma * epsilon

这样一来,随机性从采样挪到了噪声 ε 上,μ_z 和 σ_z 就都变成可导的了。嗯,这里要注意——torch.randn_like 生成的是标准正态噪声,不需要额外缩放。

核心理解:重参数化的本质是把不可导的随机采样,变成“可导的线性变换 + 固定噪声”。没有这一步,整个模型的梯度回传就断了。

3.3 解码器:从潜在变量到基因表达

解码器的任务很明确:给定潜在变量 z 和批次标签 s,还原出每个基因的表达分布。

3.3.1 条件输入

解码器不像编码器那样只接收 z。它把 z 和 s 拼接起来,作为完整的输入:

# z: (batch, n_latent)
# s: (batch, n_batches) — one-hot 编码
decoder_input = torch.cat([z, s], dim=-1)

之所以要拼接批次标签,就是为了让解码器知道“当前这条数据来自哪个批次”。这样它就能针对不同批次学习不同的位移和缩放。

3.3.2 输出层:ZINB 分布

输出层不直接输出基因表达量的点估计。它输出三个参数:

  • π (dropout 概率):控制基因是否被“ dropout ”,即是否观测不到。不是所有零都是真的零,有些是因为技术原因漏检了。
  • μ (均值):负二项分布的均值,代表真实的表达水平。
  • θ (离散度):控制分布的离散程度。基因表达数据的方差往往远大于均值,θ 越小越离散。

这三个参数由一个全连接层映射得到:

nn.Linear(n_hidden_decoder, 3 * n_genes)

然后分别用不同的激活函数做约束:

  • π 经过 Sigmoid 后落在 [0,1]
  • μ 经过 Softplus 保持正值
  • θ 也是 Softplus 保持正值

3.3.3 为什么用 ZINB 而不是高斯?

你可以想一下,单细胞数据里零的比例经常超过 80%。如果你用高斯分布去拟合,模型会拼命去预测那些零,最后得到一个“无脑输出小值”的垃圾模型。

ZINB 有两个组件:

  • 一个零膨胀组件(π 控制的点质量),专门负责“额外多出来的零”。
  • 一个负二项组件(μ, θ 控制),负责建模实际存在、但可能因为采样深度不够而漏检的表达。
一句话总结:ZINB 把“真实的零”和“技术原因导致的零”分开建模。这是 scVI 能够正确处理单细胞数据稀疏性的核心设计。

我刚开始用 scVI 的时候,总觉得 ZINB 太复杂,试过换成标准高斯。结果模型训练完之后,校正效果差了至少一个量级。从此我再也没动过 ZINB 这部分。

3.4 损失函数:ELBO 与 KL 散度

scVI 的训练目标不是简单地让重构误差最小化。它优化的是证据下界(ELBO),包括两项:

  1. 重构损失:ZINB 分布的负对数似然,衡量重构质量。
  2. KL 散度:让潜在变量的后验分布 N(μ_z, σ²_z) 尽量接近标准正态 N(0, I)。

写成公式就是:

ELBO = E[log p(X|z, s)] - KL[q(z|X, s) || p(z)]

这里 q(z|X, s) 是编码器给出的后验近似,p(z) 是标准正态先验。KL 项相当于一个正则化器,防止潜在空间被拉得太扭曲。

我曾经调试过一个案例,模型的 KL 项突然变得特别大,导致重构质量完全崩溃。排查下来发现是学习率太高,后验分布被“推到”了先验分布覆盖不到的地方。调低学习率之后,KL 项恢复到了正常水平。

实用技巧:在训练早期,可以用 KL 权重 <1 来让模型先学好重构。等重构误差降下来,再逐步放开 KL 权重。这个策略在 scvi-tools 里叫做 kl_weight 参数。

3.5 训练策略与收敛技巧

下面是我常用的训练流程,打包成一个表格方便对照:

阶段 操作 说明
数据准备 过滤高变基因 (2000-5000) 降低输入维度,加速收敛
初始化 Xavier 初始化 + 小学习率 (1e-3) 避免初期梯度爆炸
KL 预热 kl_weight 从 0 线性增加到 1 前 200-400 步逐步放开
早停 验证集 ELBO 停止提升后停止 防止过拟合
潜在空间检查 UMAP 可视化 z 看批次是否混匀,细胞类型是否分开
避坑经验:不要一上来就用全量数据跑 1000 个 epoch。先拿 10% 的数据跑 50 个 epoch 看看 ELBO 曲线——如果曲线不下降,说明编码器或者解码器的宽度/深度不够,需要调整网络结构。我曾经在一个 10 万细胞的数据集上跳过这一步,结果跑了 6 个小时发现 loss 几乎没动。

3.6 编码器与解码器的对称性设计

你可能会问:编码器和解码器的层数一定要一样吗?答案是不需要。

我个人习惯让解码器比编码器浅一层。原因是:编码器需要把高维信息压到低维,这个过程需要的容量更大。解码器是从低维往高维生成,信息流动相对容易,太深反而容易过拟合。

一个经过验证的配置如下:

# 编码器
n_layers_encoder = 2
n_hidden_encoder = 256

# 解码器
n_layers_decoder = 1
n_hidden_decoder = 256

这种不对称设计在大量实验中被证明是稳定的。如果你增加解码器的层数,建议同时加大 Dropout 率来对抗过拟合。

总结一下:scVI 的编码器–解码器结构虽然看起来像标准的 VAE,但加了批次条件、ZINB 输出分布和 KL 预热这些针对单细胞数据的特殊设计。这些细节,才是让它在批次校正任务上脱颖而出的原因。