ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CVAE条件变分自编码器实战:用TensorFlow实现可控图像生成

CVAE条件变分自编码器实战:用TensorFlow实现可控图像生成

最近在写一个 CVAE(TensorFlow)的生成实验,从模型结构到训练循环前后改了差不多三版,才把“条件控制”这件事做得比较顺手。CVAE 的全称是 Conditional Variational Autoencoder,条件变分自编码器,它解决的是普通 VAE 最让人头疼的问题:生成结果不可控。普通 VAE 能从隐空间里采样出图像,但采出来什么类别、什么属性完全看运气;CVAE 则在编码和解码两个环节都加入条件信息,你可以用标签、属性向量、甚至文本特征来指定“这一批生成结果应该是哪一类”。

这篇文章适合两类人:一类是已经写过普通 VAE、想让生成结果变得更可控的开发者;另一类是完全刚入门、想直接看一个能跑的 TensorFlow 实现。我会把条件怎么接进编码器和解码器、损失函数怎么写、训练时常见的奇怪现象都讲清楚,代码尽量给到可以复制去跑的程度。

1. CVAE和VAE的本质差异:条件信息不能只挂在解码器上

1.1 先从标准VAE说起,问题到底出现在哪

变分自编码器的结构大家应该不陌生:编码器把输入图像压缩成隐变量 z 的均值和方差,解码器把采样后的 z 还原成图像。这个过程看起来没问题,但你真正上手生成的时候会发现一个尴尬的情况:从标准正态分布里随便采一个 z 丢给解码器,它确实能出图,可你完全预测不了这张图长什么样。如果训练集里同时有“数字0”和“数字9”,同一个 z 生成出来的可能是个说不清是0还是9的中间形态。

原因是标准VAE的隐变量空间太自由了。为了让重构误差尽可能小,编码器只能把训练集里所有类别、所有属性都塞进 z 的各个维度。z 的某一个维度既可能在编码数字的粗细,又可能在编码角度偏移,最后整个隐空间变成了一个混合体。你没法指着哪个维度说“这个维度是类别信息”,更没法在生成时通过调整 z 来切换类别。

所以问题的本质是:标准VAE缺少一个“用户侧查询接口”。你想生成猫,但模型不知道什么叫猫;你能做的只是不断采样 z,赌它碰巧落在猫的流形上。CVAE 做的事情就是把这个能力补上,在模型最关键的几个地方明确地注入外部条件。

1.2 条件信息应该出现在编码器、解码器、重构误差三个位置

我最初实现 CVAE 时犯过一个错误:只把标签拼到解码器输入里,其他什么都不改。跑出来之后生成效果确实比 VAE 略好,但你会发现标签对输出的约束力很弱,改标签图像变化也不明显。原因是解码器确实能看到标签,但编码器根本不知道条件存在,于是编码器仍然会把类别信息塞进 z。解码器拿到一个充满类别信息的 z 和一个标签,它到底该听谁的?两个来源冲突,模型只能各退一步,结果就是条件控制打了个折扣。

CVAE 的正确打开方式是让条件信息同时参与三件事:编码器学习 q(z|x,c),也就是“看到输入和条件后推断隐变量”;解码器学习 p(x|z,c),也就是“看到隐变量和条件后生成输出”;重构误差也必须基于“给定了条件”的解码结果来计算。这样 z 只需要负责类别内部的细节差异,类别本身交给 c 来管理,信息分工才清晰。

动手实现时,“条件参与编码”通常是把条件向量和编码器中间的稠密特征拼接;“条件参与解码”是把条件向量和 z 拼接。这两个拼接点只要少一个,最终效果都会有明显下滑。如果你想让条件约束更强,还可以在编码器和解码器的每一层都拼接,但那样参数会比较多,第一版没必要这么激进。

1.3 怎么直观确认条件真的起作用

训练结束以后,我建议做一个非常简单的测试:固定一个 z,把它复制10份,然后配10个不同标签去 decode。如果生成出来的10张图在类别语义上有明显切换,说明条件通路是通的;如果10张图几乎一模一样,那就说明条件信息没有真正参与梯度流动。

