1. 先把这六个模型摆到同一张坐标系里
深度学习这个领域最容易让人迷失的地方,不是公式难,而是模型太多、术语太杂,学着学着就变成了一堆孤立的记忆点。DNN、CNN、RNN、LSTM、GCN、GAN 这六个词,几乎每次面试、每次开题、每次技术选型都会被拎出来,但很多人对它们的理解是断裂的:CNN 会用,但说不清为什么要卷积;LSTM 会调库,但讲不出它比 RNN 强在哪;GCN 和 GAN 更是常年停留在"知道名字"的阶段。我自己也是这么过来的,前两年带新人做项目复盘时发现,真正卡住大家的不是某一个模型的实现细节,而是没有把这六个模型放在同一张坐标系里去看它们各自的定位和边界。
这篇总结就是我自己这两年反复梳理出来的一个版本。它不打算按教科书顺序从感知机讲到注意力,而是按"数据形态 → 模型结构 → 训练难点 → 工程坑点"这条线重新走一遍。适合已经跑过一两个 demo、但总觉得知识是碎片的人;也适合准备系统复习深度学习知识点、想把八股变成真正理解的人。如果你现在还在纠结"图像处理为啥用 CNN 不用前馈神经网络",或者"原始 GAN 公式的交叉熵为什么没有负号",那这篇应该能给你一个能自洽的解释。
1.1 数据形态决定模型形态,而不是反过来
我见过太多人先选定模型再去找数据,这是典型的反向操作。真实的工程流程里,永远是先看数据长什么样,再去决定用什么结构。
举几个我自己实际碰过的场景:一批工业质检的灰度图,像素之间有强烈的空间局部相关性,用全连接网络硬吃,参数量会直接爆炸,这就是 CNN 的主场;一批水文站点的逐小时水位序列,前后时刻存在强时序依赖,同时站点之间还有空间上的上下游关系,这时候单一模型不够用,得 RNN/LSTM 处理时间维、GCN 处理站点拓扑;再比如做数据增强或者样本生成,手里只有几百张真实缺陷图,这时候 GAN 系的方法才有意义。
所以我的判断顺序是固定的:
- 数据有没有空间局部性?有 → 优先考虑 CNN。
- 数据有没有时间顺序?有 → 优先考虑 RNN/LSTM,或者一维卷积。
- 数据样本之间有没有显式的连接关系?有 → 考虑 GCN。
- 目标是判别还是生成?判别走前面三条,生成走 GAN 或扩散类方法。
- 以上都不明显 → 回到 DNN,用全连接层做基线。
这个顺序不是绝对真理,但它能帮你避免 90% 的选型错误。把 DNN 当作永远存在的基线,把 CNN/RNN/GCN 当作针对特定数据结构的归纳偏置,把 GAN 当作生成任务的独立分支,这六个模型的关系一下子就清楚了。
1.2 三条主线:表征、序列、生成
如果非要给这六个模型分个类,我会分成三条主线。
第一条是表征学习主线:DNN 和 CNN。它们解决的核心问题是"怎么把原始输入映射成一个有用的特征向量"。DNN 用全连接做通用逼近,CNN 用卷积核做局部特征提取。两者的差别在于先验假设——CNN 假设局部性和平移不变性成立。
第二条是序列与关系建模主线:RNN、LSTM、GCN。RNN 和 LSTM 处理的是"有序"的数据,GCN 处理的是"有结构"的数据。三者放在一起的原因很实际:真实场景里时间和空间经常是耦合的,比如交通流量预测,既有时间序列,又有路网结构。
第三条是生成主线:GAN。它不关心判别,关心的是能不能造出以假乱真的样本。GAN 的训练机制和其他五个完全不同,它是两个网络互相对抗,这也是它难调的根本原因。
这三条线在具体项目里经常交叉。比如做视频异常检测,CNN 提空间特征,LSTM 提时序特征,最后还可能用一个 GAN 式的重构误差来打分。理解了三条主线,再看这种复合结构就不会觉得是硬凑的了。
2. DNN:地基没打牢,后面五个模型都是背公式
很多人看不上 DNN,觉得它太简单,面试问起来就用一句"多层感知机"带过。但我自己的经验是,DNN 里藏着后面所有模型共用的那套机制:前向传播、反向传播、梯度下降、激活函数、初始化、正则化。这些机制在 CNN、LSTM、GCN 里全都在用,只是换了个计算图。跳过 DNN 直接学 CNN,后面遇到梯度消失、参数初始化、过拟合这些问题时,你会发现自己根本不知道在修什么。
2.1 全连接层在几何上到底做了什么事
从数学上看,一个全连接层就是仿射变换加非线性:$z = Wx + b$,$a = \sigma(z)$。但光背这个式子没什么用,我更喜欢用几何视角去理解它:W 的每一行定义了一个超平面,全连接层做的事情是把输入空间切成若干半空间,再用激活函数把结果弯折。
一个只有线性层的网络,无论堆多少层,最终等价于一个单层线性变换,这是最容易被忽略的一点。证明很简单:$W_2(W_1 x) = (W_2 W_1)x$,矩阵乘法是可以合并的。所以非线性的存在不是为了"让网络更深",而是为了让每一层的切分能力可以被下一层重新组合。这也解释了为什么 ReLU 这种极其简单的函数就够了——它提供了必要的非线性,同时保持梯度形式简单。
参数量上也要有数:一个 $m$ 维输入、$n$ 维输出的全连接层,参数量是 $m \times n + n$。这个数字在后面的 CNN 对比里会反复出现,先记住。
2.2 反向传播里那几个符号,我错了不止一次
反向传播的本质是链式法则,但真正写代码时容易翻车的地方有三个。
第一个是输出层的梯度形式。以交叉熵损失配 softmax 为例,最终对 logits 的梯度是 $\hat{y} - y$,非常干净。但如果你用的是 BCE(二分类交叉熵)配 sigmoid,对 logits 的梯度同样是 $\hat{y} - y$。这个"巧合"其实来自指数族分布的性质,推导时如果硬从 $-\sum y \log \hat{y}$ 出发,很容易在符号上绕晕。
第二个是中间层的误差项。定义为 $\delta^{(l)} = \frac{\partial L}{\partial z^{(l)}}$,那么递推关系是 $\delta^{(l)} = (W^{(l+1)})^T \delta^{(l+1)} \odot \sigma'(z^{(l)})$。这里那个 $\odot$ 是逐元素乘,我见过不止一个同学把它写成矩阵乘,结果梯度维度对不上还找不到原因。
第三个是梯度的累加。在 PyTorch 里,默认梯度是累加的,所以每个 batch 开头必须optimizer.zero_grad()。这个坑我踩过一次,表现为 loss 一开始下降特别快,然后突然爆掉——因为梯度被累加了好几轮。
# 一个最小的反向传播手写实现,用来验证自己真的理解了 import numpy as np def forward(x, W1, b1, W2, b2): z1 = x @ W1 + b1 a1 = np.maximum(z1, 0) # ReLU z2 = a1 @ W2 + b2 # softmax e = np.exp(z2 - z2.max(axis=1, keepdims=True)) return z1, a1, e / e.sum(axis=1, keepdims=True) def backward(x, y, z1, a1, probs, W2): d2 = (probs - y) / x.shape[0] # 对 logits 的梯度 dW2 = a1.T @ d2 db2 = d2.sum(axis=0) d1 = (d2 @ W2.T) * (z1 > 0) # ReLU 的导数 dW1 = x.T @ d1 db1 = d1.sum(axis=0) return dW1, db1, dW2, db2这段代码不长,但建议真的手推一遍再手写一遍。手写过一次之后,再去看 CNN 的反向传播、LSTM 的 BPTT,你会发现只是计算图变复杂了,核心链式法则没变。
2.3 参数量、初始化与泛化的三角关系
深度网络的参数量和泛化能力之间不是简单的"参数多就过拟合"。经典理论里的泛化误差界给了一个上界:泛化误差大概和模型复杂度正相关、和样本量负相关,但这个界在实际深度网络里非常松,因为真实任务的假设空间远比理论上的小。
我更愿意从实操角度理解这件事。参数初始化决定了网络能不能训起来。如果把所有权重初始化成同一个值,那么同一层的所有神经元梯度完全相同,网络永远学不出差异,这叫对称性破缺失败。如果把权重初始化得太大,前向传播时激活值会逐层放大,反向传播时梯度也会爆炸;初始化太小则相反,信号逐层衰减到零。
工程上的默认选择:ReLU 系列配 He 初始化(方差 $2/n_{in}$),tanh/sigmoid 配 Xavier 初始化(方差 $1/n_{in}$)。PyTorch 里nn.Linear的默认初始化就是基于这种思路的,但如果你自己写了自定义层,记得手动初始化,否则可能用默认的均匀分布导致训练不稳。
另外提一句泛化误差界里我觉得最实用的那个概念:模型的有效容量和优化算法强相关。SGD 加动量本身带有隐式正则效应,这也是为什么同样的网络,换个优化器泛化表现会差好几个点。这部分细节我在后面 CNN 那一节还会再提。
3. CNN:图像任务为什么不用前馈网络硬碰
这个问题几乎每轮面试都会被问到,而且很多人答的是"因为卷积能提取特征",这等于没答。真正的原因非常朴素:用全连接网络处理图像,参数量会大到无法接受,而且丢掉了图像最重要的先验——局部性和平移不变性。
3.1 先算一笔参数账,答案就出来了
假设输入是 $224 \times 224 \times 3$ 的彩色图,输出是 1000 类分类。如果用一层全连接直接连:
$$224 \times 224 \times 3 \times 1000 \approx 1.5 \times 10^8$$
也就是一亿五千万个参数。这还只是第一层,后面要是再接几层,参数量会飙升到十亿级别,显存根本放不下,训练数据也不够。
同样的输入,换成一个 $3 \times 3 \times 3$ 的卷积核,共 64 个输出通道:
$$3 \times 3 \times 3 \times 64 + 64 = 1792$$
一千七百多个参数,和前面的数差了五个数量级。这个对比就是最直接的答案。
但参数少不是全部,关键是这个参数少不是因为"压缩",而是因为我们对图像做了正确的假设。全连接层假设输入的每一个像素和输出的每一个类别都可能独立相关,这对图像来说是极其浪费的假设——左上角的像素和右下角的像素之间的关系,远不如它和相邻像素的关系重要。
3.2 局部连接、权值共享与平移等变性
CNN 的核心归纳偏置有三条:
- 局部连接:每个输出神经元只看输入的一个小邻域。这源于图像的局部相关性——边缘、纹理、角点都是局部特征。
- 权值共享:同一个卷积核在整张图上滑动,参数复用。这直接带来了参数量的大幅下降,也意味着同一个特征检测器可以识别图里任意位置的同类模式。
- 平移等变性:输入平移,输出特征图也跟着平移。注意是等变(equivariance)而不是不变(invariance),真正的平移不变性通常靠后面的池化或者全局池化来获得。
这里有个常见误解:很多人以为 CNN 天然具有平移不变性,其实严格说是"卷积具有平移等变性,池化带来局部不变性"。如果你把所有的下采样都去掉,网络对位置会非常敏感。这一点在对抗样本和位置敏感任务(比如某些细粒度分类)里非常重要。
3.3 输出尺寸、感受野与通道数的实操口径
输出尺寸的公式必须记死:
$$H_{out} = \left\lfloor \frac{H_{in} - K + 2P}{S} \right\rfloor + 1$$
其中 $K$ 是卷积核大小,$P$ 是 padding,$S$ 是 stride。这个公式算错一次,后面接全连接层时就会报维度不匹配,非常浪费时间。
感受野的递推公式也很好用。从第一层开始,每一层卷积核大小为 $K_i$、步长为 $S_i$,则:
$$RF_{i} = RF_{i-1} + (K_i - 1) \times \prod_{j<i} S_j$$
感受野的意义在于判断网络"看得到多大范围"。做小目标检测时,如果感受野不够,小目标的特征根本不会被激活。一个常用的经验:分类任务里最后一层的感受野应该覆盖整张输入图的主要部分;检测任务里要保证最小目标的尺寸大于某一层的感受野。
通道数方面,从 3 增加到 64、128、256、512,这是常见的金字塔结构。通道数增加的同时空间分辨率下降,是为了在计算量大致可控的前提下提升语义抽象能力。这也是为什么 1x1 卷积这么有用——它可以在不改变空间尺寸的情况下改变通道数,对计算量的控制非常精细。
3.4 池化和全局平均池化,什么时候可以砍掉
池化层最早的作用是降维和提供一定的平移不变性。最大池化保留了邻域内最强的响应,平均池化保留了平均响应,两者各有适用场景:纹理密集型任务里平均池化有时表现更好,而目标检测里最大池化更常见。
但现代网络架构里,池化的地位在下降。原因有两个:一是步长卷积本身就能完成下采样,而且下采样方式是学出来的,比固定池化更灵活;二是全局平均池化(GAP)几乎完全取代了最后几个全连接层。用 GAP 替代全连接的好处非常实际——参数量从数千万降到零,同时大幅降低过拟合风险,这也是 ResNet 之后的标准做法。
我自己在工业质检项目里的经验是:如果缺陷是细长条状或者面积很小,前期不要过早下采样,把前面两层的 stride 设成 1,把下采样推迟到后面,对小目标的召回率提升非常明显。这个调整往往比换模型有效得多。
4. RNN 到 LSTM:长序列上的接力棒怎么才不掉
序列数据和图像最大的区别在于长度可变且存在顺序依赖。全连接和 CNN 都能勉强处理序列(比如把时间窗展平成向量喂给 DNN,或者用一维卷积),但它们在处理长距离依赖时的表现都不理想。RNN 的引入就是为了解决"用同一个参数反复处理不同时刻的输入"这个问题。
4.1 循环展开与沿时间反向传播
RNN 的基本式子很简洁:
$$h_t = \tanh(W_{xh} x_t + W_{hh} h_{t-1} + b_h)$$
关键在于 $W_{hh}$ 在所有时间步共享。把时间轴展开看,它其实是一个深度等于时间步数的共享权重网络。这也直接决定了 RNN 的两面性:参数少、能处理任意长度;但同时因为深度太深,训练非常困难。
沿时间反向传播(BPTT)就是在这个展开图上做链式法则。梯度里会出现 $W_{hh}$ 的连乘项,这就是所有问题的根源。
4.2 梯度消失的数学直觉:连乘的雅可比
每一时间步的梯度传递都要乘以一个雅可比矩阵 $\frac{\partial h_t}{\partial h_{t-1}}$。展开成 $T$ 步,梯度里就包含这个矩阵的 $T$ 次连乘。
如果这个矩阵的谱半径(最大奇异值)小于 1,连乘之后会指数级衰减到零,这就是梯度消失;如果大于 1,就会指数级爆炸。用 tanh 或 sigmoid 作为激活函数时,导数本身小于 1,进一步加剧了衰减。
这就是为什么标准 RNN 的"记忆"大概只有 10 到 20 步,再长的依赖就学不到了。梯度消失不是训练技巧问题,而是结构性问题,换优化器、调学习率都救不回来。
4.3 LSTM 的三门一状态,为什么能救回长依赖
LSTM 的核心创新是引入了一个独立的细胞状态 $c_t$,以及控制信息流动的三个门:
$$f_t = \sigma(W_f [h_{t-1}, x_t] + b_f)$$ $$i_t = \sigma(W_i [h_{t-1}, x_t] + b_i)$$ $$\tilde{c}t = \tanh(W_c [h{t-1}, x_t] + b_c)$$ $$c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}t$$ $$o_t = \sigma(W_o [h{t-1}, x_t] + b_o)$$ $$h_t = o_t \odot \tanh(c_t)$$
为什么这个结构能缓解梯度消失?看细胞状态的更新式子:$c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}t$。对 $c{t-1}$ 求导,结果就是 $f_t$。这意味着梯度在细胞状态这条路径上传递时,只需要逐元素乘一个门控值,而不是乘一整个矩阵。门控值可以学到接近 1,此时梯度几乎可以无损地穿过很多时间步。
遗忘门的作用经常被误解成"忘记旧信息",其实它更像一个信息保留率的调节阀门。当它接近 1 时保留全部历史,接近 0 时完全重置。这种连续可调的设计,比 RNN 里只能用非线性压缩历史要合理得多。
GRU 是 LSTM 的简化版,把遗忘门和输入门合并成一个更新门,同时合并了细胞状态和隐藏状态:
$$z_t = \sigma(W_z [h_{t-1}, x_t]), \quad r_t = \sigma(W_r [h_{t-1}, x_t])$$ $$\tilde{h}t = \tanh(W [r_t \odot h{t-1}, x_t]), \quad h_t = (1-z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t$$
参数量大概少三分之一,训练更快。我实测下来,在中小规模数据上 GRU 和 LSTM 的差距往往在 1% 以内,甚至 GRU 更好。数据量特别大、依赖特别长的时候 LSTM 才有优势。
4.4 用 LSTM 做时间序列预测时,我踩过的几个坑
第一个坑是数据泄漏。做归一化时,如果用了全量数据的均值和方差,训练集就偷看了未来信息。正确做法是只用训练段的统计量,然后应用到验证和测试段。这个坑的后果非常隐蔽——验证集指标格外好,上线后性能断崖式下跌。
第二个坑是滑窗构造样本。序列预测通常要把长序列切成(input_len, target_len)的样本对。如果滑窗重叠太多,训练集和验证集的样本会高度相关,验证指标会虚高。我一般会保证训练集和验证集在时间轴上完全不重叠,中间甚至留一段 gap。
第三个坑是多步预测的策略选择。递归预测是用上一步的预测作为下一步的输入,误差会累积;直接预测是一次性输出多步,但样本构造复杂。我通常两个都跑一遍做对比,样本量小时直接预测更稳,长序列上递归加 scheduled sampling 效果更好。
import torch import torch.nn as nn class LSTMForecaster(nn.Module): def __init__(self, n_feat, hidden=64, layers=2, horizon=1): super().__init__() # batch_first=True 让输入形状是 (B, T, F),比默认的 (T, B, F) 好记 self.lstm = nn.LSTM(n_feat, hidden, num_layers=layers, batch_first=True, dropout=0.2) self.head = nn.Linear(hidden, horizon) def forward(self, x): out, (h_n, c_n) = self.lstm(x) # out: (B, T, H) return self.head(out[:, -1, :]) # 取最后一个时间步这段代码里有几个细节值得注意:batch_first=True建议一直开着,否则调试时会一直被维度问题困扰;两层以上加 dropout 才有意义,单层加 dropout 基本无效;取out[:, -1, :]是标准做法,但如果序列有 padding,必须配合pack_padded_sequence才能真正取到最后一步的有效输出。
还有一点:LSTM 的输出是(output, (h_n, c_n))这个元组,很多人只取output就开始接全连接层,忽略了h_n和c_n。实际上在做分类任务时,最后一层的h_n往往比整个output序列更有信息量,因为它是经过全部时间步累积的结果。
5. GCN:数据既不是网格也不是序列的时候
CNN 处理网格数据,RNN 处理序列数据,但现实里大量数据是图结构:社交网络、分子结构、知识图谱、交通路网、水文站点的上下游网络。这些数据的每个节点邻居数量都不固定,而且没有天然的排列顺序,直接套 CNN 会出现两个问题:一是卷积核大小无法固定,二是"第几个邻居"这种排序没有意义。
5.1 把卷积搬到图上,卡在哪一步
卷积的两个核心要素是局部邻域和权重共享。在图上,局部邻域是有的——一个节点的邻居就是它的局部范围。但问题是邻居数量不固定,有的节点有 2 个邻居,有的有 200 个,怎么用一个固定大小的卷积核去处理?
另外就是顺序问题。图像里可以约定"从上到下、从左到右",但图上的邻居天然是无序的。如果你按某种顺序排列邻居,换一个排列方式结果就变了,这违背了图应该具有的置换不变性。
解决思路有两条:一条是从频域出发,借助图傅里叶变换定义卷积;另一条是从空域出发,直接定义"邻居信息聚合"。前者推导更严谨但计算复杂,后者更直观且落地更容易。
5.2 拉普拉斯矩阵与频域卷积的直觉
图卷积的经典推导从图拉普拉斯矩阵开始:
$$L = D - A$$
其中 $A$ 是邻接矩阵,$D$ 是度矩阵(对角线上是每个节点的度数)。归一化之后常用 $L = I - D^{-1/2} A D^{-1/2}$。
对 $L$ 做特征分解 $L = U \Lambda U^T$,$U$ 的列向量就是图傅里叶基,相当于"图的频率成分"。图上的低频率对应节点之间变化平缓的信号,高频率对应邻居之间差异很大的信号。
图傅里叶变换定义为 $\hat{x} = U^T x$,逆变换为 $x = U \hat{x}$。两个信号的图卷积就变成:
$$x *_G y = U \left( (U^T x) \odot (U^T y) \right)$$
如果把这个式子里的 $y$ 换成可学习的滤波器 $g_\theta$,就得到谱域图卷积。但这样做的代价是:要对 $L$ 做特征分解,复杂度 $O(N^3)$,而且学到的滤波器是全局的,换一张图就不能用了。
5.3 从谱域落到空域:那一行归一化公式
真正让 GCN 变得可用的是 ChebNet 的一阶近似。用切比雪夫多项式把滤波器展开,截断到一阶,再做一次归一化技巧(把最大特征值近似为 2),最终得到那个著名的传播公式:
$$H^{(l+1)} = \sigma\left( \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} H^{(l)} W^{(l)} \right)$$
其中 $\tilde{A} = A + I$,$\tilde{D}$ 是 $\tilde{A}$ 的度矩阵。
这行公式里有两个改动点必须理解清楚:
- 加自连接 $\tilde{A} = A + I$:不加的话,节点在聚合时只看邻居,自己的特征会被完全丢掉。加自连接相当于把"自己"也当成一个邻居,保证自身信息能被保留。
- 对称归一化 $\tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2}$:如果直接乘以 $A$,度数大的节点聚合后的数值会远大于度数小的节点,导致数值不稳定。归一化让每个节点的聚合结果处于同一量级。
从空域角度理解,这个操作就是"把邻居特征加权平均之后,再乘一个可学习的权重矩阵,最后过激活函数"。所以 GCN 本质上是一个逐层的邻居信息传播与聚合过程。
5.4 过平滑、层数与真实图数据的工程细节
GCN 最典型的失败模式是过平滑(over-smoothing)。层数一多,每个节点反复聚合邻居信息,最后所有节点的表示会趋同,分类边界完全消失。这不是过拟合,恰恰相反——是表达能力退化。
我在实践中总结的经验是:GCN 一般用 2 到 3 层就够了,超过 4 层几乎一定会掉点。如果要堆更深,得用残差连接、JKNet(跳跃知识网络)或者 PairNorm 这类专门缓解过平滑的手段。
另外几个实操细节:
| 问题 | 现象 | 处理方式 |
|---|---|---|
| 图太大,邻接矩阵装不下 | 内存溢出 | 用稀疏张量 + 邻居采样(GraphSAINT、NeighborSampling) |
| 节点特征尺度差异大 | 训练不收敛 | 特征标准化 + BatchNorm |
| 边带权重 | 直接乘权重会数值不稳 | 用带权归一化 $\tilde{D}^{-1} \tilde{A}$ |
| 异构节点 | 单一权重矩阵不够 | 按关系类型分别定义权重(R-GCN),再用平均或注意力聚合 |
| 部分节点无特征 | 只有结构信息 | 用节点度数、one-hot ID 或随机初始化当特征 |
我用 GCN 做过一个水文站点的水位预测,站点之间有明确的上下游关系,用邻接矩阵表达。效果比纯 LSTM 提升了大概 8% 的 MAE,尤其是在上游站点数据缺失的情况下提升更明显——因为模型能从邻居站点的信息里补上。这个场景的经验是:图结构必须是真实存在的物理关系,而不是硬造出来的相关性。强行建图往往带来的是噪声而不是信息。
6. GAN:目标函数里那个"负号"到底去哪了
GAN 是我接触过的模型里,公式看着最简洁、理解起来最容易出偏差的一个。尤其是那个被反复问的问题:标准交叉熵里明明有负号,为什么原始 GAN 的目标函数里看不到负号?
6.1 原始 GAN 的极小极大博弈写法
原始论文给出的值函数是:
$$\min_G \max_D V(D, G) = \mathbb{E}{x \sim p{data}}[\log D(x)] + \mathbb{E}_{z \sim p_z}[\log(1 - D(G(z)))]$$
注意前面那个 $\min_G \max_D$。这个式子同时包含两个优化方向,而且它被定义为一种"值"而不是一种"损失"。D 想让它尽可能大,G 想让它尽可能小。
判别器的目标是最大化前两项:让真实样本的 $D(x)$ 接近 1,让生成样本的 $D(G(z))$ 接近 0。生成器的目标是最小化第二项,也就是让 $D(G(z))$ 接近 1。
6.2 为什么标准交叉熵带负号,而 V(D,G) 不带
标准二分类交叉熵的定义是:
$$L_{BCE} = -\left[ y \log p + (1-y) \log (1-p) \right]$$
这个负号的存在是因为我们定义的是"损失",希望它越小越好。而 GAN 的 $V(D,G)$ 是一个**"效用"或"收益"**,判别器希望它越大越好,所以写成最大化形式,自然没有负号。
如果把判别器的目标转成最小化形式,两者就完全一致了:
$$L_D = -\mathbb{E}{x \sim p{data}}[\log D(x)] - \mathbb{E}_{z}[\log(1 - D(G(z)))]$$
也就是 $L_D = -V(D, G)$。这里的负号是"把最大化转成最小化"时引入的,跟交叉熵本身的负号来源不同,但结果是同一个东西。
我在 PyTorch 里写的时候,直接就用nn.BCEWithLogitsLoss,真实样本标签给 1,生成样本标签给 0,这个损失内部已经包含了 sigmoid 和交叉熵的处理。这样算出来的 $L_D$ 实际上就是 $-V$,跟理论完全对得上。
生成器那边有两种写法:
$$L_G^{原始} = \mathbb{E}_z[\log(1 - D(G(z)))]$$ $$L_G^{非饱和} = -\mathbb{E}_z[\log D(G(z))]$$
两者在理论上有相同的最优解,但梯度性质完全不同。原始形式在判别器很强的时候,$D(G(z))$ 接近 0,$\log(1-D(G(z)))$ 的梯度会趋近于零,生成器基本学不动,这叫"饱和问题"。非饱和形式在同样情况下梯度依然有效,所以工程上几乎都用非饱和版本。这也是 Goodfellow 在论文里就提到的一个改进点。
6.3 判别器太强、生成器梯度消失与饱和问题
GAN 训练的难点可以归结为一句话:两个网络的训练必须保持动态平衡。
如果判别器太强,它能把真假样本分得清清楚楚,此时 $D(G(z))$ 接近 0,生成器的梯度几乎为零,学不动。反过来如果生成器太强,判别器给不出有效信号,生成器就会开始乱走,甚至模式崩溃——只生成少数几种样本,把所有概率质量堆在几个点上。
我踩过的一个典型现象是:训练前几百步 loss 看着挺正常,突然判别器 loss 掉到 0.01,然后生成器的输出全部变成同一张图。这就是判别器一步学太快导致的。
工程上的处理手段有这些:
- 调整训练频率:比如每训练 1 次生成器就训练 1 次判别器,或者每 5 次生成器才训练 1 次判别器。
- 降低判别器的学习率:判别器学习率通常设成生成器的 0.2 到 0.5 倍。
- 给判别器加噪声:输入上加高斯噪声,或者用标签平滑(把真实标签从 1 改成 0.9)。
- 加梯度惩罚:限制判别器的 Lipschitz 常数,这就是 WGAN-GP 的核心。
- 谱归一化:对判别器的每一层做谱归一化,比梯度惩罚更省算力,现在很常用。
6.4 从 DCGAN 到 WGAN:训练稳定性是怎么一点点抠出来的
DCGAN 的贡献主要在于结构上的规范化:去掉所有池化,用步长卷积做下采样、转置卷积做上采样;生成器和判别器都加 BatchNorm(但生成器的输出层和判别器的输入层不加);用 ReLU(生成器)和 LeakyReLU(判别器)。这些改动看起来琐碎,但效果非常显著,是 GAN 从"能跑"到"稳定"的第一步。
WGAN 解决的是更本质的问题:原始 GAN 的 JS 散度在两个分布不重叠时会给出常数,导致梯度消失。换成 Wasserstein 距离之后,即便两个分布不重叠,距离也仍然有意义。代价是要满足 1-Lipschitz 约束,最早用权重裁剪实现,但裁剪会导致参数集中在边界上。WGAN-GP 用梯度惩罚替代裁剪,把"梯度的范数要接近 1"作为惩罚项加到损失里,效果更稳。
# 梯度惩罚的核心实现,直观感受一下 def gradient_penalty(D, real, fake, device): alpha = torch.rand(real.size(0), 1, 1, 1, device=device) interpolated = (alpha * real + (1 - alpha) * fake).requires_grad_(True) d_interp = D(interpolated) grads = torch.autograd.grad( outputs=d_interp, inputs=interpolated, grad_outputs=torch.ones_like(d_interp), create_graph=True, retain_graph=True )[0] grads = grads.view(grads.size(0), -1) return ((grads.norm(2, dim=1) - 1) ** 2).mean()这段代码的关键点在于create_graph=True——梯度惩罚本身也要参与反向传播,所以计算图必须保留。这一点很多人第一次写都会漏掉。
另外,如果你想在一个新数据集上快速试 GAN,我个人的推荐顺序是:先用 DCGAN 的结构跑通,确认能生成出可辨认的样本;再换成 WGAN-GP 看稳定性提升;最后根据任务需要调整分辨率和通道数量。跳过第一步直接上复杂的变体,往往会在调试上耗掉大量时间。
7. 按什么顺序动手写这六个模型,复习效率最高
回过头看,这六个模型如果按教科书的顺序一个个孤立地学,很容易学完就忘。我自己的经验是,按照"参数共享程度"和"计算图复杂度"这两个维度排个动手顺序,效果最好。
第一步一定是 DNN。不要跳过,手推一遍反向传播,手写一个两层网络跑 MNIST。这一步的目的是把梯度、初始化、优化器这些基础机制吃透。我见过跳过这一步直接上 CNN 的人,后面遇到 loss 不下降时完全没有排查方向。
第二步是 CNN。从 LeNet 开始写,不要一上来就 ResNet。重点是把卷积的 forward 和 backward 都手写一遍——尤其是 im2col 的实现思路,理解为什么卷积在 GPU 上会被转换成矩阵乘。这一步做完,你会对整个深度学习框架有了"其实也没那么神秘"的感觉。
第三步选 RNN 和 LSTM。RNN 一定要先写普通版本,跑一个简单序列任务,亲眼看到它在长序列上学不动;再换成 LSTM,看到效果提升。这个对比过程的冲击力,比看十篇文章都强。
第四步是 GCN。这时候你已经有了"消息传递"的概念基础,理解 GCN 就是把它推广到不规则结构。建议用 PyTorch Geometric 或 DGL,自己写一遍消息传递很容易在稀疏矩阵乘法那里卡很久。先用 Cora 这种小数据集跑通,再上手真实图数据。
第五步是 GAN。GAN 排在最后,是因为它需要你对前面所有模型都有直觉——判别器可以是 CNN,生成器也可以是 CNN,甚至可以是 LSTM 或者 GCN。而且 GAN 的调试非常依赖经验,前面几步积累的 loss 曲线判读能力在这里会派上大用场。
最后补充三个我认为贯穿全部六个模型的通用技巧:第一,永远先跑一个最简单的基线,哪怕是逻辑回归或者单层网络,有了基线才知道复杂模型到底带来了多少增益;第二,训练曲线一定要看,loss、验证指标、梯度范数三个量同时监控,异常往往在梯度范数上最先体现;第三,写代码前先把张量形状写在纸上,六个模型里的绝大多数 bug 都是形状问题。
我在实际带项目的时候发现,能把这六个模型各自"为什么是这样"讲清楚的人,往往在两三天内就能上手一个全新的模型架构,因为底层那套机制是通用的。而只会调库的人,遇到没见过的问题时只能靠搜索碰运气。这个差别在项目前期不明显,等到需要做模型改进或者排查线上问题时,就会迅速放大。