☰
Encoder也能生成图像?VQ-VAE离散隐空间实战解析
2026/10/23 5:48:34 网站建设 项目流程

如果你常看图像生成相关的技术讨论,大概率对一句话很熟悉:encoder负责压缩编码,decoder或扩散模型才真正负责生成。这个印象不算错,但它把encoder的角色想窄了。这次我把一个比较完整的链路跑通了——使用encoder来generate图像。说白了,不是让encoder直接从随机噪声变图,而是让encoder先把真实图像映射成可管理的隐空间编码,生成阶段再从这些编码的分布里采样、交给decoder还原出图像。整条链路跑完,我发现encoder在生成流程里的地位被严重低估了。

这篇帖子面向正在搭图像生成实验、或者想做一次“运行测试”的同学。我会把技术选型、数据准备、模型结构、测试脚本、常见翻车点全部串起来,尽量做到你可以照着我这套流程复现一遍。

1. 先说清楚:encoder怎么参与图像生成

1.1 为什么encoder常被误解成“只管压缩”

因为判别任务里,encoder的终点是一个分类头或者特征向量,它不需要把信息还原成像素,所以给人感觉它只负责“看懂图”。但自编码器这个家族从一开始就是配对的:encoder把图像压成编码,decoder把这个编码还原成图像。训练自编码器时,encoder和decoder是一起优化的,encoder的输出必须包含足够的信息让decoder能重建图像。

换句话说,encoder从一开始就在决定“生成图像的上限在哪里”。如果encoder把高频纹理全丢掉,那decoder再怎么强大的网络也没法凭空还原细节。所以当你说“使用encoder来generate图像”,逻辑核心其实是:让encoder学习一个高质量的隐空间,生成时从隐空间采样,再由decoder把采样结果映射回像素。

1.2 三种“encoder驱动生成”的常见形态

我梳理了一下,目前工程里经常用到的encoder参与生成方式主要有三种,先列个表:

形态encoder输出什么生成时怎么用典型场景
连续隐空间(AE/VAE)一个低维向量或多个向量从先验分布采样后直接喂给decoder最简单的图像重建、风格编码
离散token(VQ-VAE)一组离散编码索引学一个序列模型在离散token上采样,再交给decoder图像生成、图像token化
条件特征注入图像/文本encoder提取的条件特征作为生成网络的条件输入,控制生成方向图文生成、参考图驱动生成

第三种其实很常见,比如把文本或参考图经过encoder后得到的特征拼到生成网络里,只是很多人没意识到这也算“encoder在生成”。本次实验我选了第二种,也就是VQ-VAE这条路线,因为它最能体现“encoder出来的东西直接决定生成结果”这件事。

1.3 本次实验为什么选VQ-VAE

VAE用连续隐空间,生成时从高斯分布采样,图像容易模糊。原因也简单:连续高维分布不好精确建模,而且后验分布容易坍塌成标准高斯,encoder学到的东西就没有区分度。

VQ-VAE的思路是把连续特征离散化。encoder输出的特征图上每个位置,不是拿一个连续向量直接给decoder,而是先到这个位置的“码本”(codebook)里找一个最相近的离散向量,拿这个离散向量去重建。这样图像就被转成了一串有限长度的token序列,生成任务就变成在离散token序列上做采样。离散分布天然适合自回归模型,最近很多大模型做图像生成,也是先把图像token化再做序列生成。

所以本次实验的完整链路是:图像输入encoder,得到离散token;训练一个小的自回归模型学习token序列分布;测试时从token分布里采样,在encoder学到的离散编码上生成新的token序列,交给decoder还原成图像。

2. 环境与数据:把运行测试的基础打稳

2.1 依赖与硬件最低配置

我这个实验用的是开源生态里最常见的组合:Python 3.10,深度学习框架使用PyTorch 2.x,图像处理工具为配套的视觉工具库,训练监控使用tqdm和可选的tensorboard。这里不贴一长串pip依赖了,反正核心就这几个,装好后用GPU训练。

硬件方面,我的建议是至少有8GB显存,这个配置跑64×64分辨率、512码本大小的实验完全足够。如果没有独立显卡,用小分辨率32×32硬跑也可以,但训练几十轮会比较煎熬。这个实验本身就是“运行测试”性质,不是刷榜,所以没必要上多卡,单张消费级GPU就行。

