【深度学习Day3】实战首秀:PyTorch 搭建 MLP 网络与 MNIST 实战及面试指南

63 阅读13分钟

【深度学习Day3】实战首秀:PyTorch 搭建 MLP 网络与 MNIST 实战及面试指南

摘要:纸上得来终觉浅。在搞定了环境配置(Day1)和张量操作避坑(Day2)后,今天我带着 MATLAB 老鸟的尊严,正式挑战深度学习界的“Hello World”——MNIST 手写数字识别。本文不仅有保姆级可直接运行的 MLP 搭建教程(目标 Accuracy > 97%),还把 Day2 学的张量操作全落地,更结合求职目标总结了面试中关于基础神经网络的高频考点。新手跟着敲代码就能跑通,算法岗面试考点直接划重点,主打一个“学完就能用,用了能面试”!

关键词:PyTorch, MLP, MNIST, 维度变换, 面试题, 调参实战

0. 写在前面:新手必看的准备工作

作为从 MATLAB 转过来的新手,我太懂“代码缺一行,调试两小时”的痛了!先把前置依赖和完整运行环境说清楚,避免你卡壳:

# 安装必备库(如果没装的话)
pip install torch torchvision matplotlib numpy

所有代码都基于 Python 3.9 + PyTorch 2.7.1 + CUDA 11.8 测试通过(双卡2080Ti亲测),CPU 也能跑,就是速度慢一点~

1. 数据准备:告别 MATLAB 手动 load,PyTorch 一键搞定

在 MATLAB 里,我习惯先下载 MNIST 压缩包、解压、写循环读 .mat 文件、手动切分训练/测试集、洗牌……一套操作下来半小时没了。但 PyTorch 的 torchvision.datasets + DataLoader 直接把这些“脏活累活”全包了!

完整数据加载代码(带详细注释)

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np

# 1. 固定随机种子(新手必做!保证结果可复现,避免每次跑结果不一样)
torch.manual_seed(42)
# 2. 选择设备:有GPU用GPU,没有用CPU(Day1学的CUDA检测)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备:{device}")  # 我的输出:cuda

# 3. 数据预处理:转Tensor + 归一化(关键!没归一化准确率至少降5%)
# transforms.Compose:把多个预处理操作串起来,像MATLAB的函数嵌套
transform = transforms.Compose([
    transforms.ToTensor(),  # 把PIL图片转成[0,1]的Tensor,形状从(28,28)→(1,28,28)(C,H,W)
    # MNIST 的经验均值和方差
    # 作用:让数据分布更均匀,模型收敛更快,避免某类像素值主导训练
    transforms.Normalize((0.1307,), (0.3081,))  
])

# 4. 下载并加载数据集(自动下载到./data文件夹,不用手动找资源)
# train=True:训练集;train=False:测试集
train_dataset = datasets.MNIST(
    root='./data',        # 数据保存路径
    train=True,           # 训练集
    download=True,        # 自动下载(第一次运行会下载,后续跳过)
    transform=transform   # 应用上面的预处理
)
test_dataset = datasets.MNIST(
    root='./data',
    train=False,
    download=True,
    transform=transform
)

# 5. DataLoader:批量加载+洗牌+多线程(新手不用管多线程,默认就行)
# batch_size:每次喂给模型多少样本(我2080Ti显存够,选64;显存小选32)
# shuffle=True:训练集洗牌(防止模型死记硬背顺序,泛化性更好);测试集不用洗牌
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)

# 💡 新手必看:验证数据形状(复习Day2的张量维度!)
# 取一个batch看看长啥样
for batch_idx, (data, target) in enumerate(train_loader):
    print(f"单个Batch的图片形状:{data.shape}")  # [64, 1, 28, 28] → (Batch, Channel, Height, Width)
    print(f"单个Batch的标签形状:{target.shape}")  # [64] → 每个图片对应一个数字标签
    print(f"标签示例:{target[:5]}")  # 前5个标签:tensor([1, 2, 8, 5, 2])
    break

# 📌 MATLAB用户视角对比:
# MATLAB:需要手动写循环读取每个图片,reshape调整维度,手动切batch
# PyTorch:DataLoader是迭代器,只能用for循环遍历,不能像矩阵一样data(1)索引
# 就像MATLAB的for i=1:num_batches 一样,一步到位!

小彩蛋:可视化MNIST数据集

光看数字不够直观,咱们画几张图片看看,确认数据没加载错:

