Day04 | 深度学习基础(2):用 PyTorch 从 0 训练 MNIST 数字识别
苦猿的大模型日记 · Day04 · 深度学习基础(2):PyTorch 训练 MNIST-帮普通人把AI学进简历系列
前言:不动手的项目,跟没学一样
我看过太多人,看完吴恩达视频,看完 Day01-03 的全部理论,然后呢?
然后,他们在简历上写:熟练掌握 PyTorch。
面试官一句"那你训过啥模型?",就把他们问穿了。
我自己当年也这样——理论滚瓜烂熟,真要训练一个识别手写数字的 MNIST,连 DataLoader 都跑不通。
学深度学习不动手写一个完整项目,跟学游泳不下水一样。
理论再多,都是纸上谈兵。
今天这篇 Day04,就是带你真下水——写完第一个能写进简历的项目:MNST 手写数字识别。
读完你能:
- 用
torchvision加载真实数据集,搞定DataLoader数据管道 - 同时定义 MLP 和 CNN 两种模型,理解架构差异
- 封装可复用的训练循环(以后任何项目都能改)
- 跑完整评估流程,看准确率 / loss 曲线 / 错例
- 拿到一组能写进简历的数字(MLP ~97.8% vs CNN ~99.x%)

PART 01:数据加载——MNIST 数据管道
深度学习项目,90% 的麻烦在数据。
模型架构几行代码搞定,但你得先有干净的数据。
MNIST 是深度学习的"hello world"——6 万张训练 + 1 万张测试,28×28 灰度图,10 个数字(0-9)。
用 torchvision 三行加载:
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
跑完这几行,你电脑里就有一份现成的训练流水线。
transform 这两步,不能省:
ToTensor() 把 PIL 图变成 [1, 28, 28] 张量,像素值从 [0, 255] 归一到 [0, 1]。
Normalize((0.1307,), (0.3081,)) 进一步标准化——减均值除标准差,让数据分布居中。
(0.1307, 0.3081)不是瞎编的,是 MNIST 全集 6 万张图算出来的均值 / 方差。
不做归一化会咋?loss 卡在 2.3 死活不下降(别问我是怎么知道的)。
DataLoader 三个参数,新手最容易踩坑:
batch_size=64:一次喂 64 张。太大爆显存,太小训练慢。64 是甜点。shuffle=True:训练时打乱,避免模型记住数据顺序。测试集千万要False(否则评估不可复现)。num_workers:多进程加载。Windows 用户注意——不加if __name__ == '__main__':会爆进程。
最后看一眼数据长啥样:
x, y = next(iter(train_loader))
print(x.shape, y.shape)
# torch.Size([64, 1, 28, 28]) torch.Size([64])
[64, 1, 28, 28] = 64 张图,1 通道(灰度),28×28 像素。
[64] = 64 个标签(0-9 之间的整数)。
把前 16 张画出来,你会发现——就是一堆手写数字,跟你想的没差。
但模型看到的是 0-1 之间的浮点数,跟你看的完全不是一个东西。这就是为什么第一步永远是看数据——确认你喂的跟你想喂的是同一个东西。

