1.最小可运行实现
第 1 章 — 最小可运行实现
这是整套教程的第一章。目标只有一个: 用最少的代码跑通一次完整的 CIFAR-10 图像分类训练与验证 。本章假设你只了解深度学习的基本概念(知道"神经网络能学图像特征"就够),代码里出现的每个概念都会用通俗的语言解释一遍。本章刻意不做任何工程化(没有 argparse、没有 logging、没有 checkpoint),为的是让你先看清训练流程的"骨架"长什么样。
一、本章要解决的问题
- 之前:没有任何代码,面对 PyTorch 不知道从哪下手。
- 现在:我们需要一个 最小可运行 的示例,覆盖模型定义、数据加载、损失函数、优化器、训练循环、验证循环这六个核心环节。
- 判断标准:
python train.py跑起来,终端里能看到每个 epoch 的 loss 和 accuracy。
二、核心概念速览
下面 10 个概念是本章代码的全部"生词"。先花 5 分钟读完,再看代码会轻松很多。
1. 张量(Tensor)
PyTorch 的基本数据单位,可以理解成"带 GPU 加速、能自动求导的多维数组"。一张 32x32 的彩色图片在 PyTorch 里就是一个形状为 (3, 32, 32) 的张量(3 是 RGB 三个颜色通道,32x32 是宽高)。它的维度顺序约定是 C×H×W(通道×高×宽) ,跟我们平时说的"宽×高"不同,这是 PyTorch 的惯例。
2. 卷积层(Conv2d)
"扫描图像找局部特征"的层:一个小窗口(如 3x3)在图片上滑动,每次提取一个局部特征(边缘、纹理)。多个卷积层堆叠后,前面的层学低级特征(边缘、颜色块),后面的层学高级特征(眼睛、轮子)。padding=1 是在图片四周补一圈 0,让卷积后尺寸不变——这样网络深度增加时图片不会越缩越小。

3. 批归一化(BatchNorm)
让每一层的输出数值保持"均值 0、方差 1"左右的稳定分布。可以类比成"给每层的数据做一次标准化"。它显著加速收敛、减少对学习率初始值的敏感。注意:训练和推理时它的行为不同(训练时用当前 batch 统计,推理时用训练累计的全局统计),这就是后面 model.train() / model.eval() 切换的原因之一。
4. 激活函数 ReLU
给网络引入 非线性 。如果没有激活函数,多层线性变换叠起来还是线性变换,再深的网络也"白深"。ReLU 是最简单的一种:f(x) = max(0, x),负数全部压成 0,正数保持不变。

5. 池化层(MaxPool2d)
下采样:把 2x2 区域里最大的值取出来,图片尺寸减半(32→16→8→4),通道数不变。作用:缩小特征图、减少计算量、让特征有"平移不变性"(稍微移动一点不影响最大值位置)。

