资讯详情

PyTorch核心机制:张量、自动求导与线性回归实战

📅 2026/10/22 3:23:23 | 华诺云谱 👁 阅读
PyTorch核心机制:张量、自动求导与线性回归实战
说实话做深度学习这两年多我越来越觉得PyTorch是最适合新手入门的框架。原因倒不是它比别的框架“更厉害”而是它的设计思路非常贴合人的直觉你不需要在一开始就背几十个抽象概念只要抓住两个核心工具——张量和自动求导就能把模型跑起来。本文要拆解的就是这条最基础也最关键的路径张量操作、自动微分机制以及一个完整的线性回归实战。这篇内容适合刚接触深度学习、或者已经写过一点代码但总觉得地基不牢的读者。如果你是老手也可以快速扫一眼后面几节关于训练踩坑和排除思路的整理是我实际调试中反复撞过的墙多少能帮你省点时间。1. 先把地基打牢张量到底是什么1.1 张量就是带形状的多维数组很多人第一次听到“张量”这个词会被唬住觉得这是什么高深的数学概念。其实你完全可以用最简单的方式理解它张量就是一个多维数组跟你在Python里用的列表、在NumPy里用的数组是一回事只不过PyTorch给它加了两个非常重要的能力——自动求导和GPU加速。一个标量可以被看成0维张量一维数组是1维张量二维矩阵是2维张量图像这种三维数据高、宽、通道就是3维张量视频或者批量数据则常常是4维。你不需要去纠结张量在数学物理里那些拗口的定义在PyTorch的世界里你只要关心三件事它的形状shape、数据类型dtype、以及它存放在哪个设备device上。我见过不少初学者一头扎进模型代码里结果连最基础的张量属性都没搞清报错时完全懵。其实你随时可以打印出来看import torch a torch.tensor([1, 2, 3]) print(a.shape) # torch.Size([3]) print(a.dtype) # torch.int64 print(a.device) # cpu这套“先看属性再动手”的习惯能帮你省掉后续一大半的调试时间。1.2 创建张量这5种方式足够用了创建一个张量有很多种姿势但实际开发中高频使用的也就那么几个。我把最常用的列一下方式用途示例torch.tensor(list)从Python列表直接转换torch.tensor([1, 2, 3])torch.zeros/ones初始化全0或全1张量torch.zeros(2, 3)torch.randn按标准正态分布生成随机数torch.randn(3, 3)torch.arange生成等差数列torch.arange(0, 10, 2)torch.full用指定值填充torch.full((2, 3), 7)b torch.zeros(2, 3) c torch.ones(2, 3) d torch.randn(3, 3) e torch.arange(0, 10, 2) # 结果是 [0, 2, 4, 6, 8] f torch.full((2, 3), 7)还有两个非常容易混淆的点我每次讲课都会重点强调。第一torch.randn和torch.rand完全是两个东西。前者生成的是标准正态分布均值0、方差1的采样值有正有负后者生成的是0到1之间的均匀分布。新手经常在初始化权重时用错导致训练曲线怎么都不对。第二默认生成的整数张量是int64默认的浮点张量是float32。如果你要用GPU训练模型里的参数和输入数据最好都统一成float32否则后面很容易爆出类型不匹配的报错。我后面会详细展开这个问题。1.3 数据放对位置CPU和GPU的选择PyTorch里有个很容易被忽略的设计默认所有张量都创建在CPU上。CPU上的计算虽然也能跑但一旦数据量和模型规模上来训练速度会慢到怀疑人生。所以要养成一个习惯——在代码开头先检查环境有没有可用的GPU。device torch.device(cuda if torch.cuda.is_available() else cpu) print(device) a torch.randn(3, 3) a_gpu a.to(device)要注意的是CPU张量和GPU张量之间不能直接做运算。如果你写了a b_gpu大概率会看到一个device mismatch的报错。我的建议是从数据加载到模型定义统一在开头用一个device变量管理所有张量都显式调用to(device)迁移不要东一个cuda()西一个cpu()那样代码非常难维护。2. 张量操作实战索引、变形、广播一个不少2.1 索引与切片先学会精准取数张量索引和NumPy非常像这也是PyTorch让人上手快的原因之一。你可以用中括号直接取某个位置的元素也可以做区间切片还可以用布尔掩码一次性筛选出所有满足条件的元素。x torch.randn(4, 4) sub x[:2, :2] # 取左上角 2x2 子矩阵 mask x 0 positive x[mask] # 取所有大于0的元素返回一维张量这里有个坑我必须提醒切片返回的新张量和原张量共享底层内存。也就是说你改了切片的结果原张量也会跟着变。很多新手在这个地方踩了莫名其妙的坑代价是训练数据被悄悄污染。如果你需要一份独立的副本记得调用.clone()。布尔掩码有个容易被忽略的特性它返回的是被筛选后的一维张量。比如你从4x4矩阵里挑出所有大于0的元素得到的不再是矩阵而是一个线性数组。如果你想保留行列结构要做的是先找到对应索引再通过索引去取。另外gather和scatter这类高级索引在NLP和推荐系统里很常用比如从预测概率里取出某个类别对应的值。初学阶段可以先不深入但面试或处理复杂数据时几乎是绕不开的。2.2 形状变换view、reshape、squeeze怎么选深度学习里最让人头疼的操作之一就是形状变换。数据进入神经网络之前要调整维度从卷积层出来之后要展平批量数据和单条数据之间要互相切换维度。这块我把常用函数分清楚view()改变形状但要求张量内存连续否则会报错。reshape()功能类似view但更智能必要时会自动处理内存连续问题。squeeze()删除所有长度为1的维度。unsqueeze(dim)在指定位置增加一个长度为1的维度。permute()按指定顺序重排维度相当于轴交换。x torch.randn(3, 4) y x.view(-1, 2) # 变成 6x2-1表示自动推导 z x.reshape(2, -1) # 变成 2x6 s torch.randn(1, 3, 1) print(s.squeeze().shape) # torch.Size([3]) print(s.unsqueeze(0).shape) # torch.Size([1, 1, 3, 1]) t torch.randn(2, 3, 4) print(t.permute(2, 0, 1).shape) # torch.Size([4, 2, 3])view和reshape的差别在哪view要求张量在内存里是连续排布的。如果你先做了转置或切片再view可能会看到“view size is not compatible”这类报错。reshape内部会自动判断如果内存不连续就帮你拷贝一份再变形所以更省心。初学阶段我建议直接用reshape等需要极致性能优化时再手动处理contiguous()。squeeze的场景也很典型一个形状为(1, 3, 1)的数据当你需要把它当成(3,)来用的时候squeeze就直接解决问题。但要注意squeeze()默认把所有长度为1的维度全删了如果你只想删某一个特定维度得显式传入维度参数比如x.squeeze(dim0)。2.3 数学运算逐元素、矩阵乘法与归约PyTorch的数学运算分为三大类理解这个分类你就知道遇到不同结构的数据该用哪个函数。第一类是逐元素运算包括加减乘除、幂运算、三角函数等。、-、*、/都是逐元素操作它们要求两个张量的形状满足广播条件否则直接报错。a torch.tensor([1., 2., 3.]) b torch.tensor([4., 5., 6.]) print(a b) # tensor([5., 7., 9.]) print(a * b) # tensor([4., 10., 18.])第二类是矩阵运算。这里要点名提醒两个二维张量做乘法*算的是对应元素相乘才算真正的矩阵乘法。新手最容易犯的错误就是把这两个搞混代码不报错但结果完全不是预期。A torch.randn(2, 3) B torch.randn(3, 4) C A B # 正确矩阵乘法得到 (2,4) D A * A # 正确逐元素相乘形状不变 (2,3)第三类是归约运算也就是把一个张量整体或某个维度上的数据聚合起来。最常见的是sum、mean、max、min。归约运算支持指定dim参数比如对二维矩阵按行或按列求均值。x torch.randn(3, 4) print(x.mean()) # 全局均值一个标量 print(x.mean(dim0)) # 按列求均值得到 (4,) print(x.mean(dim1)) # 按行求均值得到 (3,)2.4 广播机制小张量如何自动扩张成大张量广播机制是PyTorch里最优雅、也最容易被误解的设计之一。很多人以为只有形状完全一致的张量才能做运算实际上PyTorch允许维度兼容的张量之间直接运算小的那个会自动沿着缺失或者长度为1的维度扩展。a torch.randn(3, 1) b torch.randn(1, 4) c a b # 结果是 (3, 4)这个例子中a的形状是(3,1)b的形状是(1,4)。加法运算时a沿第二维复制4份b沿第一维复制3份两个都变成(3,4)再相加。广播的规则很简单从右往左依次比较维度两个维度要么相等要么其中一个为1要么其中一个缺失。如果从右往左比下来发现某个维度既不等也不为1对不起报错。广播机制的实际意义非常大。比如线性回归里你用w * X给一批样本同时计算预测值w是一个标量或者长度为1的张量X是一整批数据广播可以让这个乘法自动作用到每个样本上完全不需要手动写循环。代码既简洁底层执行效率也高。但要注意广播是逻辑上的扩展内存并不会真的复制一份。它是在计算时通过“步长”技巧实现的效率很高。不过话说回来当你的两个张量形状差得很远时比如一个是(2,3,4)另一个是(5,5)结果往往不是你想要的形状反而会埋下逻辑隐患。我的建议是做复杂运算前先打印出参与运算的所有张量形状确认无误再跑。3. 自动求导PyTorch最核心的引擎3.1 计算图与requires_grad变量是如何跟踪梯度的PyTorch强大的地方通通藏在“自动求导”背后。你只需要定义好前向计算过程调用一行backward()所有参数关于损失函数的梯度就自动算好了。这一切的核心是计算图。当你创建了一个张量并设置requires_gradTruePyTorch会把它当作图的叶子节点并记录从它出发的每一次运算。每次运算都会生成一个新张量同时保存了一个指向运算函数的引用和一个指向输入的引用。当你在最终结果上调用backward()时PyTorch沿着这张图反向遍历从结果一路推导回叶子节点计算出每个叶子节点的梯度。x torch.tensor(3.0, requires_gradTrue) y x ** 2 2 * x 1 y.backward() print(x.grad) # tensor(8.)为什么是8因为dy/dx 2*x 2在x3时恰好等于8。PyTorch帮你把微分的活全干了。重要的一个设计是梯度只赋给叶子节点。如果你尝试打印中间结果的.grad大概率拿到的是None——除非你用register_hook做特殊处理。所以别费劲去找中间变量的梯度你真正需要的通常是模型参数的梯度它们就是叶子节点。3.2 backward()一次反向传播到底做了什么调用backward()之后会发生三件事根据前向记录的计算图逐层计算梯度把计算出的梯度累加到每个叶子节点的.grad属性里最后清空计算图默认情况下非叶子节点的缓存会被释放。这里你必须留意“累加”这个词。梯度是加到.grad上的不是替换。每次backward()结束后参数已有的梯度不会归零新的梯度会和旧梯度叠加。如果在训练循环里每步都调用backward()却忘了清空梯度积累了多个batch的梯度之后参数更新就会乱套。x torch.tensor(2.0, requires_gradTrue) for _ in range(3): y x ** 2 y.backward() print(x.grad) # tensor(12.)如果只看单次运算dy/dx 2*x 4梯度应该是4。但因为梯度累积了三次最终变成12。训练时如果你发现loss振荡得非常厉害、甚至直接发散先检查一下是不是忘了在每步更新前调用zero_grad()。3.3 梯度累积问题与zero_grad()既然梯度是累积的那常规训练循环里就必须有一个“清零”动作。优化器提供了optimizer.zero_grad()它会遍历所有参数并把.grad重新置为None或0。你也可以手动写w.grad.zero_()效果是一样的只是优化器那一行能把所有参数一次性处理干净更省事。# 三种清零写法 optimizer.zero_grad() # 推荐 model.zero_grad() # 等价写法每次更新参数的正确流程应该是前向计算得到预测值算出loss。清零梯度调用optimizer.zero_grad()。反向传播调用loss.backward()。更新参数调用optimizer.step()。顺序很重要。特别是“清零”必须放在“反向传播”之前而不是之后。你要是先backward()再zero_grad()等于把刚算出来的梯度又清掉了参数永远不会更新。3.4 推理模式no_grad()和detach()的正确用法模型训练完做推理预测时我们压根不需要计算梯度。如果不加任何处理PyTorch还是会照常构建计算图、保存中间结果白白占用大量内存和算力。这个时候用torch.no_grad()包住推理代码with torch.no_grad(): pred model(X_test)在no_grad()作用域里任何张量运算都不会被跟踪也不会建图省内存又提速。刚入门的人经常忽略这个小细节结果明明只是做个预测显存却和训练时一样紧张。还有一个比较容易混淆的函数是detach()。它的作用是截断当前张量与前向计算图的连接返回一个新张量这个新张量理论上和原张量共享数据但不再参与梯度传播。no_grad()影响的是作用域内的所有操作detach()则是针对某个具体张量的切断两者用途不同别混着用。4. 线性回归完整实现从零手写到nn.Module封装4.1 原理回顾y wx b 与均方误差线性回归虽然简单但它是理解深度学习的绝佳载体。它的目标就是从数据中学习一条直线y w*x b让预测值尽可能接近真实值。怎么衡量“接近”呢最常用的手段是均方误差MSE。它把所有样本预测误差的平方加起来取平均公式是loss (1/N) * sum((y_pred - y_true)^2)为什么要用平方而不是直接用绝对值因为平方有两个好处一是误差为正负都会变成正数方便统一优化二是平方放大了大误差的影响让模型更在意“错得离谱”的那些样本梯度方向更稳定。成本函数就是要找出让loss最小的w和b。梯度下降法怎么更新参数核心公式是w w - lr * d(loss)/dw b b - lr * d(loss)/db其中lr是学习率控制每一步迈多大。学习率太大参数会在最优点附近来回震荡太小训练又慢得感人。实践里我一般从0.01开始试效果不好再等比缩小。4.2 准备模拟数据给直线加点噪声要训练模型首先得有数据。我从实践中总结的经验是先用“人造数据”验证整个流程代码跑通了再替换成真实数据这样能帮你把“模型问题”和“数据问题”分开。import torch torch.manual_seed(42) X torch.linspace(0, 10, 100).reshape(-1, 1) true_w, true_b 2.0, 1.0 y true_w * X true_b torch.randn(X.size()) * 0.5这里我设定了真实的w为2、b为1然后添加了均值为0、标准差0.5的高斯噪声。待会儿训练完我们可以把学到的参数和真实参数对比看看误差有多大。torch.manual_seed(42)的作用是固定随机种子保证每次跑出来的数据一模一样方便复现。4.3 手动实现自己更新w和b彻底理解梯度下降很多人一上来就套nn.Linear虽然代码少了但内部发生了什么始终是黑盒。我先手写一个版本让你亲眼看到梯度下降到底是怎么动参数的。w torch.randn(1, requires_gradTrue) b torch.randn(1, requires_gradTrue) lr 0.01 epochs 500 for epoch in range(epochs): y_pred X w b loss ((y_pred - y) ** 2).mean() loss.backward() with torch.no_grad(): w - lr * w.grad b - lr * b.grad w.grad.zero_() b.grad.zero_() if (epoch 1) % 100 0: print(fEpoch {epoch1}, Loss: {loss.item():.4f}) print(f训练结果: w{w.item():.2f}, b{b.item():.2f})为什么更新参数时要套with torch.no_grad()因为w - lr * w.grad这个操作本身也是一个张量运算如果不关掉梯度追踪它会被记录进计算图下一轮反向传播就乱套了。同理w.grad.zero_()也是原地操作不需要计算梯度。跑完之后你会发现学到的w和b与真实的2.0、1.0非常接近。这就是梯度下降的实际效果。整个过程没有调用任何优化器每一步的“更新”都是你亲手写的原理一目了然。4.4 工程化实现用nn.Module、MSELoss和SGD理解了底层原理就可以切换到更工程化的写法。PyTorch用nn.Module封装模型结构用nn.MSELoss封装损失函数用torch.optim.SGD封装优化器。import torch.nn as nn class LinearRegression(nn.Module): def __init__(self): super().__init__() self.linear nn.Linear(1, 1) def forward(self, x): return self.linear(x) model LinearRegression() criterion nn.MSELoss() optimizer torch.optim.SGD(model.parameters(), lr0.01)训练循环的骨架几乎就是固定的epochs 300 for epoch in range(epochs): model.train() pred model(X) loss criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() if (epoch 1) % 50 0: print(fEpoch {epoch1}/{epochs}, Loss: {loss.item():.4f}) print(fw {model.linear.weight.item():.2f}) print(fb {model.linear.bias.item():.2f})注意nn.Linear(1, 1)里第一个1是输入维度第二个1是输出维度。你会看到model.linear.weight的形状是(1, 1)model.linear.bias的形状是(1,)。这些都是有梯度的参数优化器会自动管理。model.train()这个操作在线性回归里其实没有太大作用但在包含Dropout、BatchNorm的模型里会切换不同行为。养成随手写上model.train()的习惯能避免你以后切换模型时忘记切模式。4.5 训练结果可视化看看拟合效果训练完别急着收工把拟合结果画出来亲眼看一眼比什么都直观import matplotlib.pyplot as plt w_learned model.linear.weight.item() b_learned model.linear.bias.item() print(f真实参数: w2.0, b1.0) print(f学习参数: w{w_learned:.2f}, b{b_learned:.2f}) plt.scatter(X.numpy(), y.numpy(), alpha0.6, label原始数据) plt.plot(X.numpy(), (w_learned * X b_learned).detach().numpy(), colorred, linewidth2, label拟合直线) plt.xlabel(X) plt.ylabel(y) plt.legend() plt.show()detach().numpy()这步很关键。因为拟合线是基于带梯度的张量计算出来的直接.numpy()会报错——你没法把一个“需要梯度”的张量直接转成NumPy数组。先detach()切断梯度连接再转换就顺了。画出来的图通常是一条穿过数据云中心的红线梯度下降把直线慢慢“扭”到了最合适的位置。看到这个画面线性回归对你来说就不再是公式了变成了一个看得见摸得着的过程。5. 训练踩坑实录常见问题与解决思路5.1 梯度不更新或Loss不降怎么办这是初学者问得最多的问题loss一点动静都没有或者直接变成NaN。我把常见原因总结成了一个速查表。现象常见原因解决思路Loss不变参数也不变requires_gradFalse检查参数是否设置了requires_gradTrueLoss越来越离谱最终为NaN学习率太大降低学习率从0.001或0.0001试起Loss振荡剧烈忘记清零梯度每次backward()前调用optimizer.zero_grad()Loss一直很大但数据看着没问题数据没有归一化对输入做标准化或归一化梯度全是None计算图断开了检查是否误用了detach()或no_grad()包裹训练如果loss完全不动我建议你在训练循环里打印出参数的梯度值比如w.grad看一下是不是None。如果是问题大概率出在requires_grad或者计算图连接上。把“前向计算、反向传播、参数更新”这三步单独拆开打印能非常快地定位问题。5.2 维度报错size mismatch的定位方法PyTorch的维度报错可以说是新手的“噩梦”。什么mat1 and mat2 shapes cannot be multiplied (2x3 and 2x4)一出现就让人头大。其实这些报错信息已经写得很清楚了只是你还不知道怎么用它。定位维度报错有套路。第一步看报错里提到的两个张量形状确认它们的尺寸第二步检查你期望的运算是什么比如矩阵乘法要求前一个矩阵的列数等于后一个矩阵的行数第三步如果实在看不出来在运算前手动打印每个参与张量的.shape。我在调试时还有一个习惯在模型forward的第一行打印输入形状在每一层前后都打印中间变量形状。虽然看起来笨但尤其对于神经网络来说这种方式能瞬间告诉你哪个层把维度弄错了。等代码稳定运行后再把这些打印删掉。5.3 设备不一致CPU和GPU混用报错信息里如果出现Expected all tensors to be on the same device那基本就是CPU张量和GPU张量混用了。比如你把训练数据放到了GPU上但模型某个参数还在CPU上运算就会直接卡住。我踩过最隐蔽的一次坑是在构建两个输入特征时一个特征从数据加载器出来已经to(device)了另一个临时创建的特征忘了结果模型前向推理时压根没报错到loss计算时才暴露问题。建议从一开始就严格执行这套规范device torch.device(cuda if torch.cuda.is_available() else cpu) X X.to(device) y y.to(device) model model.to(device)每一步迁移都走同一个device变量不要一会儿写cuda()一会儿写cpu()。另外要注意的是nn.MSELoss一类的模块最好也调用.to(device)虽然大部分损失函数是无参的但养成统一风格不会出错。5.4 数据尺度差异别忽视归一化很多新手做线性回归时忽略了数据尺度的作用。如果你的特征取值范围是0到10万而另一个特征取值范围是0到1梯度下降很容易被大尺度的特征带偏收敛速度极慢甚至不收敛。最简单的处理手段是标准化让每个特征都变成均值0、标准差1def standardize(x): return (x - x.mean()) / x.std() X standardize(X)标准化之后w和b的初始值不需要很讲究梯度下降也能很快找到合适的方向。训练结束后如果你想拿模型做预测记得对新的输入数据做同样的标准化预处理。这份“先预处理数据再进入训练流程”的习惯放到真实业务项目里价值比你会写十种模型都有用。我在实际调试中还有一个体会不要一上来就追求模型复杂化。遇到loss不降或者结果不如意先把数据分布画出来看看再做归一化或调整学习率远比盲目加层数、加模型容量更有效。深度学习的入门阶段线性回归这套流程跑通了你其实就已经掌握了所有现代深度学习框架共用的底层逻辑——剩下的只是把网络结构换得更复杂而已。最后再分享一个小技巧如果你觉得每次手写训练循环太繁琐完全可以把它抽成一个通用函数把模型、数据、优化器、损失函数作为参数传进去。这个封装的过程会逼着你把“训练”这件事的所有环节都想明白。等哪一天你从零手写了一个自己的训练循环再回过来看那些高级API会有种“看穿底牌”的通透感。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