PART 02:两个模型同台登场(MLP vs CNN)
数据有了,该上模型了。
这一节,我同时定义两个模型:MLP 和 CNN,然后让你看为啥图像任务 CNN 完胜。
MLP 模型:把图拍扁了认
import torch
import torch.nn as nn
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(784, 128)
self.fc2 = nn.Linear(128, 64)
self.fc3 = nn.Linear(64, 10)
def forward(self, x):
x = x.view(-1, 784) # [B, 1, 28, 28] → [B, 784]
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
return self.fc3(x)
关键操作:x.view(-1, 784) 把 28×28 的图拍扁成 784 维向量。
这就相当于——把一张二维图扔了,只留一维像素列表。
参数量算一下:
- fc1:784×128 + 128 = 100,480
- fc2:128×64 + 64 = 8,256
- fc3:64×10 + 10 = 650
- 合计 ~109k
CNN 模型:像人眼一样扫描
class CNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
self.fc1 = nn.Linear(32 * 7 * 7, 64)
self.fc2 = nn.Linear(64, 10)
def forward(self, x):
x = self.pool(torch.relu(self.conv1(x))) # [B, 16, 14, 14]
x = self.pool(torch.relu(self.conv2(x))) # [B, 32, 7, 7]
x = x.view(-1, 32 * 7 * 7)
x = torch.relu(self.fc1(x))
return self.fc2(x)
参数量算一下:
- conv1:1×16×3×3 + 16 = 160
- conv2:16×32×3×3 + 32 = 4,640
- fc1:32×7×7×64 + 64 = 100,416
- fc2:64×10 + 10 = 650
- 合计 ~106k
等等,这跟 MLP 差不多啊? 对。我故意把 CNN 做到参数量跟 MLP 接近——这样比的是"架构",不是"参数规模"。
CNN 三个动作,你就能记住它
CNN 第一次出现的同学别慌,就三个动作。
动作 1:卷积核 = 手电筒扫描
想象你拿一把 3×3 的小手电筒,在 28×28 的图上从左到右、从上到下扫一遍。
每照一个位置,手电筒看见 9 个像素,做一次加权求和,吐一个数字。
整张图扫完,你得到一张"特征图"——记录了"边缘、纹理、角点"在哪。
多通道 = 多把手电筒:16 把手电筒各学各的,有的专找横线,有的专找竖线,有的专找圆弧。
动作 2:池化 = 压缩
MaxPool2d(2, 2) 把 2×2 小窗口里取最大值,长宽各砍一半。
28×28 → 14×14 → 7×7。
为啥要压缩?降维 + 平移不变性——数字写偏一点点,最大值还在,模型照样能认。
动作 3:全连接 = 汇总投票
最后把所有特征图拍扁,扔给全连接层,汇总投票:这些边缘、纹理、角点的组合,最像数字几?
这就是 CNN。没有黑盒,全是直觉。
一个反常识的悬念
MLP 把图拍扁了再认,丢了空间结构。
CNN 一直保留二维结构,像人眼一样扫。
参数量差不多,凭啥 CNN 准确率反而高 1-2 个百分点?
往下看,跑给你看。


PART 03:一套训练循环跑两个模型
模型有了,该训练了。
最大的工程直觉:训练代码是模板。
会写一次 train() 函数,以后任何模型都能复用——MNIST、CIFAR、ImageNet、甚至 LLM,骨架一模一样。
import torch
from torch import optim
def train(model, train_loader, epochs=3, lr=1e-3, device='cpu'):
model = model.to(device)
optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-2)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
total_loss = 0
for x, y in train_loader:
x, y = x.to(device), y.to(device)
pred = model(x)
loss = loss_fn(pred, y)
optimizer.zero_grad() # ← 不写这行,loss 直接 NaN
loss.backward()
optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(train_loader)
print(f"Epoch {epoch+1}/{epochs} loss = {avg_loss:.4f}")
就这二十行,装下了 Day03 讲过的所有理论——前向、loss、反向、优化器、weight_decay、AdamW。
几个新手必踩的坑:
optimizer.zero_grad() 漏了 → 梯度累加 → loss 突然 NaN。
model.train() 不写 → Dropout / BN 行为不对 → 训练评估准确率差几个点。
x.to(device) 不写 → 模型在 GPU,数据在 CPU → 直接报错。
train() 函数复用,一次跑俩:
device = 'cuda' if torch.cuda.is_available() else 'cpu'
mlp = MLP()
cnn = CNN()
print(">>> 训练 MLP ...")
train(mlp, train_loader, epochs=3, device=device)
print(">>> 训练 CNN ...")
train(cnn, train_loader, epochs=3, device=device)
同一套代码,两个模型。这就是模板的力量。
跑完 3 个 epoch,保存权重:
torch.save(mlp.state_dict(), 'mlp.pth')
torch.save(cnn.state_dict(), 'cnn.pth')
下次想用,加载就行,不用从头训。
提示:CPU 跑 CNN 比较慢(单 epoch 1-2 分钟)。先把 epoch 设为 1 跑通流程,确认没 bug 再调到 3-5 跑完整训练。

