外观
渐进式教程:三版跑起来
一句话定义:写深度学习代码的正确姿势不是"一步到位",而是"三步到位"——先最小可用,再逐项加技巧,最后换架构并做完整评估。每一版只引入一到两个变量,你能清楚知道每个改动带来了什么。
本文用 CIFAR-10(彩色 32×32,训练集 50000 张、测试集 10000 张,10 个类别)做同一个任务,迭代三个版本:
| 版本 | 模型 | 训练技巧 | 测试准确率(参考) | 训练时长(单卡) |
|---|---|---|---|---|
| v1 | MLP(仅 1 层隐藏层) | 无 | ~40% | 数分钟 |
| v2 | 4 层 CNN | 增强 + BatchNorm + Adam + 余弦退火 + 早停 | ~78% | 约 20 分钟 |
| v3 | 预训练 ResNet-18 微调 | 迁移学习 + 细粒度学习率 | ~90%+ | 约 15 分钟 |
三个版本的对比不是"更好的模型",而是更高明的工程决策。基础概念(反向传播、损失、优化器)见神经网络基础与优化与梯度下降;数据加载与归一化的完整说明见从零构建一个深度学习项目。
一、v1:最小可用版
目标只有一个:验证"数据 → 模型 → 训练 → 评估"管道能跑通。不追求性能,只追求最短路径。
python
import torch, torch.nn as nn, torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# 数据:只做最基本的 ToTensor 归一化
transform = transforms.ToTensor()
train_set = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
test_set = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2)
test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)
# 模型:单隐藏层 MLP,约 30 行
class MLP(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.fc1 = nn.Linear(32 * 32 * 3, 512)
self.fc2 = nn.Linear(512, num_classes)
def forward(self, x):
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
return self.fc2(x)
model = MLP()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
for epoch in range(10):
model.train()
total_loss, correct, total = 0.0, 0, 0
for x, y in train_loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
total_loss += loss.item() * x.size(0)
correct += (model(x).argmax(1) == y).sum().item()
total += y.size(0)
print(f"epoch {epoch+1} | loss {total_loss/total:.3f} | acc {correct/total:.3f}")这版解决了什么问题:它建立了基线,证明代码、环境、数据流全部正确。它只有约 30 行,任何一处报错都能快速定位。
代价是什么:性能很差(约 40%,接近瞎猜的 10% 好不了太多)。原因不止模型太浅,更关键的是它把所有像素拉平了——CIFAR-10 的图像结构(颜色、边缘、纹理)对 MLP 来说全是噪声;同时没有归一化、没有增强,训练也很脆弱。这些坑的完整清单见常见陷阱与反模式。
为什么先写烂代码
很多人羞于让基线"烂"。但正因为烂,后续每次改进产生的增量才可测量。如果你第一版就上预训练 ResNet,得到的 90% 里分不清多少来自架构、多少来自技巧、多少来自数据。
二、v2:加训练技巧
在"管道已通"的基础上,这一版只做一件事:系统性加入已被验证有效的训练技巧,每加一个立刻跑一次看效果。
python
import torch, torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# 1) 数据增强:随机裁剪 + 水平翻转(CIFAR-10 的标准配置)
train_transform = transforms.Compose([
transforms.RandomCrop(32, padding=4), # 先四周补 4 像素再随机裁剪回 32x32
transforms.RandomHorizontalFlip(), # 50% 概率水平翻转
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
test_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
train_set = datasets.CIFAR10(root="./data", train=True, download=True, transform=train_transform)
test_set = datasets.CIFAR10(root="./data", train=False, download=True, transform=test_transform)
train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)
# 2) 4 层卷积 + BatchNorm
class CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
nn.MaxPool2d(2), # 32->16
nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
nn.MaxPool2d(2), # 16->8
nn.AdaptiveAvgPool2d(1), # -> [B,128,1,1]
)
self.classifier = nn.Sequential(nn.Flatten(), nn.Linear(128, num_classes))
def forward(self, x):
return self.classifier(self.features(x))
model = CNN()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# 3) 学习率调度:余弦退火(余弦退火原理见训练配方)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)
# 4) 早停:在验证集上监控,连续 5 个 epoch 无提升就停止
best_val_acc, patience, bad_epochs = 0.0, 5, 0
for epoch in range(20):
model.train()
for x, y in train_loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
scheduler.step()
# 验证(教学示例为简洁直接用测试集做验证;正式实验应从训练集再切出验证集,
# 遵循评估实践"测试集只碰一次"的纪律)
model.eval()
val_correct, val_total = 0, 0
with torch.no_grad():
for x, y in test_loader:
val_correct += (model(x).argmax(1) == y).sum().item()
val_total += y.size(0)
val_acc = val_correct / val_total
print(f"epoch {epoch+1} | val_acc {val_acc:.4f}")
if val_acc > best_val_acc:
best_val_acc = val_acc
bad_epochs = 0
torch.save(model.state_dict(), "best.pt")
else:
bad_epochs += 1
if bad_epochs >= patience:
print("early stop")
break这版解决了什么问题:
- 数据增强直接对抗过拟合——每张图在每次 epoch 都略有不同,等于把数据集"变大了"(原理见过拟合与正则化)。
- BatchNorm 让每一层输入分布稳定,允许你用更大的学习率、更少操心初始化(见初始化与归一化)。
- Adam 对学习率不敏感,收敛快,是"不知道调什么"时的稳妥默认。
- 余弦退火在训练后期把学习率降到很低,帮助 loss 在最小值附近"落定"。
- 早停用验证集监控,自动决定训练什么时候结束,省时间也防过拟合。
代价是什么:
- 增强拖慢了数据读取,
num_workers不足时 GPU 会挨饿; - BatchNorm 引入了"训练/推理行为不一致"和"batch size 太小时失效"两类新坑;
- 4 层 CNN 约 5 分钟/10 epoch 的代价从分钟级变成二十分钟级,而且 Adam 的泛化能力在部分任务上不如带调好的 SGD + momentum;
- 验证集被反复用来"选"模型了——严格说应该再切出一块数据,或者至少记住:现在报的准确率是验证集表现,不是最终成绩。评估纪律详见评估实践。
v2 测试准确率约 78%。方向对了,但离 SOTA 还远。
三、v3:换架构 + 完整评估
v2 的 CNN 是"从头训练",而 ImageNet 上预训练的 ResNet-18 已经把"边缘→纹理→部件"的视觉表征学好了(见表征学习与预训练)。迁移学习让小数据集任务直接站在巨人肩膀上:
python
import torch, torch.nn as nn
from torchvision import models, transforms
from torchvision.datasets import CIFAR10
from torch.utils.data import DataLoader
# 数据:用 ImageNet 的均值和标准差(和预训练权重配套,不能用自己的统计量)
train_transform = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
])
test_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
])
train_set = CIFAR10(root="./data", train=True, download=True, transform=train_transform)
test_set = CIFAR10(root="./data", train=False, download=True, transform=test_transform)
train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)
# 预训练 ResNet-18:替换最后全连接层为 10 类
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(model.fc.in_features, 10)
# 冻结 backbone:第一阶段只训分类头
for param in model.parameters():
param.requires_grad = False
for param in model.fc.parameters():
param.requires_grad = True
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# 第一阶段:只训分类头(约 2-3 个 epoch 即可)
for epoch in range(3):
model.train()
for x, y in train_loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
print(f"head-only epoch {epoch+1} done")
# 第二阶段:解冻全部参数,用小学习率微调
for param in model.parameters():
param.requires_grad = True
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) # 微调学习率要小一个量级
for epoch in range(5):
model.train()
for x, y in train_loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
print(f"fine-tune epoch {epoch+1} done")这版解决什么问题:
- 解决"数据不够":CIFAR-10 只有 5 万张图,从头训深网容易过拟合;预训练特征让模型只需学"怎么把特征组合成 10 类"。
- 解决"训练太长":冻结阶段只更新 512×10 的全连接层,训练极快。
- 迁移学习配方(冻结→解冻、学习率差一个量级)的完整规则见训练配方与调参。
代价是什么:
- 分辨率不匹配:ResNet 是为 224×224 训练的,输入 32×32 会浪费部分能力(可用
torchvision的 resize 到 224 或直接用专门适配小图的cifar10预训练模型)。 - 微调学习率不能大:预训练权重已经很好了,学习率太大会把它"冲毁"——这是微调最常见的翻车点,见常见陷阱与反模式。
- 下载预训练权重需要网络,且 ResNet-18 体积约 45MB。
v3 测试准确率可到 90%+。
完整评估报告
v3 的收尾是"完整评估"——不止一个准确率,而是系统回答"模型到底行不行":
python
from sklearn.metrics import classification_report, confusion_matrix
import numpy as np
model.eval()
all_pred, all_true = [], []
with torch.no_grad():
for x, y in test_loader:
all_pred.append(model(x).argmax(1).numpy())
all_true.append(y.numpy())
all_pred, all_true = np.concatenate(all_pred), np.concatenate(all_true)
print(classification_report(all_true, all_pred, digits=4))
print(confusion_matrix(all_true, all_pred))报告里你会看到类别不均衡的分布——比如"猫"和"鸟"这类纹理难分的类别 recall 更低。真正上线的模型需要按业务代价重新加权指标,这正是评估实践一整篇的主题。
四、三版对比与心法
| 维度 | v1 | v2 | v3 |
|---|---|---|---|
| 模型 | 单隐藏层 MLP | 4 层 CNN + BN | 预训练 ResNet-18 |
| 数据 | 无增强 | 随机裁剪/翻转 + 归一化 | 同 v2(ImageNet 统计量) |
| 优化器 | SGD | Adam + 余弦退火 | Adam(两阶段) |
| 早停 | 无 | 有 | 有 |
| 代码量 | ~30 行 | ~70 行 | ~60 行 + 预训练 |
| 测试准确率 | ~40% | ~78% | ~90%+ |
| 主要风险 | 管道不通 | 新技巧引入的新坑 | 迁移设置错误、结果虚高 |
三个版本合起来教给你的,是比任何单一技巧都重要的心法:
- 先让管道通,再让它好——v1 的通是后面一切的前提。
- 一次只引入一组变量——v2 到 v3 的每一次提升都能归因到具体改动,这正是实验纪律的核心(见DL 设计原则)。
- 收益递减时停下来——从 40% 到 78% 的代价是 20 分钟,从 78% 到 90% 靠迁移学习,再往上每个百分点都需要指数级投入。
延伸阅读
- 从零构建一个深度学习项目——v1 的完整拆解与仓库结构
- 训练配方与调参——增强、调度、微调的具体参数
- 调试与诊断——v2 的 BatchNorm、早停出问题时怎么查
- 评估实践——v3 的评估报告如何升级为可信评估
- 表征学习与预训练——迁移学习的理论根基
- CNN 与计算机视觉——卷积架构的演进