4. scVI中的条件变分推断:引入批次标签

做单细胞数据分析的朋友都知道,批次效应就像房间里的大象——你绕不开它。我之前处理过一个包含6个样本的PBMC数据集,同样一批细胞,不同实验室跑出来的结果简直像两个物种。嗯,这就是批次效应的“威力”。

scVI解决批次干扰的核心思路,其实并不复杂。它采用了条件变分自编码器(Conditional VAE),说白了就是把批次标签作为一个额外条件,喂给编码器和解码器。这样模型就能学着区分“这个差异是因为生物学原因,还是因为批次不一样”。

我个人习惯把条件变分推断理解为:模型在推断隐变量z的时候,先看一眼批次标签c,然后再做推断。这样隐变量z里就尽量少带批次信息,从而得到更干净的低维表示。

4.1 从普通VAE到条件VAE

先回顾一下普通VAE。它的生成过程是:

z ~ p(z)  (先验分布,通常是标准正态)
x ~ p(x|z)(解码器,从隐变量生成观测数据)

但这里没有区分不同批次。如果你把两个批次的细胞混在一起训VAE,隐变量z很可能把批次差异也编码进去。我在项目里见过这种情况——降维后的UMAP图,细胞按批次聚类,生物学差异反而被掩盖了。

条件VAE的做法很简单:在生成过程中加入条件c(即批次标签)。

z ~ p(z)  (先验不变,仍为标准正态)
x ~ p(x|z, c)(解码器接收z和c,生成观测数据)

同时,编码器也接收c:

q(z|x, c)  (编码器根据x和c推断z的后验分布)

你想想看,这样一来,解码器在生成x的时候,已经知道了它来自哪个批次,就不需要把批次信息塞到z里了。z就可以专注于表达真实生物学差异。

核心洞察:条件VAE的本质是让模型学会“在不改变生物学信号的前提下,对批次特征进行条件化”。这就像你给人拍照时,背景不同(批次),但人脸(生物学信号)不变。

4.2 scVI的条件变分推断结构

scVI的具体实现,把条件变分推断嵌入了深度神经网络中。整个架构可以拆成三块:

  • 编码器网络:输入基因表达x和批次标签c(一般是one-hot向量),输出隐变量z的后验参数(均值和方差)
  • 解码器网络:输入隐变量z和批次标签c,输出基因表达分布的参数(比如负二项分布的均值和离散度)
  • 先验分布:固定为标准正态分布,与c无关

我画了一张简单的结构图,帮你理清这些关系。

scVI条件变分推断结构 基因表达 x 批次标签 c 编码器 q(z|x, c) 隐变量 z ~ N(μ, σ²) 先验 p(z) N(0, I) KL 条件 c 解码器 p(x|z, c) μ σ² 关键点 • c控制编码/解码 • z不存储批次信息 • 先验与c无关 • KL散度约束z接近先验

我的经验:刚开始用scVI时,我总担心加入批次标签会让模型“过拟合批次”。后来发现,只要批次标签是离散的、数量不多(比如几十个以内),条件VAE的效果非常稳定。我曾在10个批次的数据集上测试,UMAP校正后几乎看不出批次痕迹。

4.3 损失函数里的门道

scVI的训练目标还是标准的ELBO(证据下界),但是是条件版本的:

ELBO = E[log p(x|z, c)] - KL(q(z|x, c) || p(z))

第一项是重构损失,衡量解码器重建x的能力。第二项是KL散度,让z的后验分布尽量靠近标准正态先验。

你可能会问:为什么要让z靠近标准正态?说白了,就是防止z随意漂移。如果没有KL约束,编码器可能把不同批次的z推到完全不同的区域,那样批次效应反而被强化了。KL散度相当于一个“正则化器”,强制z的分布规范统一。

我遇到过一种情况:某个批次细胞数特别少(只有几十个),模型在这批上的重构损失几乎为零,但KL散度却非常大。这说明编码器在“强记”那个批次,而不是学习通用特征。遇到这种极端不平衡数据,可以考虑对ELBO中两项的权重做调整——scVI默认没有这个开关,但你可以自己实现。

注意:条件VAE并不是万能药。如果批次标签和生物学标签高度相关(比如所有疾病样本都在批次1,对照在批次2),模型可能错误地抹掉真实差异。我曾经在某个项目的验证集上看到疾病标记基因被误校正,排查后发现就是批次-生物学混杂太严重。这时需要谨慎评估,或者改用更复杂的去混杂方法。

4.4 代码实现:条件变分层的定义

在scVI的PyTorch实现里,条件信息通常通过拼接的方式注入网络。下面是一个简化版的Encoder模块,展示了批次标签c怎么进入网络:

import torch
import torch.nn as nn

class ConditionalEncoder(nn.Module):
    def __init__(self, n_input, n_batch, n_hidden=128, n_latent=10):
        super().__init__()
        # 输入维度 = 基因数 + 批次one-hot长度
        input_dim = n_input + n_batch
        self.fc1 = nn.Linear(input_dim, n_hidden)
        self.fc_mu = nn.Linear(n_hidden, n_latent)
        self.fc_logvar = nn.Linear(n_hidden, n_latent)
    
    def forward(self, x, batch_onehot):
        # x: (batch_size, n_genes)
        # batch_onehot: (batch_size, n_batch)
        combined = torch.cat([x, batch_onehot], dim=1)
        h = torch.relu(self.fc1(combined))
        mu = self.fc_mu(h)
        logvar = self.fc_logvar(h)
        return mu, logvar

解码器同理,在输入端拼接z和c。

实践建议:实际scVI的实现比上述示例复杂得多,但核心逻辑完全一致。我个人建议你调试时先从这样最简单的条件VAE开始,理解透了再去看scVI的官方源码。

4.5 条件变分推断的效果评估

怎么知道模型确实“学会了忽略批次”呢?我一般用这三个指标:

指标 说明 我的标准
批次混合度 降维后不同批次细胞是否混在一起 UMAP上批次间重叠率 > 60%
生物学保留度 已知的细胞类型差异是否仍然清晰 细胞类型间AUC > 0.85
KL散度监控 训练过程中KL损失是否稳定 不出现剧烈波动

我记得有一次,KL散度在训练中期突然飙升,排查发现是某个批次的one-hot向量写错了,两个样本共享了同一个批次ID。fix之后训练就稳了。所以说,数据预处理阶段花再多时间都值得。

避坑指南:我曾经在多个GPU上并行训练时忘记同步批次索引顺序,导致不同卡上的批次标签含义错位。解决办法是在DataLoader里固定batch_id到c的映射表,并在每个epoch开始前广播到所有进程。

4.6 小结一下

条件变分推断是scVI抵抗批次干扰的基石。理解它,你就掌握了scVI这个工具箱里最核心的扳手。

  • 把批次标签当成条件输入编码器和解码器
  • 隐变量z尽力去表达生物学信号,批次信息交给c来承载
  • ELBO损失函数的KL项保证z的分布不跑偏
  • 评估时既要看批次混合效果,也要确认生物学差异没被误删