PART 04:对比可视化——让差异"看得见"
光看准确率数字,你感受不到差距。我跑了完整的训练 + 评估,结果摆出来:
测试准确率对比:
| 模型 | 测试准确率 |
|---|---|
| MLP(109k 参数) | ~97.8% |
| CNN(106k 参数) | ~99.2% |
1.4 个百分点,听着不多。
但放大看:1 万张测试图上,1.4% 就是 140 张。
面试官盯着简历问"你训的 MNIST 准确率多少?",你说 97.8% 还是 99.2%,印象分差一个档。
loss 曲线:CNN 收敛更快更稳
MLP 的 loss 下降慢且抖,CNN 的 loss 下降快且稳。
为啥?
CNN 的卷积核专门提取边缘,这玩意儿对数字识别就是降维打击;MLP 把图拍扁了,等于把零件打散了再认。
错例可视化:CNN 错的"有道理"
把两个模型预测错的样本画出来,你会看到一个反直觉的现象——
MLP 错的,CNN 也常错;但 CNN 错的,MLP 错得更多。
而且错的都是写得潦草的:7 写得像 1,4 写得像 9,5 写得像 6。
CNN 错的那张,CNN 错得"有道理"——确实像另一个数字。
MLP 错的就比较随机,连笔迹清晰的都能搞错。
四维对比表(简历能直接抄)
| 维度 | MLP | CNN |
|---|---|---|
| 测试准确率 | ~97.8% | ~99.2% |
| 参数量 | ~109k | ~106k |
| 单 epoch 训练时间(CPU) | ~30s | ~90s |
| 推理时间(单张) | ~1ms | ~3ms |
关键洞察:CNN 在哪赢的
参数量差不多,准确率反超。原因就两个:
- 局部特征:卷积核专看局部,提取边缘 / 纹理,天生适合图像
- 平移不变性:数字写偏一点,池化取最大值,模型照样认
MLP 把空间结构扔了,像素位置一变就懵。
结论:CV 任务默认上 CNN。MLP 适合做 baseline 对比,或者表格数据那种没有空间结构的输入。


PART 05:踩坑总结 + 下一篇预告
Day03 那篇我列了 10 个理论坑。这次跑实战,真实又踩了 6 个。
坑 1:model.eval() 漏写,Dropout / BN 失效
测试时忘了切 eval 模式,Dropout 还在丢,准确率直接掉 2 个点。
model.eval()
with torch.no_grad():
pred = model(x)
坑 2:CrossEntropyLoss 传 one-hot 报错
PyTorch 的 CrossEntropyLoss 只接受类别索引,不是 one-hot。
# ❌ target = torch.tensor([[1,0,0], [0,1,0]]) # shape (2,3) → 报错
# ✅ target = torch.tensor([0, 1]) # shape (2,) → 对
坑 3:optimizer.zero_grad() 漏了
第 50 步 loss 突然 NaN。原因:梯度默认累加,你没清。
坑 4:数据没归一化,loss 卡 2.3
把 Normalize((0.1307,), (0.3081,)) 这一行删掉试试——loss 死活不下降。
坑 5:测试漏 torch.no_grad(),显存爆
测试集几万张图,带梯度算的话显存瞬间爆。一行 with torch.no_grad(): 解决。
坑 6:CPU 跑 CNN 太慢
CPU 跑 CNN 一个 epoch 1-2 分钟,5 epoch 就是 10 分钟起步。
建议:先把 epoch 设为 1 跑通流程,确认没 bug 再调到 3-5 跑完整训练。
写完这些坑,我意识到:深度学习最大的门槛,从来不是数学,是工程。
理论背得滚瓜烂熟,一动手连数据都加载不出来。这也是为啥我专门写 Day04 这种实战篇——把"学过"变成"训过"。
结尾:简历上那一行,谁都能写,但谁真训过
简历上"熟练掌握 PyTorch"这一行,谁都能写。
但面试官追问"那你训过啥模型?",真训过的人,两句话就听出来了。
MNIST 不是终点,它是一张"我下过水"的凭证。
学深度学习最大的错觉,是以为自己懂了;唯一的解药,是亲手训一个模型,看 loss 从 2.3 一路落到 0.04。
下一篇 Day05,我们离开 CV,跳到 NLP 文本分类——把这套训练循环模板复用到文本任务上,为后面的 LLM 铺最后一块地基。
互动时间:你跑 MNIST 时踩过最离谱的坑是什么?评论区聊聊,我挑高频的写进下一篇。
— END —
苦猿 · 帮普通人把 AI 学进简历
更多推荐
所有评论(0)