2.2 数据预处理要统一

我用的是一个公开的图像数据集,分辨率和图像风格都很规整,非常适合做生成实验。取图像后先缩放到64×64,像素归一化到[-1, 1]区间,这样decoder输出的最后一层激活函数用Tanh,输出范围天然匹配。

这里有一个必须强调的点:训练前处理、验证前处理、测试脚本里的生成前处理,必须封装成同一个函数。很多人跑测试时图方便,直接手写了一个resize和归一化,结果训练时用的是BICUBIC插值,测试时用了NEAREST,或者通道顺序变了,生成出来的东西就会花掉。这个小问题会浪费大量排查时间,后面第五节我会再展开。

2.3 超参数的选择思路

超参数我这里先给一组可以直接用的默认值,后面解释为什么:

参数取值说明
图像分辨率64×64保证实验速度和显存占用
codebook size512码本条目数,表达力与利用率的平衡点
embedding dim64每个token的向量维度
batch size64显存不够可以降到32
学习率2e-4AdamW的常规区间,稳定优先
commitment cost0.25原版VQ-VAE的常用值,不建议调大
训练轮数50左右观察过拟合和利用率稳定情况

codebook size不能拍脑袋,太小则表达力不够,图像细节出不来;太大则很多码本条目可能长期不激活,利用率上不去。embedding dim同理,太高会让匹配过程变得不稳定。commitment loss如果设得过大,会过度约束encoder的输出,反而让重建质量上不去。

3. encoder与decoder的模型结构设计

3.1 encoder输出为什么是离散token

这一步是整个流程最关键的地方。常规卷积encoder输出的还是特征图,比如一个形状为(B, 64, 8, 8)的张量,其中64是通道数,8×8是空间尺寸。VQ-VAE的做法是:把每个空间位置上的64维向量,拿去和codebook里的所有向量算L2距离,找到距离最近的那个码本向量索引,然后用这个索引对应的码本向量替代原来的特征向量。

量化过程可以用一段很短的代码表示:

class VectorQuantizer(nn.Module): def __init__(self, num_embeddings, embedding_dim, commitment_cost=0.25): super().__init__() self.num_embeddings = num_embeddings self.embedding_dim = embedding_dim self.commitment_cost = commitment_cost self.embedding = nn.Embedding(num_embeddings, embedding_dim) def forward(self, z): # z 的形状是 (B, C, H, W),需要调整成 (B, H, W, C) z = z.permute(0, 2, 3, 1).contiguous() flat = z.view(-1, self.embedding_dim) # 计算与码本中每个向量的 L2 距离 distances = ( flat.pow(2).sum(1, keepdim=True) + self.embedding.weight.pow(2).sum(1) - 2 * flat @ self.embedding.weight.t() ) indices = distances.argmin(1) # 用码本向量替换原始特征 z_q = self.embedding(indices).view(z.shape) # 直通估计:反向传播时,梯度绕过 argmax 直接传给 encoder 输出 z_q_st = z + (z_q - z).detach() # 量化过程中实际没有梯度,所以码本和 commitment 要单独算损失 codebook_loss = torch.mean((flat.detach() - self.embedding.weight[indices]) ** 2) commitment_loss = self.commitment_cost * torch.mean((flat - self.embedding.weight[indices].detach()) ** 2) return z_q_st, indices, codebook_loss, commitment_loss

注意这里的“直通估计”(straight-through estimator)。argmax这个操作本身不可导,所以前向计算用的是量化后的向量,反向传播时直接把decoder传来的梯度复制给encoder输出。这让encoder能够继续更新,而码本向量则靠单独的损失项来更新。

3.2 codebook的初始化和EMA更新

码本的初始化质量对最终生成影响非常大。如果初始化不好,很多码本向量可能一开始就远离任何真实特征,训练中完全没有样本落入,它们就成了“死码本”,利用率上不来。

最简单的初始化是标准正态分布或均匀分布。但更好的做法是先跑几个batch,收集encoder的输出做一次k-means聚类,用聚类中心初始化码本。这样码本一开始就分布在有意义的区域里。我在实验里用的是初始化后配合EMA更新。