这个测试同时还能考察“姿势风格一致性”。同一个 z 配不同标签,理想情况下应该保持同样的笔触特点、同样的整体结构,只是类别切换。如果连这个都做不到,说明 z 和 c 仍然纠缠在一起,CVAE 的训练还没到位。

2. 环境准备与数据形态:先想清楚你到底要生成什么

2.1 版本依赖其实没那么多幺蛾子

我用的是 TensorFlow 2.10 以上的版本,Python 3.9。需要安装的库就三个:TensorFlow、NumPy、Matplotlib。Matplotlib 只用来可视化生成结果,如果你在服务器上做批量实验,也可以换成保存数组再离线画图。

创建虚拟环境这一步建议别省。我试过在系统级环境里装 TensorFlow,后来因为和某个旧项目的库版本冲突折腾了半天。用python -m venv cvae_env建一个独立环境,再pip install tensorflow numpy matplotlib,整个过程五分钟内能完成。CPU 也能跑这个实验,28x28 的小图、像素很少,训练一轮通常用不了太久;当然有 GPU 会更舒服。

2.2 条件标签的编码方式直接影响训练稳定性

CVAE 里的条件 c 可以有很多种形式。最简单的是整数标签,比如 0 到 9;复杂一点可以是人的姿态角度、文本嵌入向量、甚至离散属性的组合。但不管哪种,都不建议把整数直接丢给网络。整数带有天然的大小关系:0、1、2 这些编号本身没有排序含义,网络却可能把“编号大”理解成“某种等级高”,这会给训练引入不必要的归纳偏置。

我的做法是先用tf.keras.layers.Embedding把整数标签映射成一个低维稠密向量。比如 10 个类别映射成 16 维向量,再拼到特征里。这个 Embedding 层是可训练的,模型会自动学到不同类别之间更适合区分的向量表示。如果你只有一个条件维度,也可以先 one-hot 编码,再接一个Dense层降维,效果相近。

如果条件本身是连续属性,比如旋转角度、颜色通道值、生成目标的尺寸,那就没必要做成 Embedding,直接做数值归一化后拼接就行。连续条件最需要注意的是量纲,如果角度范围是 0 到 360,而图像特征范围是 0 到 1,直接拼接会导致网络忽略小数值的特征,有条件跟没条件一样。

2.3 数据预处理里的几个细节

我用的是公开手写数字图片集,28x28 灰度图。加载之后把像素值除以 255 归一化到 0 到 1。这个看着简单,但和损失函数选择强相关:如果解码器输出层用的激活是 sigmoid,那么像素范围必须限制在 0 到 1,否则模型一开始就处于错误区间,收敛极其缓慢。

另一个细节是标签和图片要一一对齐。tf.data.Dataset里我是先把图片和标签 zip 起来,再做shuffle和batch。顺序不要搞反。如果先shuffle图片再单独shuffle标签,数据对就散架了,模型会学到“图片和标签完全没有关系”,后面的条件生成就是空中楼阁。

batch 大小我一般取 128。批大小对 VAE 类的模型有影响,太小的话每个 batch 的统计波动大,KL 散度不稳定;太大又容易让重构损失过度平滑。28x28 这种小尺寸图像,128 是比较平衡的起点。

3. 编码器、解码器与条件融合:模型代码一步步写出来

3.1 编码器:让隐变量学会“看条件”

先定义模型输入:图像 x 的形状是(batch, 28, 28, 1),标签 labels 是(batch,)的整数。图像进入编码器后,我用两层卷积逐步下采样,再接一个全连接层把特征压到 128 维,然后把标签的 Embedding 向量拼到后面。

这个拼接位置很关键。如果等卷积全部结束、维度已经剩几十个再拼接,条件影响会被前面的特征压缩稀释;如果拼接得太后,比如直接拼到均值输出那一层,那条件就几乎只调节最终输出,无法干预中间特征。我实际跑下来,全连接层输出为 128 维时拼接,条件信息能稳定地参与隐分布推断,又不至于喧宾夺主。

