☰
CNN到GAN:按数据假设理解五大网络结构与PyTorch实现
2026/10/28 2:13:22 网站建设 项目流程

想把 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_first=True参数,这个参数需要较新版本才支持。具体版本号请以 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.txt

models/__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_first=True和不设这个参数时,输入张量的维度顺序有什么区别。默认是 [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_size=3, padding=1), # [B, 16, 28, 28] nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), # [B, 16, 14, 14] nn.Conv2d(16, 32, kernel_size=3, padding=1), # [B, 32, 14, 14] nn.BatchNorm2d(32), nn.ReLU(inplace=True), 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(inplace=True), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

几个关键点解释一下。padding=1配kernel_size=3能让特征图尺寸不变,这是最常用的"same padding"写法,好处是你在堆网络时不用反复算尺寸。BatchNorm2d放在卷积之后、激活之前,是这个顺序的通行做法,它让每层输入分布更稳定,收敛更快。inplace=True可以省一点显存,但在需要保留中间变量做可视化时要注意它会就地修改张量。

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_idx=0 表示 id 为 0 的位置不参与梯度更新 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( input_size=embed_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, # 输入形状为 [B, L, E] bidirectional=True, dropout=dropout 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, lengths=None): 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_first=True, enforce_sorted=False ) _, (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_layers=1,nn.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, dtype=torch.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_idx=0) self.pos_enc = PositionalEncoding(d_model, max_len=max_len) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward, dropout=0.1, batch_first=True, ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.fc = nn.Linear(d_model, num_classes) def forward(self, input_ids, attention_mask=None): 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_mask=padding_mask) # [B, L, D] if padding_mask is not None: # 只在有效位置上做平均池化 valid = (~padding_mask).unsqueeze(-1).float() out = (out * valid).sum(dim=1) / valid.sum(dim=1).clamp(min=1e-6) else: out = out.mean(dim=1) 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, inplace=True), nn.Linear(256, 512), nn.LeakyReLU(0.2, inplace=True), 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, inplace=True), nn.Dropout(0.3), nn.Linear(512, 256), nn.LeakyReLU(0.2, inplace=True), 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", train=True, download=True, transform=transform) test_set = datasets.MNIST("./data", train=False, download=True, transform=transform) train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=2) test_loader = DataLoader(test_set, batch_size=512, shuffle=False, num_workers=2) model = SmallCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) 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(f"epoch {epoch + 1} step {step} loss={loss.item():.4f}") print(f"epoch {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(dim=1) correct += (pred == y).sum().item() total += y.size(0) print(f"test_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", train=True, download=True, transform=transform) loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=2) G = Generator(noise_dim).to(device) D = Discriminator().to(device) criterion = nn.BCEWithLogitsLoss() # GAN 常用 beta1=0.5,减少动量带来的震荡 opt_g = torch.optim.Adam(G.parameters(), lr=lr, betas=(0.5, 0.999)) opt_d = torch.optim.Adam(D.parameters(), lr=lr, 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, device=device) fake_img = G(z).detach() loss_d = criterion(D(real_img), torch.ones(b, 1, device=device)) \ + criterion(D(fake_img), torch.zeros(b, 1, device=device)) opt_d.zero_grad() loss_d.backward() opt_d.step() # 第二步:训练生成器,目标是让判别器判为真 z = torch.randn(b, noise_dim, device=device) fake_img = G(z) loss_g = criterion(D(fake_img), torch.ones(b, 1, device=device)) opt_g.zero_grad() loss_g.backward() opt_g.step() print(f"epoch {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.py

6. 运行结果与效果验证

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) 或者除以零;形状不对,去查维度顺序或者忘了 unsqueeze;loss 完全不降,先去验证数据管道的标签有没有错位,这个错误极其常见而且极难从 loss 曲线看出来。

7. 常见问题与排查思路

问题现象可能原因排查方式解决方案
报错 shape mismatch,Linear 输入维度不对卷积输出尺寸算错,或改了输入分辨率没改全连接层在 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],生成器末端用 Tanh
GAN 生成样本高度重复模式崩溃观察多个 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, dtype=torch.long) mask[1, 10:] = 0 # 第二个样本后 6 位是 padding print("transformer out:", MiniTransformerClassifier(vocab_size=1000)(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 曲线、不亲手排查一个不收敛的问题,那种"我懂了"的感觉往往是假的。

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

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

立即咨询