news 2026/10/13 1:08:25

PyTorch入门实战:从零手写线性回归彻底搞懂张量与自动求导

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch入门实战:从零手写线性回归彻底搞懂张量与自动求导

1. 从一个例子切入:为什么PyTorch值得花时间搞明白

如果你刚开始接触深度学习,或者从别的框架迁移过来,大概率会听到一句话:“PyTorch上手快。”但“上手快”这三个字太虚了,到底快在哪?是API少?是文档好?还是报错信息友好?我一开始也犯嘀咕,直到自己动手把一个完整的训练流程从零写了一遍,才真正体会到它的设计逻辑——它把“计算”和“求导”这两件事拆得特别干净,让你写模型就像写普通Python程序一样自然。

这篇文章不打算复述官方文档,也不准备堆砌API列表。我想做的是:用一个足够小但五脏俱全的例子,把PyTorch的核心机制——张量、自动求导、模块化封装、训练循环——串起来讲透。这个例子就是一个极简的线性回归,但我会在每一步停下来,解释“为什么这么写”“不这么写会怎样”“实际项目中哪些地方容易踩坑”。无论你是刚学完Python基础想入门深度学习,还是用过其他框架但总觉得隔了一层纱,这篇内容都能帮你把PyTorch的骨架看清楚。

我个人的习惯是:学任何框架,先跑通一个最小闭环,再往里填细节。这个最小闭环必须包含数据准备、模型定义、损失计算、梯度更新四个环节,缺一个都不算真正跑通。下面我就按这个顺序,把每个环节拆开揉碎,顺便把那些文档里不会写的经验一并倒出来。

2. 核心概念拆解:张量、自动求导与计算图

2.1 张量不只是“多维数组”

很多人把PyTorch的张量(Tensor)简单理解成NumPy的ndarray加上GPU支持,这话对了一半。张量确实和ndarray共享内存布局和大部分API,但它多了一个关键属性:requires_grad。这个属性决定了PyTorch要不要追踪在这个张量上发生的所有操作,从而构建计算图。

你可以把计算图想象成一张“操作流水账”:每当你对一个requires_grad=True的张量做运算,PyTorch就在后台记一笔——“这个结果是由哪个输入、经过什么操作得来的”。等到你需要求导时,它沿着这本账反向追溯,用链式法则把梯度算出来。整个过程不需要你手动推导任何公式。

这里有个容易混淆的点:只有浮点类型的张量才能设置requires_grad=True。整数张量默认不支持求导,因为梯度本质上是连续变化的量,整数域上谈导数没有意义。我见过有同学用torch.tensor([1, 2, 3])创建张量后直接调.backward(),结果报错说“only floating point tensors can require gradients”,其实就是这个原因。

另一个实操细节:创建张量时如果从NumPy数组转换,默认是共享内存的。也就是说,你修改原ndarray,张量也会跟着变。这在数据预处理阶段很容易出问题——你以为复制了一份数据,实际上两边指向同一块内存。要切断这种关联,得显式调用.clone()或者.detach()。

2.2 自动求导:backward()到底干了什么

自动求导是PyTorch最核心的卖点,但很多人只是机械地在loss后面加一句.backward(),并不清楚里面发生了什么。我试着用最直白的方式解释:

假设你定义了一个计算:y = w * x + b,其中w和b是需要学习的参数。前向计算时,PyTorch记录下“y是由w、x、b经过乘法和加法得到的”。当你调用y.backward()时,它从y出发,反向遍历这条记录链,对每个操作应用链式法则,最终把∂y/∂w和∂y/∂b算出来,存到w.grad和b.grad里。

这里有几个关键点值得注意:

  • 梯度是累加的,不是覆盖的。每次调用backward(),梯度会加到已有的.grad属性上。所以训练循环里必须在每轮更新前手动调用optimizer.zero_grad()或把.grad置零。我刚开始学的时候忘了这一步,结果loss越训越大,排查了半天才发现梯度在无限累加。
  • backward()默认只对标量调用。如果你的输出不是标量(比如是一个向量),需要传入一个同样形状的gradient参数,相当于给每个分量指定权重。实际训练中loss通常已经reduce成标量了,所以直接调就行。
  • 计算图在backward()之后默认被释放。如果你想多次反向传播(比如某些GAN的训练逻辑),需要设置retain_graph=True。但这会占用额外内存,非必要不用。

2.3 动态图 vs 静态图:为什么调试这么舒服

