苦猿的大模型日记 · 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 之间的浮点数,跟你看的完全不是一个东西。这就是为什么第一步永远是看数据——确认你喂的跟你想喂的是同一个东西。

16 张 MNIST 手写数字样本


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 个百分点?

往下看,跑给你看。

MLP vs CNN 架构对比

卷积手电筒扫描与池化压缩示意


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 在哪赢的

参数量差不多,准确率反超。原因就两个:

  1. 局部特征:卷积核专看局部,提取边缘 / 纹理,天生适合图像
  2. 平移不变性:数字写偏一点,池化取最大值,模型照样认

MLP 把空间结构扔了,像素位置一变就懵

结论:CV 任务默认上 CNN。MLP 适合做 baseline 对比,或者表格数据那种没有空间结构的输入。

MLP 与 CNN 的 loss / accuracy 曲线对比

CNN 错例 9 宫格


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 学进简历

更多推荐