1. 从全连接到卷积:DCGAN到底解决了什么痛点
很多人第一次接触生成对抗网络,都是从最基础的全连接版本开始的。用几层Dense堆叠起来,生成器和判别器互相博弈,在MNIST这种28x28的小图上跑一跑,确实能看到模糊的数字轮廓慢慢浮现出来。但一旦把分辨率往上提,比如想生成64x64甚至128x128的彩色图片,全连接版本立刻就露馅了——参数量爆炸、训练极其不稳定、生成结果要么是噪声要么是模式坍塌,翻来覆去就那几个样子。
DCGAN(Deep Convolutional Generative Adversarial Networks)的出现,本质上是把卷积神经网络在图像识别领域积累的成功经验,系统性地迁移到了生成任务上。它不是简单地“把全连接换成卷积”这么粗暴,而是给出了一整套架构设计准则,让生成器和判别器都能在卷积框架下稳定地对抗训练。
我自己的体会是,DCGAN的核心贡献不在于发明了某个新算子,而在于它回答了一个非常实际的问题:在生成任务里,卷积应该怎么用才不出事。这个问题听起来简单,但当年踩坑的人不计其数。比如生成器里用普通的卷积做上采样,出来的图会有明显的棋盘格伪影;判别器里用BatchNorm,训练几轮之后整个模型就崩了;生成器输出层忘了加激活函数,像素值跑到[-3, 5]这种奇怪的范围里,判别器直接学废。
DCGAN给出的几条准则,放到今天依然是搭建卷积GAN时的默认起点:
- 生成器用转置卷积做上采样,而不是插值加卷积
- 判别器里去掉所有池化层,用步长卷积代替
- 生成器和判别器都加BatchNorm,但判别器的输入层和生成器的输出层除外
- 生成器输出层用tanh,把像素压到[-1, 1]
- 判别器所有层用LeakyReLU,斜率0.2
- 优化器用Adam,学习率0.0002,beta1设成0.5而不是默认的0.9
这些细节单看每一条都很朴素,但组合起来就是DCGAN能跑通的关键。后面我会逐条拆解为什么这么设计,以及我在Keras里实现时踩过的具体坑。
2. 生成器与判别器的Keras实现细节
2.1 生成器:从100维噪声到64x64彩色图
生成器的任务是把一个低维随机向量逐步“放大”成一张图片。在Keras里,我习惯用tf.keras.Sequential来搭,因为结构是线性的,没必要上函数式API。输入是一个长度为100的噪声向量,经过一个全连接层映射成4x4x1024的张量,然后reshape成空间维度,再连续做几次转置卷积。
import tensorflow as tf from tensorflow.keras import layers def build_generator(latent_dim=100): model = tf.keras.Sequential(name="generator") model.add(layers.Dense(4 * 4 * 1024, use_bias=False, input_shape=(latent_dim,))) model.add(layers.BatchNormalization()) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Reshape((4, 4, 1024))) model.add(layers.Conv2DTranspose(512, (5, 5), strides=(2, 2), padding='same', use_bias=False)) model.add(layers.BatchNormalization()) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Conv2DTranspose(256, (5, 5), strides=(2, 2), padding='same', use_bias=False)) model.add(layers.BatchNormalization()) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Conv2DTranspose(128, (5, 5), strides=(2, 2), padding='same', use_bias=False)) model.add(layers.BatchNormalization()) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Conv2DTranspose(3, (5, 5), strides=(2, 2), padding='same', use_bias=False, activation='tanh')) return model这里有几个点值得展开说。
为什么第一层用Dense而不是直接转置卷积?因为噪声向量本身没有空间结构,先通过全连接把它映射到一个4x4x1024的张量,相当于给后续的卷积层提供了一个“有空间排布的种子”。如果直接对1x1的噪声做转置卷积,感受野太小,生成出来的东西会非常局部化,缺乏全局一致性。
为什么用use_bias=False?因为后面紧跟着BatchNorm,BN本身会减去均值,偏置项会被抵消掉,加了也是浪费参数。这是DCGAN论文里明确提到的做法,我在实践中验证过,去掉偏置对生成质量没有影响,但能省一点显存。
转置卷积的kernel size为什么选5?这个没有绝对标准,3、4、5都有人用。5x5的感受野稍大一些,在4x4到8x8这种小分辨率上能覆盖更多上下文。但如果你要生成128x128的图,中间层数更多,用3x3也完全没问题,还能减少参数量。我一般遵循一个原则:kernel size不要超过当前特征图尺寸的一半,否则会出现明显的边界伪影。
输出层为什么是tanh?因为训练数据我做了归一化到[-1, 1],tanh的值域正好匹配。如果你用sigmoid把输出压到[0, 1],那训练数据也要相应归一化到[0, 1],两者必须一致。我见过有人数据归一化到[-1, 1]但输出用sigmoid,结果生成出来的图整体偏暗,因为sigmoid永远输出不了负值。
2.2 判别器:步长卷积替代池化的理由
判别器的结构基本是生成器的镜像,但把转置卷积换成了普通卷积,并且用步长来降采样。
def build_discriminator(img_shape=(64, 64, 3)): model = tf.keras.Sequential(name="discriminator") model.add(layers.Conv2D(64, (5, 5), strides=(2, 2), padding='same', input_shape=img_shape)) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Dropout(0.3)) model.add(layers.Conv2D(128, (5, 5), strides=(2, 2), padding='same')) model.add(layers.BatchNormalization()) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Dropout(0.3)) model.add(layers.Conv2D(256, (5, 5), strides=(2, 2), padding='same')) model.add(layers.BatchNormalization()) model.add(layers.LeakyReLU(alpha=0.2)) model.add(layers.Dropout(0.3)) model.add(layers.Flatten()) model.add(layers.Dense(1, activation='sigmoid')) return model判别器第一层为什么不加BatchNorm?这是DCGAN论文里特别强调的一点。判别器的输入是真实图片和生成图片,如果第一层就做BN,相当于对每张图独立做归一化,会破坏图片本身的统计特性,而且会让判别器过度依赖batch内的统计信息,导致训练不稳定。我实测过,第一层加BN之后,判别器的loss会剧烈震荡,生成器也跟着崩。
Dropout加在哪里?我在每个卷积块后面都加了0.3的Dropout,这是原论文没有的,但我在小数据集上发现能有效缓解判别器过拟合。如果你的数据集很大(比如十万张以上),可以不加或者把rate降到0.1。Dropout的位置也有讲究,放在LeakyReLU之后、下一层卷积之前,效果比放在卷积之前好。
为什么用步长卷积而不是池化?池化层是固定的下采样操作,没有可学习参数,而步长卷积让网络自己学习怎么降采样。在生成任务里,判别器需要捕捉生成图片中的细微伪影,可学习的下采样能保留更多有用信息。另外,池化会丢失空间位置信息,而步长卷积在一定程度上能保留。
2.3 损失函数与优化器的配置逻辑
DCGAN用的是标准的二元交叉熵损失,但优化器的参数需要特别调整。
cross_entropy = tf.keras.losses.BinaryCrossentropy() def discriminator_loss(real_output, fake_output): real_loss = cross_entropy(tf.ones_like(real_output), real_output) fake_loss = cross_entropy(tf.zeros_like(fake_output), fake_output) return real_loss + fake_loss def generator_loss(fake_output): return cross_entropy(tf.ones_like(fake_output), fake_output) generator_optimizer = tf.keras.optimizers.Adam(1e-4, beta_1=0.5) discriminator_optimizer = tf.keras.optimizers.Adam(1e-4, beta_1=0.5)beta1为什么是0.5而不是0.9?Adam默认的beta1是0.9,意味着动量项会累积过去很多步的梯度。在GAN训练中,生成器和判别器的梯度方向变化很快,动量太大反而会让优化器“反应迟钝”,跟不上对抗的动态变化。0.5的动量让优化器更灵敏,能更快响应梯度方向的变化。这个细节在DCGAN论文里明确写了,但很多人直接抄代码时忽略了。
学习率为什么是1e-4?1e-4比常见的1e-3小一个数量级,原因是GAN训练本身就不稳定,学习率太大会让判别器迅速变强,生成器梯度消失。1e-4是一个经验值,在大多数数据集上都能跑。如果你发现判别器loss很快降到0,生成器loss一直很高,可以试着把判别器学习率降到5e-5,或者给判别器加更多Dropout。
3. 训练循环的写法与稳定性控制
3.1 用tf.function加速单步训练
Keras里训练GAN有两种写法:一种是train_on_batch,一种是自定义训练循环。我推荐用tf.function装饰的自定义循环,速度比train_on_batch快不少,而且能更精细地控制每一步。
@tf.function def train_step(images, batch_size, latent_dim): noise = tf.random.normal([batch_size, latent_dim]) with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape: generated_images = generator(noise, training=True) real_output = discriminator(images, training=True) fake_output = discriminator(generated_images, training=True) gen_loss = generator_loss(fake_output) disc_loss = discriminator_loss(real_output, fake_output) gradients_of_generator = gen_tape.gradient(gen_loss, generator.trainable_variables) gradients_of_discriminator = disc_tape.gradient(disc_loss, discriminator.trainable_variables) generator_optimizer.apply_gradients(zip(gradients_of_generator, generator.trainable_variables)) discriminator_optimizer.apply_gradients(zip(gradients_of_discriminator, discriminator.trainable_variables)) return gen_loss, disc_loss为什么用两个独立的GradientTape?因为生成器和判别器的梯度是分开计算的,用两个tape可以避免梯度互相干扰。如果用一个tape同时记录两者的梯度,在计算生成器梯度时会把判别器的计算图也带进来,浪费显存。
noise为什么用tf.random.normal而不是均匀分布?正态分布在原点附近更集中,生成器在训练初期更容易从噪声中“找到”有意义的信号。均匀分布也可以,但正态分布收敛稍快一些。这个差异在小数据集上不明显,但在大数据集上能省不少训练时间。
3.2 训练节奏:判别器和生成器的更新比例
标准DCGAN是每步同时更新判别器和生成器,但实际训练中经常需要调整这个比例。
我的一般做法是:先让判别器多跑几步,再让生成器追上来。具体来说,在训练初期(前几百步),判别器每步更新两次,生成器更新一次。这样判别器能先建立起一个基本的“真假判断能力”,给生成器提供有意义的梯度。如果一开始就1:1更新,判别器太弱,生成器收到的梯度几乎是随机的,学不到东西。
到了训练中期,判别器已经比较强了,就改成1:1。如果发现判别器loss降到0.1以下,说明它太强了,这时候要么给判别器加Dropout,要么降低判别器学习率,要么让生成器多更新几步。
def train(dataset, epochs, latent_dim, batch_size): for epoch in range(epochs): for image_batch in dataset: # 判别器多更新一次(仅前若干epoch) if epoch < 5: train_step(image_batch, batch_size, latent_dim) gen_loss, disc_loss = train_step(image_batch, batch_size, latent_dim) # 每个epoch保存一次生成样本 generate_and_save_images(generator, epoch + 1, seed)每个epoch保存生成样本非常重要。GAN的训练过程不像分类任务那样loss能直接反映质量,判别器loss低不代表生成图好,可能只是判别器过拟合了。只有肉眼看着生成图在变好,才是真的在进步。我习惯每个epoch用固定的噪声种子生成一张网格图,这样能直观对比不同epoch的生成效果。
3.3 训练不稳定的典型信号与应对
训练GAN最让人头疼的就是不稳定。以下是我总结的几个典型信号和对应的处理方式:
| 信号 | 可能原因 | 应对措施 |
|---|---|---|
| 判别器loss迅速降到0 | 判别器太强 | 降低判别器学习率、增加Dropout、减少判别器更新次数 |
| 生成器loss持续上升 | 生成器梯度消失 | 检查判别器是否过强、给生成器加更多BN、换用Wasserstein损失 |
| 生成图全是噪声 | 生成器没学到东西 | 检查数据归一化、确认生成器输出激活函数正确 |
| 生成图模式单一 | 模式坍塌 | 增加噪声维度、给判别器加标签平滑、用mini-batch discrimination |
| loss剧烈震荡 | 学习率太大或batch太小 | 降低学习率、增大batch size |
模式坍塌是最难处理的。我遇到过一次,生成器无论输入什么噪声,都输出几乎一样的图。后来发现是判别器太强,生成器找到了一个“万能样本”能骗过判别器,就再也不更新了。解决办法是给判别器的真实标签加一点噪声(比如0.9而不是1.0),让判别器不要那么自信。这个技巧叫标签平滑,实现起来很简单:
def discriminator_loss(real_output, fake_output): real_loss = cross_entropy(tf.ones_like(real_output) * 0.9, real_output) fake_loss = cross_entropy(tf.zeros_like(fake_output), fake_output) return real_loss + fake_loss把真实标签从1.0改成0.9,判别器对真实图片的判断就不会那么绝对,给生成器留出更多空间。这个改动很小,但效果立竿见影。
4. 数据准备与预处理中的隐藏陷阱
4.1 图片归一化:[-1, 1]还是[0, 1]
前面提过,生成器输出层用tanh,值域是[-1, 1],所以训练数据也必须归一化到[-1, 1]。这个操作看起来简单,但我在实践中见过不少人在这里翻车。
def preprocess_image(image): image = tf.cast(image, tf.float32) image = (image - 127.5) / 127.5 # [0, 255] -> [-1, 1] return image为什么是127.5而不是128?因为[0, 255]的中点是127.5,除以127.5之后正好把0映射到-1,255映射到1。如果用128,0会变成-0.996,255变成0.992,虽然差别很小,但理论上127.5更精确。
不要用tf.image.per_image_standardization。这个函数做的是减均值除标准差,输出范围不固定,和tanh的值域不匹配。我见过有人用它做预处理,结果生成器输出层用tanh,训练了几十个epoch生成图还是灰蒙蒙的,就是因为输入和输出的值域对不上。
4.2 数据集加载与batch size的选择
用tf.data加载数据集时,有几个参数直接影响训练稳定性。
BUFFER_SIZE = 60000 BATCH_SIZE = 128 train_dataset = train_images.map(preprocess_image) train_dataset = train_dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)batch size为什么选128?DCGAN论文用的是128,这个值在大多数显卡上都能跑,而且batch内统计量足够稳定,BN不会因为batch太小而引入噪声。如果你的显存不够,可以降到64,但再小就不建议了,BN在batch size小于32时效果会明显变差。如果实在跑不了64,可以考虑用GroupNorm替代BN,但那是另一个话题了。
shuffle的buffer size要大于数据集大小,这样才能保证每个epoch的数据顺序都不同。如果buffer太小,数据顺序会有规律,判别器可能学到这种规律而不是图片本身的特征。
prefetch是必须的。不加prefetch的话,GPU在等CPU读数据,利用率可能只有30%。加了之后能跑到80%以上。这个优化在训练GAN时尤其重要,因为GAN训练本身就慢,数据管道再拖后腿就太亏了。
4.3 数据增强在GAN训练中的特殊考量
数据增强在分类任务里是标配,但在GAN训练里要谨慎。翻转、裁剪这类几何变换可以用,但颜色抖动、随机擦除要慎用。
原因是:GAN的生成器学习的是训练数据的分布。如果你对真实图片做了颜色抖动,判别器看到的真实图片颜色分布就被改变了,生成器也会跟着学这个被改变后的分布。结果就是生成出来的图颜色很奇怪,要么过饱和要么偏色。
我一般只做随机水平翻转,最多再加一个随机裁剪(从稍大的图中裁出目标尺寸)。这两个操作不改变图片的颜色分布,只是增加空间多样性。
def augment(image): image = tf.image.random_flip_left_right(image) image = tf.image.random_crop(image, size=[64, 64, 3]) return image注意裁剪要在归一化之前做,因为归一化之后像素值是浮点数,裁剪的边界处理会有些微妙。另外,如果你的原始图片尺寸就是64x64,那裁剪就没意义了,只能做翻转。
5. 生成效果评估与调优经验
5.1 肉眼观察之外:用FID做定量评估
GAN训练最直接的评价方式就是看生成图,但肉眼观察主观性太强,而且不同人看同一张图可能得出不同结论。我习惯在训练过程中定期计算FID(Fréchet Inception Distance),这是一个衡量生成分布和真实分布距离的指标,值越小越好。
from tensorflow.keras.applications import InceptionV3 import numpy as np from scipy.linalg import sqrtm def calculate_fid(real_images, generated_images): inception = InceptionV3(include_top=False, pooling='avg', input_shape=(299, 299, 3)) # 将图片resize到299x299并预处理 real_resized = tf.image.resize(real_images, (299, 299)) fake_resized = tf.image.resize(generated_images, (299, 299)) real_resized = tf.keras.applications.inception_v3.preprocess_input(real_resized) fake_resized = tf.keras.applications.inception_v3.preprocess_input(fake_resized) real_features = inception.predict(real_resized) fake_features = inception.predict(fake_resized) mu_real, sigma_real = real_features.mean(axis=0), np.cov(real_features, rowvar=False) mu_fake, sigma_fake = fake_features.mean(axis=0), np.cov(fake_features, rowvar=False) diff = mu_real - mu_fake covmean = sqrtm(sigma_real.dot(sigma_fake)) if np.iscomplexobj(covmean): covmean = covmean.real fid = diff.dot(diff) + np.trace(sigma_real + sigma_fake - 2 * covmean) return fidFID的计算成本不低,每次都要跑一遍InceptionV3,所以我一般每10个epoch算一次,而不是每个epoch都算。另外,FID对样本量有要求,至少需要几千张图才稳定,样本太少FID的方差会很大,参考价值有限。
FID不是万能的。它衡量的是整体分布的距离,对模式坍塌不敏感。如果生成器只生成一种图但质量很高,FID可能很低,但实际上模式坍塌了。所以FID要和肉眼观察结合使用,不能只看数字。
5.2 从生成图诊断训练问题
生成图本身能透露很多训练状态的信息。我总结了几种常见的生成图形态和对应的诊断:
生成图整体偏灰、对比度低:通常是训练不够,生成器还没学会输出饱和的颜色。继续训练一般能改善。如果训练了很久还是这样,检查数据归一化是否正确,以及生成器输出层是不是tanh。
生成图有网格状伪影:这是转置卷积的经典问题,叫棋盘格效应。原因是转置卷积的卷积核在重叠区域会产生不均匀的叠加。解决办法是把kernel size设成能被stride整除的数,比如stride=2时用4x4或6x6的kernel,而不是5x5。或者用最近邻上采样加普通卷积替代转置卷积。
生成图局部合理但全局不协调:比如人脸的眼睛、鼻子单独看都正常,但位置关系不对。这通常是生成器的感受野不够大,或者训练数据中这种全局结构本身就少。可以增加生成器的层数,或者在低分辨率层用更大的kernel。
生成图在不同epoch之间跳变很大:说明训练不稳定,生成器在多个模式之间来回切换。降低学习率、增大batch size、给判别器加Dropout通常能缓解。
5.3 超参数调优的优先级
GAN的超参数很多,但并不是每个都同等重要。根据我的经验,调优优先级从高到低是:
- 学习率:最敏感的参数,差一个数量级结果天差地别。从1e-4开始,如果训练不稳定就降到5e-5。
- 判别器更新频率:判别器太强或太弱都会导致训练失败,需要根据loss曲线动态调整。
- 噪声维度:太小(比如10)会导致生成多样性不足,太大(比如1000)会让训练变慢。100是一个比较安全的起点。
- Batch size:影响BN的稳定性和训练速度,128是默认值,显存不够降到64。
- Dropout rate:对判别器过拟合影响很大,小数据集上0.3到0.5都合理。
- 网络深度:影响生成质量和训练时间,64x64的图4层左右就够了,128x128需要5到6层。
不要同时调多个参数。我见过有人一次改学习率、batch size和网络结构,结果训练崩了都不知道是哪个参数的问题。每次只改一个,观察几个epoch的效果,再决定下一步。
6. 从64x64到更高分辨率的扩展思路
DCGAN的原始实现是针对64x64的,但实际应用中经常需要生成更大尺寸的图。直接加层数是最简单的做法,但会带来几个问题。
第一,参数量增长很快。每加一层转置卷积,参数量大概翻倍。64x64的生成器大概有300万参数,到128x128就变成1000万以上,256x256更是超过3000万。显存不够的话根本跑不起来。
第二,训练时间线性增长。分辨率翻倍,计算量大概翻四倍。64x64跑100个epoch可能需要几小时,256x256就要几天。
第三,模式坍塌更严重。高分辨率下生成器有更多自由度,更容易找到“偷懒”的方式骗过判别器。
我的建议是渐进式增长:先训练64x64的模型,等稳定之后,把生成器和判别器的最后几层替换成更高分辨率的层,用已经训练好的权重初始化前面的层,然后继续训练。这样比从头训练256x256快很多,而且更稳定。
具体操作上,生成器可以保留前几层,把最后的转置卷积从64x64改成128x128,判别器则把第一层卷积的stride从2改成1,或者加一层卷积。这个过程中学习率要调低,因为预训练权重已经比较好了,不需要大改。
另一个思路是用条件GAN。如果你有类别标签,可以把标签也作为输入喂给生成器和判别器,这样生成器能学到类别相关的特征,判别器也能利用标签信息做更准确的判断。条件DCGAN的实现只需要在生成器的输入和判别器的中间层拼接标签的embedding,改动不大但效果提升明显。
7. 我踩过的几个典型坑与修复过程
7.1 生成器输出全黑:一个低级但致命的错误
有一次我搭了一个DCGAN,训练了50个epoch,生成图全是黑的。检查了学习率、网络结构、数据归一化,都没问题。最后发现是生成器输出层的激活函数写成了ReLU。
ReLU的输出是[0, +∞),而我的训练数据归一化到了[-1, 1]。生成器输出全是非负数,判别器很容易就学会“只要看到负数就是真的,全是正数就是假的”,生成器完全学不到东西。改成tanh之后,第一个epoch生成图就有轮廓了。
这个坑的教训是:生成器输出层的激活函数必须和训练数据的值域匹配。tanh配[-1, 1],sigmoid配[0, 1],不要混用。
7.2 判别器loss为0:标签平滑救场
另一次训练中,判别器loss在第3个epoch就降到了0.001,生成器loss飙升到5以上。生成图全是噪声,完全没有结构。
我试过降低判别器学习率、增加Dropout,效果都不明显。后来用了标签平滑,把真实标签从1.0改成0.9,判别器loss不再降到0,生成器loss也稳定在2左右。继续训练了20个epoch,生成图开始出现有意义的纹理。
标签平滑的本质是给判别器一个“不要那么自信”的约束。判别器对真实图片的输出不再是1.0,而是0.9,这样即使生成器生成的图稍微差一点,判别器也不会给出极低的分数,生成器就能收到有意义的梯度。
7.3 生成图有棋盘格:kernel size的锅
生成64x64的图时,我发现生成图上有明显的网格状纹理,放大看像棋盘格。查了资料才知道这是转置卷积的固有缺陷。
原因是转置卷积在stride大于1时,卷积核会在输出上产生重叠,重叠区域的像素被多次累加,导致亮度不均匀。kernel size越不能被stride整除,这种不均匀越明显。
我的修复方案是把所有转置卷积的kernel size从5改成4,stride保持2。4能被2整除,重叠区域均匀分布,棋盘格就消失了。如果你不想改kernel size,也可以用最近邻上采样加普通卷积替代转置卷积,效果类似但计算量稍大。
7.4 训练到一半突然崩溃:BN的running statistics问题
有一次训练到第30个epoch,生成图突然从正常变成噪声,loss也剧烈震荡。检查了半天,发现是判别器的BatchNorm在推理时用了running statistics,而训练时用的是batch statistics,两者差异太大导致判别器行为突变。
解决办法是在训练判别器时,把BN层设成training=True,确保始终用batch statistics。在Keras里,discriminator(images, training=True)就能做到这一点。如果你用train_on_batch,它默认就是training模式,但自定义循环里一定要显式指定。
这个坑比较隐蔽,因为前几十个epoch可能没问题,等到running statistics和batch statistics差异积累到一定程度才会爆发。我的建议是判别器的BN层要么不用,要么确保training模式正确。如果实在不确定,干脆把判别器的BN去掉,用Dropout替代,训练稳定性会好很多。
8. 一些让训练更顺手的工程技巧
8.1 用TensorBoard监控训练过程
GAN训练不看曲线就是盲人摸象。我习惯用TensorBoard记录几个关键指标:生成器loss、判别器loss、判别器对真实图片的平均输出、判别器对生成图片的平均输出。
log_dir = "logs/gan/" summary_writer = tf.summary.create_file_writer(log_dir) with summary_writer.as_default(): tf.summary.scalar('gen_loss', gen_loss, step=step) tf.summary.scalar('disc_loss', disc_loss, step=step) tf.summary.scalar('disc_real', tf.reduce_mean(real_output), step=step) tf.summary.scalar('disc_fake', tf.reduce_mean(fake_output), step=step)判别器对真实图片和生成图片的平均输出这两个指标特别有用。理想情况下,真实图片的输出应该在0.8到0.95之间,生成图片的输出在0.05到0.2之间。如果真实图片输出降到0.5以下,说明判别器学废了;如果生成图片输出升到0.5以上,说明判别器太弱,生成器在骗它。
8.2 检查点保存与恢复
GAN训练时间长,中途可能因为各种原因中断。我习惯每5个epoch保存一次检查点,包括生成器、判别器和优化器的状态。
checkpoint_dir = './training_checkpoints' checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt") checkpoint = tf.train.Checkpoint(generator_optimizer=generator_optimizer, discriminator_optimizer=discriminator_optimizer, generator=generator, discriminator=discriminator) # 保存 checkpoint.save(file_prefix=checkpoint_prefix) # 恢复 checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))优化器的状态也要保存。Adam的动量项和方差项是训练状态的一部分,如果只保存模型权重不保存优化器状态,恢复训练后优化器要从零开始积累动量,前几个epoch的更新会很不稳定。
8.3 固定噪声种子做可视化对比
每次生成样本时用同一组噪声种子,这样不同epoch生成的图可以直接对比,能直观看到生成器在哪些方面进步了。
seed = tf.random.normal([16, latent_dim]) def generate_and_save_images(model, epoch, test_input): predictions = model(test_input, training=False) fig = plt.figure(figsize=(4, 4)) for i in range(predictions.shape[0]): plt.subplot(4, 4, i+1) plt.imshow(predictions[i] * 0.5 + 0.5) plt.axis('off') plt.savefig('image_at_epoch_{:04d}.png'.format(epoch)) plt.close()注意predictions[i] * 0.5 + 0.5是把[-1, 1]的像素值映射回[0, 1]才能用matplotlib显示。这个操作很容易忘,忘了的话显示出来的图颜色会不对。
8.4 混合精度训练加速
如果你的显卡支持混合精度(比如RTX系列或V100以上),可以用mixed_float16策略加速训练,显存占用能减少30%到40%,速度提升20%左右。
from tensorflow.keras import mixed_precision policy = mixed_precision.Policy('mixed_float16') mixed_precision.set_global_policy(policy)但要注意:混合精度下,生成器输出层的tanh和判别器输出层的sigmoid要保持float32,否则数值精度不够会导致训练不稳定。Keras的混合精度策略会自动处理大部分情况,但输出层最好显式指定dtype。
model.add(layers.Conv2DTranspose(3, (5, 5), strides=(2, 2), padding='same', use_bias=False, activation='tanh', dtype='float32'))这个细节在官方文档里不太显眼,但实际用的时候如果忘了,训练loss会出现NaN。
9. 关于DCGAN适用边界的个人体会
DCGAN在64x64到128x128这个分辨率区间内表现最好,再往上就需要更复杂的架构了。它的优势是结构简单、训练相对稳定、代码量少,适合作为入门GAN的第一个项目。但它的局限性也很明显:生成多样性有限、对超参数敏感、高分辨率下模式坍塌严重。
我在实际项目里用DCGAN主要做两件事:一是快速验证数据集的生成可行性,用DCGAN跑一遍看看数据本身有没有问题;二是作为更复杂模型的基础组件,比如把DCGAN的生成器作为风格迁移网络的解码器,或者把判别器作为感知损失的一部分。
如果你要生成人脸、动漫头像这类有大量公开数据集的任务,DCGAN能给出还不错的结果。但如果是生成特定领域的图(比如医学影像、工业缺陷图),数据量通常不大,DCGAN容易过拟合,需要配合更强的正则化或者换用少样本GAN的方案。
最后分享一个我常用的调试流程:先用小分辨率(32x32)和少量数据跑通全流程,确认生成器能生成有意义的图,再逐步增加分辨率和数据量。这样能在早期发现架构或数据管道的问题,避免在大模型上浪费几天时间才发现一个低级错误。