CNN到GAN:按数据假设理解五大网络结构与PyTorch实现 想把 CNN、RNN、LSTM、Transformer、GAN 这五个东西真正学明白最容易踩的坑不是数学太难而是学习顺序错了。很多人一上来就啃 Transformer 的注意力公式或者直接抄一段 GAN 的训练循环跑起来看生成图结果跑通了却说不清为什么要这么写换一个任务立刻卡死。这篇文章不按100 集的目录顺序讲而是按一条更本质的线索重新组织这五个结构本质上是对数据做假设的五种不同方式假设变了网络结构就变了。我先给一个明确判断这五个模型并不是五个并列的知识点它们是一条连续的演进链。CNN 假设相邻的东西有关系RNN 假设顺序有意义LSTM 是对 RNN 记不住长距离信息的补丁Transformer 干脆推翻必须按顺序算这个前提GAN 则换了一个完全不同的赛道——它不解决输入到输出它解决如何生成看起来真实的数据。理解了这条主线你会发现所谓五大算法其实只要抓住三种思维方式空间局部性、时序依赖、对抗训练。读完这篇文章你能够获得三件具体的东西第一能用统一视角解释这五个结构各自解决什么问题、在什么场景下不该用第二能照着一份可运行的 PyTorch 代码把五个最小模型全部跑通知道每一行在干什么第三能建立一套排查习惯在形状报错、梯度消失、GAN 不收敛这些典型问题上知道第一步该看哪里。文中的代码都是最小可运行版本重点在结构和张量形状不在刷指标。1. 这五个网络结构不是并列的五个知识点1.1 一个被100 集掩盖的事实市面上大量课程会把 CNN、RNN、LSTM、GAN、Transformer 并列成五个模块一集讲一个讲完收工。这种组织方式看起来很整齐但它掩盖了一件事这五个结构之间是有因果关系的不是并列关系。如果你不按因果关系学学完就是五个互不相干的 API 记忆遇到真实问题依然不知道该选哪个。真正的因果链条是这样的。图像数据有一个天然属性一个像素和它旁边的像素强相关和隔了三百个像素的位置基本无关。CNN 就是把这个先验写进结构里——用局部卷积核加权重共享把参数量从全连接的百万级压到千级。文本、语音、时间序列不一样它们的属性是顺序第 5 个词的含义依赖前 4 个词。于是有了 RNN沿时间步共享同一组参数把历史信息压进一个隐状态。但 RNN 有个硬伤反向传播要穿过所有时间步梯度连乘之后要么爆炸要么消失长距离依赖基本学不到。LSTM 用门控加一条细胞状态通路来缓解这个问题让梯度能沿着相对平坦的路径回传。这两步是同一赛道上的修补关系不是两个独立知识点。真正的断裂发生在 Transformer。它问了一个问题既然注意力机制可以让任意两个位置直接建立联系为什么还要一步步递归这一问把顺序从结构约束降级成了位置编码信息从此训练可以完全并行。而 GAN 走的是另一条路它压根不关心输入到输出的映射它关心的是能不能造出以假乱真的样本。所以严格说前四个是表示学习的演进GAN 是生成建模的另一套范式。1.2 谁最该读这篇谁可以跳过如果你是刚学完反向传播和梯度下降、想找一个能跑通的完整项目练手的同学这篇文章正好补齐结构为什么要长这样这层认知。如果你已经会用 HuggingFace 加载 BERT 做分类但说不清attention_mask和padding_mask的区别第 5 节的 Transformer 示例会帮你把这层窗户纸捅破。如果你是做工程的需要判断某个需求该用卷积还是注意力、该不该上生成模型第 2 节的横向对比表和第 8 节的工程建议是为你写的。如果你的目标只是调用现成 API 完成业务暂时不打算理解内部机制那这篇会显得偏重——但第 7 节的排查表依然值得收藏因为线上模型出问题时能不能看懂张量形状和 loss 曲线直接决定你排查的速度。2. 核心原理一条假设怎么变的演进主线2.1 CNN把局部相关这个先验写进结构用最通俗的话讲卷积就是拿一个小窗口在图上滑动每滑到一个位置就做一次加权求和。这个窗口叫卷积核窗口里的权重在整个图上共享同一份。共享这件事带来的直接后果是参数量骤降一张 28×28 的灰度图如果接全连接到 128 维隐藏层需要 28×28×128 ≈ 10 万个参数而一个 3×3 的卷积核接 16 个通道只需要 3×3×1×16 144 个参数。更关键的是它带来的两个性质。第一个是局部连接每个输出只看到输入的一小块区域这符合图像的统计规律。第二个是平移等变性图像里的猫往右移三个像素卷积输出的特征图也整体往右移三个像素后面的池化层再把这种位置敏感性进一步削弱成近似不变性。所以 CNN 天然适合目标长什么样比它在哪里更重要的任务图像分类、缺陷检测、医疗影像分割都是这个路子。但反过来说如果你的数据不具备局部相关性硬套卷积就是在浪费算力。比如用户行为序列里第 1 个行为和第 30 个行为可能强相关中间那些无关这时候 3×3 的卷积核根本看不到这对关系得靠注意力。这是很多人从 CV 转推荐时踩的第一个坑。2.2 RNN 与 LSTM处理顺序的代价RNN 的思路极其朴素维护一个隐状态 h每读入一个新输入就更新一次公式就是 h_t tanh(W_x·x_t W_h·h_{t-1} b)。所有时间步共用同一组 W这就是它参数量小的原因也是它能处理不定长序列的原因。问题出在训练上。反向传播要沿时间展开梯度是雅可比矩阵连乘。如果这些矩阵的谱半径普遍小于 1梯度连乘几十次之后就趋近于零网络根本收不到远处时间步的信号大于 1 则指数爆炸loss 直接变成 NaN。这就是经典的梯度消失与梯度爆炸。LSTM 的解法不是加大初始化而是改结构。它引入了一条细胞状态通路 c_t并在上面只做加法和逐元素乘法让梯度有一条接近恒等的传播路径。控制信息流动的是三个门遗忘门决定旧信息留多少输入门决定新信息写多少输出门决定当前暴露多少给隐状态。这三个门本身都是小的全连接层加 sigmoid输出在 0 到 1 之间相当于可学习的阀门开度。这里真正容易误解的地方是LSTM 并没有消灭梯度消失它只是把有效记忆长度从几十步拉长到几百步。序列再长比如整本文档级别LSTM 依然吃力。GRU 是 LSTM 的简化版把三个门合并成两个参数量更少很多任务上效果差不多工程上很值得先试。2.3 Transformer把顺序从结构里拿掉Transformer 的核心只有一句话每个位置都去看所有位置看谁、看多重由数据自己学。这就是自注意力。具体做法是把每个 token 的向量投影成三份——查询 Q、键 K、值 V然后用 Q 和所有 K 做点积算相关性softmax 归一化成权重再对 V 加权求和。写成公式是 softmax(QKᵀ/√d_k)V那个 √d_k 是为了防止维度大时点积过大导致 softmax 进入饱和区、梯度几乎为零。这个设计的代价是 O(n²) 的计算和显存复杂度因为要构造 n×n 的注意力矩阵。序列长度翻倍显存翻四倍这是长文本场景最大的瓶颈也是后来各种稀疏注意力、线性注意力方案要解决的问题。由于自注意力本身对位置完全不敏感打乱输入顺序输出只是跟着打乱必须额外注入位置信息这就是位置编码的作用。原始论文用的是正弦余弦函数现在更常见的是可学习的位置嵌入或者 RoPE 旋转位置编码。多头注意力则是把 d_model 切成几份并行做注意力让不同的头关注不同的关系模式比如有的头关注语法依存有的头关注指代。2.4 GAN换一个赛道用对抗代替回归前面四个结构都在做同一件事给定输入 x预测输出 y用某种损失函数衡量预测和真值的差距。GAN 完全不同它没有真值标签只有真和假两个判断。生成器 G 试图把随机噪声变成逼真样本判别器 D 试图分辨真假两者在博弈中共同进步。这个设计的巧妙之处在于它不需要人为设计损失函数来度量生成图像和真实图像差多少。像素级 L1 损失会让图像变模糊因为模型会倾向于输出平均值而判别器提供的是一个可学习的、不断变强的度量标准。理论上当判别器无法区分真假输出概率趋近 0.5时生成分布就等于真实分布。但工程上这是个极难训的结构。最常见的问题是模式崩溃生成器发现某一类样本特别容易骗过判别器就疯狂生成那一类loss 看起来还挺正常但样本多样性完全丧失。另一个问题是训练不平衡判别器太强则生成器梯度消失生成器太强则判别器学不动。所以实践中有大量技巧用 BCEWithLogitsLoss 代替手动 sigmoid、Adam 的 beta1 设成 0.5、生成器用 Tanh 输出并把数据归一化到 [-1,1]、判别器加 Dropout 或谱归一化。这些不是玄学都是针对博弈不稳定性开的药方。2.5 五个结构的横向对比结构核心假设参数共享方式主要优势典型短板适用场景CNN相邻位置强相关卷积核在空间上共享参数少、可并行、平移等变感受野受限难建长程依赖图像分类、检测、分割、一维信号RNN顺序决定语义时间步间共享天然处理变长序列、模型小无法并行、长依赖丢失短序列、流式预测LSTM长距离信息需要选择性记忆时间步间共享 门控缓解梯度消失、记忆更长依然串行、计算慢时序预测、短文本分类、语音Transformer任意两位置可能直接相关注意力权重由数据生成全局建模、高度可并行、可扩展O(n²) 复杂度、需大量数据大模型、长文本、多模态GAN数据分布可由对抗逼近生成器/判别器各自独立生成样本锐利、无需显式似然训练不稳定、模式崩溃图像生成、超分、数据增强看完这张表你会发现一个规律越往下模型对数据的先验假设越少需要的参数量和数据量越多。CNN 假设很强所以小数据也能训Transformer 假设最弱所以必须靠大数据和大模型把结构补回来。这就是为什么小样本用 CNN大数据用 Transformer在实践中往往成立。3. 环境准备与前置条件3.1 硬件与运行环境本文所有示例都用 PyTorch 实现CPU 就能跑通因为用的都是 MNIST 和玩具级文本数据。如果你想跑得舒服一点有一块支持 CUDA 的显卡会快很多CNN 那个例子在单卡上三个 epoch 只需要几分钟。没有显卡也不影响理解代码里已经做了cuda if available else cpu的自动切换。操作系统上 Windows、Linux、macOS 都可以但要注意 Windows 下 DataLoader 的num_workers设大于 0 时必须把训练入口写在if __name__ __main__:里面否则会因为多进程重复导入模块而报错。这是新手最常撞的一个墙先记住。3.2 Python 与框架依赖Python 建议 3.9 及以上。PyTorch 建议使用 2.x 系列因为示例里用到了nn.TransformerEncoderLayer的batch_firstTrue参数这个参数需要较新版本才支持。具体版本号请以 PyTorch 官方安装页给出的命令为准不要抄网上过期的安装命令CPU 版和 CUDA 版的包名不一样。# 创建并激活虚拟环境推荐避免污染全局环境 python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # 安装依赖具体命令请按官方安装页选择 CPU 或对应 CUDA 版本 pip install torch torchvision pip install numpy matplotlib # 验证安装是否成功 python -c import torch, torchvision; print(torch.__version__, torchvision.__version__); print(cuda:, torch.cuda.is_available())最后那行验证很重要。如果torch.cuda.is_available()返回 False 但你确信有显卡多半是装成了 CPU 版或者 CUDA 驱动版本不匹配这时候不要急着改代码先把环境问题解决掉否则后面所有为什么这么慢的困惑都源于此。3.3 推荐的目录结构代码虽然简单但从一开始就养成分文件组织的习惯比把所有东西塞进一个 notebook 里强得多。后面调试时你会感谢自己。nn-basics/ ├── data/ # 数据集自动下载到这里 ├── models/ │ ├── __init__.py │ ├── cnn_mnist.py │ ├── lstm_text.py │ ├── mini_transformer.py │ └── gan_mnist.py ├── train_cnn.py ├── train_gan.py └── requirements.txtmodels/__init__.py这个空文件不要漏掉否则from models.cnn_mnist import SmallCNN会报模块找不到。这是 Python 包机制的基础但在赶进度的时候特别容易被忽略。4. 学习与实现路线拆解4.1 推荐顺序与理由我建议的顺序是 CNN → LSTM → Transformer → GAN而不是按标题里的排列。理由有三点。第一CNN 是最容易建立张量形状直觉的模型输入 [B, C, H, W]输出 [B, num_classes]四维张量走一遍后面看 Transformer 的 [B, L, D] 会轻松很多。第二LSTM 让你理解状态这个概念和序列维度的处理方式这是理解 Transformer 里 padding mask 的前提。第三GAN 放最后因为它的调试方式和其他四个完全不同loss 不下降不代表失败需要看生成样本这个心智模型最好在有了前面的训练经验之后再建立。4.2 每一步的验收标准学每个模型时不要以代码跑通为终点要以能说清三件事为终点这个模型的输入输出形状是什么它的参数量大概由哪几部分构成如果我把某个超参改掉效果会往哪个方向变。举个具体的例子。CNN 那个模型你能答出把 MaxPool2d 从 2 改成 4最后的 Linear 输入维度会变成多少吗答案是 32×1×1因为 28 经过两次池化是 7再池化一次变成 1向下取整所以是 32×1×1。能算对这个说明你真的理解了形状是怎么流动的而不是复制粘贴。再比如 LSTM你要能答出batch_firstTrue和不设这个参数时输入张量的维度顺序有什么区别。默认是 [L, B, E]设了之后是 [B, L, E]。这个参数忘了设是形状报错的第一大来源。5. 完整示例代码实现5.1 CNN手写数字分类先从一个最小的 CNN 开始。这个模型只有两层卷积但结构要素齐全卷积、批归一化、激活、池化、展平、全连接、Dropout。# 文件路径models/cnn_mnist.py import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes: int 10): super().__init__() # 特征提取部分输入 [B, 1, 28, 28] self.features nn.Sequential( nn.Conv2d(1, 16, kernel_size3, padding1), # [B, 16, 28, 28] nn.BatchNorm2d(16), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # [B, 16, 14, 14] nn.Conv2d(16, 32, kernel_size3, padding1), # [B, 32, 14, 14] nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # [B, 32, 7, 7] ) # 分类头 self.classifier nn.Sequential( nn.Flatten(), # [B, 32*7*7] nn.Linear(32 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))几个关键点解释一下。padding1配kernel_size3能让特征图尺寸不变这是最常用的same padding写法好处是你在堆网络时不用反复算尺寸。BatchNorm2d放在卷积之后、激活之前是这个顺序的通行做法它让每层输入分布更稳定收敛更快。inplaceTrue可以省一点显存但在需要保留中间变量做可视化时要注意它会就地修改张量。Flatten()之后接Linear(32*7*7, 128)这个 7 是 28 经过两次 2 倍池化得到的。如果你改了输入尺寸或者池化层数这个数字必须跟着改否则会报 shape mismatch。新手最常见的做法是先print(x.shape)看一眼这比算错再回头找要快得多。5.2 LSTM文本二分类接下来是 LSTM。这里用一个双向 LSTM 做情感二分类输入是已经转成 id 的 token 序列。# 文件路径models/lstm_text.py import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size: int, embed_dim: int 128, hidden_dim: int 128, num_classes: int 2, num_layers: int 1, dropout: float 0.3): super().__init__() # padding_idx0 表示 id 为 0 的位置不参与梯度更新 self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, # 输入形状为 [B, L, E] bidirectionalTrue, dropoutdropout if num_layers 1 else 0.0, ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_classes) # 双向所以乘 2 def forward(self, input_ids, lengthsNone): x self.embedding(input_ids) # [B, L, E] if lengths is not None: # 有真实长度时用 pack避免 padding 位置污染隐状态 packed nn.utils.rnn.pack_padded_sequence( x, lengths.cpu(), batch_firstTrue, enforce_sortedFalse ) _, (h_n, _) self.lstm(packed) # h_n: [num_layers*2, B, H]取最后一层的两个方向 last torch.cat([h_n[-2], h_n[-1]], dim-1) # [B, 2H] else: out, _ self.lstm(x) # [B, L, 2H] last out[:, -1, :] # 取最后一个时间步 return self.fc(self.dropout(last))这里有两个值得展开的细节。第一pack_padded_sequence的作用是告诉 LSTM 每个样本的真实长度让它只算到有效位置为止。如果不做这一步直接取out[:, -1, :]那当序列是左填充padding 在后面时最后一个位置就是 padding等于把无意义的向量喂给了分类头。这是文本任务里非常隐蔽的一个 bug模型能训但指标会莫名其妙地低几个点。第二注意lengths.cpu()。pack_padded_sequence要求长度参数在 CPU 上如果你的数据已经搬到 GPU 上这里会报错。这是很多人拷代码后遇到的第一个报错记住就行。如果num_layers1nn.LSTM的dropout参数必须为 0否则会有警告。示例里用条件表达式处理了这一点。5.3 Mini Transformer自注意力分类器Transformer 部分拆成两块位置编码和编码器分类器。这样拆的好处是位置编码可以单独测试。# 文件路径models/mini_transformer.py import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): 正弦位置编码把位置信息加到词向量上 def __init__(self, d_model: int, max_len: int 512, dropout: float 0.1): super().__init__() self.dropout nn.Dropout(dropout) pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) # register_buffer 让它随模型迁移到 GPU但不会被当成参数训练 self.register_buffer(pe, pe.unsqueeze(0)) # [1, max_len, d_model] def forward(self, x): x x self.pe[:, : x.size(1), :] return self.dropout(x) class MiniTransformerClassifier(nn.Module): def __init__(self, vocab_size: int, d_model: int 128, nhead: int 4, num_layers: int 2, num_classes: int 2, max_len: int 512, dim_feedforward: int 256): super().__init__() # nhead 必须能整除 d_model这是最容易写错的约束 assert d_model % nhead 0, d_model 必须能被 nhead 整除 self.embedding nn.Embedding(vocab_size, d_model, padding_idx0) self.pos_enc PositionalEncoding(d_model, max_lenmax_len) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwarddim_feedforward, dropout0.1, batch_firstTrue, ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.fc nn.Linear(d_model, num_classes) def forward(self, input_ids, attention_maskNone): x self.embedding(input_ids) # [B, L, D] x self.pos_enc(x) # src_key_padding_mask 中 True 表示该位置需要被忽略 padding_mask None if attention_mask is not None: padding_mask attention_mask 0 out self.encoder(x, src_key_padding_maskpadding_mask) # [B, L, D] if padding_mask is not None: # 只在有效位置上做平均池化 valid (~padding_mask).unsqueeze(-1).float() out (out * valid).sum(dim1) / valid.sum(dim1).clamp(min1e-6) else: out out.mean(dim1) return self.fc(out)register_buffer这个用法值得单独说一句。位置编码是固定的不是要学习的东西但你又希望它跟着模型一起.to(device)。如果写成普通属性模型搬到 GPU 时它不会跟着走前向计算时就会报 expected all tensors on same device。用register_buffer就同时满足了不参与梯度更新和跟随设备迁移两个需求。padding_mask的方向也特别容易搞反。PyTorch 的约定是 True 表示这个位置要考虑屏蔽掉所以你要把 mask 里的 0即 padding转成 True写法就是attention_mask 0。写成attention_mask 1就会得到完全相反的效果而且不报错只是效果变差这种静默 bug 最难查。验证方法很简单构造一个明显被 padding 的样本对比加不加 mask 时输出是否一致——如果一致说明 mask 没起作用。5.4 GAN噪声生成手写数字GAN 由生成器和判别器两个网络组成这里都用全连接实现重点是看清训练循环的结构。# 文件路径models/gan_mnist.py import torch.nn as nn class Generator(nn.Module): def __init__(self, noise_dim: int 100): super().__init__() self.net nn.Sequential( nn.Linear(noise_dim, 256), nn.LeakyReLU(0.2, inplaceTrue), nn.Linear(256, 512), nn.LeakyReLU(0.2, inplaceTrue), nn.Linear(512, 28 * 28), nn.Tanh(), # 输出范围 [-1, 1] ) def forward(self, z): return self.net(z).view(z.size(0), 1, 28, 28) class Discriminator(nn.Module): def __init__(self): super().__init__() self.net nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, 512), nn.LeakyReLU(0.2, inplaceTrue), nn.Dropout(0.3), nn.Linear(512, 256), nn.LeakyReLU(0.2, inplaceTrue), nn.Linear(256, 1), # 输出 logit不接 sigmoid ) def forward(self, img): return self.net(img)生成器最后用Tanh意味着输出被限制在 [-1, 1]所以数据预处理必须把像素也归一化到 [-1, 1]写法是Normalize((0.5,), (0.5,))。这两个地方只要有一处不对齐生成器就会学得很别扭loss 看着在降但样本全是灰蒙蒙的。判别器最后不接 sigmoid、直接输出一个值是为了配合BCEWithLogitsLoss这个损失函数内部做了 log-sum-exp 的数值稳定处理比先 sigmoid 再 BCELoss更稳不容易出现梯度溢出。中间的LeakyReLU(0.2)而不是普通的 ReLU是 GAN 的常见选择。原因是判别器如果用 ReLU一旦某个神经元进入负区间就永久失活梯度传不回生成器训练会突然卡死。LeakyReLU 给负区间留了一个 0.2 的斜率让梯度始终有通路。5.5 训练与启动CNN 的训练脚本如下包含了训练循环、测试评估和准确率计算可以直接跑。# 文件路径train_cnn.py import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms from models.cnn_mnist import SmallCNN def main(epochs: int 3, batch_size: int 64, lr: float 1e-3): device torch.device(cuda if torch.cuda.is_available() else cpu) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), # MNIST 的均值方差 ]) train_set datasets.MNIST(./data, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_set, batch_sizebatch_size, shuffleTrue, num_workers2) test_loader DataLoader(test_set, batch_size512, shuffleFalse, num_workers2) model SmallCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrlr) for epoch in range(epochs): model.train() running_loss 0.0 for step, (x, y) in enumerate(train_loader): x, y x.to(device), y.to(device) optimizer.zero_grad() loss criterion(model(x), y) loss.backward() optimizer.step() running_loss loss.item() if step % 200 0: print(fepoch {epoch 1} step {step} loss{loss.item():.4f}) print(fepoch {epoch 1} avg_loss{running_loss / len(train_loader):.4f}) # 评估阶段一定要切到 eval 并关闭梯度 model.eval() correct, total 0, 0 with torch.no_grad(): for x, y in test_loader: x, y x.to(device), y.to(device) pred model(x).argmax(dim1) correct (pred y).sum().item() total y.size(0) print(ftest_acc{correct / total:.4f}) if __name__ __main__: main()GAN 的训练循环结构不一样因为它一次迭代要更新两个网络而且顺序不能乱。# 文件路径train_gan.py import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms from models.gan_mnist import Generator, Discriminator def main(epochs: int 20, batch_size: int 128, noise_dim: int 100, lr: float 2e-4): device torch.device(cuda if torch.cuda.is_available() else cpu) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), # 必须与 Tanh 输出范围对齐 ]) dataset datasets.MNIST(./data, trainTrue, downloadTrue, transformtransform) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue, drop_lastTrue, num_workers2) G Generator(noise_dim).to(device) D Discriminator().to(device) criterion nn.BCEWithLogitsLoss() # GAN 常用 beta10.5减少动量带来的震荡 opt_g torch.optim.Adam(G.parameters(), lrlr, betas(0.5, 0.999)) opt_d torch.optim.Adam(D.parameters(), lrlr, betas(0.5, 0.999)) for epoch in range(epochs): for real_img, _ in loader: real_img real_img.to(device) b real_img.size(0) # 第一步训练判别器生成器部分要 detach 掉梯度 z torch.randn(b, noise_dim, devicedevice) fake_img G(z).detach() loss_d criterion(D(real_img), torch.ones(b, 1, devicedevice)) \ criterion(D(fake_img), torch.zeros(b, 1, devicedevice)) opt_d.zero_grad() loss_d.backward() opt_d.step() # 第二步训练生成器目标是让判别器判为真 z torch.randn(b, noise_dim, devicedevice) fake_img G(z) loss_g criterion(D(fake_img), torch.ones(b, 1, devicedevice)) opt_g.zero_grad() loss_g.backward() opt_g.step() print(fepoch {epoch 1}/{epochs} | loss_d{loss_d.item():.4f} | loss_g{loss_g.item():.4f}) return G, D if __name__ __main__: main()G(z).detach()这一行是 GAN 训练的关键。训练判别器时我们不希望梯度通过判别器回传到生成器否则两个网络会互相干扰。detach()把张量从计算图上摘下来反向传播到它就停了。忘了写这一行代码照样能跑但生成器的梯度是错的训练效果会明显变差。运行方式很简单python train_cnn.py python train_gan.py6. 运行结果与效果验证6.1 各个模型的预期表现CNN 在 MNIST 上非常容易收敛三个 epoch 之后测试准确率通常能到 0.98 以上。训练日志里你会看到第一个 epoch 的 step 0 loss 大概在 2.3 左右这是 ln(10)因为一开始模型在十个类别上均匀猜测随着训练推进loss 会掉到 0.1 以下。如果你跑了两个 epoch loss 还卡在 2.3 附近那不是模型问题是数据或标签出问题了。LSTM 的示例需要你自己准备一个文本数据集和词表把句子转成 id 序列。验证方式建议先构造一个极小的合成数据集比如包含关键词就是正类的规则数据看模型能不能在几十步内把 loss 打到接近 0。这叫过拟合一个小批量是验证训练代码正确性最快的手段。如果连这个都做不到说明代码有 bug而不是数据不够。Transformer 的示例同样建议先做小批量过拟合测试。另外建议专门验证一下 mask 是否生效构造两个样本一个长度为 8一个长度为 3 且后面全是 padding用同一个模型推理看加了 mask 之后第二个样本的输出是否与只用前 3 个 token 推理的结果一致。这个检查花不了几分钟但能帮你排掉一大类静默错误。GAN 的验证方式和前面完全不同。不要看 loss要看生成的图。训练初期生成的全是噪声几轮之后开始出现数字的轮廓二十轮左右能看到比较清晰的数字形状但细节模糊这属于正常水平。判断标准是如果连续好几个 epoch 打印出来的 loss_d 和 loss_g 都在 0.69 附近晃也就是 ln 2说明判别器已经完全无法区分真假这在理论上是收敛信号但实际中往往是生成器模式崩溃的前兆因为它在用同一张图骗过所有情况。反过来如果 loss_d 迅速掉到 0.01说明判别器太强生成器梯度几乎为零需要降低判别器的学习率或者减少它的更新次数。6.2 出问题时第一步看哪里养成一个固定习惯任何训练异常先打印三样东西——输入张量的形状、模型输出的形状、loss 的值。这三者能定位绝大多数问题。形状对但 loss 是 nan去查学习率是不是太大、有没有 log(0) 或者除以零形状不对去查维度顺序或者忘了 unsqueezeloss 完全不降先去验证数据管道的标签有没有错位这个错误极其常见而且极难从 loss 曲线看出来。7. 常见问题与排查思路问题现象可能原因排查方式解决方案报错 shape mismatchLinear 输入维度不对卷积输出尺寸算错或改了输入分辨率没改全连接层在 forward 里print(x.shape)看 Flatten 之后的维度按H_out (H_in 2p - k)/s 1重算或改用nn.AdaptiveAvgPool2d(1)LSTM 报错 expected scalar type Long but found Float词 id 被转成了浮点检查input_ids.dtype用input_ids.long()显式转换pack_padded_sequence 报错 lengths 必须在 CPU长度张量随数据搬到了 GPU打印lengths.device传参时写lengths.cpu()Transformer 报错 d_model 不能被 nhead 整除超参设置冲突检查两个数值保证d_model % nhead 0常见配置是 512/8、128/4加了 attention_mask 但效果没变化mask 方向搞反或者传错了参数名对比加不加 mask 的输出差异确认src_key_padding_mask中 True 表示屏蔽padding 位置应转成 True位置编码报错 tensors on different devices位置编码没注册成 buffer打印模型和 pe 所在设备用register_buffer而不是普通属性Windows 下 DataLoader 卡死或报多进程错误num_workers 0且入口没保护看是否有重复执行日志把训练入口放进if __name__ __main__:训练 loss 一直是 ln(类别数)标签错位、学习率为 0、参数没传进优化器打印一个 batch 的 y、检查model.parameters()修正数据管道确认优化器收到参数loss 变成 nan学习率过大、梯度爆炸、log 内出现 0打印梯度范数降学习率、加clip_grad_norm_、检查损失函数输入CNN 准确率停在 10% 左右池化后维度与全连接层不匹配但被广播掩盖或标签未对齐检查 Flatten 输出维度修正维度先做单 batch 过拟合验证GAN 生成的图全是噪点数据归一化范围与 Tanh 不匹配检查Normalize参数数据归一化到 [-1,1]生成器末端用 TanhGAN 生成样本高度重复模式崩溃观察多个 epoch 的样本多样性降判别器学习率、加 Dropout 或谱归一化、调整两者更新频率GAN 判别器 loss 迅速趋近 0判别器过强看 D 的真假预测准确率降低 D 的层数或学习率给 D 的输入加噪声GPU 显存不足batch 太大或注意力矩阵过大用torch.cuda.max_memory_allocated()观察减小 batch、用梯度累积、缩短序列长度这张表里的每一条都是从实际报错里反推出来的不是凭空罗列的。其中最值得强调的是mask 方向搞反和标签错位这两类因为它们不报错只是默默让效果变差靠看代码很难发现必须靠对比实验。8. 最佳实践与工程建议8.1 把张量形状当成一等公民写深度学习代码我建议在forward里给每一步都加上形状注释就像前面示例里那样。这不是形式主义而是因为绝大多数 bug 的根源是维度顺序错了而报错信息往往指向的不是真正出错的那一行。养成注释习惯之后你读别人的代码速度也会快很多。另外强烈建议在每个模型写完后用一个随机张量跑一次前向确认输出形状符合预期。这一步只需要三行代码但能省掉后面半小时的调试。# 快速的形状自检 import torch from models.cnn_mnist import SmallCNN from models.mini_transformer import MiniTransformerClassifier x torch.randn(2, 1, 28, 28) print(cnn out:, SmallCNN()(x).shape) # 期望 [2, 10] ids torch.randint(1, 1000, (2, 16)) mask torch.ones(2, 16, dtypetorch.long) mask[1, 10:] 0 # 第二个样本后 6 位是 padding print(transformer out:, MiniTransformerClassifier(vocab_size1000)(ids, mask).shape) # 期望 [2, 2]8.2 数据管道比模型结构更容易出问题在实际项目中模型结构往往是现成的、成熟的真正决定效果的是数据。我在实践中见过太多情况模型换了好几版没提升最后发现是数据增强的参数写错了或者验证集的预处理和训练集不一致。所以每次搭好管道之后一定要做两件事把一批数据可视化出来看确认图像没有被裁歪、标签没有错位把训练集和验证集的预处理流程用同一份代码定义避免手抖写出两份不一致的代码。对文本任务还有一个高频问题是词表和 id 的对应关系。训练时用一套词表推理时换了另一套模型不会报错但输出全是垃圾。工程上应该把词表和使用它的模型绑定在一起保存比如存进 checkpoint 里而不是让它成为一个独立的、容易被替换的文件。8.3 训练稳定性优先于结构创新对刚上手的人来说最该练的不是设计新结构而是把训练跑稳。几个具体的做法固定随机种子保证实验可复现每轮记录训练 loss、验证指标和学习率用早停而不是死磕固定 epoch 数在验证集上选模型而不是在测试集上反复调参。对 GAN 这类不稳定结构还有额外的建议保存每个 epoch 的生成样本图用肉眼观察而不是只看数字如果训练两三次都崩先把判别器的容量调小再试很多时候是判别器太强导致的不要在 GAN 上做太多花哨的调参先把最基础的版本跑到能生成可辨认的样本再考虑加技巧。8.4 关于生产环境的边界文中这些模型都是教学级的最小实现参数量小、数据简单绝对不能直接搬到生产。真实场景里你需要考虑模型体积和推理延迟是否满足要求输入数据的分布会不会随时间漂移需要不需要定期重训推理服务有没有做输入校验防止异常形状或超长序列把服务打挂模型文件的加载和版本管理怎么做出问题能不能快速回滚。还有一点容易被忽略如果你在项目中使用了预训练模型或者第三方数据集需要确认它们的许可协议是否允许你的使用方式。这部分不属于技术问题但在实际交付时是硬性门槛。9. 继续深入的几条具体路径学完这五个结构下一步不该是马上去追最新的模型而应该先把其中一条线走深。如果你偏视觉方向建议在 CNN 这条线上继续从 ResNet 的残差连接理解为什么能训到上百层再到检测和分割任务里看特征金字塔和上采样是怎么设计的。这条线的工程价值非常直接落地场景也多。如果你偏 NLP 或多模态那就把 Transformer 吃透重点看三件事一是缩放点积注意力里那个 √d_k 到底在解决什么数值问题二是各种位置编码方案的差别为什么现在大模型普遍用 RoPE三是训练时的显存优化手段包括混合精度、梯度检查点和 FlashAttention 这类 IO 优化。这三件事理解了你再去看任何一个大模型的技术报告都不会觉得陌生。如果你想做生成方向GAN 是一个很好的入门但要有心理准备它比较难调。更稳妥的路径是先把自编码器的思路搞清楚再过渡到扩散模型因为扩散模型的训练目标比 GAN 稳定得多工程上更好落地这也是近两年生成方向的主流选择。最后给一个可执行的建议不要试图一次学完五个。挑一个和你当前工作最相关的用它把一个真实的小需求从头做到尾——准备数据、训练、评估、导出、写一个简单的推理脚本。这个完整闭环带来的理解比看完一百集视频要扎实得多。视频看得再多不动手调一次形状、不看一次 loss 曲线、不亲手排查一个不收敛的问题那种我懂了的感觉往往是假的。