拼接之后再用一个全连接层混合信息,最后分出两个分支:一个输出均值 mu,一个输出对数方差 logvar。隐变量维度我设成 32。32 维对 10 类手写数字已经足够,再大容易过拟合噪声,再小则重构压力太大,生成图像会模糊得厉害。

3.2 重参数化层:让随机采样也变得可求导

VAE 的核心技巧是重参数化:训练时要从分布里采样 z,但采样操作本身不可导,所以把“随机性”从主路径里抽出来,变成输入一个标准正态噪声 eps,然后计算z = mu + exp(logvar * 0.5) * eps。这样 mu 和 logvar 的梯度可以顺畅地流回网络。

实现上我直接写了一个函数,不额外封装成层:

def reparameterize(self, mu, logvar): eps = tf.random.normal(shape=tf.shape(mu)) return mu + tf.exp(0.5 * logvar) * eps

注意这里是exp(0.5 * logvar),不是exp(logvar)。logvar 表示的是对数方差 log(σ²),标准差是 σ = exp(0.5 * logvar)。我第一次写的时候把 logvar 当标准差,结果隐变量方差被指数放大,KL 项爆炸,训练直接崩掉。

3.3 解码器:隐藏变量和条件如何拼进生成过程

解码器的输入由两部分组成:采样得到的 z,以及标签 Embedding。先把 z 和标签向量拼起来,再经过全连接层升维到 7x7x64,然后 reshape 成特征图,用转置卷积一步步上采样回 28x28。

转置卷积这里有个经验之谈:上采样不要一步到位。直接从一个很小的特征图一次转置到 28x28,图像容易出棋盘格噪声。我分成两步,先从 7x7 上采样到 14x14,再上采样到 28x28,每步都配合 ReLU 激活,生成结果平滑很多。

最后一层卷积输出通道为 1,激活函数用 sigmoid,保证输出像素落在 0 到 1 之间。这一步和解码器的信息分工直接挂钩:z 负责细节,c 负责类别,解码器要学会把两者组合起来。如果你发现生成图像有强烈的标签风格但细节模糊,多半是 z 被弱化、条件被过度放大,可以考虑增大隐变量维度或者减少条件 Embedding 的维度。

3.4 完整模型类的代码骨架

把上面这些拼起来,就是一个简洁的 CVAE 模型类。我用tf.keras.Model子类方式组织:

import tensorflow as tf from tensorflow.keras import layers class CVAE(tf.keras.Model): def __init__(self, latent_dim=32, num_classes=10, embed_dim=16): super().__init__() self.latent_dim = latent_dim self.num_classes = num_classes self.label_embedding = layers.Embedding(num_classes, embed_dim) self.conv1 = layers.Conv2D(32, 3, strides=2, padding="same", activation="relu") self.conv2 = layers.Conv2D(64, 3, strides=2, padding="same", activation="relu") self.enc_flat = layers.Flatten() self.enc_dense = layers.Dense(128, activation="relu") self.mu_layer = layers.Dense(latent_dim) self.logvar_layer = layers.Dense(latent_dim) self.dec_dense = layers.Dense(7 * 7 * 64, activation="relu") self.dec_reshape = layers.Reshape((7, 7, 64)) self.deconv1 = layers.Conv2DTranspose(64, 3, strides=2, padding="same", activation="relu") self.deconv2 = layers.Conv2DTranspose(32, 3, strides=2, padding="same", activation="relu") self.dec_out = layers.Conv2D(1, 3, padding="same", activation="sigmoid") def encode(self, x, labels): embed = self.label_embedding(labels) h = self.conv1(x) h = self.conv2(h) h = self.enc_flat(h) h = self.enc_dense(h) h = tf.concat([h, embed], axis=-1) mu = self.mu_layer(h) logvar = self.logvar_layer(h) z = self.reparameterize(mu, logvar) return mu, logvar, z def decode(self, z, labels): embed = self.label_embedding(labels) h = tf.concat([z, embed], axis=-1) h = self.dec_dense(h) h = self.dec_reshape(h) h = self.deconv1(h) h = self.deconv2(h) return self.dec_out(h) def reparameterize(self, mu, logvar): eps = tf.random.normal(shape=tf.shape(mu)) return mu + tf.exp(0.5 * logvar) * eps def call(self, x, labels): mu, logvar, z = self.encode(x, labels) recon = self.decode(z, labels) return recon, mu, logvar, z