6. 损失函数(CrossEntropyLoss)
衡量"模型预测 vs 真实答案"差多少的函数,是训练的目标函数。CrossEntropyLoss 内部做了两件事:把网络输出的 logits(未归一化的分数)转成概率分布(softmax),再计算这个分布与真实类别的"距离"(交叉熵)。 数值越小 = 预测越准 ,训练就是不断减小它。
7. 优化器(Adam)与梯度下降
训练的核心循环:算出损失 → 反向传播得到每个参数的"梯度"(该往哪个方向调、调多少能让损失变小)→ 优化器按梯度更新参数。Adam 是增强版的梯度下降(自动调整每个参数的学习步长),对新手最友好,不需要手动调学习率策略就能收敛。optimizer.step() 就是"按梯度走一步"。
8. Epoch / Batch / Iteration
- Epoch:把整个训练集完整过一遍 = 1 个 epoch;
- Batch:每次喂给模型的样本组(如 64 张图);
- Iteration:处理完一个 batch 叫一次 iteration。 CIFAR-10 训练集 50000 张、batch_size=64 时,一个 epoch ≈ 782 次 iteration。GPU 显存有限,不能一次吃下全部数据,所以分批喂。
9. Dataset / DataLoader
Dataset:定义"数据长什么样 + 如何取一条样本"(我们直接用 torchvision 自带的 CIFAR-10,不用自己写);DataLoader:在 Dataset 之上自动做 分批、打乱顺序、多进程预加载 。shuffle=True保证每个 epoch 训练样本顺序不同(防止模型学到顺序规律)。
10. 数据归一化(Normalize)
把像素值从 0~255 缩放到"均值 0 附近"(0~1 再减去均值除以方差)。为什么要做?因为神经网络对 数值量级敏感 :如果输入数值很大,梯度会不稳定,训练很难收敛。CIFAR-10 的均值/方差是固定的经验值,直接抄即可。
11. 训练模式 vs 推理模式
model.train():启用 BatchNorm 的 batch 统计、Dropout 等训练专属行为;model.eval():切换到推理行为,且 torch.no_grad() 关闭梯度计算(省显存、省时间)。验证/测试时两者都要用,这是新手最常犯的错误之一。
三、解决思路
- 模型:用一个 3 层卷积 + 全连接的
SimpleCNN,够小、够快,能在 CPU 上几分钟内完成一轮训练。 - 数据:直接使用
torchvision.datasets.CIFAR10,download=True自动下载;用ToTensor()+Normalize()做最基本预处理。 - 循环:训练循环中
model.train()+ 清零梯度 + 前向 + 反向 +optimizer.step();验证循环中model.eval()+torch.no_grad(),只算指标不更新参数。 - 不做什么:不拆分验证集细节、不做任何配置化——这些留给后续章节。
trade-off:最小代码必然牺牲工程性(超参数写死、指标只打印不记录),但作为第一步,它把"训练的本质"暴露得最清楚。
四、代码变更
本章是从零开始,无上一章可比。整体结构为:
+ train.py # 唯一的新文件,全部代码都在这五、完整代码
创建 train.py:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# ----------------------------------------------------------------------------
# 0. 设备:有 GPU 用 GPU,没有就用 CPU
# ----------------------------------------------------------------------------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
# 说明:torch.cuda.is_available() 返回当前机器是否有可用的 NVIDIA GPU。
# 后续所有"数据/模型/计算"都要 .to(device) 放到同一个设备上,
# 否则会出现 "Expected all tensors to be on the same device" 报错。
# ----------------------------------------------------------------------------
# 1. 模型:SimpleCNN —— 3 个卷积块 + 全局平均池化 + 线性分类头
# ----------------------------------------------------------------------------
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
# 块1:3 -> 32 通道。CIFAR-10 是 32x32 输入,padding=1 保持分辨率不变
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32), # 稳定数值分布,加速收敛
nn.ReLU(inplace=True), # inplace=True 节省内存(原地修改)
nn.MaxPool2d(2), # 32x32 -> 16x16
# 块2:32 -> 64 通道(注意输入通道必须=上一层输出通道 32)
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 16x16 -> 8x8
# 块3:64 -> 128 通道
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 8x8 -> 4x4
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # 任意输入尺寸 -> 1x1,省去手算展平维数
nn.Flatten(), # (B,128,1,1) -> (B,128)
nn.Linear(128, num_classes), # 128 维特征 -> 10 类 logits
)
def forward(self, x):
# 前向传播:特征提取 -> 分类。x 形状: (batch, 3, 32, 32)
return self.classifier(self.features(x))
# ----------------------------------------------------------------------------
# 2. 数据:CIFAR-10,训练集 50000 张,测试集 10000 张
# ----------------------------------------------------------------------------
transform = transforms.Compose([
transforms.ToTensor(), # PIL 图片(0~255 int) -> tensor(0~1 float),并调整通道顺序
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
# 减去均值、除以方差,把像素分布中心化到 0 附近,加速收敛
])
train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
val_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)
BATCH_SIZE = 64
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=2)
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
# ----------------------------------------------------------------------------
# 3. 损失函数 + 优化器
# ----------------------------------------------------------------------------
model = SimpleCNN().to(device) # 把模型搬到 GPU/CPU
criterion = nn.CrossEntropyLoss() # 分类任务的默认损失:softmax + 交叉熵
optimizer = optim.Adam(model.parameters(), lr=1e-3) # 优化器管理模型的全部可学习参数
# ----------------------------------------------------------------------------
# 4. 训练与验证函数
# ----------------------------------------------------------------------------
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train() # 训练模式:启用 BN 的 batch 统计
total_loss, correct, total = 0.0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device) # 数据搬上设备
optimizer.zero_grad() # 清零梯度(梯度会累加,不清会叠加)
outputs = model(images) # 前向传播 -> (batch, 10) 的 logits
loss = criterion(outputs, labels) # 计算这批的损失
loss.backward() # 反向传播:算出每个参数的梯度
optimizer.step() # 按梯度更新一次参数
total_loss += loss.item() * images.size(0) # 累加损失(乘样本数,后面加权平均)
correct += (outputs.argmax(dim=1) == labels).sum().item() # 预测类别=得分最高的类
total += images.size(0)
return total_loss / total, correct / total # 返回 平均loss, 准确率
def validate(model, loader, criterion, device):
model.eval() # 评估模式:BN 用全局统计量
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # 验证不计算梯度,省显存、更快
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += images.size(0)
return total_loss / total, correct / total
# ----------------------------------------------------------------------------
# 5. 主训练循环
# ----------------------------------------------------------------------------
EPOCHS = 10
for epoch in range(1, EPOCHS + 1):
train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
val_loss, val_acc = validate(model, val_loader, criterion, device)
print(f"Epoch {epoch:02d}/{EPOCHS} | "
f"Train loss {train_loss:.4f} acc {train_acc:.4f} | "
f"Val loss {val_loss:.4f} acc {val_acc:.4f}")六、知识补充:用 torchsummary 看清模型结构
新手常困惑"这个模型到底有多少层、多少参数?"。社区库 torchsummary 一行代码就能打印每一层的输出形状和总参数量:
pip install torchsummary在训练前加上:
from torchsummary import summary
summary(model, input_size=(3, 32, 32), device=str(device))输出示例:
----------------------------------------------------------------
Layer (type) Output Shape Param #
----------------------------------------------------------------
Conv2d-1 [-1, 32, 32, 32] 896
BatchNorm2d-2 [-1, 32, 32, 32] 64
ReLU-3 [-1, 32, 32, 32] 0
MaxPool2d-4 [-1, 32, 16, 16] 0
Conv2d-5 [-1, 64, 16, 16] 18,496
...
Linear-13 [-1, 10] 1,290
----------------------------------------------------------------
Total params: 146,762
Trainable params: 146,762读这张表的方法:
Output Shape 每行的
-1表示 batch 维度(-1= 任意值),后面的[32,32,32]是"通道×高×宽";Param # 是这一层的可学习参数个数,卷积层是
输出通道×(输入通道×核宽×核高+1),+1是偏置;参数量从 0 到 14 万,对 CNN 来说非常小——这就是它能在 CPU 上快速训练的原因。
如果运行时报 ModuleNotFoundError: No module named 'torchsummary',说明还没安装,执行上面的 pip install 即可。
七、本章小结
- 学到了什么
- 训练的本质就是一个循环:
model.train()→zero_grad→forward→backward→step。 - 验证必须在
torch.no_grad()下进行,且model.eval()会改变 BN/Dropout 的行为,两者缺一不可。 argmax取预测类别,再与标签比较得到 accuracy。- 卷积网络通过"卷积提特征 → 池化缩尺寸 → 全连接分类"三步完成图像分类。
- 训练的本质就是一个循环:
- 常见坑
- 忘了
model.train()/eval()切换:BN 统计量错乱,指标忽高忽低。 - 忘了
optimizer.zero_grad():梯度累加,loss 爆炸。 - 数据/模型不在同一个设备(
device不匹配)会直接报错,运行时先看报错里的 "device" 字样。 download=True首次运行需要网络;root目录会缓存数据,别随意删。
- 忘了
- 下一章预告:第 1 章里"验证集"其实是测试集,且指标只是打印一下就没了——我们无法判断模型泛化能力,也无法分析训练过程,第 2 章将引入训练/验证/测试三段划分与指标记录。
八、动手练习
- 改结构:把
num_classes改成 2,训练一个猫狗二分类(用torchvision.datasets.CIFAR10过滤出前两类),观察准确率变化。 - 改优化器:把
Adam换成SGD(lr=0.1, momentum=0.9),对比收敛速度,并解释为什么 SGD 需要更大的 lr。 - 观察梯度:在
loss.backward()后打印model.features[0].weight.grad.abs().mean().item(),体会梯度在网络中的流动。 - 玩转 torchsummary:把
SimpleCNN的卷积层数从 3 改成 4(或调整通道数),用 torchsummary 观察参数量变化,验证你算出的参数量与打印结果一致。