PyTorch用的是动态计算图(define-by-run),意思是图是在前向计算过程中实时构建的。相比之下,早期的一些框架要求你先定义好整个计算图,再喂数据进去执行。这两种模式的区别,在调试时体现得淋漓尽致。

动态图的好处是:你可以像调试普通Python程序一样,在任意位置打断点、打印中间结果、甚至根据条件改变网络结构。比如你想看看某一层输出的分布,直接print就行,不需要额外开session或者sess.run。静态图则必须先编译整个图,中间结果拿不到,出错时只能看一堆符号化的报错信息。

当然动态图也不是没有代价。由于每次前向都要重新建图,理论上比静态图多了一些开销。但实际用下来,这点开销在大多数场景下可以忽略,换来的是开发效率的大幅提升。我个人的经验是:研究阶段用动态图,部署阶段再考虑图优化,PyTorch也提供了torch.jit等工具来做这件事。

3. 手把手实操:从零写一个完整的训练流程

3.1 数据准备:别小看这一步

我们用一个最简单的线性回归例子:给定一批二维数据点,拟合一条直线。虽然简单,但数据准备的套路和实际项目是一样的。

import torch # 设定随机种子,保证结果可复现 torch.manual_seed(42) # 生成100个样本,每个样本1个特征 # 真实关系:y = 3x + 2,再加一点噪声 x = torch.randn(100, 1) y = 3 * x + 2 + 0.1 * torch.randn(100, 1) print(x.shape, y.shape) # torch.Size([100, 1]) torch.Size([100, 1])

这里有几个细节值得说:

  • 形状要对齐。x是(100, 1),y也是(100, 1),这样后续做矩阵运算时不会出现广播导致的意外结果。我见过有人把y写成(100,),结果loss计算时形状不匹配,报错信息又不够直观,找半天才发现问题。
  • 噪声项不能省。如果数据完全落在一条直线上,模型会很快拟合到完美,loss直接归零,反而看不出训练过程的变化。加一点噪声更接近真实场景。
  • 随机种子要固定。调试阶段固定种子能保证每次运行结果一致,方便对比不同超参数的效果。正式训练时可以去掉,或者用不同的种子做交叉验证。

数据准备好了,接下来要把它转成模型能吃的格式。PyTorch提供了Dataset和DataLoader两个类来做这件事,但对于这种小规模数据,直接手动分批次也完全可以。我倾向于先用最简单的方式跑通,再逐步引入工程化的封装。

3.2 模型定义:nn.Module的正确打开方式

PyTorch定义模型的标准做法是继承nn.Module,在__init__里声明层,在forward里定义计算逻辑。这个套路看起来简单,但里面有不少门道。

import torch.nn as nn class LinearRegression(nn.Module): def __init__(self): super().__init__() self.linear = nn.Linear(1, 1) # 输入1维,输出1维 def forward(self, x): return self.linear(x) model = LinearRegression() print(model) # LinearRegression( # (linear): Linear(in_features=1, out_features=1, bias=True) # )

为什么一定要继承nn.Module?因为它帮你做了几件脏活累活:

  • 自动注册参数。你在__init__里赋值的nn.Linear、nn.Parameter等,会被自动加入model.parameters(),优化器就能拿到这些参数进行更新。如果你用普通Python属性存一个张量,优化器是看不到它的。
  • 管理子模块。如果模型里嵌套了其他Module,它们会被自动注册,调用model.cuda()或model.to(device)时会递归地把所有子模块搬过去。
  • 提供train()和eval()切换。这两个方法会递归地设置所有子模块的训练/评估模式,对Dropout、BatchNorm等层的行为有影响。

forward方法里写的就是前向计算逻辑。注意不要显式调用forward(),而是直接把模型实例当函数用,比如model(x)。这是因为nn.Module实现了__call__方法,在里面做了一些钩子(hook)相关的处理,直接调forward会绕过这些机制。

3.3 损失函数与优化器:选型背后的逻辑

线性回归用均方误差(MSE)作为损失函数,这是最自然的选择。PyTorch提供了nn.MSELoss,直接拿来用就行。

criterion = nn.MSELoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

这里重点说优化器的选择。SGD(随机梯度下降)是最基础的优化器,但实际项目中很少有人直接用纯SGD,因为它的收敛速度对学习率非常敏感。常见的改进版本包括:

优化器核心改进适用场景
SGD基础版本,带动量可选简单问题、需要精细调参
Adam自适应学习率 + 动量大多数场景的默认选择
RMSprop自适应学习率RNN类模型
AdamWAdam + 权重衰减解耦Transformer类模型

对于这个线性回归例子,SGD足够了。但如果你在做实际项目,我建议先用Adam跑一版baseline,它的默认学习率(1e-3)在多数情况下都能work,省去大量调参时间。等baseline跑通后,再尝试SGD+动量,有时候能获得更好的泛化性能。

学习率的设置也有讲究。0.01对于SGD来说偏大,但因为我们的数据简单、参数少,问题不大。如果loss出现震荡或者发散,第一反应应该是调小学习率。反过来,如果loss下降太慢,可以适当调大。我通常会用学习率预热(warmup)或者余弦退火(cosine annealing)等策略,在训练过程中动态调整学习率。

3.4 训练循环:每一步都要知道在干什么

训练循环是整个过程的核心,标准写法如下:

epochs = 100 for epoch in range(epochs): # 1. 前向传播 y_pred = model(x) loss = criterion(y_pred, y) # 2. 清空梯度 optimizer.zero_grad() # 3. 反向传播 loss.backward() # 4. 更新参数 optimizer.step() if (epoch + 1) % 10 == 0: print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")

这四步的顺序不能乱,我逐个解释:

第一步:前向传播。把输入喂给模型,得到预测值,然后计算损失。注意这里loss是一个标量张量,带有grad_fn属性,说明它记录了计算历史。

第二步:清空梯度。这一步必须在反向传播之前做。前面说过,PyTorch的梯度是累加的,如果不清零,上一轮的梯度会和这一轮叠加,导致更新方向错误。optimizer.zero_grad()会把所有被优化器管理的参数的.grad置零。

第三步:反向传播。loss.backward()沿着计算图反向求导,把每个参数的梯度算出来存到.grad里。

第四步:更新参数。optimizer.step()根据梯度和优化器内部的规则(比如动量、自适应学习率)更新参数值。

跑完100轮后,你可以打印出学到的权重和偏置:

print(model.linear.weight.item(), model.linear.bias.item()) # 应该接近3和2

实测下来,用SGD+0.01学习率跑100轮,权重能收敛到2.9左右,偏置在2.0附近,和真实值非常接近。如果换成Adam,收敛会更快,大概30轮就能达到类似效果。

4. 常见问题与排查技巧实录

4.1 梯度为None或形状不对

这是新手最常遇到的问题之一。调用loss.backward()后,发现某个参数的.grad是None,或者形状和参数本身不一致。原因通常有以下几种:

  • 该参数没有参与前向计算。比如你定义了一个层但forward里没用它,自然不会有梯度。检查方法是打印计算图,或者在前向过程中确认每个参数都被用到了。
  • 参数被重新赋值了。如果你在forward里写了self.weight = self.weight * 2这样的操作,实际上创建了一个新的张量,原来的参数已经不在计算图里了。正确做法是用原地操作或者重新设计逻辑。
  • requires_grad被意外关闭。有时候从NumPy转换过来的张量默认requires_grad=False,需要手动设置。或者在某些上下文管理器(如torch.no_grad())里做了前向计算,梯度不会被记录。

排查这类问题的通用思路是:从loss出发,沿着计算图往回查,看哪一步断了。PyTorch提供了torch.autograd.gradcheck等工具可以做数值验证,但更常用的还是打印中间变量的grad_fn属性。

4.2 Loss不下降或出现NaN

Loss不下降的原因很多,按出现频率排序大概是:

  1. 学习率太大。这是最常见的原因。loss震荡、发散、甚至变成NaN,第一反应就是把学习率调小一个数量级试试。
  2. 数据没有归一化。如果输入特征的量级差异很大(比如一个特征是0.001量级,另一个是1000量级),梯度更新会非常不稳定。标准做法是对每个特征做零均值单位方差归一化。
  3. 梯度消失或爆炸。深层网络中容易出现,可以通过梯度裁剪(torch.nn.utils.clip_grad_norm_)或者换用ReLU类激活函数来缓解。
  4. 损失函数选错了。比如分类问题用了MSE而不是交叉熵,回归问题用了交叉熵而不是MSE,都会导致训练困难。

我自己的排查顺序是:先看loss曲线,如果一开始就NaN,大概率是学习率或数据问题;如果训练一段时间后NaN,可能是梯度爆炸;如果loss平稳但不下降,检查数据标签是否对齐、模型是否真的有可学习参数。