这种写法方便之处在于,训练时可以直接拿到recon, mu, logvar, z四个量,损失函数的所有组成项都能显式计算,不用去model.layers里翻中间结果。生成时也可以单独调用decode,输入随便构造的 z 和标签。

4. 损失函数不是把公式抄过来就完事

4.1 损失函数的数学结构

CVAE 的优化目标仍然是最大化证据下界 ELBO,和标准 VAE 类似,只是所有分布都加上了条件 c。损失由两部分构成:重构损失,加上 KL 散度项。写成实现语言就是:

total_loss = recon_loss + beta * kl_loss

其中重构损失衡量解码器生成的图像和原始输入图像之间的差异,KL 散度项约束编码器输出的隐分布不要偏离标准正态分布。条件信息通过三个方式进入整个目标:编码器输入、解码器输入和重构对象本身。如果你在写代码时发现条件只在解码器输入里,那最终优化出来的模型会偏向普通 VAE,条件控制的强度会大打折扣。

4.2 重构损失的选择依据是输出分布

很多教程直接把均方误差 MSE 当成重构损失,但其实应该根据图像的分布类型来选。二值图像或者像素值归一化到 0 到 1、解码器用 sigmoid 输出时,更适合用二值交叉熵;连续值图像比如自然照片、深度图,才用 MSE。

我用的手写数字图片集像素非 0 即 1 的趋势很强,所以选了binary_crossentropy。实现时要注意求和方向:每个像素给一个交叉熵,然后沿空间轴累加,再对 batch 取平均。如果忘记空间轴求和,KL 项和重构损失的尺度会差好几个数量级,表现为训练早期重建出来的图像就发白或者发黑。

recon_loss = tf.reduce_mean( tf.reduce_sum( tf.keras.losses.binary_crossentropy(images, recon), axis=[1, 2, 3] ) )

4.3 KL项的β系数与KL退化的关系

KL 散度项的表达式是:

kl_loss = 0.5 * tf.reduce_mean( tf.reduce_sum(tf.exp(logvar) + tf.square(mu) - 1.0 - logvar, axis=1) )

这里的系数 β 极其关键。β 太小,隐空间几乎不受约束,z 的分布和标准正态差很远,生成时从标准正态采样会踩到训练时没覆盖过的区域,图像崩坏;β 太大,模型会找到一个偷懒解:让 mu 接近 0、logvar 接近某个很小的负值,这样 KL 项趋近于 0,但重构损失会保持在一个较高水平。这个现象叫 KL 崩塌,后面我会单独讲怎么治。

比较好的做法是给 β 设计一个从 0 到目标值的退火过程。前 5 个 epoch 从 0 线性增加到 1,让模型先用重构损失学会基本的生成能力,再逐步引入 KL 约束。也有人用更激进的退火曲线,比如分段指数,但我的经验是线性退火已经足够,训练稳定性明显优于固定 β = 1。

4.4 条件信息丢失的观察方式

训练十几个 epoch 后,如果重构图像清晰,但固定 z 变换标签却无法切换类别,说明条件信息在损失函数里没有形成足够的梯度压力。我这时候会去检查 KL 项和重构损失的量级。如果 KL 项只有重构损失的千分之一,模型会把所有精力放在重构上,条件信息容易被忽略。

还有一种隐蔽的情况:重构损失已经很小,但条件 Embedding 的那部分梯度回传路径几乎消失。解决办法是把条件融合的 Dense 层加一个 LeakyReLU,或者把条件向量复制一份同时拼到两个位置,让梯度更容易流过去。这类问题不是数学上出错,而是优化路径不畅,需要从工程细节上找原因。

5. 训练循环与条件生成:完整可跑通的代码路径

5.1 自定义训练循环最少需要几步