EMA更新说的是不通过反向传播来更新码本向量,而是维护每个码本向量的累计特征和计数。每当有样本选中某个码本条目,就把该样本的特征累积进去,训练到一定步数后做平滑更新。这样更新更稳定,不容易出现几个条目被反复选中、其余条目彻底闲置的情况。跑下来我明显感觉EMA比直接对码本反向传播稳得多。

3.3 损失函数的配比与踩坑

整体损失是重建损失、码本损失、commitment损失三者的和。我在重建部分用了L1和L2加起来,原因是L1保留边缘更锐利,L2让整体平滑。如果只用L1,训练曲线会稍微抖,但生成图往往更清爽。

重建损失用的是decoder输出和原图的像素差,这是最朴素的方案。严谨一点可以加感知损失,但对这个运行测试级别的小模型,像素损失已经够用了。

这里有一个特别容易写错的地方:量化模块forward里,一定要把“传给decoder的量化特征”和“用于计算码本损失的量化特征”分开。前者要用直通估计,后者必须用detach隔离,否则梯度流向乱了,训练大概率会掉进NaN。我第二次跑这个流程时就是在这里踩了坑,预训练了几小时后发现重建图像一直是死灰色,检查半天才发现码本损失里少了一个detach。

4. 运行测试:从隐空间采样到生成图像

4.1 生成方式的三种选择

网络训练完,重头戏就是怎么把“使用encoder来generate图像”真正跑出来。生成端有三个做法,我按复杂度从低到高说一下:

  1. 均匀随机采样token序列。encoder把图像变成8×8的token网格,也就是64个token。如果这64个token都从码本里均匀随机抽,再交给decoder,生成出来的图几乎必然是一团噪声或色块。原因很简单,训练时token之间有极强的相关性,均匀分布没有覆盖这个相关性。

  2. 训练一个小自回归模型。这是VQ-VAE的标准配套玩法。我额外训练了一个两层的小Transformer,把所有token拼接成一个序列,学习下一个token的概率分布。生成时逐token采样,拿着前一步的token作为条件预测下一步。这个模型不大,训练也快,生成图像质量会明显上一个台阶。

  3. 条件生成。给定一张参考图,先用同一个encoder提取它的token序列,然后保留其中一部分token,重新采样另外一部分,这样可以得到结构相似但细节不同的变体。这个玩法用来验证encoder提取的语义信息是否稳定非常直观。

4.2 完整的测试脚本流程

我写测试脚本时坚持几个原则:可复现、可见中间结果、有快速失败的断言。流程大概是这样的:

1. 加载VQ-VAE权重和自回归模型权重,设置eval模式 2. 固定随机种子,保证多次采样结果可复现 3. 从自回归模型里逐token采样,得到64个token索引 4. 将token索引reshape成(1, 8, 8)的网格 5. 用VQ-VAE的码本查询得到embedding特征图 6. 喂给decoder,输出(1, 3, 64, 64)的图像 7. 检查输出shape、数值范围、是否有全黑/全白异常 8. 映射到[0,255]保存png

这段生成函数的简化代码大约是:

def generate(model, token_model, device, seq_len=64, temperature=0.9, top_k=50): model.eval() token_model.eval() tokens = [] with torch.no_grad(): for i in range(seq_len): logits = token_model(tokens) # (1, current_len, num_tokens) logits = logits[:, -1, :] / temperature if top_k is not None: values, _ = torch.topk(logits, top_k) logits[logits < values[:, -1]] = -float("Inf") probs = torch.softmax(logits, dim=-1) token = torch.multinomial(probs, num_samples=1) tokens.append(token.item()) indices = torch.tensor(tokens, device=device).reshape(1, 8, 8) z_q = model.vq.embedding(indices).permute(0, 3, 1, 2).contiguous() gen_img = model.decoder(z_q) return gen_img

这个脚本里最不起眼但也最重要的,是每步都打印或断言shape。隐空间维度一旦错位,decoder不会报错,但会输出花屏或条纹图,这种错误肉眼很难第一时间定位。

