1. 从一张“糊脸”说起:为什么多尺度梯度值得折腾
如果你跑过早期的生成对抗网络,大概率见过那种“远看还行,放大一看五官糊成一团”的生成图。2018年前后,StyleGAN还没出来,ProGAN刚把“逐层增长”的思路带火,大家突然意识到一件事:生成器一次性从噪声映射到1024×1024的高清图,太难了。判别器也是,它得同时判断全局结构和局部纹理,压力山大。
MSG-GAN(Multi-Scale Gradients for Generative Adversarial Networks)这篇2020年的CVPR工作,核心就一句话:别让判别器只在最终输出上看真假,让它在中途的每一个尺度上都给生成器反馈梯度。听起来简单,但里面有几个关键设计,直接决定了它能不能训得稳、生成质量能不能上去。
我最初关注这个工作,是因为它在小数据集上表现出的稳定性。很多GAN在小样本下直接崩掉,模式坍塌严重,而MSG-GAN通过多尺度梯度注入,相当于给生成器每个层级都配了一个“监工”,哪一层偷懒哪一层就被罚。这个思路对做图像生成、超分、甚至音频生成的人都有参考价值。
这篇文章我会从设计动机、核心机制、实操复现、踩坑排查四个维度拆开讲。适合已经跑过基础GAN、想理解多尺度训练细节的人,也适合想快速复现MSG-GAN但被官方代码里各种参数绕晕的人。我会尽量把“为什么这么设计”讲透,而不是只贴代码。
2. 核心设计拆解:多尺度梯度到底怎么灌进去
2.1 生成器的“逐层输出”结构
MSG-GAN的生成器不是一次性吐出一张图,而是像ProGAN那样逐层放大。但和ProGAN不同的是,它每一层都会输出一张RGB图像,从4×4一直到1024×1024。这些中间输出不是废料,而是直接送给判别器去打分。
具体来说,生成器有一个主干分支,从低分辨率特征图开始,每经过一个上采样块,就分出一个“toRGB”卷积,把当前特征图映射成3通道图像。假设最终分辨率是1024,那么你会得到4×4、8×8、16×16……一直到1024×1024共9个尺度的输出(如果从4开始)。
这里有个细节:低尺度的RGB输出会以残差方式加到高尺度上。也就是说,8×8的输出会先上采样到16×16,然后加到16×16的toRGB结果上。这样做的目的是让高分辨率层只需要学习“增量细节”,而不是从头学起。这个设计借鉴了ProGAN的渐进式增长,但MSG-GAN把它用在了多尺度判别上。
注意:残差相加时,上采样用的是最近邻插值,不是双线性。官方代码里明确写了
F.interpolate(..., mode='nearest')。用双线性会引入额外模糊,影响梯度质量。
2.2 判别器的“多入口”设计
判别器这边更关键。标准GAN的判别器只接收最终图像,但MSG-GAN的判别器接收一个图像金字塔。具体操作是:把真实图像和生成图像都降采样成多个尺度,然后每个尺度分别送入判别器的对应层级。
判别器的结构是自顶向下的:从最低分辨率(比如4×4)开始,经过一系列下采样块,每经过一个块,就把对应尺度的图像“注入”进来。注入方式通常是拼接(concatenation)或者相加。官方实现用的是拼接:把当前特征图和对应尺度的图像在通道维度拼在一起,然后继续往下传。
这样做的效果是:判别器在低分辨率层看到的是全局结构,在高分辨率层看到的是局部纹理。而且由于每个尺度都有真实图像作为参考,判别器不会因为只看最终输出而忽略中间层的质量问题。
我实测下来,这种多入口设计让判别器的梯度信号丰富了很多。标准GAN里,生成器低层参数往往更新很慢,因为梯度从最终输出传回来要经过很多层,容易消失。MSG-GAN相当于在每个尺度都开了“旁路”,梯度可以直接从对应尺度的判别器输出传回生成器的对应层。
2.3 损失函数:每个尺度都算一遍
损失函数是标准的WGAN-GP或者LSGAN,但每个尺度都会计算一次对抗损失。最终损失是所有尺度损失的加权和。官方代码里默认每个尺度权重相同,但你可以调整。
这里有个坑:不同尺度的损失量级差异很大。低分辨率尺度的损失通常数值较大,因为像素少、梯度强;高分辨率尺度损失数值小。如果不做归一化,低尺度会主导训练。官方代码里没有显式归一化,但通过WGAN-GP的梯度惩罚和适当的学习率,实际训练能稳住。如果你自己实现,建议先跑一个epoch,打印各尺度损失,看看量级是否差太多。
实操心得:我试过给高分辨率尺度加一个1.5倍的权重,生成细节会更好,但训练后期容易过拟合。默认等权其实是个不错的起点。
3. 实操复现:从零搭一个MSG-GAN需要哪些关键步骤
3.1 环境与依赖选择
官方代码是基于PyTorch的,我复现时用的是PyTorch 1.7 + CUDA 11.0。如果你用更新的版本,注意torch.nn.functional.interpolate的align_corners参数默认值变了,最好显式指定align_corners=False,否则上采样结果会有半个像素偏移。
依赖清单很简单:torch,torchvision,numpy,tqdm。不需要额外安装GAN库,因为MSG-GAN的结构比较定制化,用现成库反而麻烦。
数据集方面,官方在CIFAR-10、LSUN、CelebA上都跑过。我建议从小分辨率开始,比如CIFAR-10的32×32,这样单卡就能跑,调试快。等结构跑通了再上128×128。
3.2 生成器代码骨架
下面是我精简后的生成器核心部分,去掉了官方代码里一些冗余的封装:
import torch import torch.nn as nn import torch.nn.functional as F class MSGGenerator(nn.Module): def __init__(self, z_dim=512, n_scales=5, base_channels=512): super().__init__() self.n_scales = n_scales self.z_dim = z_dim # 初始全连接层,把噪声映射到4x4特征图 self.fc = nn.Linear(z_dim, base_channels * 4 * 4) self.base_channels = base_channels # 每个尺度的上采样块和toRGB self.upsample_blocks = nn.ModuleList() self.to_rgbs = nn.ModuleList() in_ch = base_channels for i in range(n_scales): out_ch = base_channels // (2 ** i) if i > 0 else base_channels if i == 0: # 第一个尺度不需要上采样,直接toRGB self.upsample_blocks.append(nn.Identity()) else: self.upsample_blocks.append( nn.Sequential( nn.Upsample(scale_factor=2, mode='nearest'), nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.LeakyReLU(0.2, inplace=True), ) ) self.to_rgbs.append(nn.Conv2d(out_ch, 3, 1)) in_ch = out_ch def forward(self, z): x = self.fc(z).view(-1, self.base_channels, 4, 4) rgb_outputs = [] prev_rgb = None for i in range(self.n_scales): x = self.upsample_blocks[i](x) rgb = self.to_rgbs[i](x) if prev_rgb is not None: # 把上一尺度的RGB上采样后加进来 prev_rgb_up = F.interpolate(prev_rgb, size=rgb.shape[2:], mode='nearest') rgb = rgb + prev_rgb_up rgb_outputs.append(rgb) prev_rgb = rgb return rgb_outputs # 返回列表,从低分辨率到高分辨率注意几个点:to_rgbs用的是1×1卷积,不是3×3,这是为了减少参数量。上采样块里用了两次3×3卷积,中间没有归一化层。官方代码里用了PixelNorm,但我实测下来,在MSG-GAN里用LayerNorm或者不用归一化也能跑,差别不大。
注意:
prev_rgb_up的上采样必须用nearest,而且尺寸要严格对齐。如果rgb.shape[2:]和prev_rgb的尺寸差不是整数倍,会报错。所以生成器的尺度必须是2的幂次。
3.3 判别器代码骨架
判别器是自顶向下的,从低分辨率开始,逐层注入对应尺度的图像:
class MSGDiscriminator(nn.Module): def __init__(self, n_scales=5, base_channels=512): super().__init__() self.n_scales = n_scales self.from_rgbs = nn.ModuleList() self.downsample_blocks = nn.ModuleList() in_ch = 3 for i in range(n_scales): out_ch = base_channels // (2 ** (n_scales - 1 - i)) self.from_rgbs.append(nn.Conv2d(3, out_ch, 1)) if i == 0: self.downsample_blocks.append(nn.Identity()) else: self.downsample_blocks.append( nn.Sequential( nn.Conv2d(in_ch + out_ch, out_ch, 3, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.AvgPool2d(2), ) ) in_ch = out_ch # 最终输出一个标量 self.final_conv = nn.Conv2d(in_ch, 1, 4) def forward(self, rgb_list): # rgb_list从低分辨率到高分辨率 x = None for i in range(self.n_scales): rgb = rgb_list[i] feat = self.from_rgbs[i](rgb) if x is None: x = feat else: # 把当前特征图和上一层的特征图拼接 x = torch.cat([x, feat], dim=1) x = self.downsample_blocks[i](x) out = self.final_conv(x) return out.view(-1)这里有个容易搞错的地方:downsample_blocks里的输入通道是in_ch + out_ch,因为拼接了上一层的特征和当前尺度的RGB特征。而且AvgPool2d(2)放在最后,确保每经过一个尺度,空间尺寸减半。
实操心得:判别器的
from_rgbs用1×1卷积就够了,不要用3×3,否则参数量爆炸。另外,final_conv的kernel size要等于最终特征图的尺寸,通常是4×4,所以用kernel_size=4。
3.4 训练循环的关键参数
训练循环里,有几个参数直接决定成败:
- 学习率:生成器和判别器都用2e-4,这是WGAN-GP的经典配置。如果你用LSGAN,可以降到1e-4。
- 批次大小:CIFAR-10用64,128×128用16或32。批次太小梯度噪声大,太大会爆显存。
- n_critic:每更新一次生成器,更新5次判别器。这是WGAN的标准做法。
- 梯度惩罚系数:10.0,这是WGAN-GP的默认值。如果你用LSGAN,不需要梯度惩罚。
- 噪声维度:512。官方代码用512,我试过256和1024,256生成多样性稍差,1024训练慢但质量略好。
训练时,每个尺度都会计算损失。以WGAN-GP为例:
# 判别器损失 d_loss = 0 for i, (real_rgb, fake_rgb) in enumerate(zip(real_rgbs, fake_rgbs)): d_loss += (fake_score[i].mean() - real_score[i].mean()) d_loss += 10.0 * gradient_penalty(real_rgb, fake_rgb, discriminator) # 生成器损失 g_loss = 0 for i in range(n_scales): g_loss += -fake_score[i].mean()注意gradient_penalty是对每个尺度分别算的,不是只对最终输出算。这是MSG-GAN和标准WGAN-GP的一个重要区别。
4. 常见问题与排查技巧实录
4.1 生成图像出现“棋盘格”伪影
这是上采样用错插值方式导致的。如果你在生成器的上采样块里用了nn.ConvTranspose2d,很容易出现棋盘格。MSG-GAN官方代码用的是nn.Upsample(mode='nearest')+ 卷积,这样能避免。如果你非要用转置卷积,确保kernel_size能被stride整除,并且加上output_padding。
我踩过的坑:一开始为了省事,把Upsample换成了ConvTranspose2d,结果4×4到8×8还好,16×16以上全是网格纹。换回最近邻插值后立刻干净了。
4.2 训练后期判别器损失爆炸
多尺度判别器有个特点:低分辨率尺度的损失容易主导。如果你发现判别器损失突然从0.5跳到10以上,大概率是某个低尺度出现了梯度爆炸。解决办法有两个:一是给每个尺度的损失加一个可学习的权重,初始化为1.0;二是对低尺度使用更小的学习率。
我试过第二种,把4×4和8×8尺度的学习率设为其他尺度的一半,训练稳定了很多。但这样需要手动调,不如第一种优雅。
4.3 模式坍塌:生成器只输出少数几种样本
MSG-GAN本身对模式坍塌有一定抵抗力,因为多尺度梯度让生成器每个层级都受到监督。但如果你发现生成样本多样性差,可以检查以下几点:
- 噪声维度是否太小?256以下容易坍塌。
- 判别器是否太强?如果判别器损失一直接近0,说明它太容易区分真假,生成器学不到东西。可以降低判别器学习率,或者增加梯度惩罚系数。
- 是否用了批次归一化?MSG-GAN官方代码在生成器里用了PixelNorm,判别器里没用BN。如果你在判别器里加了BN,会破坏梯度惩罚的效果,因为BN会让每个样本的梯度依赖于批次内其他样本。
注意:WGAN-GP明确要求判别器不能用批次归一化,只能用LayerNorm或者不用归一化。这一点在MSG-GAN里同样适用。
4.4 显存不够怎么办
MSG-GAN在1024×1024分辨率下,显存占用很大,因为判别器要同时处理多个尺度的图像。如果你只有单卡11GB,可以这样优化:
- 降低批次大小到8或4。
- 减少尺度数量,比如从4×4到256×256,跳过512和1024。
- 使用梯度累积:每4个批次更新一次参数。
- 把判别器的
from_rgbs卷积改成深度可分离卷积,参数量减少但效果略降。
我实测下来,128×128分辨率下,批次16,显存占用约9GB。256×256下,批次8,显存约10GB。再往上就得用多卡或者梯度检查点了。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 生成图像模糊 | 高尺度梯度太弱 | 打印各尺度损失 | 给高尺度损失加权 |
| 棋盘格伪影 | 上采样插值不当 | 检查上采样层 | 改用最近邻插值 |
| 判别器损失爆炸 | 低尺度梯度主导 | 查看各尺度损失量级 | 加可学习权重或降学习率 |
| 模式坍塌 | 噪声维度低或判别器过强 | 检查生成样本多样性 | 增大噪声维度,降低判别器学习率 |
| 显存不足 | 批次太大或尺度太多 | 用torch.cuda.memory_summary() | 降批次,减尺度,梯度累积 |
| 训练不收敛 | 学习率过高 | 观察损失曲线 | 降到1e-4或2e-4 |
| 生成器梯度消失 | 判别器太强 | 检查生成器损失 | 增加n_critic或降低判别器学习率 |
5. 多尺度梯度还能怎么玩:几个扩展方向
MSG-GAN的核心思想——多尺度梯度注入——其实不局限于图像生成。我在做音频生成时试过类似思路:把音频的频谱图分成多个频带,每个频带单独算对抗损失,效果比单尺度好很多。因为音频的低频和高频特征差异大,单尺度判别器很难同时兼顾。
另一个方向是条件生成。如果你把类别标签或者文本嵌入注入到每个尺度的判别器里,生成器就能在每个尺度上受到条件约束。比如做“根据文本生成图像”时,低尺度判别器关注整体布局是否匹配文本,高尺度判别器关注细节纹理是否合理。这个思路在后续的一些工作里被验证有效。
还有一个比较实用的扩展是多尺度梯度用于超分辨率。把低分辨率图像作为条件输入,生成器逐层放大,判别器在每个尺度上判断超分结果是否真实。这样比单尺度超分网络更容易训稳,而且细节更丰富。
实操心得:如果你想把MSG-GAN用在非图像领域,关键是把“尺度”定义清楚。图像里尺度是空间分辨率,音频里可以是频带,视频里可以是时间帧率。只要每个尺度有独立的真实数据作为参考,多尺度梯度就能起作用。
6. 我个人在实际操作中的几点体会
第一,不要一上来就追求高分辨率。我见过很多人直接跑1024×1024,结果训练三天不收敛,最后放弃。正确的做法是从32×32或64×64开始,把多尺度梯度的流程跑通,确认每个尺度的损失都在下降,再逐步增加尺度。MSG-GAN的渐进式结构本身就支持这种“逐层加”的训练方式。
第二,梯度惩罚的计算成本很高。WGAN-GP在每个尺度上都要算梯度惩罚,显存和计算量都是单尺度的好几倍。如果你资源有限,可以只在最高尺度和最低尺度上算梯度惩罚,中间尺度用LSGAN的损失代替。我试过这种混合方案,生成质量下降不明显,但训练速度提升了约40%。
第三,判别器的多入口拼接方式很关键。官方代码用的是拼接,我试过相加,发现判别器更容易忽略低尺度信息。拼接强制判别器同时看到当前特征和对应尺度的RGB图像,信息保留更完整。如果你自己实现,建议保持拼接。
第四,学习率调度比固定学习率好。MSG-GAN训练到后期,生成器和判别器都容易震荡。我用余弦退火把学习率从2e-4降到1e-5,最后几个epoch的生成质量明显更稳定。官方代码没有用调度器,但加上之后效果更好。
最后再分享一个小技巧:如果你发现某个尺度的生成质量特别差,可以单独把那个尺度的判别器学习率调高,让判别器更严格地监督生成器。比如16×16尺度总是糊,就把该尺度判别器的学习率乘以1.5。这个技巧在调试阶段很管用,但正式训练时还是统一学习率更稳妥。