
打开PyTorch教程很多人是兴奋的环境装好了张量玩熟了看一眼CPU还是GPU在跑一切都挺顺利。可一旦翻到“深度学习基础”这一章画风突然就变了前向传播、反向传播、计算图、损失函数、优化器……一堆术语砸过来有的人在这页停留了很久有的人直接划走了。这篇内容就是把这个章节真正拆开揉碎用能用得上的代码和踩坑记录把深度学习训练的基础路径走通。不管你刚装好PyTorch还没写出一个完整训练循环还是学了一阵子但概念仍然发虚这篇都适合静下心跟着过一遍。1. 第四章到底在讲什么先看清深度学习的基础拼图1.1 前三章之后的认知拐点很多教程把环境搭建和张量操作放在前三章是有意安排的。第一步装好Python环境、创建conda虚拟环境、装好PyTorch第二步学会张量的创建、索引、变形、拼接第三步开始接触autograd自动求导。到这个时候你手里的工具箱已经凑齐了但最大的困惑也跟着出来了这些东西到底怎么组合起来才能让一个程序去“学习”?第四章就是在回答这个问题的。它不是想让你背一堆名词而是要把深度学习训练的过程整体过一遍数据怎么进模型模型怎么算预测预测怎么跟真实答案比较比较出来的差距怎么转化成参数的更新方向。这一整套流程打通了后面看CNN、RNN、Transformer都是在同一套框架里换不同的积木一旦基础环节理解不到位后续每个模型都会学得吃力。我带过不少初学者一个很明显的规律是凡是卡在第四章后面很久的大多不是模型结构看不懂而是“训练循环里那几行代码为什么这样写”没有真正理解。比如loss.backward()到底做了什么为什么每次迭代都要optimizer.zero_grad()为什么有的代码里能看到model.train()有的又没有。这些细节才是基础章节最值钱的部分。1.2 把训练收敛到“调整参数”这一件事上深度学习模型说白了就是一个带大量参数的函数。输入是一张图片、一段文本或者几个数字输出是一个预测结果。模型刚初始化的时候参数是随机或者按照某种规则生成的预测结果自然一塌糊涂。所谓训练就是不断调整这些参数让模型的预测结果逐步逼近我们想要的正确答案。这里有一个容易被忽略但极其重要的认知训练不是让模型“记住正确答案”而是让模型通过大量样本找到一组在“见过的数据”和“没见过的新数据”上都能表现良好的参数。所以我们既要有损失函数来度量当前参数的好坏也要有优化器来决定参数往哪个方向调整、每步调整多大。第四章的所有内容其实都是围绕“参数调整”这一个核心动作展开的。如果拿做饭类比模型是菜谱上的配料比例数据是端上桌的菜损失函数是顾客的差评优化器是厨师根据差评去调整盐和糖的比例。每跑一轮训练厨师就调一次配方直到顾客普遍满意为止。这个类比虽然简单但能帮你把后面那些抽象名词全部挂在具体的画面里不至于学着学着就飘了。2. 张量与自动求导不把地基看清楚后面全白搭2.1 张量不只是多维数组关键是它带着“历史记录”PyTorch里的张量Tensor最基本的形态就是多维数组跟上章学的差不太多。但深度学习中用的张量很多是带着计算历史的。当你对一个张量做加法、乘法、矩阵乘、reshape这些操作时PyTorch会记录下这个结果是怎么来的构成一张计算图。这张图就是反向传播的依据。我在教新手的时候经常强调一个点普通的数值计算和深度学习训练之间隔着的不是代码量的多少而是“是否构建计算图”。如果只是做张量运算又不需要梯度比较推荐的做法是使用torch.no_grad()把这些运算包起来省内存也能提速。尤其是在评估模型、转换模型、做推理的时候这个习惯必须养成。import torch # 默认情况下一个叶子张量如果有requires_gradTrue它就会被记录进计算图 x torch.tensor([2.0], requires_gradTrue) y x ** 2 3 * x 1 # 查看 y 是怎么算出来的grad_fn 指向一个运算节点 print(y.grad_fn) # AddBackward0 object at 0x...上面这段代码里y不再是一个普通的数它记录了“由x经过幂运算、乘法、加法得出”的全部路径。一旦调用y.backward()梯度就会沿着这张计算图传回x。2.2 计算图与链式法则反向传播并没有那么神秘要理解梯度是怎么“传回来”的只需回忆一下链式法则。假设有三个张量a经过函数f得到bb经过函数g得到c。那c对a的偏导等于c对b的偏导乘以b对a的偏导。PyTorch的autograd做反向传播时就是沿着计算图从输出往输入反向走把一连串局部导数相乘。我见过很多人在这一步去死扣数学公式其实没有太大必要。你只需要清楚两件事调用backward()之后计算图上所有requires_gradTrue的叶子张量都会在.grad属性里拿到梯度值。梯度默认是累加的这就是为什么每次更新参数前都需要把之前的梯度清零。这里补充一个非常关键的实践点在验证反向传播是否写对时最好先用一个小例子手动算一遍导数再对比代码输出。比如y x²x3时导数是6用PyTorch跑一遍看.grad是不是6。这种小测试比直接莽一个大型网络要稳得多。x torch.tensor(3.0, requires_gradTrue) y x ** 2 y.backward() print(x.grad) # tensor(6.)一旦你把这一步跑通了后面所有训练循环的代码都会变得顺理成章。2.3 什么时候用no_grad什么时候必须关掉实际项目里很多人会在验证集和测试集上忘记包一层torch.no_grad()。表面上代码也能跑但有几个隐藏问题一是会额外消耗显存因为验证过程也往里塞了大量计算图二是如果验证集中某个操作意外触发了梯度累积结果可能被污染。所以我个人的习惯是只要不是训练阶段一律用torch.no_grad()包住前向计算。另外如果模型里有Dropout、BatchNorm这类在训练和推理阶段行为不同的层还需要时刻记得切换model.train()和model.eval()。这是很多基础教程不强调、但在真实项目里经常踩的坑。两者缺一不可且各管各的事前者管梯度与计算图后者管层的运行模式。3. 损失函数与优化器让模型知道错在哪也知道怎么改3.1 回归和分类损失函数的选择逻辑完全不同损失函数的作用是用一个数值来回答“当前模型到底有多差”。但“差”的定义在回归和分类场景里是完全不同的。回归问题最常见的是均方误差MSE。它的直觉是预测值和真实值的差距平方一下再求平均。为什么用平方而不是绝对值因为平方对大误差更敏感梯度也连续优化起来更平稳。不过平方也意味着个别离群点会主导梯度所以如果你的数据里有明显的异常值可以换成Smooth L1这类鲁棒性更强的损失。分类问题则不太一样。以多分类为例PyTorch里最常用的CrossEntropyLoss实际上是LogSoftmax和NLLLoss的组合体。它先对模型输出做softmax转成概率分布再计算预测分布与真实分布之间的交叉熵。这里有一个新手高频翻车点CrossEntropyLoss内部自带softmax所以你的模型最后一层不需要再手动加softmax。如果加了训练时相当于做了两次softmax看起来也能跑但梯度会不正常收敛速度明显变慢甚至训不出来。import torch.nn as nn loss_mse nn.MSELoss() loss_ce nn.CrossEntropyLoss() # 回归场景预测值和真实值都是向量 pred torch.tensor([[1.2, 2.3]], dtypetorch.float32) target_reg torch.tensor([[1.0, 2.0]], dtypetorch.float32) print(loss_mse(pred, target_reg)) # 分类场景pred是每个类别的原始分数target是类别索引 logits torch.tensor([[2.0, 1.0, 0.1]]) target_cls torch.tensor([0]) print(loss_ce(logits, target_cls))3.2 优化器不是越多越好SGD和Adam的取舍PyTorch自带的优化器有不少但入门最重点就两个SGD和Adam。SGD是最朴素的优化器按整个batch梯度的反方向更新参数。它配合动量机制momentum之后收敛路径更平滑也更容易逃出局部极小和坑坑洼洼的区域。很多经典模型和论文的默认优化器仍然是SGD并不是因为它快而是它的泛化能力往往更好只是需要更仔细地调学习率。Adam则是带自适应学习率的优化器它会根据每个参数的历史梯度信息为每个参数单独调整更新步长。它的最大优点是“对学习率不那么敏感”新手用起来更容易上手。在很多图像分类、文本分类任务中Adam的开局速度明显快于SGD。我实际用下来的经验是如果你刚开始跑一个新模型对数据分布和模型结构都没什么把握先用Adam把流程跑通等模型结构稳定了、损失曲线也正常了再换成带动量的SGD继续调优。不是说Adam一定不能做最后训练而是在很多任务里SGD微调后的结果要更稳。做学术实验或者攻坚比赛时这两种思路都值得试一遍。对比项SGDMomentumAdam学习率敏感度高容易震荡相对低上手友好收敛速度前期偏慢前期快泛化性能调好之后通常更好某些场景下可能略差适用阶段结构稳定后精调新模型、新数据快速验证3.3 学习率和batch size两个最容易被忽略的“大旋钮”第四章如果只让你记住一个参数那一定是学习率。学习率决定每一步参数更新的幅度太大会导致损失爆炸或者来回震荡太小会让训练慢到怀疑人生。一个很实用的观察法正常训练时损失曲线应该是平滑下降的如果损失在某个区间来回弹先降低学习率如果损失一下子跳到nan很可能是学习率过大或者数据里有数值问题。我自己的排查顺序是先确认没有维度错误和数据异常再把学习率从默认值调低一个数量级试试。batch size的影响则更微妙。增大的话梯度方向更稳训练起来更平滑但对显存的要求跟着上去了而且有时候会掉进尖锐的极小值点。减到很小的batch size梯度噪声大反而能带来一定的正则化效果但也可能让训练不稳定。实操中我会先按显存容量选择尽量大的batch size然后结合学习率一起做调整。如果你把batch size从32改到128最好把学习率也相应调大一点很多教程不提这个但实际影响非常大。4. 五步法跑通第一个神经网络从线性模型到多层感知机4.1 准备数据Dataset与DataLoader是训练的入口进入实操环节。写一个完整训练循环逻辑上其实就五步准备数据、定义模型、选择损失函数、创建优化器、写训练循环。先说数据。PyTorch提供了Dataset和DataLoader两件套。Dataset负责定义“一条数据长什么样”DataLoader负责把数据按照设定的batch size打包还能自动打乱顺序。初学阶段很多人图省事直接用全量数据跑梯度下降那样也能跑但完全没发挥出小批量梯度下降的优势。用DataLoader每个迭代都能拿到一批数据既省内存训练也稳定。import torch from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, x, y): self.x x self.y y def __len__(self): return len(self.x) def __getitem__(self, idx): return self.x[idx], self.y[idx] x_data torch.randn(100, 4) y_data torch.randn(100, 1) dataset MyDataset(x_data, y_data) loader DataLoader(dataset, batch_size16, shuffleTrue) for batch_x, batch_y in loader: # batch_x 的形状是 [16, 4]batch_y 是 [16, 1] break每次从DataLoader里拿出来的已经是一个batch张量模型前向计算前不需要再手动拼batch。这里有一个基础但重要的认知模型接收的输入第一维通常是batch大小。4.2 定义模型nn.Module与Sequential的常用姿势用PyTorch定义模型一般继承nn.Module在__init__里声明层结构在forward里写好前向计算过程。如果模型就是一层接一层的堆叠用nn.Sequential可以把代码缩得非常短。以最简单的多层感知机为例输入维度4中间隐藏层16个神经元输出维度1激活函数用ReLU。这里动手前建议大家自己先算一遍参数数量隐藏层的权重形状是[4, 16]偏置是[16]输出层权重是[16, 1]偏置是[1]一共41616161197个参数。心里有这笔账后面模型参数排查会快很多。import torch.nn as nn class MLP(nn.Module): def __init__(self): super().__init__() self.net nn.Sequential( nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, 1) ) def forward(self, x): return self.net(x) model MLP() print(model)关于激活函数这里插一句ReLU是当前最常用的默认选择计算快且不容易饱和。实际项目中如果遇到梯度消失问题可以试试LeakyReLU或者ELU。第四章里不可能把所有激活函数都讲透但你至少要知道一件事如果没有非线性激活函数叠加再多Linear层也等价于一个线性变换模型的表达能力会被牢牢限制住。4.3 训练循环每次迭代的四步固定动作训练循环是整个第四章的核心。每个batch的流程固定就四步清零梯度optimizer.zero_grad()。前向计算得到预测值。计算损失预测值与真实值比较。反向传播并更新loss.backward()算出每个参数的梯度optimizer.step()按梯度更新参数。import torch.optim as optim model MLP() loss_fn nn.MSELoss() optimizer optim.Adam(model.parameters(), lr0.01) for epoch in range(50): for batch_x, batch_y in loader: optimizer.zero_grad() pred model(batch_x) loss loss_fn(pred, batch_y) loss.backward() optimizer.step()为什么每次都要zero_grad()因为PyTorch的反向传播是梯度累加机制默认会把新算出来的梯度加到已有梯度上。如果不手动清零每个batch的梯度会像滚雪球一样越积越大参数更新方向完全错乱训练也不可能稳定。还有一点需要厘清epoch和batch的区别。epoch是整个数据集被完整遍历一遍的次数batch是一次更新用到的样本数量。一次epoch里DataLoader通常会跑很多个batch也就对应很多次参数更新。所以上面代码里是两层循环外层管epoch内层管batch。4.4 测试集评估训练损失低不代表模型好训练还没结束就急着欢呼是新手最喜欢犯的错误。模型在训练集上的损失低只能说明它对见过的数据拟合得还行不能说明它对没见过的数据也有同样表现。这就需要从完整数据里留出一部分通常是训练集和测试集按8:2或者7:3划分训练过程完全不碰测试集。在测试集上评估时除了用torch.no_grad()包住前向计算还建议把模型切换到评估模式model.eval()。如果模型里有Dropout或BatchNorm这一行必不可少。评估完毕再切回model.train()继续训练否则后面训练的行为会被悄悄改变。model.eval() with torch.no_grad(): test_pred model(test_x) test_loss loss_fn(test_pred, test_y)一个比较健康的训练信号是训练损失和测试损失都在同步下降且两者差距不大。如果训练损失一直降测试损失却开始上升说明模型开始过拟合了。这时候可以先停掉训练再考虑加正则化、增大数据量或者减小模型容量。5. 训练翻车现场我排过的坑和固定的排查顺序5.1 训练不收敛先从这三个地方查遇到损失不下降、抖动或者突然变成nan我的建议是不要慌按固定顺序排查。第一看数据。数据里如果有nan或者极端大的数值模型很容易训飞。先打印x_data和y_data的基本统计量确认最小值、最大值、均值是否在合理范围。如果数值跨度很大做标准化归一化会非常有效。很多人在第四章就开始忽略数据预处理的威力等到后面做真实项目时才后悔。第二看损失函数。确认回归问题用的不是CrossEntropyLoss分类问题用的不是MSELoss。我之前就见过有人把分类问题硬套MSE训练半天效果极差原因就是损失函数与任务类型根本不匹配。第三看学习率。如果损失曲线在某个值附近上下抖动几乎可以肯定是学习率太大如果损失降得极其缓慢则可能是学习率太小。一个粗暴但有效的办法把学习率依次踩0.1、0.01、0.001、0.0001看哪一条曲线最顺再在附近细调。这个方法虽然费点时间但比凭空猜要可靠得多。5.2 维度不匹配用shape调试法而不是盯着报错发呆维度报错是新手进入第四章后遇到最多的报错类型比如mat1和mat2的形状对不上或者runtime error提示size mismatch。这类错误靠肉眼盯代码有时候很难发现因为问题往往不在报错的那一行而在数据从DataLoader出来之后、进入模型之前是否被正确变形。我的习惯是在训练循环开始前单独取一个batch然后沿着数据流把每个环节的shape依次打出来输入的形状、Linear层之后的形状、损失函数输入的形状。一旦某一步和预期不符马上就能定位。、batch_x, batch_y next(iter(loader)) print(input:, batch_x.shape) # [16, 4] print(target:, batch_y.shape) # [16, 1] out model(batch_x) print(output:, out.shape) # [16, 1]如果第一个Linear层的in_features和输入特征数对不上模型定义看一眼就能发现比在报错信息里猜来猜去快得多。还有一点容易被忽略在PyTorch里向量和矩阵的形状规则很严格[4]和[1, 4]不是一个形状前者在部分操作里会被当成一维向量处理后者会被当成一行矩阵。当你的代码里出现诸如“形状[4]与[16, 4]无法计算”的提示通常是某个地方少了一个维度。5.3 随机种子与可复现性为自己的实验结果负责如果你希望训练结果能够复现无论是写作业还是做研究都要在训练前固定随机种子。PyTorch本身的随机数、Python的random、NumPy的random甚至CUDA相关的随机数都会影响结果。漏掉任何一个别人复现出来的数值都可能和你有偏差。import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)固定随机种子之后同一份代码应该能跑出相同的结果。如果仍然有细微差异检查代码里有没有依赖全局随机状态而未设置种子的地方。这个问题非常隐蔽但越早养成习惯后面做对比实验时越省心。5.4 环境层面容易被坑的几件事第四章虽然以代码为主但环境没配对代码再对也跑不动。最典型的是GPU版本的PyTorch和CUDA版本不匹配。检查能不能用GPU别光看安装时选了哪个版本要在代码里实跑一下print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else CPU mode)如果is_available()返回False先别急着怀疑代码先去确认安装的PyTorch是不是属于当前CUDA对应的版本。另一个很常见的问题是conda环境混用pip和conda安装的包混在一起版本冲突之后报错非常难查。我的习惯是一个项目一个独立conda环境所有依赖都在这个环境里用同一种方式安装避免混用。6. 学完这一章你不该只是“看懂了”第四章的信息密度确实高但它是最值得反复看的一章。我自己的体会是看一遍代码跟亲手敲一遍完全是两码事亲手跑通一个最小训练循环的收获抵得上翻十遍教程。所以如果你现在已经装好了PyTorch我建议你先别急着往CNN、Transformer那边冲就拿最简单的多层感知机在随便一个数据集上把前向传播、反向传播、参数更新、测试评估完整跑一遍等你能不看教程默写出那个十几行的训练循环时再往后面走会顺很多。最后再分享一个小技巧训练时每隔几个epoch打印一次当前epoch数和损失值别每个batch都打印否则刷屏刷到看不清趋势。打印的间隔里看着损失一步步降下来这种反馈本身就是最好的学习动力。等哪一天你看到损失不再降了不用怀疑自己那就是第四章给你打下的基本功开始在起作用了。