Skip to content

渐进式教程:三版跑起来

本页速览 同一个图像分类任务,用三版代码迭代演进:v1 最小可用 MLP 先让管道跑通,v2 加数据增强、BatchNorm、学习率调度与早停,v3 换预训练 CNN 并做完整评估。每一版都讲清"解决什么问题、代价是什么",并给出对比表。

渐进式教程:三版跑起来

一句话定义:写深度学习代码的正确姿势不是"一步到位",而是"三步到位"——先最小可用,再逐项加技巧,最后换架构并做完整评估。每一版只引入一到两个变量,你能清楚知道每个改动带来了什么。

本文用 CIFAR-10(彩色 32×32,训练集 50000 张、测试集 10000 张,10 个类别)做同一个任务,迭代三个版本:

版本模型训练技巧测试准确率(参考)训练时长(单卡)
v1MLP(仅 1 层隐藏层)~40%数分钟
v24 层 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 更低。真正上线的模型需要按业务代价重新加权指标,这正是评估实践一整篇的主题。

四、三版对比与心法

维度v1v2v3
模型单隐藏层 MLP4 层 CNN + BN预训练 ResNet-18
数据无增强随机裁剪/翻转 + 归一化同 v2(ImageNet 统计量)
优化器SGDAdam + 余弦退火Adam(两阶段)
早停
代码量~30 行~70 行~60 行 + 预训练
测试准确率~40%~78%~90%+
主要风险管道不通新技巧引入的新坑迁移设置错误、结果虚高

三个版本合起来教给你的,是比任何单一技巧都重要的心法

  1. 先让管道通,再让它好——v1 的通是后面一切的前提。
  2. 一次只引入一组变量——v2 到 v3 的每一次提升都能归因到具体改动,这正是实验纪律的核心(见DL 设计原则)。
  3. 收益递减时停下来——从 40% 到 78% 的代价是 20 分钟,从 78% 到 90% 靠迁移学习,再往上每个百分点都需要指数级投入。

延伸阅读

参考资料