ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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(dim=0)。

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(dim=0)) # 按列求均值,得到 (4,) print(x.mean(dim=1)) # 按行求均值,得到 (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_grad=True,PyTorch会把它当作图的叶子节点,并记录从它出发的每一次运算。每次运算都会生成一个新张量,同时保存了一个指向运算函数的引用和一个指向输入的引用。当你在最终结果上调用backward()时,PyTorch沿着这张图反向遍历,从结果一路推导回叶子节点,计算出每个叶子节点的梯度。

x = torch.tensor(3.0, requires_grad=True) y = x ** 2 + 2 * x + 1 y.backward() print(x.grad) # tensor(8.)

为什么是8?因为dy/dx = 2*x + 2,在x=3时恰好等于8。PyTorch帮你把微分的活全干了。

重要的一个设计是:梯度只赋给叶子节点。如果你尝试打印中间结果的.grad,大概率拿到的是None——除非你用register_hook做特殊处理。所以别费劲去找中间变量的梯度,你真正需要的通常是模型参数的梯度,它们就是叶子节点。

3.2 backward():一次反向传播到底做了什么

调用backward()之后会发生三件事:根据前向记录的计算图,逐层计算梯度;把计算出的梯度累加到每个叶子节点的.grad属性里;最后清空计算图(默认情况下,非叶子节点的缓存会被释放)。

这里你必须留意“累加”这个词。梯度是加到.grad上的,不是替换。每次backward()结束后,参数已有的梯度不会归零,新的梯度会和旧梯度叠加。如果在训练循环里每步都调用backward()却忘了清空梯度,积累了多个batch的梯度之后,参数更新就会乱套。

x = torch.tensor(2.0, requires_grad=True) 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() # 等价写法

每次更新参数的正确流程应该是:

  1. 前向计算:得到预测值,算出loss。
  2. 清零梯度:调用optimizer.zero_grad()。
  3. 反向传播:调用loss.backward()。
  4. 更新参数:调用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_grad=True) b = torch.randn(1, requires_grad=True) 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(f"Epoch {epoch+1}, 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(), lr=0.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(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item():.4f}") print(f"w = {model.linear.weight.item():.2f}") print(f"b = {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"真实参数: w=2.0, b=1.0") print(f"学习参数: w={w_learned:.2f}, b={b_learned:.2f}") plt.scatter(X.numpy(), y.numpy(), alpha=0.6, label="原始数据") plt.plot(X.numpy(), (w_learned * X + b_learned).detach().numpy(), color="red", linewidth=2, label="拟合直线") plt.xlabel("X") plt.ylabel("y") plt.legend() plt.show()

detach().numpy()这步很关键。因为拟合线是基于带梯度的张量计算出来的,直接.numpy()会报错——你没法把一个“需要梯度”的张量直接转成NumPy数组。先detach()切断梯度连接,再转换就顺了。

画出来的图通常是一条穿过数据云中心的红线,梯度下降把直线慢慢“扭”到了最合适的位置。看到这个画面,线性回归对你来说就不再是公式了,变成了一个看得见摸得着的过程。

5. 训练踩坑实录:常见问题与解决思路

5.1 梯度不更新或Loss不降怎么办

这是初学者问得最多的问题:loss一点动静都没有,或者直接变成NaN。我把常见原因总结成了一个速查表。

现象常见原因解决思路
Loss不变,参数也不变requires_grad=False检查参数是否设置了requires_grad=True
Loss越来越离谱,最终为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、标准差1:

def standardize(x): return (x - x.mean()) / x.std() X = standardize(X)

标准化之后,w和b的初始值不需要很讲究,梯度下降也能很快找到合适的方向。训练结束后,如果你想拿模型做预测,记得对新的输入数据做同样的标准化预处理。这份“先预处理数据,再进入训练流程”的习惯,放到真实业务项目里,价值比你会写十种模型都有用。

我在实际调试中还有一个体会:不要一上来就追求模型复杂化。遇到loss不降或者结果不如意,先把数据分布画出来看看,再做归一化或调整学习率,远比盲目加层数、加模型容量更有效。深度学习的入门阶段,线性回归这套流程跑通了,你其实就已经掌握了所有现代深度学习框架共用的底层逻辑——剩下的只是把网络结构换得更复杂而已。

最后再分享一个小技巧:如果你觉得每次手写训练循环太繁琐,完全可以把它抽成一个通用函数,把模型、数据、优化器、损失函数作为参数传进去。这个封装的过程,会逼着你把“训练”这件事的所有环节都想明白。等哪一天你从零手写了一个自己的训练循环,再回过来看那些高级API,会有种“看穿底牌”的通透感。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进