外观
从零构建一个深度学习项目
一句话定义:从零构建深度学习项目,是把你已经学过的概念(数据、模型、损失、优化、评估)串成一条可运行的流水线——从裸机到"一个能训练、能评估、能复现、能给别人讲清楚的模型"才算走完。
纸上概念(见神经网络基础与反向传播与自动微分)与真正跑起来之间隔着大量工程细节。本文用一个经典任务——MNIST 手写数字识别(0–9,灰度 28×28,训练集 60000 张、测试集 10000 张)走通全部环节。全程使用 PyTorch,代码可直接运行。
一、目标与环境搭建
1. 先明确项目目标
任何项目开工前,先写下三句话:
- 任务:给定一张 28×28 灰度手写数字图,输出 0–9 的类别。
- 成功标准:测试集准确率 > 99%(MNIST 上 LeNet 级 CNN 很容易达到,MLP 也能到 98%)。
- 边界:暂不做数据增强、不做模型部署,先把管道跑通。
目标决定了你的资源分配。MNIST 是"hello world"级任务,几分钟就能训完;换成 CIFAR-10 或 ImageNet 则要重新考虑设备与时长(见评估与实验)。
2. 创建独立环境
深度学习依赖冲突频繁,永远不要装到全局 Python。用 venv 或 conda 隔离:
bash
# 创建虚拟环境
python -m venv .venv
# 激活(Linux/macOS)
source .venv/bin/activate
# 激活(Windows PowerShell)
.venv\Scripts\activate
# 确认解释器来自虚拟环境
which python # Linux/macOS
where python # Windows3. 安装 PyTorch
PyTorch 的 GPU 支持依赖 CUDA,安装命令与机器配置强相关。下表为写作时的稳定版本情况,具体版本号请以 PyTorch 官网 为准:
| 场景 | 安装命令 | 说明 |
|---|---|---|
| 无 GPU(学习首选) | pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu | CPU 版体积小、无兼容性问题 |
| NVIDIA GPU + CUDA 12.4 | pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124 | 需本机已装对应 CUDA 驱动 |
| NVIDIA GPU + CUDA 12.1 | pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 | 旧卡兼容性更好 |
| 无 GPU 快速体验 | 安装 CPU 版后即可训练(MNIST 每次 epoch 约 1 分钟) | 无需 GPU |
检查安装
bash
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"torch.cuda.is_available() 为 True 才说明 CUDA 可用。即使为 False 也不影响本文,CPU 也能跑。
依赖清单:torch、torchvision(含 MNIST 数据集与常用预处理)、matplotlib(画图)、numpy。训练与实验纪律的更多细节见训练配方与调参。
二、数据加载与预处理
1. 下载与查看数据
torchvision 直接提供 MNIST,root 指定缓存目录,download=True 自动下载:
python
from torchvision import datasets, transforms
# 训练/测试共用:转 Tensor 并归一化到 [0,1]
transform = transforms.Compose([
transforms.ToTensor(), # 把 PIL/ndarray 变成 [0,1] 的 Tensor
transforms.Normalize((0.1307,), (0.3081,)) # MNIST 均值/标准差
])
train_set = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
test_set = datasets.MNIST(root="./data", train=False, download=True, transform=transform)2. 先"看"数据再写模型
在写任何模型之前,先打印数据形状、画几张样本:
python
import matplotlib.pyplot as plt
x, y = train_set[0]
print(x.shape, y) # torch.Size([1, 28, 28]) 3
fig, axes = plt.subplots(1, 5, figsize=(10, 3))
for i in range(5):
img, label = train_set[i]
axes[i].imshow(img.squeeze(0), cmap="gray")
axes[i].set_title(f"label={label}")
axes[i].axis("off")
plt.savefig("data_samples.png", dpi=150)这一步看似多余,却能立刻暴露通道顺序、取值范围、标签含义三类最常见的数据问题。数据工程更多细节见数据与数据工程。
3. 归一化为什么重要
像素原始范围 [0, 255] 或 [0,1] 之间差别巨大:神经网络偏好数值在 0 附近、方差接近 1 的输入(与权重初始化尺度匹配),否则梯度在深层传递时会被放大或缩小(机理见初始化与归一化)。MNIST 的均值 0.1307、标准差 0.3081 是官方按全量训练集统计的常数,直接查表使用即可。
4. DataLoader 与批处理
python
from torch.utils.data import DataLoader
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)要点:
shuffle=True只用于训练——打乱顺序让每个 batch 独立同分布,避免模型学到批内顺序;测试集无需打乱。batch_size是显存占用与梯度噪声的平衡点,它与学习率的联动法则见训练配方与调参。
三、模型定义:从 MLP 起步
先不要急着上 CNN。第一步模型的目标是验证"数据→模型→训练→评估"管道是通的,哪怕它只是个简单的多层感知机(MLP):
python
import torch
import torch.nn as nn
import torch.nn.functional as F
class MLP(nn.Module):
"""输入 28*28=784 维,两个隐藏层,输出 10 类"""
def __init__(self, input_dim=784, hidden=256, num_classes=10):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden)
self.fc2 = nn.Linear(hidden, hidden)
self.fc3 = nn.Linear(hidden, num_classes)
def forward(self, x):
x = x.view(x.size(0), -1) # [B,1,28,28] -> [B,784],展平
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x) # 输出 logits,不在这里做 softmax
return x
model = MLP()
print(model)两个细节值得注意:
- 输出层不加 softmax:
nn.CrossEntropyLoss内部自己做了LogSoftmax + NLLLoss,你只需给 logits。先加 softmax 反而会造成数值不稳定与双重归一化(见损失函数与输出层)。 view展平:MLP 不感知二维结构,把 28×28 拉平成 784 维向量,像素间空间关系被丢弃——这正是后面换 CNN 的理由。
四、训练循环:显式写一遍
把训练循环完整写出来——前向 → 算损失 → 反向 → 更新,一个都不能少:
python
def train_one_epoch(model, loader, optimizer, criterion, device):
model.train() # 切换到训练模式(影响 Dropout/BatchNorm)
total_loss, correct, total = 0.0, 0, 0
for x, y in loader:
x, y = x.to(device), y.to(device)
# 1. 前向:输入 -> 预测 logits
logits = model(x)
# 2. 计算损失
loss = criterion(logits, y)
# 3. 反向传播:计算每个参数的梯度
optimizer.zero_grad()
loss.backward()
# 4. 更新参数:沿梯度下降一步
optimizer.step()
total_loss += loss.item() * x.size(0)
pred = logits.argmax(dim=1)
correct += (pred == y).sum().item()
total += y.size(0)
return total_loss / total, correct / total为什么要有 optimizer.zero_grad():PyTorch 的梯度是累积的(loss.backward() 把梯度累加到 param.grad)。不清零的话,每个 batch 的梯度会叠加,更新方向就错了。这是新手最常踩的坑之一(更多坑见常见陷阱与反模式)。
主循环与评估:
python
def evaluate(model, loader, criterion, device):
model.eval() # 切换评估模式
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # 不跟踪梯度,省显存、省时间
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss = criterion(logits, y)
total_loss += loss.item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
total += y.size(0)
return total_loss / total, correct / total
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
for epoch in range(10):
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device)
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
print(f"epoch {epoch+1:2d} | train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | test_acc {test_acc:.4f}")训练/评估模式切换
model.train() 与 model.eval() 必须配对。当前模型没有 Dropout/BatchNorm 时影响不大,但加上之后(下一节)忘切模式会造成训练/推理行为不一致的诡异 bug,机理见常见陷阱与反模式。
在 MNIST 上,这个 MLP 训练 10 个 epoch(CPU 也只需几分钟)测试准确率约 98%——第一个里程碑完成。
五、升级到 CNN:让模型看见结构
MLP 的 98% 已经不错,但它的天花板来自把图像当向量:它看不到"相邻像素形成笔画"这一事实。卷积层用局部感受野 + 权重共享来编码这种先验(原理见CNN 与计算机视觉):
python
class CNN(nn.Module):
"""LeNet-5 风格的轻量 CNN:卷积特征提取 + 全连接分类"""
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, padding=1), # 28x28 -> 28x28
nn.ReLU(),
nn.MaxPool2d(2), # 28x28 -> 14x14
nn.Conv2d(32, 64, kernel_size=3, padding=1), # 14x14 -> 14x14
nn.ReLU(),
nn.MaxPool2d(2), # 14x14 -> 7x7
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, num_classes),
)
def forward(self, x):
return self.classifier(self.features(x))
model = CNN().to(device)改动仅三处,但带来了本质变化:
- 去掉手动展平:
nn.Conv2d直接吃[B,1,28,28],Flatten只发生在特征提取之后。 - 通道数加深:32 → 64,特征图逐层抽象(边缘 → 笔画 → 部件)。
- 参数量反而更小:这个 CNN 约 3.4 万参数,而上面 MLP 是约 26 万——卷积的权重共享让它更高效、更抗过拟合。
换用相同的训练代码,CNN 测试准确率可到 99%+。同时建议在 CNN 中加入 nn.Dropout(0.25) 观察正则化效果(原理见过拟合与正则化)。
六、评估与错误分析
准确率只是起点。一个合格的评估要回答:模型在哪些样本上犯错?为什么?
1. 混淆矩阵
python
from sklearn.metrics import confusion_matrix, classification_report
import numpy as np
all_pred, all_true = [], []
model.eval()
with torch.no_grad():
for x, y in test_loader:
logits = model(x.to(device))
all_pred.append(logits.argmax(1).cpu().numpy())
all_true.append(y.numpy())
all_pred = np.concatenate(all_pred)
all_true = np.concatenate(all_true)
cm = confusion_matrix(all_true, all_pred)
print(classification_report(all_true, all_pred, digits=4))classification_report 给出每个类别的 precision/recall/f1。MNIST 上常见规律:混淆集中在"4–9""3–8"这类笔形相近的数字,且"9"的 recall 往往略低——这类洞察比单个准确率有用得多,因为真实业务中的错误往往高度集中(评估设计方法见评估实践)。
2. 可视化错例
python
mis_idx = np.where(all_true != all_pred)[0][:16]
fig, axes = plt.subplots(4, 4, figsize=(8, 8))
for i, idx in enumerate(mis_idx):
img = test_set[idx][0].squeeze(0)
axes[i // 4][i % 4].imshow(img, cmap="gray")
axes[i // 4][i % 4].set_title(f"true={all_true[idx]} pred={all_pred[idx]}")
axes[i // 4][i % 4].axis("off")
plt.suptitle("Misclassified samples")
plt.savefig("misclassified.png", dpi=150)看错例图通常能立刻发现两类问题:一是数据问题(标注错误、模糊样本、书写风格极端),二是模型盲区(某种写法系统性识别失败)。这两者的处理方向完全不同——前者该修数据,后者才该改模型。可解释性分析的更多手段见可解释性与公平性。
七、项目仓库结构
一个能给别人看懂、能复现的项目,结构比代码长度更重要:
mnist-project/
├── README.md # 项目简介、安装、运行、结果(模板见[作品集项目](/practice/portfolio-projects))
├── requirements.txt # 锁定依赖:torch==2.x.x, torchvision==...
├── config.yaml # 所有超参数集中一处(或 argparse)
├── src/
│ ├── __init__.py
│ ├── data.py # 数据集下载、预处理、DataLoader
│ ├── model.py # 模型定义(MLP、CNN)
│ ├── train.py # 训练循环
│ ├── evaluate.py # 评估、混淆矩阵、错例可视化
│ └── utils.py # 种子设置、设备选择等公共工具
├── notebooks/
│ └── explore.ipynb # 数据探索与实验记录
├── scripts/
│ └── run_experiments.sh # 一键复现实验
└── results/
├── checkpoints/ # 模型权重
└── figures/ # 训练曲线、混淆矩阵、错例图::: tip 版本锁 requirements.txt里写torch==2.6.0这样的精确版本,而不是torch>=2.0`。别人复现不了,往往不是代码错,而是依赖版本不同——这正是"复现实验纪律"的核心。 :::
八、常见坑(这一节最值钱)
| 坑 | 症状 | 解法 |
|---|---|---|
忘记 Normalize | 一开始 loss 偏大、收敛慢 | 检查输入范围,正确归一化 |
忘记 optimizer.zero_grad() | loss 曲线剧烈震荡不下降 | 每个 batch 开始前清零梯度 |
忘记 model.eval() / no_grad() | 评估指标异常、显存膨胀 | 评估前调用 model.eval() 并包 torch.no_grad() |
| 数据泄漏 | 测试准确率虚高、上线崩 | 数据增强、归一化统计只从训练集计算(见评估实践) |
| 设备不一致 | "Expected all tensors to be on the same device" 报错 | 统一 x.to(device)、model.to(device) |
| 在测试集上调参 | 测试集"过拟合",成绩不可信 | 调参只看验证集,测试集只碰一次 |
九、进阶方向
管道跑通之后,按兴趣往三个方向延伸:
- 换更难的数据集:CIFAR-10(彩色 32×32,训练集 50000)需要数据增强 + 更强的 CNN/ResNet,训练技巧见渐进式教程:三版跑起来。
- 加训练技巧:学习率调度、BatchNorm、早停、混合精度——训练配方与调参逐项讲透。
- 走向真实部署:导出、推理优化、上线监控属于 MLOps 范畴,见MLOps 与模型部署。
最后一条建议:把这次踩过的每个坑记下来。你踩坑的速度和复盘质量,才是深度学习能力增长的真正速率。
延伸阅读
- 渐进式教程:三版跑起来——同一个任务的 v1/v2/v3 迭代示范
- 训练配方与调参——每个超参数的默认值与调整方法
- 调试与诊断——模型不工作时按图索骥
- 数据集与工具档案——MNIST 之外的数据集目录
- 精选资源清单——更多实践工具与资源
- MLOps 与模型部署——把模型送上线的完整链路
参考资料
- LeCun, Bottou, Bengio, Haffner. Gradient-Based Learning Applied to Document Recognition (IEEE 1998)——LeNet-5 与 MNIST 原始论文
- Yann LeCun. THE MNIST DATABASE——MNIST 数据集主页
- PyTorch. PyTorch Documentation——本文全部 API 的权威参考