用PyTorch实现一个多层感知机(MLP),是我心里深度学习入门阶段最值得亲手走一遍的项目。它不涉及CNN的卷积、RNN的时序、Transformer的注意力,只围绕全连接层、激活函数、损失函数和反向传播这四件事展开,而这几件事恰恰是所有深度学习模型共用的骨架。做完这个项目,你会真正掌握“定义模型—加载数据—训练—评估”这套在PyTorch里的核心工作流,之后再接触任何复杂模型,都会有底气去拆解和调试。无论你是刚接触PyTorch的小白,还是想系统梳理神经网络基础的老手,这篇从原理到代码再到坑点排查的完整记录,都能给你一份可以直接参考的实操路线。
1. 动手前的思路:为什么MLP是第一个练手项目
1.1 MLP是一个完整学习闭环的最小单元
很多人学深度学习,一上来就看CNN、Transformer,卷积、多头注意力、残差连接这些名词堆在一起,很容易被复杂结构淹没,反而忽略了最本质的东西:模型如何从输入得到输出,损失如何计算,梯度如何回传,参数如何更新。
多层感知机恰好把这一整套流程压缩到了最小规模。它没有卷积的局部连接,没有循环的时序依赖,没有注意力机制,就是简单的全连接层堆叠。正因为结构单纯,你才能清楚地看到每一层矩阵运算的参数从哪来、梯度经过哪些路径回到每个参数身上。把MLP彻底搞懂之后,再去看ResNet里的残差跳连、Transformer里的LayerNorm,你会发现它们都是在这个基础骨架上做局部优化,核心逻辑并没有变。
这个项目还逼着你走完一个“完整项目循环”:数据准备、模型搭建、训练迭代、评估验证、结果可视化。这五个环节在任何一个实际任务里都缺一不可,而MLP因为结构简单,让你把注意力全部放在流程本身,而不是被复杂模型的问题带走。
1.2 跑通这个项目,你掌握的是一套可迁移能力
具体来说,完成这个MLP项目后,你会获得四方面能力。第一,数据加载与预处理能力:会用Dataset和DataLoader组装数据流,知道为什么要把图像转成张量、为什么要归一化、为什么要打乱顺序。第二,模型定义能力:会用nn.Module组织网络结构,理解forward里每一步计算和维度变化。第三,训练循环能力:能写出包含前向传播、损失计算、反向传播、参数更新的标准训练代码,并且明白每个环节为什么必不可少。第四,评估与调参能力:会区分训练集和测试集的作用,能通过曲线判断模型是没学够还是学过头。
这四块能力是所有PyTorch项目的通用底座。以后你换一个目标检测数据集,换一个Transformer模型,外围的数据加载、训练评估代码几乎可以原样复用,你要改的只是模型定义和输入输出适配。这就是为什么我强烈建议入门者先把MLP做扎实,而不是急着去跑那些看着炫酷的大模型。
1.3 适合什么基础的人,能达到什么目标
如果你是刚接触PyTorch的初学者,建议照着本文流程把代码完整敲一遍,重点观察代码结构和每个模块的职责,先不求调出多么惊人的精度,只要能把loss曲线降下来、测试集上看到一个合理准确率,就算达标。如果你已经会写一些简单模型,但遇到问题上手就慌,这篇文章后半部分的训练经验和排查清单会更适合你,里面整理了我实际踩过的坑和固定的排查顺序。如果你正在准备面试或复习深度学习基础,也值得快速过一遍第2章,把激活函数作用、交叉熵与Softmax的关系这些关键概念理清楚。
2. 核心原理拆解:MLP由哪些部件组成,为什么要这样设计
2.1 从单层感知机说起:线性模型的天然天花板
要理解多层感知机,得先知道它要解决的问题在哪。单层感知机的形式很简单:把输入向量和权重矩阵相乘,加上偏置,过一个激活函数。在二分类任务里,它本质上是在特征空间中画一条直线或者一个超平面,把两类样本分隔开。如果数据本身是线性可分的,这条线就能解决问题;但如果数据是非线性分布的,单层感知机就完全无能为力。
最经典的例子是异或问题:四个样本点,(0,0)和(1,1)是一类,(0,1)和(1,0)是另一类。无论你怎么画直线,都不可能把这两类完美分开。这个看似简单的问题,直接暴露了线性模型的局限:它只能做线性分割,无法处理特征之间的非线性组合关系。要让模型具备非线性表达能力,就必须引入多个神经元的分层组合,这就是多层感知机出现的原因。
多层结构的核心思想是:先用前面的隐藏层把原始输入逐步变换到新的特征空间,在这个空间里,原来线性不可分的模式可能变得线性可分,最后一层只要再做一个简单的线性分类即可。层数越深,理论上可以表达的函数关系越复杂。这也是为什么MLP可以被视为所有现代深度学习模型的雏形。
2.2 激活函数为什么不可或缺
如果仅仅是多层线性变换堆叠,那无论堆多少层,最终等价于一次线性变换。道理很简单:线性函数套线性函数,结果还是线性函数。比如第一层输出是W1x加b1,第二层再对结果做一次线性变换,矩阵相乘后合并起来,还是某个权重矩阵乘以输入加上某个偏置。换句话说,没有非线性激活函数,深层网络就失去了“深”的意义,模型的表达能力不会随着层数增加而增强。
激活函数的作用就是把非线性引进来,让每一层都会对上一层的输出做一次非线性扭曲。看到负值时直接输出0,这种简单的截断操作在数学上就足以让多层网络拥有拟合任意复杂函数的能力。实际工程里,隐藏层选择有很多种,我整理了一个对比表:
| 激活函数 | 输出范围 | 主要特点 | 常见使用场景 |
|---|---|---|---|
| Sigmoid | 0到1 | 平滑可导,但两端梯度接近0,容易梯度消失 | 二分类输出层 |
| Tanh | -1到1 | 比Sigmoid梯度更强,但仍存在饱和区 | 隐藏层历史方案 |
| ReLU | 0到正无穷 | 计算快,正区间梯度恒为1,有效缓解梯度消失 | 隐藏层首选 |
| LeakyReLU | 负半轴保留小斜率 | 避免ReLU的神经元死亡问题 | 负值信息重要的场景 |
输出层的选择取决于任务类型。做多分类时,通常在最后一层接Softmax,把输出转换成概率分布;做回归时则不需要激活函数,直接输出连续值。有一点要注意:在PyTorch里,如果你用的是nn.CrossEntropyLoss,模型最后一层不需要手动加Softmax,这个损失函数内部已经把Softmax和对数损失融合在一起。我见过不少新手在这里画蛇添足,手动加一层Softmax之后发现loss很难收敛,就是这个原因。
2.3 前向传播、损失函数与反向传播:训练到底在做什么
把整个MLP的数学过程拆开看,其实很清晰。假设输入是x,第一层权重是W1,偏置是b1,那么第一隐藏层输出h1 = relu(W1 x + b1)。接着第二层权重W2和偏置b2,得到h2 = relu(W2 h1 + b2)。最后输出层z = W3 h2 + b3。整个过程从输入到输出,就是前向传播。它做的事情很直白:把原始数据不断变换,直到最后一层产生一个预测值。
有了预测值,就要算和真实标签之间的差距,这个差距就是损失。在分类任务里,交叉熵损失是常用选择,它的数值越小,说明预测分布和真实分布越接近。训练的目标就是找到一组参数,让损失尽可能小。那怎么找呢?用梯度下降:计算损失对每个参数的偏导数,也就是梯度,然后让参数沿着梯度的反方向走一小步,损失就会下降。这一小步的尺度就是学习率。
反向传播做的就是高效计算梯度这件事。它利用链式法则,从输出层开始,一层一层的往回传播误差,把损失对每一层参数的梯度都算出来。在早期框架里,这些梯度要手动推导并编码,非常痛苦。PyTorch的自动求导机制把这个过程完全自动化了:当你执行loss.backward()时,框架会遍历计算图,自动算出每个需要梯度的参数的梯度值,然后存到参数对应的grad属性上。之后optimizer.step()读取这些梯度,完成参数更新。理解了这一整条链路,你就会明白训练代码里为什么是那几行固定的操作,也就能在模型不收敛时知道应该去查哪个环节。
3. 一步一步用PyTorch实现MLP训练全流程
3.1 环境准备与数据加载
实现这个项目只需要最基本的深度学习环境。你需要安装PyTorch和torchvision,数据分析方面用matplotlib画曲线就足够了。如果你有NVIDIA显卡,可以把模型放到GPU上训练;没有的话,CPU上跑这个规模的MLP也完全没问题,一开始不涉及复杂模型,CPU慢不了多少。
数据我建议用公开的手写数字数据集MNIST。它是图像分类里的“入门标准题”:每张图片是28×28的灰度图,共10个类别,训练集有6万张,测试集有1万张。选它有三个理由:图片尺寸小,适合MLP这种把像素展平做全连接的模型;类别均衡且任务难度适当,MLP在这样的数据集上很容易达到97%以上的准确率,能直观看到效果;下载和加载都方便,torchvision一行代码就能搞定。
数据加载的代码很简单:
import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader import matplotlib.pyplot as plt transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = torchvision.datasets.MNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = torchvision.datasets.MNIST( root='./data', train=False, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=1024, shuffle=False)这里有两个容易被忽略的细节。第一个是transforms.ToTensor(),它会把形状为(H, W, C)且在0到255范围的数据,转成形状为(C, H, W)、数据类型为浮点、范围在0到1之间的张量。第二个是transforms.Normalize(),它用均值0.1307和标准差0.3081做标准化。这两个数值是MNIST数据集的整体统计量,标准化后像素分布近似均值为0、方差为1,能让模型收敛更稳定。
DataLoader里的batch_size和shuffle也很关键。shuffle=True让模型在每个epoch看到的样本顺序都不同,避免模型按照数据顺序学到不必要的规律。测试集不需要shuffle,因为评估结果与顺序无关,而且保持固定顺序更便于复现。batch_size为64是比较常见的默认选择,它既不会小到让训练过于震荡,也不会大到占用太多显存。
3.2 模型定义:用nn.Module封装一个三层MLP
有了数据流,接下来就是模型本体。用nn.Module定义一个三层全连接网络,输入是784维,也就是28×28像素展平后的长度,两个隐藏层分别有256和128个神经元,输出层是10维,对应10个数字类别。
class MLP(nn.Module): def __init__(self, input_dim=784, hidden_dims=(256, 128), output_dim=10): super(MLP, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dims[0]) self.fc2 = nn.Linear(hidden_dims[0], hidden_dims[1]) self.fc3 = nn.Linear(hidden_dims[1], output_dim) self.relu = nn.ReLU() def forward(self, x): x = x.view(x.size(0), -1) x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) x = self.fc3(x) return x第一个关键点是x.view(x.size(0), -1)。CNN能直接处理二维图像,而Linear层要求输入是二维的,即[batch_size, feature_dim]。所以需要把每个批次里的28×28图像展平成784维向量,这个操作不会改变样本数量,只改变每个样本的表示形式。如果你忘记做这一步,会直接遇到矩阵维度不匹配的报错。
第二个关键点是为什么选择256和128这两个隐藏层大小。理论上,只要隐藏层神经元足够多,单隐藏层就能逼近任何连续函数,但实际中多一层往往能更好地平衡表达能力和参数规模。256到128的过渡也不是硬性标准,256→128→10是一个“不算太浪费、又不至于欠拟合”的常规配置。如果数据集更复杂,可以适当放大这些数字;如果追求轻量,也可以单隐藏层128个神经元。
第三个关键点是继承nn.Module的好处。它让PyTorch自动识别并注册模型里的所有子模块和参数,后续调用.to(device)模型会被整体搬到GPU,调用.state_dict()能保存权重,调用model.train()和model.eval()能切换训练和推理模式。这些能力都是现成封装好的,自己手工管理权重列表会非常繁琐。
3.3 训练循环:损失函数与优化器选型
模型定义好后,要选择训练用的损失函数和优化器。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = MLP().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3)损失函数用CrossEntropyLoss,优化器先选Adam。这里解释一下选择逻辑。CrossEntropyLoss内部做了三件事:对模型输出做Softmax、取对数、计算负对数似然。把这些步骤合并在一起,不只是为了代码简洁,更是为了数值稳定性。如果你手动先Softmax再算交叉熵,在模型预测置信度极高时,log运算可能得到负无穷或NaN,而融合版本用特殊数学变换避免了这个问题。
优化器为什么先选Adam而不是SGD?Adam会根据历史梯度自适应调整每个参数的学习率,对学习率的选择不那么敏感,即使lr设置得稍大一些,也有较高的概率正常收敛。这对入门阶段非常友好。SGD配合动量也有它的优势,比如有时能收敛到泛化能力更好的解,但它对学习率的依赖更强,需要更细致的调节。我个人的建议是:第一版代码用Adam跑通流程,后续想追求更好泛化性能时再切换到SGD加动量做对比实验。
接下来是每个epoch循环里的核心代码:
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(dim=1) == labels).sum().item() total += images.size(0) return total_loss / total, correct / total这五行的顺序是有讲究的。optimizer.zero_grad()必须放在前向传播之前,因为PyTorch的梯度是累加模式,如果不把上一batch的梯度清零,这一batch的梯度会和之前的叠加,导致更新方向完全错误。然后是前向传播得到outputs,计算loss,调用backward()让框架自动求出所有参数梯度,最后step()真正更新参数。
评估函数和训练函数结构类似,但有两个关键区别:
def evaluate(model, loader, device): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) correct += (outputs.argmax(dim=1) == labels).sum().item() total += labels.size(0) return correct / total第一个区别是model.eval()。很多模型在训练和推理模式下行为不同,比如Dropout在训练时随机丢弃神经元,评估时需要关闭,BatchNorm在训练时统计新批次的均值方差,评估时要用历史统计值。虽然我们这个纯全连接模型当前没有这些层,但养成切换模式的习惯很重要。第二个区别是torch.no_grad()。它告诉PyTorch这段计算不需要构建计算图,也就不会保存中间梯度信息,能大幅减少显存占用并加快评估速度。在with语句块里,即使你忘记写model.eval(),至少也不会有梯度累积的问题。
3.4 多轮训练:记录指标并可视化
整体训练流程就是把上面的函数循环执行多个epoch,同时记录每个epoch的训练损失、训练准确率和测试准确率。
epochs = 10 train_losses, train_accs, test_accs = [], [], [] for epoch in range(epochs): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device ) test_acc = evaluate(model, test_loader, device) train_losses.append(train_loss) train_accs.append(train_acc) test_accs.append(test_acc) print(f"Epoch {epoch+1}/{epochs} | " f"Loss {train_loss:.4f} | " f"Train Acc {train_acc:.4f} | " f"Test Acc {test_acc:.4f}")为什么要同时记录训练集指标和测试集指标?因为只看训练集loss下降,无法判断模型是否真的学到了通用规律。如果训练集准确率很高、测试集准确率明显偏低,说明模型是在记忆训练样本而不是在泛化,这种情况就是过拟合。只有两个指标同步变化、差距不大,才可以认为模型状态比较健康。
训练结束后,把损失曲线和准确率曲线用matplotlib画出来:
plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(range(1, epochs + 1), train_losses, marker='o') plt.title("Training Loss") plt.xlabel("Epoch") plt.ylabel("Loss") plt.subplot(1, 2, 2) plt.plot(range(1, epochs + 1), train_accs, marker='o', label="Train") plt.plot(range(1, epochs + 1), test_accs, marker='s', label="Test") plt.title("Accuracy") plt.xlabel("Epoch") plt.ylabel("Accuracy") plt.legend() plt.tight_layout() plt.show()观察曲线时典型的健康状态是:loss在第一轮快速下降,之后逐步减缓;训练和测试准确率同步上升并在几个epoch后趋于平稳。针对MNIST这个数据集,这个三层MLP在未做任何精细调参的情况下,测试准确率通常在97%到98%左右。如果你得到的数值略低,不用惊慌,随机种子会带来1%左右的波动,更有可能的原因是学习率或批次参数不同,稍加调整就能改善。
4. 训练中的关键经验:归一化、学习率与过拟合控制
4.1 数据归一化不是可选项,而是默认项
很多新手第一次跑模型时,为了省事直接去掉归一化,然后发现loss下降非常慢,甚至出现训练不稳定的情况。原因很简单:原始图像像素值在0到255之间,不同维度数值大小差异很大,而神经网络对输入数值范围是敏感的。归一化把所有特征统一到差不多的尺度,让梯度更新更加平滑,也能避免某个维度因为数值太大而主导整个更新方向。
在MNIST里用到的标准化公式是(x减均值)除以标准差。标准化后的数据均值接近0、标准差接近1,这在数学上等价于让损失函数在不同方向上的“坡度”更加均衡,梯度下降走得更直、更稳。实际对比一下就知道,未归一化时可能第5个epoch才勉强开始出效果,而归一化后往往第1个epoch就已经看到明显精度提升。
要特别注意,推理阶段的数据处理必须和训练阶段保持一致。如果训练时用了这个标准化,预测时却忘了减均值除以标准差,模型看到的分布就跟训练时完全不同,精度会大幅下降。所以transform这套预处理逻辑要定义成一个可复用对象,训练和预测都走同一套。
4.2 学习率与batch size的配合关系
学习率可以说是训练时最需要反复调的参数。学习率太大会导致loss震荡甚至直接变成NaN,因为参数一步跨出的距离太大,越过了损失函数的最低点,甚至在陡峭区域出现数值爆炸。学习率太小则会让训练极其缓慢,一个epoch下来loss几乎没动,浪费大量时间。
batch size的影响同样关键。小批量比如32或64,每个batch的梯度方向带有更多随机性,这种噪声反而能帮助模型跳出局部极值,对小数据集来说等效于一种正则化效果。大批量比如256或512,梯度更平滑,训练更稳定,但可能更容易收敛到比较尖锐的极值点,泛化性能并不一定更好。大批量还意味着每个epoch更新的次数变少,模型看到的“多样性”被压缩了。
学习和批次配合的经验我整理一下:起步阶段推荐batch size取64,Adam学习率取0.001。如果训练速度太慢,先把学习率调大一倍到0.002或0.005试试;如果发现loss震荡厉害,就调小到0.0005或0.0001。调整幅度建议按10倍或2倍阶梯来,不要一次改动太大。也可以用学习率调度器,比如torch.optim.lr_scheduler.ReduceLROnPlateau,当测试准确率连续几个epoch不再改善时自动降低学习率,省去手动干预的麻烦。我在项目里试过这个方法,它最大的好处是后期学习率能自适应当前的收敛状态。
4.3 过拟合与欠拟合:怎么看、怎么办
训练过程中最常遇到的两个状态就是欠拟合和过拟合,两者要区分对待。
欠拟合的表现是训练集上的loss一直降不下来,准确率也上不去,模型压根没有学会训练数据。排查方向是先确认模型容量是不是太小,比如隐藏层只有几十个神经元,表达力肯定不足,可以加宽或加深。然后再看训练是不是没跑够,epoch太少模型还处于欠学习阶段。如果数据和loss计算都没问题,加大模型规模并延长训练时间通常会带来明显改善。
过拟合的表现正好相反:训练集准确率一路飙到接近100%,但测试集准确率停在某个点不再上升,甚至开始下降。这说明模型把训练集中的噪声和细节都背下来了,失去了泛化能力。处理过拟合有几种常用手段。第一种是加Dropout层,在训练时随机丢弃一部分神经元输出,迫使模型不能过度依赖某几个特征,相当于让网络学得更鲁棒。第二种是加权重衰减,也就是weight decay,它在损失函数中增加对参数的平方惩罚,让参数值保持相对较小,抑制模型的复杂度。第三种是减少模型容量或增加数据量,如果能拿到更多训练样本,过拟合问题通常会自然缓解。
我建议做一个小实验来加深理解:在模型里加入Dropout层,设置dropout概率为0.2或0.5,对比前后测试集准确率的变化。你会发现训练准确率可能略有下降,但测试准确率通常会更好,尤其是当原始模型已经处于过拟合区间时,这种效果会更加明显。注意使用Dropout时,训练代码里的model.train()和评估代码里的model.eval()就变得至关重要了,因为Dropout只在训练模式下生效。
4.4 随机种子与可复现性
还有一个容易被忽视的细节是实验可复现性。PyTorch里,模型权重初始化是随机的,DataLoader打乱数据顺序也是随机的,如果跑到GPU上,某些运算还可能引入不确定性的额外波动。这意味着同一条代码跑两次,结果可能略有不同。如果你需要做对比实验,比如比较加Dropout前后的精度差异,这种随机波动会干扰你的判断。
解决的方法很直接,在训练脚本开头统一设置随机种子:
def set_seed(seed=42): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)固定随机种子后,第二次运行会得到和第一次一样的结果,这样可以更准确地判断精度变化到底是来自代码改动还是纯随机。当然,固定种子只是保证自己实验的内部一致性,不同机器、不同环境之间仍可能存在细微差异,这是正常的,不必钻牛角尖。
5. 踩坑记录:常见报错与排查思路汇总
5.1 维度不匹配与device不一致
MLP项目里最频繁的报错就是矩阵乘法维度不匹配,典型提示是“mat1 and mat2 shapes cannot be multiplied”。这个错误几乎都是因为Linear层收到的输入维度不对。比如输入还是三维的[batch, 1, 28, 28],而Linear期望的是[batch, features]。解决的办法就是在forward第一行加入x.view(x.size(0), -1),把每个样本展平成784维。
排查这类问题最直接的方式是在forward里临时打印x.shape,从第一层开始逐步看每个阶段的维度变化,很快就能定位到出错的那一层。你还可以前向构造一个假数据,比如torch.randn(4, 1, 28, 28),在模型外面手动调用一次并打印结果,这样不必启动整个训练就能验证维度链路。
另一个高频问题是device不匹配。当你用GPU训练时,数据和模型都需要搬到同一块GPU上。如果数据还在CPU上,模型在GPU上,运行就会报错。我的习惯是定义device变量,然后统一用images.to(device)和model.to(device)保证一切都在同一设备上,这样就不会出现一边在显卡一边在内存的尴尬局面。
5.2 loss变成NaN或inf怎么办
训练时loss变成NaN,是最让人头疼又最常见的问题。根据我自己的踩坑经验,它通常来自这么几个原因:学习率过大导致梯度更新过度震荡,数据没有归一化导致数值范围太大,损失函数里的log操作遇到0或负数,以及某些层的中间激活值出现爆炸。
排查时可以按顺序来。先把学习率缩小十倍,比如从0.001改成0.0001,如果loss恢复正常,说明是学习率的问题。然后检查数据预处理,确认ToTensor和Normalize是否都生效。再做一次单batch过拟合测试,用训练集里取出的第一个batch反复迭代几十次,看看loss能否正常下降,如果这一步都发生NaN,那基本可以确定是模型结构或数据本身有问题,而不是训练参数的问题。对于某些梯度容易爆炸的模型,还可以在反向传播之后手动加上梯度裁剪,限制梯度的最大范数,让训练更稳定。
5.3 模型不收敛的通用排查顺序
如果模型训练了很久,loss就是不下降,准确率一直在随机猜测水平徘徊,我建议按固定的顺序排查,不要东看一个西看一个。第一步,取一个batch的数据,让模型反复在这个batch上训练,如果模型连一个小批数据都拟合不了,那问题一定在模型结构、数据预处理或损失函数上,而不是优化策略的问题。第二步,检查标签是不是从0开始的连续整数,因为CrossEntropyLoss要求标签是类别索引,比如0到9,如果混入其它数值会直接报错或导致错误的监督信号。第三步,确认每个batch进入模型前是否调用了zero_grad,忘记清零梯度可能会导致更新方向混乱。第四步,检查输出层是否放了不合适的激活函数,比如在分类任务里输出层加了ReLU,会直接把负值截断,网络整体表达能力立刻受损。第五步,再看学习率和初始化参数,如果学习率太小或者模型初始权重全零,梯度更新也会很慢。
把这些排查项整理成一个速查表,遇到问题按表逐项走,效率会高很多:
| 现象 | 典型原因 | 排查顺序 |
|---|---|---|
| loss不下降 | 学习率过小、模型欠拟合 | 先测单batch拟合,再放大学习率 |
| loss剧烈震荡 | 学习率过大、batch太小 | 调小学习率,适当增大batch |
| 训练精度高、测试精度低 | 过拟合 | 加Dropout、权重衰减,减少模型容量 |
| 训练测试精度都低 | 模型容量不足、数据未归一化 | 加宽加深网络、检查预处理 |
| loss为NaN | 学习率过大、数值不稳定 | 调小lr、归一化数据、梯度裁剪 |
5.4 用一个小技巧显著提升调试效率
最后分享一个我在调试所有神经网络项目时都在用的习惯:不要一开始就在全量数据上训练。先用一个很小的子集,比如从训练集里取出100张图片,把batch_size设为16,跑2个epoch,确认整个流程能正常跑通,再切回全量数据正式训练。这种小规模试跑只需要几秒钟,能帮你快速发现代码里的低级错误,比如维度不对、标签类型错误、设备不统一等,避免你在全量训练跑到一半时才看到报错,浪费大量时间。
另一个建议是每次训练时把关键指标记录下来,保存到日志或者历史列表里,不要只print到控制台。尤其当你调整了好几个参数,想在两个实验之间做对比时,记录下来的曲线和数值会帮你定位是哪一步改动让效果变好或变坏,远比凭印象判断可靠。
我个人跑完这个项目后最大的体会,是终于建立了对“训练”这件事本身的直觉:loss不降时知道先去查单batch拟合,准确率上不去时知道判断是数据问题还是模型容量问题,测试集突然变差时知道这大概率和过拟合有关。这种直觉不是看文档能得来的,必须靠亲手把模型跑起来、改坏、修好、再改坏、再修好,才能慢慢沉淀下来。所以我建议你拿到代码之后,不要只让它跑通看一遍结果就结束,而是主动去折腾:把学习率调大一倍看loss曲线怎么崩,把Dropout去掉看过拟合有多明显,把隐藏层改成一层看精度掉多少。只有亲手经历过这些变化,才算真正把多层感知机装进了脑子。