☰
PyTorch深度学习基础:从张量到训练循环的实践指南
2026/9/26 6:24:54 网站建设 项目流程

打开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_grad=True,它就会被记录进计算图 x = torch.tensor([2.0], requires_grad=True) 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得到b,b经过函数g得到c。那c对a的偏导,等于c对b的偏导乘以b对a的偏导。PyTorch的autograd做反向传播时,就是沿着计算图从输出往输入反向走,把一连串局部导数相乘。

我见过很多人在这一步去死扣数学公式,其实没有太大必要。你只需要清楚两件事:

  • 调用backward()之后,计算图上所有requires_grad=True的叶子张量,都会在.grad属性里拿到梯度值。
  • 梯度默认是累加的,这就是为什么每次更新参数前都需要把之前的梯度清零。

这里补充一个非常关键的实践点:在验证反向传播是否写对时,最好先用一个小例子手动算一遍导数,再对比代码输出。比如y = x²,x=3时导数是6,用PyTorch跑一遍看.grad是不是6。这种小测试比直接莽一个大型网络要稳得多。

x = torch.tensor(3.0, requires_grad=True) 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]], dtype=torch.float32) target_reg = torch.tensor([[1.0, 2.0]], dtype=torch.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微调后的结果要更稳。做学术实验或者攻坚比赛时,这两种思路都值得试一遍。

对比项SGD+MomentumAdam
学习率敏感度高,容易震荡相对低,上手友好
收敛速度前期偏慢前期快
泛化性能调好之后通常更好某些场景下可能略差
适用阶段结构稳定后精调新模型、新数据快速验证

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_size=16, shuffle=True) 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],一共416+16+161+1=97个参数。心里有这笔账,后面模型参数排查会快很多。

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的流程,固定就四步:

  1. 清零梯度:optimizer.zero_grad()。
  2. 前向计算:得到预测值。
  3. 计算损失:预测值与真实值比较。
  4. 反向传播并更新:loss.backward()算出每个参数的梯度,optimizer.step()按梯度更新参数。
import torch.optim as optim model = MLP() loss_fn = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=0.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都打印,否则刷屏刷到看不清趋势。打印的间隔里,看着损失一步步降下来,这种反馈本身就是最好的学习动力。等哪一天你看到损失不再降了,不用怀疑自己,那就是第四章给你打下的基本功开始在起作用了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询