3. scVI 模型架构:编码器与解码器设计
我个人习惯把 scVI 看作一个两端粗中间细的“沙漏”。
基因表达数据进来,动辄两万个基因,维度很高。模型先把它们压缩到一个低维的“瓶颈”——这就是潜在空间。然后再从这个瓶颈里把数据还原回去,同时还要把批次信息剥离干净。
编码器干的是降维和去噪的活。解码器则是生成和校正的主力。这两部分加起来,才是 scVI 能打的核心。
3.1 架构总览:从高维到低维再到生成
先看一张总图,把整体流程走一遍。
说白了,编码器负责“看懂”数据,解码器负责“复述”出来。但这里的复述不是原样照搬,而是需要考虑到批次效应的影响。
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)。为什么用对数方差?因为方差必须为正,取对数之后就不需要额外加约束了。
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 有两个组件:
- 一个零膨胀组件(π 控制的点质量),专门负责“额外多出来的零”。
- 一个负二项组件(μ, θ 控制),负责建模实际存在、但可能因为采样深度不够而漏检的表达。
我刚开始用 scVI 的时候,总觉得 ZINB 太复杂,试过换成标准高斯。结果模型训练完之后,校正效果差了至少一个量级。从此我再也没动过 ZINB 这部分。
3.4 损失函数:ELBO 与 KL 散度
scVI 的训练目标不是简单地让重构误差最小化。它优化的是证据下界(ELBO),包括两项:
- 重构损失:ZINB 分布的负对数似然,衡量重构质量。
- 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_weight 参数。
3.5 训练策略与收敛技巧
下面是我常用的训练流程,打包成一个表格方便对照:
| 阶段 | 操作 | 说明 |
|---|---|---|
| 数据准备 | 过滤高变基因 (2000-5000) | 降低输入维度,加速收敛 |
| 初始化 | Xavier 初始化 + 小学习率 (1e-3) | 避免初期梯度爆炸 |
| KL 预热 | kl_weight 从 0 线性增加到 1 | 前 200-400 步逐步放开 |
| 早停 | 验证集 ELBO 停止提升后停止 | 防止过拟合 |
| 潜在空间检查 | UMAP 可视化 z | 看批次是否混匀,细胞类型是否分开 |
3.6 编码器与解码器的对称性设计
你可能会问:编码器和解码器的层数一定要一样吗?答案是不需要。
我个人习惯让解码器比编码器浅一层。原因是:编码器需要把高维信息压到低维,这个过程需要的容量更大。解码器是从低维往高维生成,信息流动相对容易,太深反而容易过拟合。
一个经过验证的配置如下:
# 编码器
n_layers_encoder = 2
n_hidden_encoder = 256
# 解码器
n_layers_decoder = 1
n_hidden_decoder = 256
这种不对称设计在大量实验中被证明是稳定的。如果你增加解码器的层数,建议同时加大 Dropout 率来对抗过拟合。