4.3 显存不够用怎么办

虽然这个线性回归例子用不到GPU,但实际项目中显存不足是高频问题。几个立竿见影的优化手段:

  • 减小batch size。这是最直接的方法,但要注意batch size太小会影响BatchNorm的效果。
  • 使用混合精度训练。PyTorch的torch.cuda.amp可以自动把部分计算转成float16,显存占用能降30%到50%。
  • 及时释放中间变量。用del删除不再需要的张量,或者用with torch.no_grad()包裹不需要梯度的计算。
  • 梯度累积。如果显存只够跑小batch,可以累积多个batch的梯度再更新一次参数,等效于大batch训练。

还有一个容易被忽视的点:PyTorch的缓存分配器会保留已释放的显存,所以nvidia-smi看到的占用可能比实际需要的高。如果确实需要释放,可以调用torch.cuda.empty_cache(),但频繁调用会影响性能,不建议在训练循环里用。

4.4 模型评估时的常见坑

训练完模型后,评估阶段也有几个容易出错的地方:

  • 忘记切换eval模式。如果模型里有Dropout或BatchNorm,训练和评估时的行为是不一样的。评估前一定要调model.eval(),评估完再调model.train()切回来。
  • 忘记关闭梯度计算。评估时不需要反向传播,用with torch.no_grad()包裹可以节省显存和计算时间。
  • 指标计算方式不对。比如分类问题,有人直接用argmax后的结果算准确率,但忽略了模型输出是logits还是概率。PyTorch的CrossEntropyLoss内部已经做了softmax,所以模型输出logits即可,不需要额外加softmax层。

5. 从例子到项目:下一步该怎么扩展

这个线性回归例子虽然简单,但它包含了所有深度学习项目的核心要素。如果你想把它扩展成一个真正的项目,可以从以下几个方向入手:

数据管道工程化。用Dataset和DataLoader替换手动分批次,支持shuffle、多进程加载、自定义collate_fn等功能。这是处理大规模数据集的必备技能。

模型结构复杂化。把单层线性层换成多层感知机、卷积网络或Transformer,体会不同结构对特征提取能力的影响。每换一种结构,都要重新考虑初始化方式、激活函数选择、正则化策略。

训练流程规范化。加入验证集、学习率调度器、早停机制、模型保存与加载、TensorBoard可视化等组件。这些是工业级训练脚本的标准配置。

部署与推理。用torch.jit.trace或torch.jit.script把模型转成TorchScript,或者导出成ONNX格式,方便在其他运行时环境中使用。这一步会暴露很多训练阶段不会遇到的问题,比如动态控制流不支持、自定义算子无法导出等。

我个人的体会是:PyTorch的学习曲线在前半段很平缓,后半段变陡。入门容易,但要把训练效率、显存占用、数值稳定性都调到最优,需要大量实践积累。建议每学一个新概念,都回到这个最小例子上试着改一改,看看会发生什么。这种“小步快跑”的方式,比一上来就啃大项目要高效得多。

最后分享一个我常用的调试技巧:在训练循环里加一个断言,检查loss是否为有限值。一旦出现inf或nan,立刻中断训练并打印相关变量的统计信息。这个习惯帮我省下了大量排查时间,尤其是在调参阶段。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:08:21

从D触发器到RAM64:手把手教你构建计算机内存

如果你刚开始做《计算机系统要素》实验,到Pro3的时候多半会愣一下:前面几关都是拼逻辑门、多路选择器,怎么突然要做出一个RAM?我第一次看到这个题目也是这样想的,RAM不是内存芯片吗?不是该去查芯片手册、看…

作者头像 李华
网站建设 2026/10/13 1:07:35

ROS Melodic安装避坑指南:Ubuntu 18.04环境配置与rosdep初始化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:07:32

Redis高并发实战:缓存、分布式锁、排行榜与消息队列全解析

1. 别把Redis当缓存库,它是你的业务加速器先聊点实在的。很多Java程序员一提到Redis,脑子里蹦出来的第一个词就是“缓存”,然后就是“用起来简单,set一个key,get一个key”。这话没错,但格局小了。我在实际项…

作者头像 李华
网站建设 2026/10/13 1:07:13

数字痕迹解剖学:从JPEG文件结构逆向提取隐藏信息

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:06:59

Hexo写作实操:从文章创建到草稿发布与模板配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华