写 PyTorch 训练代码这些年,我最常被问到的不是某个 API 怎么用,而是“训练一个神经网络到底要经过哪些步骤”。新手看了太多零零碎碎的教程,今天学一个卷积层,明天看一个损失函数,真到自己要跑通一个训练流程的时候,反而不知道该从哪里拼起。这里我直接给出最核心的答案:定义模型、准备数据、选择损失函数和优化器、编写训练循环。这四个环节,就是一次神经网络训练的完整骨架。这篇文章我会把这四步拆开揉碎,把每一步背后的原理、常见坑和实操细节都讲清楚,给你一套可以直接照着写代码的框架。
这篇内容适合两类人:一类是刚接触 PyTorch、想系统搞懂训练流程的初学者;另一类是有一定基础,但每次写训练循环都要翻旧代码的人。我会用最直白的方式讲原理,不堆公式,用生活化的类比帮你在脑子里建立直觉,同时给出可以 running 的代码和参数选择的依据。
1. 整体设计与思路拆解
训练神经网络本质上是在做一件事:通过反复调整模型的参数,让模型在数据上的表现越来越好。所谓“表现好”,就是你选的损失函数数值不断下降。整个训练流程设计出来,就是围绕“计算误差 → 根据误差调整参数”这个循环来转的。
四个环节各司其职,缺一不可:
- 定义模型:决定“怎么算”。也就是从输入到输出,数据是怎么一层层变换过去的。模型决定了网络的能力边界——一个太小的网络学不到复杂规律,一个太大的网络又容易死记硬背。
- 准备数据:决定“学什么”。模型是从数据里学规律的,数据的质量、数量、预处理方式,直接决定学出来的模型好不好用。
- 选择损失函数和优化器:决定“怎么学”。损失函数告诉模型“你离目标还差多远”,优化器则根据这个差距去更新权重。这个组合选错了,轻则训练慢,重则根本学不进去。
- 编写训练循环:把前三者串起来,按批次喂数据、算损失、更新参数,一遍一遍迭代,直到模型收敛。
这四个环节有强顺序依赖:没有数据和模型,损失算不出来;没有损失和优化器,参数没法更新;没有训练循环,整个过程无法持续。所以训练一个神经网络的本质,就是用代码把这条流水线搭起来。
很多时候初学者喜欢一上来就往模型里塞各种高级技巧——注意力机制、数据增强、正则化,我的建议是先把这条基础流水线跑通。你后面加的任何技巧,实际上都是在这四个环节中的某一个里做文章:数据增强是在“准备数据”里加处理逻辑,权重衰减是改“优化器”的配置,调整网络结构就是改“定义模型”的部分。骨架立住了,后续的所有优化才有落点。
2. 定义模型:从网络结构到可运行代码
PyTorch 里定义模型的标准姿势是继承nn.Module。这个基类帮你管理了参数、状态和很多底层机制,你自己的类只需要做两件事:在__init__里声明需要用到的层,在forward里定义数据怎么流过这些层。
我拿一个手写数字识别任务举例。MNIST 数据集每张图是 28×28 的灰度图,要分类成 0 到 9 共 10 个类别。最简单的入门模型可以是这样:
import torch import torch.nn as nn class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(28 * 28, 128) self.fc2 = nn.Linear(128, 64) self.fc3 = nn.Linear(64, 10) self.relu = nn.ReLU() def forward(self, x): x = x.view(x.size(0), -1) # 把 28x28 展平成 784 维向量 x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) x = self.fc3(x) return x这里要理解两个关键点。第一,nn.Linear(784, 128)表示一个全连接层,它内部会自动创建两个参数:权重矩阵weight和偏置向量bias。你不需要手动初始化它们,PyTorch 默认帮你做了。第二,forward定义了数据的前向传播路径,这个函数在每次训练迭代中被调用。
前两层的ReLU激活函数是必须的——如果没有非线性激活,多层线性层叠在一起本质上还是一个线性变换,那再深的网络也表达不了复杂规律。最后一层不加激活函数,因为我们后面要配合交叉熵损失函数一起用,这一点我放到损失函数那节详细说。
在实际做项目的时候,模型定义这一环我最想提醒三件小事:
第一,检查输出形状。初学者最常见的报错就是维度对不上。我自己的习惯是模型定义完之后,先造一个假输入x = torch.randn(64, 1, 28, 28)跑一遍model(x),看输出的是什么形状。这个动作成本极低,但能避免你把模型搭进训练循环之后才发现问题。如果输出维度不对,后面的训练直接崩,排查起来更麻烦。
第二,用nn.Sequential简化。如果你的模型就是一层接一层,不需要复杂的分支结构,可以用Sequential包起来,代码更简洁:
self.net = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10), )第三,模型设备要统一。只要用了 GPU,就要在模型创建后调用.to(device),同时确保每个 batch 的数据也.to(device)。模型参数在 GPU 上、数据还在 CPU 上,是新手最常踩的坑之一。
3. 准备数据:Dataset 与 DataLoader 的正确姿势
数据准备这步在简单项目里看起来不起眼,但它的重要性怎么强调都不过分。模型学到的所有东西都来自数据,数据没准备好,后面的一切都是空中楼阁。PyTorch 处理数据的标准做法是用Dataset和DataLoader。
Dataset负责定义“怎么读取单条数据”,DataLoader负责把这些数据打包成批次(batch)、打乱顺序、并行加载。对新手来说,最直观的方式是直接用 PyTorch 内置的torchvision.datasets.MNIST:
from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)这里有两个细节值得掰开讲。
第一个是ToTensor()。它有两个作用:把 PIL 图像变成 PyTorch 张量,并且把取值范围从 0 到 255 缩放到 0 到 1。这一步很多人会忽略,但如果不做缩放,后续算损失的时候数值会很大,梯度也容易爆炸。
第二个是Normalize。它做的是标准化:(x - mean) / std。MNIST 的全局均值和标准差大约是 0.1307 和 0.3081,把数据标准化之后,每个维度的分布都变成均值 0、方差 1 的形态,这会大大加快收敛速度。你可以理解为,如果输入数据有的很大有的很小,梯度更新就会像“一个高个子一个矮个子的队伍走路”,互相拖累。标准化之后,大家都差不多的尺度,步子才迈得齐。
再来说batch_size和shuffle这两个参数的意义。
batch_size=64表示每次取 64 张图作为一个批次,算一次损失、做一次参数更新。为什么不用单张图直接更新?因为单张图的噪声太大,参数更新方向会很抖,训练不稳定。为什么不用全量数据一次更新?因为数据量大时计算太慢,而且全量梯度容易陷入局部最优。分批训练相当于“每次取一小撮样本估一个大概方向就往前走”,虽然方向不完全准,但走得快,整体效果反而好。
shuffle=True也很关键。它让每个 epoch 中样本的出场顺序都不同,避免模型学到数据排列的规律。如果数据不做打乱,模型可能在某个批次里只看到某一类样本,导致梯度方向偏斜。打乱之后,每个批次都是整个数据集的一个随机抽样,梯度方向的偏差就会被平均掉。
在真实项目里,数据准备远远不止“读数据、归一化”这么简单:图片类任务要做随机裁剪、翻转、色彩抖动,文本类任务要做 tokenization 和 padding,表格数据要做缺失值填充和特征缩放。但它们的本质都是一样的——让数据以最适合模型训练的形式进入网络。
4. 选择损失函数和优化器:聪明的学习策略
损失函数和优化器是整个训练流程的“方向盘”和“发动机”,我习惯把这一对放在一起选,因为它们高度耦合。
损失函数衡量的是“模型现在的输出离正确答案有多远”。分类任务选交叉熵,回归任务选均方误差(MSE),这是两条最基本的原则。我手写数字识别这个分类例子,用:
criterion = nn.CrossEntropyLoss()CrossEntropyLoss在 PyTorch 里其实已经内置了一个LogSoftmax,也就是说它同时完成了两件事:先把模型的原始输出转换成概率分布,再计算预测概率和真实标签之间的交叉熵。这就是为什么我前面说最后一层不加激活函数——如果你在最后一层加了 ReLU 或者 Sigmoid再做CrossEntropyLoss,反而会破坏数值的稳定性,效果变差。
如果任务换成预测房价这种连续数值,那就要用:
criterion = nn.MSELoss()MSE 对大的误差惩罚更重(误差平方放大差距),所以模型会更优先去纠正那些错得离谱的预测。
优化器负责根据损失函数计算出的梯度,去更新模型的参数。最常用的两个优化器是SGD和Adam:
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)SGD 就像一个人拿着地图在山谷里走,每一步都沿着最陡的方向下山。它的逻辑直观,但要收敛好,往往需要精心调节学习率和动量参数。Adam 可以理解成“给每个参数配了一个自适应步长”,它会根据每个参数的历史梯度大小动态调整更新的幅度——梯度大的方向步子小一点,梯度小的方向步子大一点。这就让 Adam 在很多问题上不需要精细调参就能飞快收敛,所以它是新手友好型选择。
学习率是这里最重要的超参数。它决定了每次参数更新的步长。学习率太大,参数会在最优解附近震荡甚至发散;学习率太小,收敛像蜗牛爬。我自己的经验值是:Adam 起步设0.001,SGD 起步设0.01,然后看训练的 loss 曲线再调整。
权重衰减(weight decay)也是优化器里一个值得从第一天就用上的参数。在torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5)里加上它,等效于给损失函数加了一个 L2 正则项,惩罚过大的权重,让模型更倾向于用较小的参数拟合数据。这能显著抑制过拟合,而且几乎不需要额外成本。
5. 编写训练循环:把一切都串起来的发动机
前面准备的模型、数据、损失函数和优化器,最终都要靠训练循环驱动起来。训练循环是整个流程的主干,每次迭代执行四步操作:前向传播、计算损失、反向传播、更新参数。代码如下:
def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss = 0.0 for images, labels in train_loader: images = images.to(device) # 对应模型所在的设备 labels = labels.to(device) # 1. 前向传播:把数据喂给模型,得到预测输出 outputs = model(images) # 2. 计算损失:比较预测和真实标签的差距 loss = criterion(outputs, labels) # 3. 反向传播:根据损失计算每个参数的梯度 optimizer.zero_grad() # 清空旧梯度,这一步不能忘 loss.backward() # 计算梯度 optimizer.step() # 用梯度更新参数 running_loss += loss.item() * images.size(0) epoch_loss = running_loss / len(train_loader.dataset) return epoch_loss这里我要花大篇幅讲一下optimizer.zero_grad()这行,因为它是新手最容易忽略、却又最关键的行之一。
PyTorch 的反向传播是累加梯度的:每次调用loss.backward(),新的梯度会叠加到参数已有的梯度上。如果在一次更新后不清空旧的梯度,下一轮迭代的梯度就是“新旧混合”的。数值上,这会导致参数更新方向被旧数据污染,训练过程变得极不稳定。所以标准顺序必须是:先zero_grad()清空昨天的账,再backward()算今天的账,最后step()用今天的账去更新。
这个坑我见过太多人踩。很多人把zero_grad()放在backward()后面甚至完全忘记写,结果 loss 曲线各种震荡,还以为是学习率的问题。
再来看整个循环的大结构。一个完整的训练过程通常是这样的:
num_epochs = 10 for epoch in range(num_epochs): train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device) print(f'Epoch {epoch+1}/{num_epochs}, Loss: {train_loss:.4f}')外层循环遍历epoch。一个 epoch 意味着整个训练集被模型看过一遍。内层循环按批次遍历train_loader,每个批次执行上面的四步操作。训练过程就是不断重复“看一批数据、学一点东西”,一个 epoch 一个 epoch 地推进,直到模型收敛。
在训练循环里,有两个模式开关值得专门提一下。一个是model.train(),它会启用训练模式下的一些机制(比如 dropout、batch norm 的更新)。另一个是model.eval(),用在验证和测试阶段,它会关闭这些机制,让模型的参数保持固定、行为可预期。我建议训练时养成习惯,在循环开头加上model.train(),在评估时加上model.eval(),别偷懒省略。
6. 验证与测试:训练完不等于模型能用
很多人把训练循环跑完就以为大功告成了,其实训练集 loss 低只能说明“模型把见过的题做对了”,完全不能说明“模型把没见过的题也能做对”。所以在训练过程中留出一部分数据做验证,是评估模型真实能力的唯一标准。
验证循环和训练循环结构上很像,但有三个关键区别:不用计算梯度、不用反向传播、不用更新参数。用torch.no_grad()包住验证过程,可以显著减少显存占用和计算量。代码长这样:
def evaluate(model, val_loader, criterion, device): model.eval() val_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() * images.size(0) # 计算准确率:取预测概率最大的类别作为预测结果 _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) avg_loss = val_loss / total accuracy = correct / total return avg_loss, accuracy这里我用torch.max(outputs, 1)取出每个样本预测概率最大的那个类别索引。outputs的形状是(batch_size, num_classes),torch.max的第二个参数1表示在类别维度上取最大值。predicted和labels逐元素比较,相等的数量就是这批数据里预测正确的样本数。
在训练过程中,每个 epoch 结束后跑一次验证,把训练 loss 和验证 loss 都记录下来。这两个数值的变化趋势会告诉你训练状态是否健康:
- 训练 loss 和验证 loss同步下降:训练正常,模型在学到通用规律。
- 训练 loss 下降但验证 loss不降反升:模型开始过拟合了,它在死记训练集的数据,失去了泛化能力。这时可以提前停止训练,或者回去加正则化、加数据增强、减小模型容量。
- 两个 loss 都不降:可能是学习率太小,或者模型结构表达能力不够,又或者数据预处理出了问题。
这种做法相当于给训练过程装了一个仪表盘,让你能实时看到模型状态,而不是训练完一锤子买卖。
7. 常见问题与排查技巧实录
训练神经网络翻车是常态,不翻车才奇怪。这里我把自己踩过和帮别人排查过的高频问题整理一下,每条都是真实经验。
问题一:维度不匹配报错。最常见的报错长这样:Expected input batch_size (64) to match target batch_size (32)。这通常说明模型的输入输出维度和数据的 shape 对不上。排查思路先打印每个环节的 shape:print(images.shape)、print(outputs.shape)、print(labels.shape),逐层定位。尤其注意数据经过view/flatten之后尺寸是否正确。
问题二:Loss 一直是 0 或者 NaN。Loss 为 0 往往是因为在分类任务中误把最后一层加了 Softmax,再配合CrossEntropyLoss重复计算,导致数值异常。Loss 变 NaN 常见原因是学习率太大,梯度爆炸到超出数值范围,把学习率调小一个量级再试。还有一种可能是输入数据里有 NaN 值,这个在用真实数据集的时候尤其常见,需要检查原始数据。
问题三:loss 下降很慢或者压根不动。我遇到这种情况的第一反应是按顺序排查:数据有没有归一化、学习率是不是太小、模型结构是不是太简单。很多时候新手在 MNIST 上用很小的模型加未归一化的输入,loss 就会卡在一个高位不动。数据标准化、适当调大学习率、加一层网络宽度,通常能解决。
问题四:训练集效果很好,测试集一塌糊涂。这就是过拟合。最直接的应对是数据增强——让模型看到更多样化的输入;其次加weight_decay;再其次用 dropout 层。还有一招是早停(early stopping):监控验证 loss,一旦连续几个 epoch 不降反升,就停住训练,用之前验证 loss 最低的那一轮模型参数。
问题五:GPU 利用率低,训练慢。这个在入门项目里不明显,但数据量上来之后会烦死人。常见原因是batch_size太小、DataLoader 的num_workers没设置、或者数据在 CPU 和 GPU 之间频繁搬运。batch_size加大到 128 或 256,num_workers设置成 4 或 8,尽量在把数据放到 GPU 之前就完成所有预处理,都能明显提速。
我做训练的习惯是全程盯住两样东西:训练 loss 曲线和验证指标。把曲线打出来看趋势,比自己凭感觉调参靠谱得多。如果 loss 下降得太平滑,往往是学习率偏小;如果 loss 剧烈抖动,学习率可能偏大。这条经验在我带过的人里反复被验证,比任何技巧都实用。
PyTorch 训练神经网络的这四步——定义模型、准备数据、选损失函数和优化器、写训练循环——看似简单,但它构成的是所有深度学习项目的地基。我现在写任何复杂的模型,不管是 Transformer 还是扩散模型,回到最底层,跑起来的仍然是这样一套流程。把这条流水线焊死在肌肉记忆里,再去谈各种花哨的模块和技巧,会顺手非常多。希望你跑通自己的第一个完整训练流程之后,能感受到这件事并没有想象中那么神秘——它只是一套逻辑清晰的工程流程而已。