Day 21|第 3 周复盘与 CIFAR-10 图像分类项目:把 5 天零件拼成第一个深度学习完整 Pipeline(AI 学习笔记 · 深度学习周 · 第 21 篇)
Week 3 用 5 天把深度学习的「五块零件」安装到位——神经网络基础(Day 15)、PyTorch 工程接口(Day 16)、CNN 卷积视觉(Day 17)、RNN/LSTM 序列建模(Day 18)、Transformer 与 Self-Attention(Day 19)。今天用 CIFAR-10 + ResNet-18 把它们拼成一条端到端图像分类 Pipeline:数据加载 → 增强 → 残差网络 → SGD/Adam 训练 → 测试集评估 → 单图推理,从 32×32×3 的彩色图像里把 airplane / automobile / bird / cat / deer / dog / frog / horse / ship / truck 十类物体分开。
1. Week 3 全景回顾:5 天学了什么、用在哪里
1.1 7 天主题串联表(Day 20 训练技巧本批已删除)
| Day | 主题 | 核心技能(1 行) | 关键概念 / 产物 |
|---|---|---|---|
| Day 15 | 神经网络基础 | 从感知机到多层 MLP,理解「层 / 激活 / 反向传播」三件套 | 神经元、ReLU、链式法则、梯度消失 |
| Day 16 | PyTorch 入门 | nn.Module 写模型、DataLoader 喂数据、optim 跑训练 |
Tensor、autograd、model.train() / eval() |
| Day 17 | CNN | 卷积 / 池化 / 感受野三件套,LeNet → VGG 的演化 | Conv2d、MaxPool2d、stride、padding |
| Day 18 | RNN 与 LSTM | 用循环单元处理变长序列,Gate 机制缓解梯度消失 | 隐藏状态、h_t、LSTM 的三个 gate |
| Day 19 | Transformer 深入 | Self-Attention 让每个 token 看全局,完全并行 | Q/K/V、Multi-Head、位置编码、因果 mask |
| Day 21 | 周复盘 + 图像分类项目 | 把上面五块零件拼成完整 Pipeline | CIFAR-10 + ResNet-18 + 数据增强 + 训练循环 + 评估 |
1.2 一句话串起 5 天
Day 15 给「神经网络是什么」的统一语言;Day 16 把语言翻译成 PyTorch 工程代码;Day 17 用 CNN 把视觉任务做到 90% 准确率;Day 18 用 RNN/LSTM 把序列任务做到可用;Day 19 用 Transformer 把 RNN 的串行瓶颈解决——这是今天所有 LLM 的地基。今天(Week 3 收官)用 CIFAR-10 把视觉零件全部上膛,验证「读图 → 卷积 → 池化 → 全连接分类」完整路径跑得通。
1.3 最小图像分类 Pipeline 长什么样
把 5 天零件拆成 6 步 Pipeline:
[原始图像 32×32×3] → 读 (Day 16 torchvision.datasets)
→ 改 (Day 17 数据增强:随机裁剪/翻转/归一化)
→ 喂 (Day 16 DataLoader 批量)
→ 算 (Day 17 卷积/池化 / Day 19 ResNet 残差块)
→ 学 (Day 15 反向传播 + Day 16 Adam/AdamW)
→ 评 (Day 15 准确率 + Day 16 model.eval())
每一步的失败模式都不同:读错了 是路径 / 通道顺序(RGB vs BGR);改错了 是归一化均值用错(ImageNet 统计量 vs CIFAR 统计量);喂错了 是 batch size 不匹配显存;算错了 是卷积核尺寸算错导致 feature map 尺寸为零;学错了 是学习率过大 / 损失函数写错(分类用 CrossEntropy 不是 MSE);评错了 是忘记 model.eval() 导致 Dropout/BatchNorm 仍随机。
2. 关键概念回顾:5 个零件每个 1 段
2.1 神经网络基础(Day 15)
一个 $L$ 层 MLP 把输入 $x \in \mathbb{R}^{n}$ 通过若干次「线性变换 + 非线性激活」映射到输出 $\hat y$:
h^{(l)} = \sigma(W^{(l)} h^{(l-1)} + b^{(l)}),\quad l=1,\dots,L
σ 用 ReLU(避免 sigmoid 的梯度饱和);最后一层根据任务决定维度(10 分类用 10 维 logits,接 softmax)。反向传播用链式法则把 $\partial L / \partial W^{(l)}$ 一层层传回来。Day 15 是后续所有「可学习」组件的理论底座——CNN、RNN、Transformer 都是带特定归纳偏置的神经网络。
2.2 PyTorch 工程接口(Day 16)
torch.Tensor 是带 autograd 的多维数组,requires_grad=True 时所有操作会建计算图。nn.Module 封装「参数 + 前向」,nn.Parameter 自动加入 parameters() 迭代器;optim 负责按梯度更新参数;CIFAR-10 项目里这三个对象是流水线工人:
import torch.nn as nn
import torch.optim as optim
model = ResNet18().to(device) # nn.Module
criterion = nn.CrossEntropyLoss() # 损失
optimizer = optim.AdamW(model.parameters(), # 优化器
lr=1e-3, weight_decay=5e-4)
2.3 CNN(Day 17)
卷积层用局部连接 + 权重共享两个归纳偏置,把「图像的局部性 + 平移不变性」硬编码进架构。一个 Conv2d(in_c, out_c, kernel_size=3, padding=1) 把 $(C, H, W)$ 变成 $(C’, H, W)$(same padding 时)。堆叠多层后,深层神经元能看到原图更大的感受野——这就是「层级特征提取」的几何基础。CIFAR-10 项目里的 ResNet-18 是「加了残差连接的 CNN 堆叠」。
2.4 RNN / LSTM(Day 18)
RNN 用同一个权重矩阵在时间步之间循环,适合变长序列;但 tanh + 链式法则导致长序列梯度消失。LSTM 引入三个 gate(输入 / 遗忘 / 输出)和一条 cell state 高速公路,让「该记的记、该忘的忘」成为可学习行为。Week 4 Day 22-25 的 LLM 全部抛弃 RNN 改用 Transformer(Day 19),但 LSTM 在中小规模序列任务上仍然是默认起点。
2.5 Transformer 与 Self-Attention(Day 19)
Self-Attention 让每个位置直接 attend 到所有其他位置,完全并行。Multi-Head 把 d_model 切成 h 个子空间并行计算。Decoder-only 加 causal mask 阻止偷看未来。Week 3 末尾的 4 篇 LLM 文章全部基于这一天的 Scaled Dot-Product 公式:
\mathrm{Attention}(Q, K, V) = \mathrm{softmax}(Q K^\top / \sqrt{d_k}) \cdot V
2.6 Week 3 知识地图(纵向串联)
Day 15 (NN 基础) → 把「学习」写成 y=f(Wx+b) + Loss + 梯度
Day 16 (PyTorch 工程) → 把 Day 15 翻译成 nn.Module / DataLoader / optim
Day 17 (CNN) → 把「图像的局部性」硬编码进架构
Day 18 (RNN/LSTM) → 把「序列的时序依赖」硬编码进架构
Day 19 (Transformer) → 把「全局依赖」硬编码进架构,并行 + 长依赖
Day 21 (CIFAR-10 项目) → 把视觉零件全部上膛,跑通 10 分类端到端
每一层都是下一层的前置条件——Day 17 的 CNN 没有 Day 16 的 nn.Module 写不出来,Day 16 的反向传播要靠 Day 15 的链式法则,Day 19 的 Q @ K^T / √d_k 是 Day 21 ResNet 残差块里 1×1 conv 的同一套数学。
3. CIFAR-10 + ResNet-18 完整项目:6 步跑通端到端
3.1 数据集与任务
CIFAR-10 是深度学习的「Hello World」,由 Hinton 学生 Alex Krizhevsky 整理:
| 项 | 值 |
|---|---|
| 类别数 | 10(airplane/auto/bird/cat/deer/dog/frog/horse/ship/truck) |
| 图像尺寸 | 32×32×3(RGB) |
| 训练集 | 50,000 张 |
| 测试集 | 10,000 张 |
| 类别均衡 | 每类 5,000 / 1,000 训练 / 测试样本 |
| 基线 ResNet-18 准确率 | ~94-95% |
10 类物体中 airplane 与 ship 都是「蓝天背景」、automobile 与 truck 都是「路上物体」——视觉上类别间相似度高,baseline 模型就要 90%+ 才能算「学到了」。
3.2 步骤一:数据加载
import torch
import torchvision
import torchvision.transforms as T
CIFAR_MEAN = (0.4914, 0.4822, 0.4465)
CIFAR_STD = (0.2470, 0.2435, 0.2616)
train_tfm = T.Compose([
T.RandomCrop(32, padding=4),
T.RandomHorizontalFlip(),
T.ColorJitter(brightness=0.2, contrast=0.2),
T.ToTensor(),
T.Normalize(CIFAR_MEAN, CIFAR_STD),
])
test_tfm = T.Compose([
T.ToTensor(),
T.Normalize(CIFAR_MEAN, CIFAR_STD),
])
train_set = torchvision.datasets.CIFAR10(
root='./data', train=True, download=True, transform=train_tfm)
test_set = torchvision.datasets.CIFAR10(
root='./data', train=False, download=True, transform=test_tfm)
train_loader = torch.utils.data.DataLoader(
train_set, batch_size=128, shuffle=True, num_workers=4, pin_memory=True)
test_loader = torch.utils.data.DataLoader(
test_set, batch_size=256, shuffle=False, num_workers=4, pin_memory=True)
坑预警:download=True 第一次跑会去 CIFAR-10 官网下载 ~170 MB,需要联网;若离线,把 download=False 并预先把 cifar-10-batches-py/ 放到 ./data 下。num_workers=4 利用多进程预取,Linux/macOS 上提速 2-3 倍。
3.3 步骤二:数据增强
数据增强是 CIFAR-10 上分最便宜的「免费午餐」:
| 增强 | 作用 | 关键参数 |
|---|---|---|
RandomCrop(32, padding=4) |
4 像素 padding 后随机裁剪 | 等价于「物体轻微平移」 |
RandomHorizontalFlip() |
50% 概率水平翻转 | 等价于「左右镜像都是同一类」 |
ColorJitter |
亮度 / 对比度抖动 | 等价于「不同光照」 |
Normalize(mean, std) |
归一化到 0 附近 | 必须用 CIFAR 自己的统计量,不是 ImageNet |
RandomErasing / Cutout / MixUp / CutMix 是进阶选项,常能再提 0.5-1.5%。测试集永远不做随机增强,只 Normalize——保证评估是确定性的。
3.4 步骤三:模型定义(ResNet-18)
ResNet 的核心思想是「残差连接」:让某一层学习「残差 $F(x)$」而不是直接拟合 $H(x)$,最终输出 $H(x) = F(x) + x$。这让 100+ 层网络的梯度可以走「捷径」直达浅层,解决深度网络的退化问题。
import torch.nn as nn
import torch.nn.functional as F
class BasicBlock(nn.Module):
"""ResNet-18/34 用的小残差块:两个 3x3 conv + 残差"""
expansion = 1
def __init__(self, in_c, out_c, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_c, out_c, 3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_c)
self.conv2 = nn.Conv2d(out_c, out_c, 3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_c)
self.short = nn.Sequential() # 维度对齐捷径
if stride != 1 or in_c != out_c:
self.short = nn.Sequential(
nn.Conv2d(in_c, out_c, 1, stride=stride, bias=False),
nn.BatchNorm2d(out_c),
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)), inplace=True)
out = self.bn2(self.conv2(out))
out = out + self.short(x)
return F.relu(out, inplace=True)
class ResNet18(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.stem = nn.Sequential(
nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
)
self.layer1 = self._make_layer(64, 64, num_blocks=2, stride=1)
self.layer2 = self._make_layer(64, 128, num_blocks=2, stride=2)
self.layer3 = self._make_layer(128, 256, num_blocks=2, stride=2)
self.layer4 = self._make_layer(256, 512, num_blocks=2, stride=2)
self.avgpool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Linear(512, num_classes)
def _make_layer(self, in_c, out_c, num_blocks, stride):
layers = [BasicBlock(in_c, out_c, stride=stride)]
for _ in range(num_blocks - 1):
layers.append(BasicBlock(out_c, out_c, stride=1))
return nn.Sequential(*layers)
def forward(self, x):
x = self.stem(x)
x = self.layer1(x); x = self.layer2(x)
x = self.layer3(x); x = self.layer4(x)
x = self.avgpool(x).flatten(1)
return self.fc(x)
model = ResNet18(num_classes=10).to(device)
print(sum(p.numel() for p in model.parameters()) / 1e6, 'M params')
# 预期: 11.17 M params
3.5 步骤四:训练循环
def train_one_epoch(model, loader, criterion, optimizer, device, epoch, total_epochs):
model.train()
running_loss, correct, total = 0.0, 0, 0
for step, (x, y) in enumerate(loader):
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
logits = model(x)
loss = criterion(logits, y)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
running_loss += loss.item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
total += x.size(0)
if step % 100 == 0:
print(f'epoch {epoch}/{total_epochs} step {step}/{len(loader)} '
f'loss {loss.item():.4f} acc {correct/total:.4f}')
return running_loss / total, correct / total
@torch.no_grad()
def evaluate(model, loader, criterion, device):
model.eval()
running_loss, correct, total = 0.0, 0, 0
for x, y in loader:
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
logits = model(x)
loss = criterion(logits, y)
running_loss += loss.item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
total += x.size(0)
return running_loss / total, correct / total
关键细节:set_to_none=True 比 zero_grad() 更省显存(把梯度置 None 而不是清零);non_blocking=True 配合 pin_memory=True 让 CPU→GPU 拷贝异步;model.train() / model.eval() 切换必须配对出现,否则 Dropout/BatchNorm 行为不一致。
3.6 步骤五:优化器与学习率调度
EPOCHS = 30
optimizer = torch.optim.AdamW(model.parameters(),
lr=1e-3, weight_decay=5e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=EPOCHS)
criterion = nn.CrossEntropyLoss()
best_acc = 0.0
for epoch in range(1, EPOCHS + 1):
tr_loss, tr_acc = train_one_epoch(model, train_loader, criterion,
optimizer, device, epoch, EPOCHS)
te_loss, te_acc = evaluate(model, test_loader, criterion, device)
scheduler.step()
if te_acc > best_acc:
best_acc = te_acc
torch.save(model.state_dict(), 'best_resnet18_cifar10.pth')
print(f'epoch {epoch:2d} '
f'train loss {tr_loss:.4f} acc {tr_acc:.4f} | '
f'test loss {te_loss:.4f} acc {te_acc:.4f} | '
f'best {best_acc:.4f}')
预期输出(在 V100 / RTX 3090 上):
epoch 1 train loss 1.45 acc 0.48 | test loss 1.10 acc 0.62 | best 0.62
epoch 10 train loss 0.32 acc 0.89 | test loss 0.45 acc 0.87 | best 0.91
epoch 20 train loss 0.12 acc 0.96 | test loss 0.32 acc 0.92 | best 0.94
epoch 30 train loss 0.05 acc 0.98 | test loss 0.31 acc 0.94 | best 0.9461
30 个 epoch 在 CIFAR-10 上跑 ResNet-18 应达到 94-95% 测试准确率。低于 90% 通常是「数据增强不足 / 学习率不合适 / BatchNorm 没配对 model.eval()」三选一。
3.7 步骤六:单图推理与部署
训练完成后,模型可以直接对单张图像推理:
from PIL import Image
import torchvision.transforms as T
CIFAR_CLASSES = ['airplane','automobile','bird','cat','deer',
'dog','frog','horse','ship','truck']
infer_tfm = T.Compose([
T.Resize((32, 32)),
T.ToTensor(),
T.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
model = ResNet18(num_classes=10).to(device)
model.load_state_dict(torch.load('best_resnet18_cifar10.pth'))
model.eval()
img = Image.open('test_cat.jpg').convert('RGB')
x = infer_tfm(img).unsqueeze(0).to(device)
with torch.no_grad():
logits = model(x)
probs = torch.softmax(logits, dim=1)
top5_prob, top5_idx = probs.topk(5)
for prob, idx in zip(top5_prob[0], top5_idx[0]):
print(f'{CIFAR_CLASSES[idx]:12s} {prob.item()*100:.2f}%')
unsqueeze(0) 把 (3, 32, 32) 加一维 batch 维变 (1, 3, 32, 32);softmax + topk(5) 给出 Top-5 类别与概率。生产部署还会做:① torch.jit.script(model) 序列化;② ONNX 导出;③ TensorRT 加速;④ 转成服务端 Flask/FastAPI endpoint。
4. PyTorch 完整 Pipeline 代码(Mini-Pipeline,单文件可跑)
下面是一份端到端可运行的 Mini-Pipeline,涵盖「数据加载 → 增强 → 训练 → 评估 → 单图推理」全流程,直接在 Linux + GPU 环境复制粘贴即可。
# train_cifar10.py
import time, torch, torchvision
import torch.nn as nn, torch.nn.functional as F
import torchvision.transforms as T
device = 'cuda' if torch.cuda.is_available() else 'cpu'
torch.manual_seed(0)
# ---------- 数据 ----------
CIFAR_MEAN = (0.4914, 0.4822, 0.4465)
CIFAR_STD = (0.2470, 0.2435, 0.2616)
train_tfm = T.Compose([
T.RandomCrop(32, padding=4), T.RandomHorizontalFlip(),
T.ToTensor(), T.Normalize(CIFAR_MEAN, CIFAR_STD)])
test_tfm = T.Compose([T.ToTensor(), T.Normalize(CIFAR_MEAN, CIFAR_STD)])
train_set = torchvision.datasets.CIFAR10('./data', True, True, train_tfm)
test_set = torchvision.datasets.CIFAR10('./data', False, True, test_tfm)
train_loader = torch.utils.data.DataLoader(
train_set, batch_size=128, shuffle=True, num_workers=2, pin_memory=True)
test_loader = torch.utils.data.DataLoader(
test_set, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)
# ---------- 模型 ----------
class BasicBlock(nn.Module):
expansion = 1
def __init__(self, in_c, out_c, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_c, out_c, 3, stride, 1, bias=False)
self.bn1 = nn.BatchNorm2d(out_c)
self.conv2 = nn.Conv2d(out_c, out_c, 3, 1, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_c)
self.short = (nn.Sequential(
nn.Conv2d(in_c, out_c, 1, stride, bias=False),
nn.BatchNorm2d(out_c))
if stride != 1 or in_c != out_c else nn.Identity())
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)), inplace=True)
out = self.bn2(self.conv2(out))
return F.relu(out + self.short(x), inplace=True)
class ResNet18(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.stem = nn.Sequential(nn.Conv2d(3, 64, 3, 1, 1, bias=False),
nn.BatchNorm2d(64), nn.ReLU(inplace=True))
self.layer1 = self._make(64, 64, 2, 1)
self.layer2 = self._make(64, 128, 2, 2)
self.layer3 = self._make(128, 256, 2, 2)
self.layer4 = self._make(256, 512, 2, 2)
self.avgpool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Linear(512, num_classes)
def _make(self, in_c, out_c, n, s):
layers = [BasicBlock(in_c, out_c, s)]
for _ in range(n - 1):
layers.append(BasicBlock(out_c, out_c, 1))
return nn.Sequential(*layers)
def forward(self, x):
x = self.stem(x)
x = self.layer1(x); x = self.layer2(x)
x = self.layer3(x); x = self.layer4(x)
return self.fc(self.avgpool(x).flatten(1))
model = ResNet18().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=5e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)
# ---------- 训练 ----------
@torch.no_grad()
def evaluate():
model.eval()
correct, total = 0, 0
for x, y in test_loader:
x, y = x.to(device), y.to(device)
correct += (model(x).argmax(1) == y).sum().item()
total += y.size(0)
return correct / total
for epoch in range(1, 31):
model.train()
for x, y in train_loader:
x, y = x.to(device), y.to(device)
loss = criterion(model(x), y)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
scheduler.step()
if epoch % 5 == 0:
print(f'epoch {epoch:2d} test acc {evaluate():.4f}')
# 预期输出(简化):
# epoch 5 test acc 0.8803
# epoch 10 test acc 0.9185
# epoch 15 test acc 0.9356
# epoch 20 test acc 0.9421
# epoch 25 test acc 0.9452
# epoch 30 test acc 0.9478
4.1 跑通后的最小验证
| 检查项 | 期望 |
|---|---|
model.parameters() 总参数量 |
~11.17 M |
| 单 epoch 时间(V100) | ~15 s |
| 30 epoch 总时间(V100) | ~8 min |
| 单 epoch 时间(RTX 3090) | ~20 s |
| 测试集准确率(30 epoch) | ≥ 0.94 |
| 单图推理耗时 | < 5 ms |
| 模型权重文件大小 | ~45 MB |
4.2 与 Day 14 ML Pipeline 的对照
| 维度 | Day 14 经典 ML | Day 21 深度学习 |
|---|---|---|
| 输入维度 | 手工特征(如 Titanic 12 列) | 原始像素(3072 维) |
| 特征工程 | 必须(缺失值 / 类别编码) | 模型内部学(端到端) |
| 模型参数量 | < 1 K | 11.17 M |
| 训练时长(CPU) | 秒级 | 小时级 |
| 训练时长(GPU) | 秒级 | 分钟级 |
| 推理速度 | < 1 ms | 1-5 ms |
| 评估指标 | Accuracy / F1 | Accuracy / Top-5 |
| 部署方式 | sklearn pickle | TorchScript / ONNX |
| 失败模式 | 过拟合 / 类别不平衡 | 训练不稳定 / 数据增强错 |
核心区别:Day 14 的 Pipeline 把「特征工程」当主角,模型只做最后一步分类;Day 21 的 Pipeline 把「特征工程」内化到 CNN 卷积层,只要给足够数据和算力,模型自动学到最优特征表示——这是深度学习相对经典 ML 的范式飞跃。
5. Week 3 vs Week 4:难度与心智模型对比
| 维度 | Week 3(深度学习) | Week 4(大模型与生成式 AI) |
|---|---|---|
| 任务类型 | 视觉 / 序列基础任务 | 自然语言生成 / 预训练 / 微调 |
| 模型规模 | 11 M(ResNet-18) | 1.5 B - 175 B(GPT 系列) |
| 数据规模 | 5 万图像(CIFAR-10) | 300 B tokens(LLM 预训练) |
| 训练硬件 | 单卡 GPU(8 GB 显存足够) | 多机多卡 / 数百 GPU |
| 训练时长 | 30 分钟 | 数周 |
| 损失函数 | Cross-Entropy | Causal LM / Masked LM |
| 优化器 | SGD / AdamW | AdamW(必有 warmup + cosine) |
| 评估方式 | 准确率 / Top-5 | PPL / MMLU / HumanEval |
| 调试难点 | 训练不收敛 / 过拟合 | 显存溢出 / 长文本 OOM / 复读 |
| 抽象层级 | 「这个卷积核看到什么」 | 「这个 token 在学什么语义」 |
| 产出物 | 一个会分类的 CNN | 一个会说话 / 推理 / 写作的 LLM |
| 核心心智 | 层级特征 + 残差连接 | 自监督预训练 + 涌现能力 |
Week 4 的难度跃迁点:① 损失函数从 Cross-Entropy 变成「下一个 token 预测」;② 模型从「分类头 + 全连接」变成「几十层 Transformer 堆叠」;③ 评估从「测试集准确率」变成「多任务榜单 + 人类偏好」。Week 3 学会的「Pipeline 思维」(读 → 改 → 喂 → 算 → 学 → 评)在 Week 4 不变,变的是「算」这一步——从 CNN 残差块变成 GPT 的 Self-Attention 堆叠。
6. 常见坑(8 条,CIFAR-10 实战典型)
6.1 训练 30 epoch 后测试准确率只有 80%
症状:train acc 99%、test acc 80%,严重过拟合。
原因:没有数据增强或归一化用错;CIFAR-10 训练集只有 5 万张 32×32 图像,没增强直接训必然过拟合。
修法:① 加 RandomCrop(32, padding=4) + RandomHorizontalFlip();② 用 CIFAR 自己的 (0.4914, 0.4822, 0.4465) 归一化,不要用 ImageNet;③ 加 weight_decay=5e-4(L2 正则);④ 加 Dropout 或 Label Smoothing(0.1)。
6.2 训练 loss 不下降,卡在 2.3(Cross-Entropy 的均匀猜测值)
症状:train loss 一直 2.30、test acc 10%,等价于随机猜。
原因:学习率过大 或 标签全错 或 logits 全是 NaN。
修法:① 学习率从 1e-3 降到 1e-4 试;② 检查 y 是不是 [0, 9];③ 检查 nn.CrossEntropyLoss() 默认 reduction='mean',不要传 reduction='none';④ 检查是否把 logits 又过了一遍 softmax。
6.3 训练 loss 下降但 test acc 抖动剧烈
症状:test acc 在 88%-92% 之间大幅跳动,不收敛。
原因:测试时没 model.eval()——BatchNorm 仍用当前 batch 的均值方差,Dropout 仍随机置零。
修法:evaluate 函数第一行 model.eval(),且用 @torch.no_grad() 装饰;还要确认 evaluate 里不调用 loss.backward()(即使显式 @torch.no_grad 也不该)。
6.4 ResNet 残差块 shape 对不上,报 mat1 and mat2 mismatch
症状:RuntimeError: The size of tensor a (128) must match the size of tensor b (256)
原因:残差连接的捷径没对齐维度——stride=2 的下采样层把 feature map 缩小一半、通道翻倍,但 self.short 是 nn.Identity()。
修法:在 BasicBlock.__init__ 里加:
if stride != 1 or in_c != out_c:
self.short = nn.Sequential(nn.Conv2d(in_c, out_c, 1, stride, bias=False),
nn.BatchNorm2d(out_c))
6.5 用了 ImageNet 的归一化均值,训练崩坏
症状:loss 不下降 / 训练完全发散。
原因:ImageNet 用 (0.485, 0.456, 0.406),CIFAR 是 (0.4914, 0.4822, 0.4465)——虽然差得不远,但训练集小、数据分布差异会让前几 epoch 崩。
修法:永远用当前数据集自己的均值 / 标准差,可以用 train_set.data.mean(axis=(0,1,2)) / 255 计算或直接抄公开数值。
6.6 DataLoader num_workers 设太大,RAM 爆掉
症状:训练卡住,系统 swap 飙升,甚至 OOM Killed。
原因:num_workers 越大,每个 worker 进程独立加载数据,RAM 占用 = num_workers × 单进程占用。Linux 默认 4-8 没问题,但在 16 GB 内存的开发机设 16 会爆。
修法:num_workers 从 2-4 起,边训边看 top 的 RES 占用;如果是 SSD + Linux,可以加大;机械硬盘反而设 0 更稳。
6.7 没 pin_memory=True,GPU 利用率低
症状:nvidia-smi 显示 GPU 利用率 30-50%,训练慢。
原因:DataLoader 输出的 Tensor 在 CPU 内存,与 GPU 之间传输时是 pageable,速度慢。
修法:DataLoader(..., pin_memory=True),再在 .to(device, non_blocking=True) 时加 non_blocking=True——CPU→GPU 拷贝异步执行,GPU 利用率可上 90%+。
6.8 训练完模型保存成整个 model 对象,加载报 pickle 错
症状:torch.load('best.pth') 报 RuntimeError: storage has wrong size 或 module not found。
原因:torch.save(model, ...) 把整个对象序列化(包含 model_class 定义),换机器 / 改类名后加载失败。
修法:只保存 state_dict:torch.save(model.state_dict(), 'best.pth');加载时先实例化模型,再 model.load_state_dict(torch.load('best.pth'))。
7. 自检三问
A. ResNet 为什么用残差连接 $H(x) = F(x) + x$ 而不是直接拟合 $H(x)$?在反向传播时梯度怎么流过这个加法?
要点:残差连接的工程意义是给梯度一条「捷径」。$\partial L / \partial x = \partial L / \partial H \cdot (1 + \partial F / \partial x)$,即使 $\partial F / \partial x$ 很小(深层网络退化时常常如此),加号带来的 1 也让梯度至少为 $\partial L / \partial H$——梯度不会消失。几何上,「学残差」比「学完整映射」更易优化:输入附近的恒等映射只需把 $F$ 推到 0,而不是从零学起一个全新映射。详见 §3.4。
B. 数据增强在训练时用 RandomCrop + Flip,测试时只用 Resize + Normalize,这是为什么?能否用同样的增强?
要点:数据增强是训练时的正则化——给模型看「同一物体的多种变体」,强迫它学「什么是不变特征」。测试/推理时必须用确定性的预处理,否则同一张图跑两次得到不同结果,无法复现实验。永远不要把随机增强用到 test / val / inference。详见 §3.3。
C. CIFAR-10 用 ResNet-18 跑 30 epoch 可以到 94-95%,如果只用 5 epoch 就停,准确率会停在多少?为什么?
要点:5 epoch 大约停在 88-90%。原因:① 数据增强让模型需要更多 epoch 才能拟合;② AdamW 的初始学习率 1e-3 在前 5 epoch 处于 warmup 期;③ CosineAnnealing 在前 5 epoch 衰减幅度小,模型「还没学完」。深度学习训练 epoch 数 = (模型参数量 × 数据量) / 经验常数,CIFAR-10 + ResNet-18 经验值就是 30 epoch。如果想 5 epoch 达到 94%,需要换 SGD + 大学习率 + StepLR,或者用预训练权重(Week 4 主题)。
8. 推荐资源(5 类)
视频
- CS231n · Convolutional Neural Networks (Stanford) Lecture 5-9——CNN 基础 + ResNet + 训练技巧的完整体系
- 李沐 · 深度学习课程(d2l.ai) 第 7 章——ResNet 论文精读 + PyTorch 实现
- Yannic Kilcher · ResNet 论文讲解(YouTube)——30 分钟读完原论文
教科书
- 《动手学深度学习》(d2l.ai) 第 7-8 章——CNN / 现代 CNN(ResNet / DenseNet)
- 《Deep Learning》(Goodfellow) 第 9 章——卷积网络数学基础
- 《深度学习:核心算法与实践》——CIFAR-10 全流程中文教材
论文
- He et al. 2015《Deep Residual Learning for Image Recognition》——ResNet 原论文,ImageNet 2015 冠军
- Krizhevsky et al. 2009《Learning Multiple Layers of Features from Tiny Images》——CIFAR-10 数据集原始论文
- Ioffe & Szegedy 2015《Batch Normalization》——BatchNorm 原论文,CIFAR-10 上必读
博客 / 课程
- PyTorch 官方教程 · Training a Classifier——CIFAR-10 完整 Pipeline
- paperswithcode · CIFAR-10 Leaderboard——查 SOTA 模型与准确率
- Lil’Log · ResNet 解读——图解 ResNet 内部工作机制
代码
- PyTorch 官方 Examples · ImageNet Training——官方 ResNet-50 实现
- timm (Ross Wightman)——数百种预训练视觉模型,可直接
timm.create_model('resnet18') - kornia——可微分的数据增强库,与 PyTorch 训练管线无缝集成
9. 本节要点
- Week 3 五天零件:神经网络(Day 15)→ PyTorch(Day 16)→ CNN(Day 17)→ RNN/LSTM(Day 18)→ Transformer(Day 19),逐层深入,从「什么是学习」到「怎么写工程代码」到「图像 / 序列 / 全局依赖的架构选择」
- CIFAR-10 是深度学习的「Hello World」:50K 训练 / 10K 测试、10 类、32×32×3,基线 ResNet-18 应达到 94-95%
- 残差连接 $H(x) = F(x) + x$ 通过「恒等捷径」让梯度可以跳过深层而不消失,是 100+ 层网络可训练的关键
- 数据增强是 CIFAR-10 上分最便宜的「免费午餐」:
RandomCrop(32, padding=4) + RandomHorizontalFlip通常带来 5-10% 提升,测试集永远不做随机增强 - PyTorch Pipeline 六步:读(
Dataset)→ 改(transforms)→ 喂(DataLoader)→ 算(nn.Module)→ 学(optim + scheduler)→ 评(model.eval() + @torch.no_grad()) - 常见失败模式:归一化用错数据集、BatchNorm/Dropout 没切 train/eval、num_workers 设太大、DataLoader 没 pin_memory、model 直接 save 而非 state_dict
- Week 3 vs Week 4 心智跃迁:CNN 的「层级特征提取」→ LLM 的「自监督预训练 + 涌现」;Week 4 在 Week 3 Pipeline 基础上把「算」这一步换成 Self-Attention 堆叠
10. 下一节:Day 22 · 预训练范式与 Tokenization
主题:从「专门任务的监督学习」跃迁到「自监督预训练 + 下游微调」,这是过去 7 年 NLP 最重要的范式革命——GPT 用 Causal LM(预测下一个 token)、BERT 用 Masked LM(预测被遮蔽的 token),两者共同奠定 LLM 时代。
覆盖:
- GPT-1 (2018, 117M 参数) / BERT (2018, 340M 参数) / GPT-2 (2019, 1.5B) / GPT-3 (2020, 175B) 的发布时间、参数量、核心创新
- BPE 算法手算与 WordPiece / SentencePiece / Unigram 4 种子词 tokenizer 的核心区别
- HuggingFace
tokenizers库与 OpenAItiktoken库的现状与典型用法
产出物:用 HuggingFace tokenizers 从零训练一个 BPE tokenizer(语料取自 Day 14 处理的 Titanic 文本),再用它编码 / 解码一个句子,理解「为什么 Hello world 会被切成 1-3 个 token」。
作者:林馨予 + 林晓月