# 取第一个batch的前6张图可视化
fig, axes = plt.subplots(2, 3, figsize=(8, 5))
axes = axes.flatten()  # 把 2 * 3 的轴拉平,方便循环(复习 Day2 的 view!)

# 取数据(记得把Tensor从GPU转到CPU,否则画图报错)
images, labels = next(iter(train_loader))
for i in range(6):
    # 把Tensor从(1,28,28)转成(28,28),并转到CPU(numpy 不支持 GPU Tensor)
    img = images[i].squeeze().cpu().numpy()  # squeeze()去掉维度为1的通道维
    ax = axes[i]
    ax.imshow(img, cmap='gray')  # 灰度图显示
    ax.set_title(f"Label: {labels[i].item()}")
    ax.axis('off')  # 隐藏坐标轴
plt.tight_layout()
plt.show()

mnist_sample.png

2. 核心难点:张量维度的“变形记”——从2D图片到1D向量

Day2学的view今天终于派上大用场!全连接网络(MLP)的致命特点:只认一维向量,但MNIST是28×28的二维图片,必须先“拉平”(Flatten)。

完整MLP网络搭建代码

class SimpleMLP(nn.Module):
    """
    三层全连接网络
    结构:784(28×28) → 512 → 256 → 10(10个数字分类)
    """
    def __init__(self):
        super(SimpleMLP, self).__init__()  # 继承nn.Module,必须写!
        # 定义全连接层(nn.Linear=MATLAB的全连接层,但不用手动写权重矩阵)
        # 输入维度=784(28×28拉平),隐藏层1=512,隐藏层2=256,输出层=10(0-9)
        self.fc1 = nn.Linear(28 * 28, 512)  # 第一层:输入层→隐藏层1
        self.fc2 = nn.Linear(512, 256)      # 第二层:隐藏层1→隐藏层2
        self.fc3 = nn.Linear(256, 10)       # 第三层:隐藏层2→输出层
        self.relu = nn.ReLU()               # 激活函数(避免线性叠加,必须加!)

    def forward(self, x):
        """
        前向传播:定义数据怎么通过网络
        x:输入,形状[Batch, 1, 28, 28]
        """
        # ⚠️ 核心操作:拉平(Flatten)—— 复习Day2的view!
        # x.view(-1, 28*28):-1表示自动计算Batch维度,不用手动算64
        # 相当于MATLAB的reshape(x, [], 784),但更智能!
        x = x.view(-1, 28 * 28)  # 拉平后形状:[64, 784]

        # 前向传播:全连接→激活→全连接→激活→输出
        x = self.relu(self.fc1(x))  # 第一层+ReLU激活
        # x = self.dropout(x)  # 可选:Dropout防止过拟合
        x = self.relu(self.fc2(x))  # 第二层+ReLU激活
        # x = self.dropout(x)
        x = self.fc3(x)             # 输出层:不用加激活!CrossEntropyLoss自带Softmax

        return x

# 实例化网络,并放到GPU/CPU上(关键!不然数据在GPU,模型在CPU会报错)
model = SimpleMLP().to(device)
# 打印网络结构,看看对不对(新手必做,确认层没写错)
print("\nMLP网络结构:")
print(model)

MLP网络结构.png

关键知识点

  1. 为什么输出层不加Softmax? 因为我们后面要用CrossEntropyLoss,它内部已经集成了LogSoftmax + NLLLoss,加了反而会导致梯度不稳定(面试高频考点!)。
  2. ReLU激活函数的作用? 全连接层是线性变换,叠加再多也是线性的,ReLU 引入非线,让模型能拟合复杂的数字特征(比如 “8” 的环形结构)。
  3. view(-1, 784) 的 -1 是什么意思? 自动计算 Batch 维度,比如 batch_size=64 时,-1 = 64;如果 batch_size = 32,-1 = 32,不用手动改,超方便!

3. 训练循环:背诵“五步曲”——面试手写代码必考!

无论多复杂的深度学习模型,训练核心逻辑永远是这五步,背下来!面试时让你手写训练循环,这五步就是标准答案。

完整训练+测试代码(带详细注释)