4.3 怎么判断生成结果“有效”

很多同学跑通生成后,不知道怎么看结果好坏。一个最基础的方法是先跑“round trip”:拿一张训练集图像,原图喂给encoder,再让decoder重建,看重建图和原图的差异。如果重建就已经糊到没法看,那生成端再努力也白搭。

第二步才是看全新采样生成的图。判断标准可以分三档:第一档是图像不是噪声,能看到明确物体轮廓;第二档是图像细节接近训练集分布;第三档是图像多样性足够,不是翻来覆去那几种构图。我还会统计一下token序列的熵,熵太低说明自回归模型学成了“复读机”,生成图像会非常单调。

如果生成图灰蒙蒙一片,大概率是采样分布太均匀;如果生成了几个清晰物体但彼此不协调,多半是自回归模型的上下文长度不够,token之间远距离依赖没学好。

5. 实测中最容易翻车的三个点

5.1 codebook利用率太低,生成图像花色单一

我跑第一版实验时,训练了40轮,重建效果已经不错了,但生成出来的图像颜色极其单调。排查了半天,发现码本利用率只有不到15%。也就是说,512个码本条目里真正被用到的只有七八十个,encoder反复选那几十个,生成时自回归模型再怎么采样,也跳不出这几个候选,图像自然单调。

这种问题通常是初始化不好或EMA更新不给力导致的。我当时的处理是先跑少量batch做k-means初始化,然后把EMA的decay调到0.99,同时加入dead code reset:如果一个码本条目很多个训练周期都没被启用,就直接用当前batch里最活跃的encoder输出把它替换掉。改完之后利用率一周内爬到了60%以上,生成多样性肉眼可见提升。

5.2 训练和测试预处理不一致,脚本直接报废

这是最蠢也最容易犯的错。训练时图像被归一化到[-1,1],测试脚本为了节省几行代码,直接读取原始图像后没有做归一化,喂给encoder的像素范围变成[0,255]。结果encoder得到的特征完全偏离训练分布,重建图全是黑块。

更隐蔽的是插值方式不一致。训练用的是双三次插值,测试脚本里顺手用了最近邻,肉眼几乎看不出来,但生成图像的边缘会变得非常脏。我的解决方式是把所有图像变换逻辑放进同一个工具模块,训练、验证、测试脚本都从那里import,严禁在脚本里重复手写。实践之后这类问题再没出现过。

5.3 重建效果不错,但生成图像总是发糊

如果round trip已经清晰了,但随机采样生成出来的图还是糊,问题就不在encoder和decoder的重建能力,而在采样阶段。原因通常有两个:一个是自回归模型没学好,token分布过于保守;另一个是decoder本身缺少锐化机制,像素级损失下生成的边界偏平滑。

对策也分两步。自回归采样时用temperature=0.9、top_k=50,比直接贪心解码保留更多细节。更高阶的做法是给decoder加一个小型判别器,用对抗损失让输出更锐利。这一步算是进阶玩法,我在当前实验里还没上,属于后续优化方向。

6. 测试结果分析与下一步扩展

6.1 从运行测试看整体链路是否通

以我的小实验来看,整个过程确实验证了标题里的核心结论:使用encoder来generate图像是完全可行的,关键在于encoder之后要有一个匹配的隐空间采样机制。跑通链路后,重建质量和对全新采样的生成质量要分开评估。重建质量反映的是encoder/decoder的信息保留能力,全新采样反映的是隐空间分布建模能力。这两者经常不呈正相关。

如果想进一步逼近真实感,可以优先在token分布建模上下功夫。把那个小Transformer换成更大的序列模型,或者用扩散模型直接在离散token序列上做采样,效果会显著提升。现在很多图像生成方案都默认先把图像token化,这个思路和本次实验完全同源,所以这一个小项目其实是一整套生成范式的缩小版,后续往哪个方向扩展都有路可走。

我自己最大的体会是,排查生成质量问题时,不要一上来就认定是decoder不行。先回看encoder输出的隐空间分布有没有塌陷,再检查采样分布是否合理,最后才去动decoder。按这个顺序排查,能少走很多弯路。希望这份运行测试记录对准备做类似实验的同学有参考价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询