Keras 的compile+fit方式虽然方便,但 CVAE 的损失要同时拿编码器输出、解码器输出和隐变量,直接写自定义训练循环反而更清晰。一个标准的 epoch 循环如下:

optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4) def train_one_epoch(model, dataset, optimizer, beta): for images, labels in dataset: with tf.GradientTape() as tape: recon, mu, logvar, z = model(images, labels) recon_loss = tf.reduce_mean( tf.reduce_sum( tf.keras.losses.binary_crossentropy(images, recon), axis=[1, 2, 3] ) ) kl_loss = tf.reduce_mean( 0.5 * tf.reduce_sum( tf.exp(logvar) + tf.square(mu) - 1.0 - logvar, axis=1 ) ) total_loss = recon_loss + beta * kl_loss grads = tape.gradient(total_loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))

训练循环里我特意把beta作为参数传入,这样退火策略在外部控制,不需要反复改模型内部逻辑。每个 epoch 你用tf.keras.optimizers.Adam就行,学习率可以先从1e-4开始,后面再说调参经验。

5.2 训练过程中的监控指标与可视化

只看 loss 数值很难判断 CVAE 训练是否正常,因为重构损失下降、KL 项上升都是正常现象,两者之间还有一个跷跷板关系。我每个 epoch 结束都会做两件事:一是记录平均重构损失和平均 KL;二是随机采样生成一组图像,保存成一张网格图。

生成图像用不了多少代码:

import matplotlib.pyplot as plt def generate_images(model, epoch, latent_dim=32, output_path="generated.png"): fixed_z = tf.random.normal([10, latent_dim]) labels = tf.range(10) preds = model.decode(fixed_z, labels).numpy() fig, axes = plt.subplots(1, 10, figsize=(10, 2)) for i, ax in enumerate(axes): ax.imshow(preds[i, :, :, 0], cmap="gray") ax.axis("off") plt.savefig(output_path) plt.close()

如果在生成图上,同一行的图像随着标签变化有明显结构切换,说明 CVAE 在往正确方向走。如果到第 20 个 epoch 图像依然全是噪声,就不是训练次数不够的问题,而是模型结构或损失配置有问题。

5.3 生成图像时应该固定z还是采样z

生成 CVAE 图像有两种玩法。一种是纯随机:从标准正态分布中采样新的 z,配上目标标签,生成一张全新图像。这种玩法适合展示“模型学会了这个类别的分布”。另一种是控制变量:固定同一个 z,只切换标签,观察类别变化。这种玩法更适合分析条件通路是否有效。

实际测试时,我建议两种都做。第一类图用来评估生成质量,第二类图用来确认条件控制力。若第二类图中标签切换导致图像彻底乱掉,说明 z 和 c 的信息分工没学好,可以尝试把条件向量从 Embedding 维度再降低一点,或者把 z 的维度提高一点,让两边各有充足容量。

5.4 一个简单的生成结果客观评估

人眼看着挺清晰,不代表 CVAE 真正学会了条件控制。我后来加了一个更客观的小评估:抽一批生成的图像,用现成的数字分类器去判断类别,看准确率是否接近随机。如果分类器告诉我说“标签为3的图像里面只有15%被识别成3”,那就说明条件生成是失败的,模型只是把数字图像的平均长相记下来了。

这种评估不用做得很严谨,目的只是为了快速暴露问题。手写数字图片集比较简单,理想情况下生成图像的分类准确率能做到 95% 以上;如果低于 90%,我会优先怀疑条件输入被弱化,而不是模型结构出了问题。

6. 训练CVAE时最常踩的几个坑与解决办法

6.1 条件信息被模型无视,生成结果和普通VAE没区别

这是我最开始遇到且最让人沮丧的问题:loss 在下降,重构图像也还算清楚,但固定 z 换标签后图像几乎不变。排查了一圈,原因是对齐问题。

第一,标签和图像在数据管道里没对齐。前面说过tf.data.Dataset的 shuffle 顺序,如果图片和标签分别预处理后再 shuffle,两组数据会各自乱序,模型完全学不到映射。第二,条件拼接位置太浅,或者是拼接之后马上接输出层,导致条件只影响最后一点特征。第三,标签 Embedding 维度设得太小,模型没有足够容量去表征不同类别。