# 1. 定义损失函数和优化器
# 损失函数:CrossEntropyLoss(分类问题首选,适合多分类)
criterion = nn.CrossEntropyLoss()
# 优化器:Adam(比SGD收敛快,不用调太多参数)
# lr=0.001:学习率(黄金初始值)
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 2. 定义训练函数(可复用,后续写CNN也能用)
def train_one_epoch(model, train_loader, criterion, optimizer, epoch):
    model.train()  # 切换到训练模式(启用Dropout等)
    total_loss = 0.0  # 累计损失
    correct = 0       # 训练集正确数
    total = 0         # 训练集总数

    for batch_idx, (data, target) in enumerate(train_loader):
        # 把数据和标签放到GPU/CPU上(必须!否则数据和模型设备不匹配)
        data, target = data.to(device), target.to(device)

        # 🎯 训练五步曲(面试必考!)
        # Step1:梯度清零(PyTorch默认累加梯度,必须手动清!)
        # 类比MATLAB:每次更新权重前,手动把梯度置0
        optimizer.zero_grad()
        
        # Step2:前向传播(喂数据给模型,得到预测结果)
        output = model(data)  # output形状:[64, 10] → 每个样本对应10个数字的概率
        
        # Step3:计算损失(对比预测值和真实标签,算差距)
        loss = criterion(output, target)
        
        # Step4:反向传播(自动求导,不用手动算梯度)
        # 类比MATLAB:需要手动写链式法则求导,复杂到哭
        loss.backward()
        
        # Step5:更新参数(用梯度调整网络权重)
        optimizer.step()

        # 统计损失和准确率
        total_loss += loss.item()  # item()把Tensor转成普通数字
        # 找预测结果:output.argmax(dim=1) → 取10个概率中最大的那个索引(就是预测的数字)
        pred = output.argmax(dim=1, keepdim=True)
        correct += pred.eq(target.view_as(pred)).sum().item()  # 统计正确数
        total += target.size(0)

    # 计算本轮训练的平均损失和准确率
    avg_loss = total_loss / len(train_loader)
    avg_acc = 100. * correct / total
    print(f'\nEpoch [{epoch}] Train Finished | Avg Loss: {avg_loss:.4f} | Avg Acc: {avg_acc:.2f}%')
    return avg_loss, avg_acc

# 3. 定义测试函数
def test(model, test_loader, criterion):
    model.eval()  # 切换到测试模式
    test_loss = 0.0
    correct = 0
    total = 0

    # 测试时不用算梯度
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += criterion(output, target).item()
            pred = output.argmax(dim=1, keepdim=True)
            correct += pred.eq(target.view_as(pred)).sum().item()
            total += target.size(0)

    avg_loss = test_loss / len(test_loader)
    avg_acc = 100. * correct / total
    print(f'Test Result | Avg Loss: {avg_loss:.4f} | Avg Acc: {avg_acc:.2f}%\n')
    return avg_loss, avg_acc

# 4. 开始训练(5轮)
num_epochs = 5
train_losses = []  # 记录每轮训练损失,后续画图
train_accs = []    # 记录每轮训练准确率
test_losses = []   # 记录每轮测试损失
test_accs = []     # 记录每轮测试准确率

print("========== 开始训练 ==========")
for epoch in range(1, num_epochs + 1):
    # 训练一轮
    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, epoch)
    # 测试一轮
    test_loss, test_acc = test(model, test_loader, criterion)
    # 保存数据,后续可视化
    train_losses.append(train_loss)
    train_accs.append(train_acc)
    test_losses.append(test_loss)
    test_accs.append(test_acc)

# 5. 保存模型(面试时说“会保存/加载模型”是加分项!)
torch.save(model.state_dict(), 'mnist_mlp_model.pth')
print("模型已保存到 mnist_mlp_model.pth")

我的训练结果(双卡2080Ti)

训练速度还是比较快的,结果如下(新手能复现!):

训练结果.png

训练过程可视化

画个损失和准确率曲线,直观看到训练趋势:

mnist_train_curve.png

预测结果可视化

选几个测试集样本,看看模型预测对不对:

mnist_pred_sample.png

4. 调参小技巧:新手也能从96%冲到98%(附避坑)

我总结出新手能操作的调参技巧,不用改网络结构就能提准确率:

🌟 核心调参技巧(按优先级排序)

