神经网络的学习最容易出现一种错觉:视频看完了,公式抄下来了,但让自己从零写一个 CNN 做图像分类、写一个 LSTM 做序列预测、或者搭一个能跑起来的 Transformer,仍然无从下手。CNN、RNN、LSTM、GAN、Transformer 这五种结构经常被塞进同一份学习清单,可它们的输入类型、核心算子、训练目标和失败模式完全不同,混在一起看,很容易只记住名词。下面按工程实践的路线,把这五种结构分别落到一段可以运行的最小代码上:先看它解决什么问题,再看张量形状怎么变,然后跑通训练,最后学会读损失曲线和报错信息。适合已经会 Python、了解梯度下降和反向传播基本概念、但缺少动手经验的读者。走完这条路线,你应该能独立搭出五种结构的最小样例,并且知道形状不匹配、损失不下降、生成结果塌缩这类问题该从哪一层查起。
1. 先把五种网络放回同一张技术地图
很多人学神经网络失败,不是因为数学太难,而是因为一开始就把五种结构当成了五个孤立的知识点。真正有效的做法是先建立一张对照表:它们各自接收什么样的输入,用什么算子提取特征,最终输出什么,典型的失败模式是什么。有了这张表,后面每写一段代码,你都知道自己是在补哪一格。
1.1 五种结构各自解决什么问题
用通俗的话说,神经网络的核心工作是“把输入变成有用的表示”。区别在于,不同数据的内在结构不一样,所以提取表示的方式也不一样。
CNN 面对的是有空间局部性的数据,比如图像。它的假设是“相邻像素关系更紧密”,于是用卷积核在局部窗口上滑动,同一组权重在整个图上共享。这个假设叫归纳偏置,它让 CNN 在图像任务上用远少于全连接层的参数就能学到边缘、纹理、形状这类特征。
RNN 面对的是有序序列,比如文本、传感器读数、日志时间线。它的假设是“当前输出依赖之前的输入”,于是用一个循环单元把上一步的隐藏状态传下来。问题是普通 RNN 的记忆很短,长序列上梯度在反向传播时连乘,很容易衰减到接近零。
LSTM 是 RNN 的改进版本。它引入输入门、遗忘门、输出门和一个细胞状态,让信息可以选择性地写入、保留和读出。遗忘门的存在使梯度可以沿细胞状态较平稳地流动,长依赖建模能力明显强于普通 RNN。GRU 是它的简化版,把三个门压缩成两个,参数更少,很多场景下效果接近。
GAN 面对的是生成问题。它不直接学“输入到输出”的映射,而是同时训练一个生成器和一个判别器,让两者对抗:生成器努力骗过判别器,判别器努力分辨真假。训练收敛时,生成器分布接近真实数据分布。它难在训练动态不稳定,而不是难在网络结构。
Transformer 面对的是长距离依赖和并行计算。它用自注意力让序列中任意两个位置直接交互,一步就能建立远距离联系,不像 RNN 那样必须逐步传递。代价是计算量随序列长度呈平方增长,所以长文本场景要考虑分块、稀疏注意力或线性注意力变体。
| 结构 | 输入形态 | 核心算子 | 擅长 | 典型任务 | 主要短板 |
|---|---|---|---|---|---|
| CNN | [N, C, H, W] |
卷积、池化 | 局部空间特征 | 图像分类、检测、分割 | 全局关系建模弱,需要堆深或加注意力 |
| RNN | [N, T, D] |
循环单元、权重共享 | 短序列时序 | 序列标注、简单预测 | 长依赖差,无法并行 |
| LSTM/GRU | [N, T, D] |
门控循环单元 | 中长序列时序 | 语音、传感器、时序预测 | 仍是串行计算,长序列慢 |
| GAN | 噪声向量或条件输入 | 生成器与判别器对抗 | 数据分布拟合 | 图像生成、数据增强 | 训练不稳定,模式塌缩 |
| Transformer | [N, T, D] |
缩放点积注意力、前馈网络 | 长依赖、并行 | 文本分类、翻译、多模态 | 显存与算力开销大,需要大量数据 |
这张表不需要背,但要在写代码时反复回看。因为一旦你把输入形状搞错,比如把 [N, T, D] 的序列直接喂给只看最后两维的 Conv2d,报错信息往往非常难懂。
1.2 用张量形状把五种结构串起来
形状是神经网络调试中最可靠的语言。CNN、RNN、LSTM、Transformer 的输入其实都能统一理解成“一批样本 + 每条样本的结构”,只是结构的解释不同。
[N, C, H, W]:N 是批大小,C 是通道数,H、W 是空间尺寸。灰度图 C=1,彩色图 C=3。[N, T, D]:N 是批大小,T 是时间步或序列长度,D 是每个时间步的特征维度。[N, Z]:GAN 里常见的噪声向量,Z 是隐变量维度。[N, L]:Transformer 的 token id 序列,L 是序列长度,取值是词表下标的整数。
同一份序列数据,在 RNN 和 Transformer 里的形状都可以是 [N, T, D],只是 RNN 要求你按时间步循环,Transformer 要求你额外提供 padding mask。搞清楚这一点,你就能理解为什么两者可以在同一份数据管道上互换。
下面这段代码用来在训练前做形状自检,比看十遍论文都直接:
python复制import torch
# CNN 形状检查
x_img = torch.randn(4, 1, 28, 28)
print("图像输入:", x_img.shape) # [4, 1, 28, 28]
# 序列形状检查
x_seq = torch.randn(4, 32, 1)
print("序列输入:", x_seq.shape) # [4, 32, 1]
# Transformer 输入是整数 token
x_ids = torch.randint(1, 1000, (4, 32))
print("token 输入:", x_ids.shape, x_ids.dtype) # [4, 32] torch.int64
最后一行尤其要注意:nn.Embedding 只接受整型张量,如果你把浮点张量传进去,会得到 Expected tensor for argument #1 'indices' to have scalar type Long 一类的报错。这类问题的根源不是模型写错,而是数据管道没有做类型转换。
1.3 学习顺序为什么不宜从 Transformer 开始
Transformer 的代码看起来最短,但它依赖的前置概念最多:注意力、多头、位置编码、mask 语义、LayerNorm 放置位置、学习率预热。初学者直接上手,常见结果是“代码能跑,但完全不知道哪一行在做什么”,一旦 loss 不降,就无从排查。
更稳的顺序是 CNN、RNN、LSTM、Transformer、GAN。原因很实际:
- CNN 的形状变化直观,卷积核可视化容易,能帮你建立“参数共享”和“感受野”的直觉。
- RNN 让你理解“时间步”和“隐藏状态”,同时亲眼看到梯度消失现象。
- LSTM 在 RNN 基础上加门控,你能对比出改进点到底在哪。
- Transformer 用注意力替代循环,此时你已经知道循环的痛点,才能理解它为什么值得付出平方复杂度。
- GAN 放在最后,因为它的难点是训练动态,需要你已经能读懂两个网络的损失曲线。
如果时间有限,前四个结构先跑通,GAN 只看最小例子理解原理,也是合理的取舍。
注意:不要用“能跑通”当作学习完成的标志。每跑通一个模型,至少要做三件事:打印一次中间张量形状、跑一次故意写错的输入看报错、观察一次损失曲线的形状。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:一套能跑通五种模型的 PyTorch 基线
五种结构没必要装五套环境。用一个干净的 Python 虚拟环境,固定 PyTorch 主版本,把数据、模型、训练循环分成三个目录,后面所有例子都能复用。这一节的目标不是讲安装,而是把“环境导致的玄学问题”提前排除掉。
2.1 版本、硬件与目录结构
深度学习的很多“莫名其妙”报错,最后都追到版本不一致上。比如 nn.TransformerEncoderLayer 的 batch_first 参数是较新版本才加入的,老版本没有这个关键字,代码直接抛 TypeError。所以第一件事是把版本号固定下来,并且记录下来。
| 组件 | 建议 | 说明 |
|---|---|---|
| Python | 3.10 或 3.11 | 版本过老会缺少部分类型语法支持 |
| PyTorch | 2.x 系列 | 需要 batch_first 支持,建议 2.0 以上 |
| NumPy | 与 PyTorch 匹配的稳定版 | 避免与底层 ABI 冲突 |
| 显卡驱动 | 与 CUDA 版本对应 | 只用 CPU 可跳过 |
| 显存 | 8GB 起步 | Transformer 小模型足够,大批量需下调 batch size |
如果原始材料没有给出明确版本,落地前要先确认依赖版本,不要直接照抄别人博客里的版本号。安装命令按官方提供的组合执行,CPU 版本和 GPU 版本不要混着装。
目录结构建议这样划分,后面每加一个模型只增加一个文件:
text复制nn-lab/
├── data/ # 数据集缓存
├── models/
│ ├── cnn.py
│ ├── rnn_lstm.py
│ ├── transformer.py
│ └── gan.py
├── common/
│ ├── seed.py # 随机种子与设备
│ └── engine.py # 通用训练循环
├── train_cnn.py
├── train_lstm.py
└── requirements.txt
把训练循环抽出来是最值得做的一步。CNN 和 LSTM 的训练流程几乎一样,都是“取 batch、前向、算损失、反向、更新”,只有损失函数和输入形状不同。抽出来之后,你排错时就能确定问题出在模型还是出在循环。
2.2 依赖安装与随机种子
安装完成后,先跑一段自检脚本,确认 PyTorch 能正常导入、能识别设备、能完成一次反向传播。这一步花两分钟,能省掉后面大量困惑。
bash复制python -m venv .venv
source .venv/bin/activate # Windows 用 .venv\Scripts\activate
pip install --upgrade pip
pip install torch torchvision
pip install -r requirements.txt
python复制# common/seed.py
import os
import random
import numpy as np
import torch
def set_seed(seed: int = 42) -> torch.device:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
os.environ["PYTHONHASHSEED"] = str(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device={device}, torch={torch.__version__}")
return device
固定种子的意义在于可复现。当你调整了一个参数、loss 却变化很大时,先排除随机性干扰,才能判断改动是否真的有效。需要注意的是,即使固定了种子,GPU 上的某些算子仍然存在非确定性,所以不要把 loss 精确到小数点后很多位去对比。
cudnn.deterministic = True 会牺牲一点速度换确定性,学习阶段建议打开,追求吞吐的生产训练可以关掉。
2.3 公共训练循环与数据加载
下面这个训练函数会被 CNN 和 LSTM 共用。它返回平均损失和准确率,并把统计口径写清楚,避免“训练集准确率算错”这种低级但常见的问题。
python复制# common/engine.py
import torch
def train_one_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss, correct, total = 0.0, 0, 0
for batch in loader:
x, y = batch[0].to(device), batch[1].to(device)
optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
total_loss += loss.item() * x.size(0)
correct += (logits.argmax(dim=1) == y).sum().item()
total += x.size(0)
return total_loss / total, correct / total
关键点在 loss.item() * x.size(0)。如果直接累加 loss.item() 再除以 batch 数量,当最后一个 batch 不满时会引入偏差,验证集上尤其明显。按样本数加权才是正确口径。
optimizer.zero_grad(set_to_none=True) 比默认的置零更省内存,也是较新版本的推荐写法。如果你在旧代码里看到 zero_grad(),不必改,但新写的代码可以用这个形式。
数据加载部分,DataLoader 的 num_workers 在 Windows 上容易出问题,学习阶段先设 0,确认流程跑通再调大。pin_memory=True 只在 GPU 训练时有意义。
python复制from torch.utils.data import DataLoader
loader = DataLoader(
dataset,
batch_size=64,
shuffle=True,
num_workers=0,
pin_memory=torch.cuda.is_available(),
drop_last=False,
)
注意:
shuffle=True只应该用在训练集。验证集和测试集打乱顺序不会影响指标,但会让你在排查样本级错误时无法复现,建议保持shuffle=False。
3. CNN:从卷积核到图像分类的最小可运行样例
CNN 是最适合建立直觉的结构。它的每个超参数都能对应到一个具体的形状变化,你能用手指算出输出尺寸。这一节用一个两层卷积的网络跑 MNIST,重点不在刷准确率,而在把形状、参数和感受野讲透。
3.1 卷积、池化、感受野的通俗解释
卷积核可以理解成一个“小窗口滤镜”。它在图像上滑动,每到一个位置就和窗口内的像素做加权求和。这个权重在整个图像上共享,所以一个 3×3 的卷积核只有 9 个参数,却能在所有位置检测同一种模式。这就是参数共享带来的效率。
输出尺寸的计算公式是后续排查形状问题的核心工具:
text复制H_out = floor((H_in + 2 * padding - kernel_size) / stride) + 1
以 28×28 输入、kernel_size=3、padding=1、stride=1 为例,输出仍然是 28×28。加 padding 的目的是让边缘像素也能被卷积核完整覆盖,否则每经过一层,特征图就会缩小一圈。
池化则是降采样。MaxPool2d(2) 把 2×2 区域里最大的值保留下来,尺寸减半,同时保留最显著的响应。它的作用有两个:减少计算量,以及扩大后续层的感受野。
感受野指的是输出上一个点“看到”了输入多大范围。两层 3×3 卷积堆叠,感受野是 5×5;三层是 7×7。理解这一点,你就明白为什么深层网络能识别更大的物体,也明白为什么小目标检测需要特征金字塔这类结构。
3.2 用一个小型 CNN 跑通 MNIST
下面这个网络刻意保持简单:两层卷积、两层池化、一个隐藏层分类头。它能稳定达到较高准确率,代码量又足够小,方便你逐行核对形状。
python复制# models/cnn.py
import torch.nn as nn
class SmallCNN(nn.Module):
def __init__(self, num_classes: int = 10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, padding=1), # [N,1,28,28] -> [N,16,28,28]
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # -> [N,16,14,14]
nn.Conv2d(16, 32, kernel_size=3, padding=1), # -> [N,32,14,14]
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # -> [N,32,7,7]
)
self.classifier = nn.Sequential(
nn.Flatten(),
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))
Flatten 之后是 32×7×7=1568 维。这个数字必须和 nn.Linear 的第一个参数一致,否则会得到 mat1 and mat2 shapes cannot be multiplied。初学者最常见的错误就是改了输入尺寸或卷积层数,却忘了同步改这里。
训练脚本骨架如下:
python复制from torch import nn, optim
from common.seed import set_seed
from common.engine import train_one_epoch
from models.cnn import SmallCNN
device = set_seed(42)
model = SmallCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(5):
loss, acc = train_one_epoch(model, train_loader, optimizer, criterion, device)
print(f"epoch={epoch+1} loss={loss:.4f} acc={acc:.4f}")
CrossEntropyLoss 内部包含 softmax,所以模型最后一层不要再加 Softmax,否则相当于做了两次归一化,梯度会被压平,收敛明显变慢。这是一个非常隐蔽的坑,因为代码不会报错,只是效果变差。
如果运行环境无法联网下载数据集,可以提前把数据文件放到 data/ 目录,用 download=False 加载,避免训练脚本卡在下载阶段。
3.3 卷积层参数与形状核对表
卷积相关的参数不多,但每一个都会影响形状和计算量。下面这张表建议贴在编辑器旁边。
| 参数 | 含义 | 常见值 | 调大的影响 | 配错的表现 |
|---|---|---|---|---|
kernel_size |
卷积窗口大小 | 3、5、7 | 感受野变大,参数增多 | 输出尺寸算错,Linear 维度不匹配 |
stride |
滑动步长 | 1、2 | 输出尺寸变小,计算量下降 | 尺寸除不尽时特征丢失明显 |
padding |
边缘补零圈数 | 1(配合 3×3) | 保持输出尺寸 | 设为 0 时每层缩小一圈 |
| 输出通道数 | 特征图数量 | 16、32、64、128 | 表达能力增强,显存上升 | 通道过多在小数据集上过拟合 |
Dropout |
随机失活比例 | 0.2 到 0.5 | 正则化更强 | 设得过高导致欠拟合 |
一个实用的自检习惯:在写完模型后立刻跑一次前向,把中间形状打印出来。
python复制import torch
from models.cnn import SmallCNN
model = SmallCNN()
x = torch.randn(4, 1, 28, 28)
print("input:", x.shape)
print("features:", model.features(x).shape)
print("logits:", model(x).shape)
正常输出应该是 [4, 32, 7, 7] 和 [4, 10]。如果 features 的输出不是 7×7,说明池化层数或输入尺寸被改动过,此时 Linear 的输入维度必须跟着改。养成这个习惯,能避免大量“训练跑了半小时才在反向传播时报错”的时间浪费。
4. RNN 与 LSTM:把序列的时序信息留住
序列任务和图像任务的根本差异在于,样本的各个部分有顺序,而且长度不固定。CNN 处理序列需要先把时间维当作空间维,RNN 则是天然按时间步展开。这一节先解释普通 RNN 为什么会忘事,再手写一个 LSTM 单元,最后用一个正弦波预测任务把流程跑通。
4.1 普通 RNN 为什么记不住长依赖
普通 RNN 的更新公式很简单:
text复制h_t = tanh(W_x * x_t + W_h * h_{t-1} + b)
每个时间步都用同一组权重,把上一步的隐藏状态和当前输入结合。这个设计参数少、结构清晰,但训练时问题出在反向传播。梯度需要沿着时间步一路连乘,而每次连乘都包含权重矩阵和 tanh 的导数。tanh 的导数最大是 1,在饱和区接近 0,多次相乘后梯度会指数级衰减。序列长度到几十步以上,最早的输入对最终损失几乎没有影响。
这就是梯度消失。反过来,如果权重较大,也可能出现梯度爆炸,表现为 loss 突然变成 nan。工程上的应对手段包括梯度裁剪、使用门控结构、改用注意力机制。梯度裁剪很便宜,几行代码就能加上:
python复制torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
max_norm 是梯度范数的上限,超过就按比例缩放。它不能解决梯度消失,但能有效防止爆炸导致的训练崩溃。推荐在所有 RNN、LSTM 训练里默认加上。
4.2 手写一个 LSTM 单元,理解门控在做什么
LSTM 的核心是三个门和一个细胞状态。门用 sigmoid 把值压到 0 到 1 之间,相当于一个软开关:接近 0 表示“别通过”,接近 1 表示“全通过”。
text复制i_t = sigmoid(W_i * [x_t, h_{t-1}] + b_i) # 输入门:新信息写多少
f_t = sigmoid(W_f * [x_t, h_{t-1}] + b_f) # 遗忘门:旧信息留多少
g_t = tanh(W_g * [x_t, h_{t-1}] + b_g) # 候选值
o_t = sigmoid(W_o * [x_t, h_{t-1}] + b_o) # 输出门:当前状态输出多少
c_t = f_t * c_{t-1} + i_t * g_t # 细胞状态更新
h_t = o_t * tanh(c_t) # 隐藏状态输出
关键在 c_t 这一行。细胞状态的更新是加法而不是连乘,梯度可以沿着这条“高速公路”较平稳地回传,遗忘门则决定保留多久的历史。这就是 LSTM 比普通 RNN 更能记住长依赖的原因。
手写一遍比看十遍公式有用:
python复制import torch
import torch.nn as nn
class ManualLSTMCell(nn.Module):
def __init__(self, input_size: int, hidden_size: int):
super().__init__()
self.hidden_size = hidden_size
self.linear = nn.Linear(input_size + hidden_size, 4 * hidden_size)
def forward(self, x_t, state):
h_prev, c_prev = state
gates = self.linear(torch.cat([x_t, h_prev], dim=-1))
i, f, g, o = gates.chunk(4, dim=-1)
i, f, o = torch.sigmoid(i), torch.sigmoid(f), torch.sigmoid(o)
g = torch.tanh(g)
c_t = f * c_prev + i * g
h_t = o * torch.tanh(c_t)
return h_t, (h_t, c_t)
gates.chunk(4, dim=-1) 把一次矩阵乘法的输出切成四份,这是工程实现里常见的技巧:把四个门的权重拼成一个大矩阵,一次算完,比四次小矩阵乘法更快。
4.3 用 LSTM 做序列预测并避开 batch_first 的坑
下面用正弦波构造一个滑动窗口数据集,输入 32 个点,预测第 33 个点。任务本身简单,但能暴露序列建模的绝大多数工程问题。
python复制import torch
def make_sine_windows(n: int = 2000, window: int = 32):
t = torch.arange(n, dtype=torch.float32) * 0.1
y = torch.sin(t)
xs, ys = [], []
for i in range(len(y) - window):
xs.append(y[i:i + window])
ys.append(y[i + window])
X = torch.stack(xs).unsqueeze(-1) # [N, window, 1]
Y = torch.stack(ys) # [N]
return X, Y
X, Y = make_sine_windows()
print(X.shape, Y.shape) # [1968, 32, 1] [1968]
模型部分用 nn.LSTM,注意两个参数:batch_first=True 让输入形状是 [N, T, D],num_layers>1 时才能设置 dropout,否则 PyTorch 会给出警告。
python复制class LSTMRegressor(nn.Module):
def __init__(self, input_size=1, hidden_size=32, num_layers=2, dropout=0.1):
super().__init__()
self.lstm = nn.LSTM(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0.0,
)
self.head = nn.Linear(hidden_size, 1)
def forward(self, x):
out, (h_n, c_n) = self.lstm(x) # out: [N, T, H]
last = out[:, -1, :] # 取最后一步
return self.head(last).squeeze(-1)
这里最容易踩的坑是 batch_first。默认值是 False,输入被解释成 [T, N, D]。如果你按 [N, T, D] 传进去,代码不一定报错,但模型实际上把 batch 维当成了时间维,训练出来的结果会非常奇怪,比如预测值几乎是一条直线。这不是模型能力问题,而是维度语义反了。
第二个坑是取最后一步的方式。序列里如果包含 padding,out[:, -1, :] 取到的可能是填充位,应该按真实长度索引,或者用 pack_padded_sequence。学习阶段用定长窗口可以绕过,但处理变长文本时必须面对。
第三个坑是损失函数选择。回归任务用 MSELoss,输出层不要加激活函数;如果误用了 CrossEntropyLoss,会因为期望整型标签而直接报错。
5. Transformer:用自注意力替代循环
Transformer 的出现改变了序列建模的默认选择。它不再按时间步传递状态,而是让每个位置直接和所有位置交互。理解它的关键不是记住结构图,而是理解注意力在算什么、mask 在屏蔽什么、位置编码为什么不能省。
5.1 自注意力的计算过程与多头的作用
自注意力的一句话解释是:每个位置根据和其他位置的相关性,加权汇总全局信息。数学形式是:
text复制Attention(Q, K, V) = softmax(Q * K^T / sqrt(d_k)) * V
Q、K、V 都是由输入经过线性变换得到。Q * K^T 计算两两相似度,除以 sqrt(d_k) 是为了防止维度变大时点积过大、softmax 进入饱和区导致梯度接近 0。softmax 归一化后得到权重,再对 V 加权求和。
多头注意力是把这一步并行做多次,每个头使用不同的投影矩阵,最后拼接。它的意义在于让模型在不同子空间里关注不同类型的关系,比如有的头关注相邻词,有的头关注句法结构。头的数量必须能整除 d_model,否则会报维度错误。
位置编码解决的是另一个问题:自注意力本身对顺序不敏感,打乱输入顺序结果不变。但语言是有顺序的,所以需要把位置信息注入进去。常见做法是加一个可学习的位置嵌入,或者使用正弦函数生成的固定编码。前者实现简单,后者在超出训练长度时表现更稳。
5.2 用 TransformerEncoder 做文本分类
下面这个模型用 nn.TransformerEncoder 做情感二分类,包含词嵌入、位置嵌入、编码器和池化头。它是理解 Transformer 工程细节的最小样本。
python复制import torch
import torch.nn as nn
class TinyTransformerClassifier(nn.Module):
def __init__(self, vocab_size, d_model=64, nhead=4, num_layers=2,
num_classes=2, max_len=128, dropout=0.1):
super().__init__()
self.embed = nn.Embedding(vocab_size, d_model, padding_idx=0)
self.pos = nn.Embedding(max_len, d_model)
layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=nhead,
dim_feedforward=128,
dropout=dropout,
batch_first=True,
)
self.encoder = nn.TransformerEncoder(layer, num_layers=num_layers)
self.norm = nn.LayerNorm(d_model)
self.head = nn.Linear(d_model, num_classes)
def forward(self, input_ids, attention_mask=None):
b, l = input_ids.shape
pos = torch.arange(l, device=input_ids.device).unsqueeze(0).expand(b, l)
x = self.embed(input_ids) + self.pos(pos)
key_padding_mask = (attention_mask == 0) if attention_mask is not None else None
x = self.encoder(x, src_key_padding_mask=key_padding_mask)
if attention_mask is not None:
m = attention_mask.unsqueeze(-1).float()
x = (x * m).sum(dim=1) / m.sum(dim=1).clamp(min=1e-6)
else:
x = x.mean(dim=1)
return self.head(self.norm(x))
几个必须解释的点:
padding_idx=0 让填充位的嵌入向量保持为零且不参与梯度更新。词表里 0 要留给 <pad>,否则填充位会学到无意义的表示。
src_key_padding_mask 的语义是 True 表示忽略。所以传入的是 attention_mask == 0,而不是 attention_mask。这个布尔值取反是最常见的错误之一,写反了模型会把真实 token 屏蔽掉,只关注填充位,表现为 loss 长期不降。
池化用 mask 加权平均而不是直接 mean,是因为不同样本的有效长度不同。直接平均会把填充位的零向量算进分母,长度短的样本表示会被稀释。
max_len 是位置嵌入的上限。如果输入的序列长度超过它,会抛 IndexError: index out of range in self。工程上要么在数据管道里截断,要么在模型里做长度检查。
5.3 Transformer 与 RNN、LSTM 的选型对比
两种结构都能处理序列,但适用场景不同。选型时不要只看效果,还要看数据量、序列长度和推理成本。
| 维度 | RNN / LSTM | Transformer |
|---|---|---|
| 并行能力 | 时间步串行,难并行 | 全序列并行,训练快 |
| 长依赖 | 需要门控缓解,长度受限 | 单层即可建立全局连接 |
| 计算复杂度 | 随长度线性增长 | 随长度平方增长 |
| 数据需求 | 小数据也能训练 | 通常需要较多数据或预训练 |
| 显存占用 | 较低 | 较高,长序列明显 |
| 推理特性 | 逐 token 递增,天然流式 | 需要缓存 Key/Value 才能高效 |
| 位置信息 | 结构自带顺序 | 必须显式注入 |
实践中的判断标准可以简化成三句话:数据量小、序列短、需要流式推理,优先考虑 LSTM 或 GRU;数据量中等以上、追求训练效率、需要建模长距离关系,用 Transformer;序列长度超过几千时,两者都要考虑分块或稀疏化,不能直接硬上。
注意:从 LSTM 换到 Transformer 时,学习率和 warmup 策略往往要跟着改。Transformer 对初始学习率更敏感,常见做法是前若干步线性预热,再进入衰减。
6. GAN:生成模型训练为什么容易崩
GAN 的结构不复杂,难的是训练。生成器和判别器互相博弈,任何一方过强都会导致训练失效。这一节用二维点云生成作为最小例子,把损失函数、更新顺序和典型失败模式讲清楚。
6.1 生成器与判别器的博弈目标
GAN 的目标函数是一个极小极大问题:
text复制min_G max_D E[log D(x)] + E[log(1 - D(G(z)))]
判别器 D 要为真实样本输出高分、为生成样本输出低分;生成器 G 要让自己生成的样本被 D 判为真。理论上两者达到平衡时,生成分布等于真实分布。
工程实现上通常不直接用这个原始形式,而是把生成器的损失改成“让 D 认为假样本是真的”,即最大化 log D(G(z))。这样梯度信号更强,训练初期不容易饱和。
6.2 用 MLP 生成二维点云
真实数据用两个高斯混合的二维点,生成器从噪声映射到二维坐标。这个例子小到能在 CPU 上几十秒跑完,但包含了 GAN 训练的全部关键步骤。
python复制import torch
import torch.nn as nn
class Generator(nn.Module):
def __init__(self, z_dim=8, hidden=64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(z_dim, hidden), nn.ReLU(inplace=True),
nn.Linear(hidden, hidden), nn.ReLU(inplace=True),
nn.Linear(hidden, 2),
)
def forward(self, z):
return self.net(z)
class Discriminator(nn.Module):
def __init__(self, hidden=64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, hidden), nn.LeakyReLU(0.2, inplace=True),
nn.Linear(hidden, hidden), nn.LeakyReLU(0.2, inplace=True),
nn.Linear(hidden, 1),
)
def forward(self, x):
return self.net(x)
判别器用 LeakyReLU 而不是 ReLU,是为了避免负区间梯度全为零,导致判别器过早失去学习能力。这是 GAN 里的常规做法。
训练循环的更新顺序很关键:先更新判别器,再更新生成器,生成器更新时要对假样本做 detach,否则梯度会回传到判别器。
python复制bce = nn.BCEWithLogitsLoss()
z_dim = 8
for step, (real,) in enumerate(loader):
real = real.to(device)
bs = real.size(0)
# 1) 更新判别器
z = torch.randn(bs, z_dim, device=device)
fake = G(z).detach()
d_loss = bce(D(real), torch.ones(bs, 1, device=device)) + \
bce(D(fake), torch.zeros(bs, 1, device=device))
opt_d.zero_grad(set_to_none=True)
d_loss.backward()
opt_d.step()
# 2) 更新生成器
z = torch.randn(bs, z_dim, device=device)
g_loss = bce(D(G(z)), torch.ones(bs, 1, device=device))
opt_g.zero_grad(set_to_none=True)
g_loss.backward()
opt_g.step()
D(fake) 里的 fake 必须 detach,否则第一步的反向传播会同时改到生成器的参数,训练动态就乱了。使用 BCEWithLogitsLoss 而不是手动 sigmoid 加 BCELoss,是因为前者在数值上更稳定。
6.3 训练不稳定的典型现象与判断方法
GAN 的失败往往没有报错,只能从损失和样本上看出来。下面几种现象要会区分。
| 现象 | 可能原因 | 判断方式 | 处理方向 |
|---|---|---|---|
| D 损失迅速接近 0,G 损失暴涨 | 判别器过强 | 看 D 对真假样本的输出均值 | 降低 D 的学习率或减少 D 的更新次数 |
| 生成样本几乎相同 | 模式塌缩 | 采样多批观察分布范围 | 增加噪声、调整结构、换损失形式 |
| 两者损失来回震荡 | 学习率过大 | 观察 loss 曲线周期 | 降低学习率,加梯度裁剪 |
| 生成样本始终是噪声 | G 学不到信号 | 检查是否忘记 detach 或更新顺序错 | 逐步打印中间统计量 |
判断判别器是否过强,一个实用指标是看 D 对真实样本和生成样本的平均输出概率。如果真实样本接近 1、生成样本接近 0,说明 D 已经能完美区分,此时 G 的梯度几乎为零,训练停滞。常见的缓解办法是降低 D 的学习率,或者给真实标签加一点标签平滑,比如用 0.9 代替 1.0。
7. 常见报错与排查链路
五种结构跑下来,报错类型其实高度集中。掌握一套固定的排查顺序,比记住具体报错文案更有用。这一节按“形状、设备、类型、数值、收敛”五层展开。
7.1 形状、设备与数据类型三类高频错误
第一类是形状不匹配。典型报错是 mat1 and mat2 shapes cannot be multiplied 或 Expected input batch_size to match target batch_size。这类问题的根源几乎都在维度语义上:把 [N, T, D] 传给了期望 [N, C, H, W] 的层,或者全连接层输入维度没跟着前面的层改。
排查方式很直接:在 forward 里打印每一步的形状。
python复制def forward(self, x):
print("step0:", x.shape)
x = self.features(x)
print("step1:", x.shape)
return self.classifier(x)
第二类是设备不一致。报错是 Expected all tensors to be on the same device, but found at least two devices。常见原因是模型 .to(device) 了但输入没有,或者中途新建的张量没有指定设备,比如 torch.zeros(...) 默认在 CPU。
第三类是数据类型。nn.Embedding 需要 torch.long,CrossEntropyLoss 的标签需要 torch.long,回归任务的目标需要 torch.float32。如果你从 NumPy 转过来忘了 .long(),报错信息往往指向一个看起来无关的位置。
| 报错关键字 | 根因 | 检查方式 | 处理 |
|---|---|---|---|
shapes cannot be multiplied |
全连接层输入维度不对 | 打印 flatten 后的形状 | 修改 Linear 第一个参数 |
found at least two devices |
输入或中间张量没上设备 | 打印张量 .device |
统一 .to(device) |
expected scalar type Long |
标签或索引类型错误 | 打印 .dtype |
转换 .long() |
index out of range in self |
词表或位置编码越界 | 检查最大 id 与 vocab_size |
扩充词表或截断序列 |
Target size must be the same |
输出与标签形状不一致 | 对比 output.shape 与 target.shape |
用 squeeze 或 view 对齐 |
7.2 损失不下降时的排查顺序
没有报错但 loss 不降,是最消耗时间的情况。推荐按下面的顺序逐层排查,因为前面的问题会掩盖后面的问题。
- 先看数据。取一个 batch,打印输入范围、标签分布、是否有全零样本。输入没做归一化是常见原因,像素值 0 到 255 直接进网络会让训练极慢。
- 再看标签。分类任务检查类别是否严重不均衡,标签是否和输入错位,比如数据管道里 shuffle 了两次导致输入和标签不配套。
- 看模型输出。打印 logits 的均值和方差,如果全部相同,说明参数初始化或前向逻辑有问题。
- 看损失函数是否匹配任务。分类用交叉熵,回归用均方误差,多标签用带 sigmoid 的二元交叉熵。
- 看学习率。学习率过大会导致 loss 震荡甚至发散,过小会导致几乎不动。先用 1e-3 试,再按数量级调整。
- 做一次过拟合测试。取 10 个样本反复训练几十步,如果 loss 降不到接近零,说明模型或流程有 bug,而不是数据不够。
这个“小样本过拟合”测试非常有效,能在几分钟内区分“模型学不动”和“数据不够”。
7.3 一份可以直接照着走的排查清单
把上面的经验压缩成清单,遇到问题时从第一条开始勾。
- 模型和输入是否在同一设备上。
- 输入张量的
dtype是否与层的要求一致。 - 打印每一步的形状,确认没有维度语义颠倒。
- 确认损失函数的输入格式:logits 还是概率,整型标签还是 one-hot。
- 确认优化器接管了所有需要训练的参数,尤其是手动创建的层。
- 确认
zero_grad在每次反向之前调用。 - 确认没有在
torch.no_grad()块里做需要梯度的计算。 - 用 10 个样本做一次过拟合测试。
- 观察 loss 曲线的形状,而不是只看最后一个值。
- 检查随机种子是否固定,排除偶然性干扰。
注意:排查时一次只改一个变量。同时改学习率、batch size 和模型结构,即使 loss 降了,你也不知道是哪一项起了作用,下次遇到同类问题仍然无从下手。
8. 从跑通到可用:工程化与学习路线
把五个模型跑通只是起点。真实项目里,决定成败的往往不是网络结构,而是数据管道、训练稳定性、评估方式和上线后的监控。这一节把学习环境和生产环境的差异讲清楚,并给出一条可以长期执行的路线。
8.1 学习环境与生产环境的差异
学习阶段追求“快”:数据小、轮次少、脚本单文件、结果看得见就行。生产环境追求“稳”和“可复现”,关注点完全不同。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 数据 | 内置数据集,几万条 | 持续增量,需要版本管理和校验 |
| 配置 | 硬编码在脚本里 | 外置配置文件,区分环境 |
| 训练 | 单机单卡,跑完即止 | 分布式或任务调度,需要断点续训 |
| 日志 | print 输出 |
结构化日志,记录指标和超参 |
| 评估 | 看准确率 | 多指标、分群评估、线上回流对比 |
| 异常 | 手动重跑 | 自动重试、告警、回滚 |
| 模型 | 保存在本地 | 版本化存储,附训练配置与数据指纹 |
如果要在生产环境使用,至少要做到三件事:把超参数写进配置文件而不是代码、把每次训练的配置和指标记进日志、保存模型时一并记录输入预处理方式。缺了最后一条,模型上线后输入格式对不上,指标会莫名其妙变差。
8.2 一份可复用的实验检查清单
每次开一个新实验,按这个清单过一遍,能避免大部分返工。
- 数据划分是否固定,验证集是否参与过训练。
- 输入是否做了归一化,统计量是否来自训练集。
- 随机种子是否设置,结果能否复现。
- 模型参数量是否记录,是否与显存占用匹配。
- 学习率、batch size、轮数是否写进配置。
- 是否保存了最优模型而不只是最后一个 epoch。
- 是否记录了训练曲线,而不只是最终指标。
- 是否在小样本上做过过拟合测试。
- 推理脚本是否和训练脚本使用同一套预处理。
- 是否准备了基线模型作为对照。
8.3 接下来的扩展方向
跑通五种结构之后,可以从三个方向继续深入。
第一个方向是把 CNN 和序列结合。图像分类只是 CNN 的入门用法,目标检测、语义分割、时序卷积都值得看。特别是把一维卷积用在序列上,很多时候比 LSTM 更快,效果也不差。
第二个方向是预训练与微调。Transformer 的真正威力来自大规模预训练,理解 from_pretrained 加载权重、冻结部分层、分层设置学习率这些操作,比从头训练更贴近实际工作。
第三个方向是训练工程。混合精度、梯度累积、分布式数据并行、学习率调度,这些和模型结构无关,但直接决定你能不能把模型训到可用状态。规模稍大的任务里,工程能力的价值往往高于换一个更复杂的网络。
如果只能选一条练习路径,建议是:先用 CNN 把图像分类的完整流程走一遍,包括数据增强、验证集评估、保存最优模型;再用 LSTM 做一个真实的时间序列任务,比如设备温度预测;最后用 Transformer 做一次文本分类,重点体会 mask 和位置编码的作用。三个任务都做完,五种结构的知识就不再是零散的名词,而是一套能直接迁移到新问题上的方法。