我的解决顺序一般是:先打印一个 batch 里图像和标签的对应关系,肉眼确认没问题;然后把编码器拼接点提前到全连接层之前;最后把 Embedding 维度从 8 提到 16。改完这三处,条件控制力通常会有明显改善。

6.2 KL散度突然坍缩,隐变量不工作了

KL 崩塌是 VAE 家族的经典问题。表现是训练到中途,kl_loss快速降到接近 0,同时重构损失卡在一个相对较高的值不再下降。这时候你去看 mu 的均值,会发现几乎所有维度都挤在 0 附近;logvar 的均值则是一个较大的负数。这意味着编码器不再对输入做有意义的区分,所有输入都被映射到同一个分布附近,z 成了摆设。

我的处理方式分两级。第一级是 β 退火,前几个 epoch 从 0 线性升到 1,给重构损失先跑起来的窗口。第二级是 free bits 技巧:给 KL 项设定一个底线,比如每个维度至少要贡献 0.5 的 KL 信息,低于这个值就补到 0.5。实现上可以这样:

free_bits = 0.5 kl_loss = tf.reduce_mean( tf.reduce_sum( tf.maximum(0.5 * (tf.exp(logvar) + tf.square(mu) - 1.0 - logvar), free_bits), axis=1 ) )

这个技巧不一定每轮都用,但当你发现即使加了退火还是会突然崩塌时,free bits 通常能兜住。

6.3 标签噪声和不均衡导致质量波动

条件生成模型对标签质量特别敏感。我在训练中做过一次混入 5% 错误标签的实验,最终生成的图像里,“4”和“9”的边界模糊程度肉眼可见地变差。原因是模型试图同时拟合正确的条件和错误的条件,编码器不知道该信任哪条路,最终只能学一个折中分布。

解决办法有两个。一是保证标签质量,清洗数据,这个不用多说。二是标签平滑,把原本的 one-hot 硬标签变成软标签,比如0.9对当前类别、0.1分给其他类别。标签平滑可以减少模型对错误标签的过度自信,也能让条件 Embedding 的梯度更温和。对公开手写数字图片集这种相对干净的数据集,标签平滑不是必须;但如果你要扩展到更大规模、自动标注的数据,它几乎是标配。

类别不均衡的问题也需要留意。如果训练集里某个类别只有其他类别的十分之一,模型学到这个类别的隐空间分布会非常稀疏,生成质量差。我习惯在采样时做类别重加权,让每个 batch 里各类别比例尽量均匀,而不是完全随机抽样。

6.4 优化器学习率:1e-3看着没问题,真训起来容易翻车

CVAE 的损失函数由两个差异很大的项组成,它们的梯度尺度经常不匹配。我用 Adam 默认学习率1e-3试过一次,前几百步重构损失下降很快,但 KL 项剧烈震荡,图像生成结果时好时坏,非常不稳定。

后来把学习率降到1e-4,训练速度虽然慢了一点,但 loss 曲线平滑很多。如果你觉得1e-4收敛太慢,也可以在前面加一个线性 warmup,前 500 步从1e-5慢慢抬到1e-4。这种节奏对两个损失项并存的模型通常比较友好。

还有一个很容易被忽略的点:不要对trainable_variables全量使用同一个学习率。如果条件 Embedding 的维度设得比较小,它的梯度经常比卷积层小几个量级,这时候可以考虑给 Embedding 单独一个稍大的学习率。不过这是进阶调参,第一版可以先不动。

最后再分享一个我在收尾时常用的小技巧:训练完成后不要急着保存模型,先多采样几组 z 和标签,检查边缘情况。比如标签为 0 的图像里,能不能生成一些笔画特别细的样本;标签为 9 的图像里,有没有出现上半部分和下半部分明显分离的结构。这些细粒度表现比一张整洁的样例图更能反映 CVAE 是否真的学会了条件流形上的平滑变化。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进