调参项新手推荐值调整依据避坑点
优化器Adam(首选)比SGD收敛快,不用调动量;SGD需要调lr+momentum,易翻车别同时用多个优化器,选一个就到底
Batch Size32/64显存够选64,显存小选32;太大(如256)会降低泛化性,太小(如16)训练震荡别选1(单样本),训练极不稳定
学习率(lr)0.001(黄金值)Loss不下降→调大到0.01
Loss乱跳→调小到0.0001
后期收敛慢→学习率衰减
别调太大(如0.1),模型直接发散
Epoch数5~10太少(<3)训不透,太多(>20)过拟合(训练准确率高,测试准确率低)看测试准确率,不涨了就停,别硬训
Dropout0.2~0.3在隐藏层后加Dropout,防止过拟合(训练准确率99%,测试95%就是过拟合)测试时要切eval(),否则Dropout还在生效
隐藏层神经元数512→256(新手)别堆太多(如1024),显存不够且易过拟合;别太少(如64),拟合能力不够层数越多,越容易过拟合,新手先2~3层

🚨 新手常见调参踩坑

  1. 学习率调太大:Loss直接变成NaN,模型崩了(解决方案:调小到0.001,重新训);
  2. 忘记切eval() :测试时还开着Dropout,准确率莫名低5%;
  3. Batch Size太大:训练准确率98%,测试准确率95%(过拟合,调小到64);
  4. 没归一化:数据像素值0-255直接喂模型,Loss降得慢,准确率上不去。

5. 面试避坑专栏:MNIST高频问题(算法岗必背)

既然是为了找工作,这些问题我都按“新手能听懂、面试官满意”的思路整理好了,直接背!

Q1:CrossEntropyLoss前为什么不加Softmax层?

PyTorch的nn.CrossEntropyLoss内部已经集成了LogSoftmaxNLLLoss两个步骤。如果在网络最后再加Softmax,相当于做了两次Softmax,会导致梯度数值不稳定(比如梯度消失),甚至模型无法收敛。而且Softmax输出的概率和为1,CrossEntropyLoss的公式已经考虑了这一点,重复加反而画蛇添足。

Q2:训练时为什么要先optimizer.zero_grad()?

PyTorch默认会累加梯度(这个设计是为了RNN等需要累加梯度的场景),但在MLP/CNN的普通训练中,我们需要每个Batch独立计算梯度。如果不清零,梯度会叠加到上一个Batch的梯度上,导致梯度方向混乱,模型学偏。比如第2个Batch的梯度会包含第1个Batch的信息,相当于“记仇”,训练结果完全不对。

Q3:数据归一化(Normalize)的作用是什么?

MNIST的像素值原本是0-255,归一化后变成均值0、方差1左右的分布。这么做有两个核心作用:

  1. 让不同维度的特征(像素)处于同一量级,避免某类像素值主导训练;
  2. 加速模型收敛,梯度下降时方向更稳定,不用迭代很多轮才能找到最优解。(类比MATLAB里做数据标准化(zscore),原理是一样的,都是为了让模型更好学。)

Q4:过拟合了怎么办?(新手能操作的解决方案)

我做MNIST时遇到过“训练准确率99%,测试准确率95%”的过拟合问题,用这几个方法解决了:

  1. 加Dropout层(隐藏层后加0.2的Dropout,随机丢弃20%的神经元,防止模型死记硬背);
  2. 减少Epoch数(从20轮降到10轮,见好就收);
  3. 调小Batch Size(从128降到64,增加训练随机性);
  4. 加L2正则化(优化器里加weight_decay=1e-4,惩罚大权重,避免模型过度依赖某几个像素)。

Q5:怎么判断模型是欠拟合还是过拟合?

  • 欠拟合:训练准确率和测试准确率都低(比如都90%),说明模型没学会,解决方案:增加隐藏层神经元数、多训几轮、调大学习率;
  • 过拟合:训练准确率很高(99%),测试准确率低(95%),说明模型学太死,只记住了训练集,没泛化能力,解决方案就是上面说的Dropout、早停、正则化。

📌 下期预告

刚用MLP啃下了MNIST,但总觉得它把图片拉平的操作浪费了像素的空间信息——这显然不是深度学习处理图像的“正确打开方式”。下一篇咱们就聚焦torch.nn模块的核心武器:卷积层与池化层!作为MATLAB老鸟,我会从咱们熟悉的MATLAB卷积函数入手,拆解PyTorch里nn.Conv2d那些关键参数(in_channelskernel_size这些到底该怎么设置),再手把手摸透MaxPool2d池化层的下采样逻辑,搞懂它为啥能让模型训练更高效、泛化性更强;等把这俩核心层吃透,再进行 CNN 基础实战,顺便还会总结一波面试里关于卷积、池化的高频考点,让这些知识点不光能落地实战,还能帮咱们在算法岗面试里攒足底气!

欢迎关注我的专栏,见证MATLAB老鸟到算法工程师的进阶之路!