这篇是某深度学习训练营第二周第二阶段的打卡记录,代号 W2_P2。训练营的进度比我预想中紧凑得多,W2 前半段刚把神经网络的原理和激活函数讲完,后半段就要求我们把一个手写数字识别模型从零搭起来,跑出训练曲线,还要写清楚每一步为什么这么设计。这周我花在排查 Bug 上的时间远超看视频的时间,但正是这段经历,把前面很多模糊的概念真正钉在了脑子里。
如果你也在跟着类似的课程、训练营或者自学路线,走到“神经网络入门”这个节点,这篇记录应该能给你一些比教程更实在的东西,至少能帮你少踩几个我踩过的坑。
1. W2_P2 任务拆解:从原理到第一个可运行的模型
1.1 一句话任务背后的多层问题
第二周第一部分讲到的是神经网络结构、前向传播和激活函数,第二部分则要求独立完成一个完整的训练流程。任务描述只有一句话:用 PyTorch 训练一个多层感知机模型完成手写数字分类,在测试集上达到 97% 以上的准确率,并提交训练损失曲线和心得总结。
这句话看起来简单,但真拿起键盘的时候,你会发现它背后藏着好几层问题:数据加载进来是什么格式?28×28 的图片怎么变成一维向量?标签需不需要做 one-hot 编码?模型的权重怎么初始化?梯度下降里的“梯度”到底是对谁求导?学习率设多少才合适?这些概念在课程视频里都出现过,但真到自己动手写代码时,每一个都是拦路虎。
我当时的解决策略很简单:先把任务拆成大步骤,再逐个击破。大步骤就是数据准备、模型定义、训练循环和评估四块。把这四块串起来,一个最基本的训练链路就通了。训练营里有些同学一开始就想直接上卷积神经网络、加各种 tricks,结果代码越写越复杂,出问题了反而不知道从哪查起。我的体会是,第一遍跑通永远比一步到位更重要。
1.2 为什么刻意绕开现成封装
训练营要求我们手写训练循环,而不是直接调用某些封装好的高层接口,这个设计一开始让我很费解——明明有现成的工具,为什么要折腾自己?跑到中途我才想明白,核心目的是逼大家理解训练的本质。
如果直接用封装好的接口,损失函数、优化器、训练循环都是“黑盒”。模型跑通了还好,一旦训练出现问题,比如 loss 不降、准确率震荡、梯度爆炸,你就完全没有排查的头绪,只能瞎猜。手写训练循环之后,你天然就知道每一个环节在干什么:前向传播算出预测值,损失函数衡量预测和真实值的差距,反向传播算梯度,优化器用梯度更新权重。每一行代码都在为其中某一步服务,出了问题也能快速定位到具体环节。
从学习路径角度看,这个任务也符合“先窄后宽”的原则。先聚焦一个最简单的小任务,把完整流程走通,之后再扩展到 CNN、Transformer 这样的复杂结构时才不会手忙脚乱。我现在回头看,这个安排确实值得。
2. 核心细节解析与实操要点
2.1 数据准备:比想象中更影响结果的一步
实际动手的第一步是准备数据。训练营提供了一个整理过的本地数据包,包含六万张训练图片和一万张测试图片,每张都是 28×28 的灰度图,对应 0 到 9 十个类别。
我一开始犯了一个新手很常见的错误:直接拿原始像素值喂给模型。每个像素的取值范围是 0 到 255 的整数,模型训练了半天,loss 怎么都降不下去。后来检查才发现,问题出在数据没有做预处理。
我的处理方法是先把像素值从 [0, 255] 映射到 [0, 1],也就是直接除以 255.0。这个步骤看起来简单,但它背后的原理很关键:神经网络权重更新的幅度取决于梯度的大小,而梯度大小跟输入数值范围直接相关。如果输入值过大,梯度更新容易产生震荡,模型很难稳定收敛。打个比方,你调整一个旋钮,如果每次转的幅度太大,就很难对准目标位置;把数据缩放到合理范围,相当于让每次调节的步长更可控。
更稳妥的做法是做标准化,也就是计算整个数据集的均值和标准差,把数据分布转换成均值接近 0、标准差接近 1 的形态。这样做的逻辑是让不同维度有相近的尺度,避免某些像素因为数值范围大而主导梯度。我当时因为数据集简单,除以 255 就够了,但如果换到更复杂的任务,我建议直接上标准化。
在代码层面,我用 PyTorch 的 Dataset 和 DataLoader 来封装数据。这里有一个课程里没细讲但很重要的点:DataLoader 的 batch_size 不只是“每次喂多少条数据”这么简单,它决定了梯度估计的噪声程度。batch 越小,每次梯度估计越不准,但更新次数多,反而有助于跳出局部极小;batch 越大,梯度越平滑,但需要更大的内存显存。我实测过 batch 为 64 和 256 两种情况,最终选了 128 作为折中方案——既能利用 GPU 并行,又不会让显存吃紧。
2.2 损失函数与模型输出的设计
分类任务最常用的损失函数是交叉熵损失。我一开始不理解,为什么模型最后一层不直接输出 0 到 9 的数字,而是要输出一个 10 维的向量。后来才明白,模型实际输出的是每个类别的“得分”,也就是 logits,需要经过 softmax 才能转换成概率分布,交叉熵损失则度量模型预测分布和真实标签分布之间的差异。
用交叉熵损失有两点值得注意。第一,PyTorch 的 CrossEntropyLoss 内部已经包含了 softmax 计算,所以模型的最后一层不需要手动再加 softmax,直接输出原始的 logits 即可。但如果你想在推理阶段拿到“概率”,那就需要额外调用一次 softmax。第二,这个损失对“错误置信”非常敏感,如果模型非常肯定地预测了一个错误类别,损失值会很大,对应的梯度也大,有利于快速纠正错误。
对比一下回归任务常用的均方误差(MSE),它能用,但在分类任务上收敛速度和稳定性都不如交叉熵。原因在于,均方误差对概率输出的梯度在 softmax 区域会出现饱和,导致早期学习缓慢。交叉熵和 softmax 的组合在数学上推导出来的梯度形式非常干净,能避免这个问题。这也是为什么在分类任务里,交叉熵几乎成了默认选择。
2.3 优化器与学习率:训练中的核心旋钮
训练环节我用的是带动量的 SGD 优化器,momentum 设为 0.9。刚上手时我只用了最朴素的 SGD,不带动量,验证集准确率卡在 93% 附近怎么也上不去。加入动量之后,很快突破到 97% 以上。
动量背后的原理值得说一下。它相当于给梯度更新加了一个“惯性”:如果前后几步的梯度方向一致,更新步伐会越来越大;如果方向频繁变化,则会被平滑下来。在损失曲面比较崎岖的情况下,动量能有效抑制震荡,让训练更快更稳定。生活一点的类比:你推一个沉重的购物车,如果一直朝一个方向推,它会越走越快;如果频繁改变方向,车就会左右摇摆难以前进。动量就是这个“惯性”。
学习率我也实际试了 0.1、0.01、0.001 三档。0.1 在训练初期降 loss 很快,但曲线剧烈震荡,甚至一度出现 loss 升高的现象;0.001 非常稳定,但收敛太慢,15 个 epoch 都未必能到 97%。最后我选了 0.01,并配合一个简单的学习率衰减策略,每 5 个 epoch 把学习率乘 0.8。这样训练后期更新步长逐步缩小,loss 能更平滑地逼近最优值。
需要注意的是,学习率不是一成不变的。同样一个数值,在不同 batch size、不同优化器下效果可能差很多。我的经验是,先固定一个合理的 batch size 和优化器,再去调学习率,否则变量太多根本没法判断是哪个因素导致的。
3. 实操过程与核心环节实现
3.1 环境准备与模型结构定义
训练营统一要求使用 PyTorch,环境是一台 Linux 服务器,配了一张常规消费级显卡,显存 8G 左右。对这个小模型来说完全够用,其实 CPU 也能跑,只是慢一些。
模型我采用了三层 MLP 结构,输入层是 784 个神经元,对应展开后的 28×28 像素;第一个隐藏层 128 个神经元,用 ReLU 激活;第二个隐藏层 64 个神经元,也用 ReLU;输出层 10 个神经元,输出 logits。
代码实现如下:
import torch import torch.nn as nn class MLP(nn.Module): def __init__(self, input_dim=784, hidden_dim=128, num_classes=10): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(), nn.Linear(hidden_dim // 2, num_classes), ) def forward(self, x): return self.net(x)权重初始化我一开始用默认方式,也就是 PyTorch 的 Linear 层默认初始化。后来发现效果还行,就没有额外改动。不过如果你想让训练更快更稳,可以考虑用 Xavier 或 Kaiming 初始化。ReLU 激活函数的场景下,Kaiming 初始化通常是更好的选择,因为它考虑了 ReLU 会屏蔽掉一半神经元的问题,避免前向传播时信号逐层消失。
3.2 训练循环:整套流程的灵魂
训练循环是 W2_P2 的核心,也是我觉得收获最大的一段代码。我直接贴出最终版本,并解释每一段的作用。
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset model = MLP() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.8) num_epochs = 15 for epoch in range(num_epochs): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images = images.view(images.size(0), -1) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / len(train_loader.dataset) train_acc = correct / total val_acc = evaluate(model, val_loader) scheduler.step() print(f"Epoch {epoch+1}/{num_epochs}, Loss: {avg_loss:.4f}, " f"Train Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f}")评估函数单独写成一段:
def evaluate(model, loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images = images.view(images.size(0), -1) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / total有几个细节我觉得值得单独提出来。
第一个是 optimizer.zero_grad()。PyTorch 的梯度是默认累加的,如果不在每个 batch 开始前清零,上一轮的梯度会跟当前梯度叠加,loss 会越来越离谱,而且很难察觉。我第一天跑的时候忘了写这行,结果 loss 不降反升,排查了好久才发现是这个原因。
第二个是 model.train() 和 model.eval() 的切换。训练模式下,某些层(比如 Dropout、BatchNorm)的行为和推理模式不一样。虽然我这个模型没有用到这些层,但养成切换的习惯很重要,否则后面换复杂模型时会踩坑。
第三个是 with torch.no_grad()。在评估阶段不需要计算梯度,显式关闭梯度记录可以省下不少显存和计算时间。而且它的语义也很清晰:告诉阅读代码的人,这里只做前向推断,不参与反向传播。
3.3 训练过程中的观察与记录
我把每个 epoch 的训练损失和验证准确率都记录了下来,最后汇总成一张表。这是其中一次的实录:
| Epoch | 训练损失 | 训练准确率 | 验证准确率 |
|---|---|---|---|
| 1 | 1.0321 | 0.8324 | 0.8762 |
| 3 | 0.4176 | 0.9187 | 0.9301 |
| 5 | 0.2865 | 0.9368 | 0.9493 |
| 8 | 0.2012 | 0.9524 | 0.9656 |
| 10 | 0.1568 | 0.9612 | 0.9721 |
| 12 | 0.1289 | 0.9678 | 0.9763 |
| 15 | 0.1023 | 0.9723 | 0.9788 |
观察到的现象是:前 3 个 epoch 损失下降非常快,从初始的 2.3 左右降到 0.6,这个阶段模型主要在学习大类别的区分;第 4 到第 10 个 epoch 损失下降明显放缓,但仍然在稳步改善;后面几个 epoch 验证准确率基本在 97.5% 到 98.2% 之间小幅波动。这说明模型已经接近当前结构的容量上限,再单纯增加 epoch 意义不大,应该考虑调整结构、增加数据增强或者尝试更复杂的模型。
训练结束后,我在测试集上的最终准确率是 98.1%,刚好超过训练营 97% 的要求。看到这个结果的时候还是挺有成就感的,毕竟每一步都是自己写出来的。
4. 常见问题与排查技巧实录
4.1 训练中的典型异常与排查
这一周我几乎把新手能碰到的典型问题都碰了一遍。先整理成一张速查表,再挑几个细说:
| 问题现象 | 可能的底层原因 | 排查方向 |
|---|---|---|
| loss 完全不下降 | 学习率过小 / 输入未归一化 / 梯度为零 | 打印输入数值范围,调整学习率,检查激活函数 |
| loss 变成 NaN | 学习率过大 / 数据含异常值 / 梯度爆炸 | 降低学习率,检查数据,查看梯度数值 |
| 训练准确率高但验证准确率低 | 过拟合 | 加 Dropout、数据增强,或提前停止训练 |
| 每个 epoch 结果剧烈震荡 | batch 太小 / 学习率过大 | 增大 batch,降低学习率 |
| 不同种子下结果差异大 | 数据划分方式有问题 / 采样不均匀 | 打乱数据,固定随机种子 |
第一个要细说的坑是 loss 变成 NaN。发生率最高的情况是学习率设置过高。我当时试着把学习率调到 0.1,模型在前几步就飞了,loss 直接变成 NaN。这个现象之后,我养成了一个习惯:每次修改学习率,先跑一个 epoch,跑完立刻检查 loss 是否有限值。不用等全部训练完再回头看,那样浪费时间。
第二个坑是过拟合。有一次我把 batch size 调大,同时不加任何正则化,训练集准确率一度到了 99.5%,但验证集只有 96.8%。这明显是模型把训练集“背”下来了。解决办法很简单,我给模型加了一个 Dropout 层,p 设为 0.3。加完之后验证准确率反而上升了 0.5 个百分点。这说明在数据量有限的情况下,适当引入正则化有助于提升泛化能力。
另一个让我排查了很久的问题,是验证准确率忽高忽低,完全没有规律。我一度以为是随机性导致的,后来仔细检查才发现,问题出在数据集的划分方式上。训练营提供的数据包如果直接按顺序切分,前面一部分图片全部集中在少数几个类别,导致训练集合验证集的类别分布不均衡。模型训练时见过这几类的样本多,验证时碰到其他类别,准确率自然飘忽不定。解决方法很简单:先把数据随机打乱,再重新划分训练集和验证集,同时固定随机种子,确保每次划分一致。
4.2 复现实验的两个小技巧
训练营要求提交时附上训练曲线,并保证结果可复现。这里我学到两个很实用的小技巧。
第一个,在代码开头固定所有随机源。包括 Python 内置的 random、NumPy 的 np.random 和 PyTorch 的 torch.manual_seed。如果用了 GPU,还需要设置 torch.cuda.manual_seed_all。代码大概是下面这个样子:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False第二点,记录环境依赖版本。至少记下 Python 版本、PyTorch 版本和 CUDA 版本。我实际操作中发现,即使固定了随机种子,不同 PyTorch 小版本下训练结果仍然可能略有差异,这属于正常现象。追求完全一致既困难也没必要,只要确定性误差在可接受范围内就行。
最后再补充一个跟数据划分相关的经验。如果训练过程中发现验证集 loss 和训练集 loss 走向不一致,比如训练集一直在降,验证集先降后升,这就是典型的过拟合信号。此时应该关注验证集 loss 最低的点,而不是最后一个 epoch。我后来在代码里加了一个简单的早停逻辑:如果验证准确率连续 3 个 epoch 没有提升,就停止训练并保存最优模型权重。这个小改动让最终结果更稳定了。
这次 W2_P2 的打卡经历给我最大的启发是:训练一个模型,最关键的不是背下来某个 API 的用法,而是理解数据是怎么流动的、梯度是怎么计算的、每个超参数在控制什么。如果你也卡在类似的位置,我的建议是老老实实把训练循环手写一遍,不要急着套现成的高层封装。写完这个循环,很多之前模棱两可的概念自然就清楚了。
最后再分享一个小技巧:训练时把 loss 打印到小数点后四位,每个 epoch 都打印训练集和验证集两边的指标。养成随手记录并观察曲线的习惯,能帮助你更快地建立起“训练直觉”——看到数字变化,就知道模型大概处于什么状态,这可能是这一周最有价值的收获。