Skip to content

从零构建一个深度学习项目

本页速览 用 MNIST 手写数字识别走通深度学习完整流程:环境搭建、数据加载与预处理、从 MLP 到 CNN 的模型演进、显式训练循环、评估与错误分析、项目仓库结构,以及新手最容易踩的坑。适合作为你的第一个端到端项目。

本页含时效性内容,数据截止于 2026-08;JD、榜单、产品功能等信息可能已变化,引用前请核对原始出处。

从零构建一个深度学习项目

一句话定义:从零构建深度学习项目,是把你已经学过的概念(数据、模型、损失、优化、评估)串成一条可运行的流水线——从裸机到"一个能训练、能评估、能复现、能给别人讲清楚的模型"才算走完。

纸上概念(见神经网络基础反向传播与自动微分)与真正跑起来之间隔着大量工程细节。本文用一个经典任务——MNIST 手写数字识别(0–9,灰度 28×28,训练集 60000 张、测试集 10000 张)走通全部环节。全程使用 PyTorch,代码可直接运行。

一、目标与环境搭建

1. 先明确项目目标

任何项目开工前,先写下三句话:

  1. 任务:给定一张 28×28 灰度手写数字图,输出 0–9 的类别。
  2. 成功标准:测试集准确率 > 99%(MNIST 上 LeNet 级 CNN 很容易达到,MLP 也能到 98%)。
  3. 边界:暂不做数据增强、不做模型部署,先把管道跑通。

目标决定了你的资源分配。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   # Windows

3. 安装 PyTorch

PyTorch 的 GPU 支持依赖 CUDA,安装命令与机器配置强相关。下表为写作时的稳定版本情况,具体版本号请以 PyTorch 官网 为准

场景安装命令说明
无 GPU(学习首选)pip install torch torchvision --index-url https://download.pytorch.org/whl/cpuCPU 版体积小、无兼容性问题
NVIDIA GPU + CUDA 12.4pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124需本机已装对应 CUDA 驱动
NVIDIA GPU + CUDA 12.1pip 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 也能跑。

依赖清单:torchtorchvision(含 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)

两个细节值得注意:

  • 输出层不加 softmaxnn.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)
在测试集上调参测试集"过拟合",成绩不可信调参只看验证集,测试集只碰一次

九、进阶方向

管道跑通之后,按兴趣往三个方向延伸:

  1. 换更难的数据集:CIFAR-10(彩色 32×32,训练集 50000)需要数据增强 + 更强的 CNN/ResNet,训练技巧见渐进式教程:三版跑起来
  2. 加训练技巧:学习率调度、BatchNorm、早停、混合精度——训练配方与调参逐项讲透。
  3. 走向真实部署:导出、推理优化、上线监控属于 MLOps 范畴,见MLOps 与模型部署

最后一条建议:把这次踩过的每个坑记下来。你踩坑的速度和复盘质量,才是深度学习能力增长的真正速率。

延伸阅读

参考资料