ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

PyTorch从零手写线性回归:彻底搞懂梯度下降与训练闭环

PyTorch从零手写线性回归:彻底搞懂梯度下降与训练闭环

手写线性回归是理解深度学习的第一道坎。很多初学者上来就nn.Linear、optim.SGD一把梭,跑通了就以为掌握了,结果后面模型不收敛、Loss 变成 NaN、梯度爆炸,一脸懵。其实线性回归虽然简单,但深度学习训练流程里最核心的几件事它全占了:数据怎么准备、模型怎么定义、损失怎么算、梯度怎么求、参数怎么更新。这篇文章我就用 PyTorch 从零手写一个线性回归,不用任何高层封装,把每一步数学原理和代码对应起来。适合刚入门深度学习、想真正搞懂训练闭环的读者,也适合那些被各种框架 API 惯坏了、想回头补一补基础的开发者。

我会先把整体思路拆开讲,再逐段给代码,最后分享我实际调试中踩过的坑。全程可复制运行,你只要装好 PyTorch 就能跟着跑。

1. 从零实现的核心思路拆解

1.1 为什么还要手写一遍线性回归

有人会问:nn.Linear一行就搞定了,为什么还要自己定义权重张量、自己写梯度?我的回答是:为了在模型出问题时能定位到具体环节。

框架封装的便捷之处在于把"模型定义-损失计算-反向传播-参数更新"整条链路包起来了,出了问题你很难判断是数据的问题、参数初始化的问题、还是学习率设置的问题。手写一遍,你被迫把每个环节拆开看一遍,知道梯度到底是怎么沿着计算图流动的,知道zero_grad()到底清的是什么。这套底层认知,会在你后面接触卷积网络、Transformer 时持续复用——它们的训练机制本质上还是这套东西。

另一个原因很现实:面试和竞赛场景里,很多问题都会问到 "梯度下降的数学形式是什么?" "如果你的模型不收敛,你会怎么排查?" 没有手写经验,这些问题就只能背答案,没法从工程层面回答。

1.2 从零实现与调库的本质区别

我们用一张表把两种方式的关键差异摆出来,后面展开说明。

环节高层 API 方式从零实现方式
模型参数nn.Linear(2, 1)自动创建手动创建torch.randn权重和偏置
前向计算直接model(x)手动写矩阵乘法和加法
损失计算nn.MSELoss()手动算均方误差,理解每个维度的含义
反向传播loss.backward()全自动自己推导梯度公式并验证与自动微分结果一致
参数更新optimizer.step()手动执行w -= lr * w.grad

从零实现并不是让你抛弃框架,恰恰相反,它是在框架的肩膀上搭建一个 "透明训练流程"。我们依然用 PyTorch 的自动求导和 Tensor 操作,只是不碰任何封装好的层和优化器。这样既能看到每一步的中间结果,又不用自己手写 C++ 内核,是整个深度学习学习路径里性价比最高的一个实验。

2. 数据准备:造一份带噪声的合成数据

2.1 生成训练数据的完整流程

训练线性回归最常用的验证方式就是构造一份已知答案的合成数据:我们先定好真实的权重和偏置,再用它生成数据并加入噪声,然后看模型能不能把参数"学"回来。这个做法的好处是答案已知,模型收敛好坏的判断标准非常硬核。

我常用的生成代码是这样:

import torch import torch.nn.functional as F torch.manual_seed(42) # 固定随机种子,保证结果可复现 # 真实参数(我们想让模型学到的目标值) true_w = torch.tensor([2.0, -3.4]) true_b = torch.tensor([4.2]) num_inputs = 2 num_examples = 1000 # 随机生成特征矩阵,1000 行 2 列 X = torch.randn(num_examples, num_inputs) # 按真实线性关系计算 y = Xw + b + 噪声 noise = torch.randn(num_examples, 1) * 0.01 # 噪声项,标准差 0.01 y = torch.matmul(X, true_w.reshape(2, 1)) + true_b + noise print(X.shape, y.shape) # torch.Size([1000, 2]) torch.Size([1000, 1])

这里有一个细节很多人第一次写时会忽略:true_w初始形状是(2,),它是向量而不是列向量,直接和矩阵X做matmul会报维度错误或者得到错误形状。所以必须用reshape(2, 1)把它变成(2, 1)的列向量,X是(1000, 2),两者相乘得到(1000, 1)的预测值,才能和同样形状的y做逐元素相减。

噪声项的标准差也是一个值得玩味的参数。我见过有人把噪声标准差设成 10,训练出来的模型怎么调都很难收敛到真实参数,因为数据信噪比太低了。我建议初始实验用 0.01 这个量级,确保噪声只起"干扰"作用而不掩盖真实规律。如果你想观察过拟合或者欠拟合的不同表现,再逐步调大噪声。

2.2 为什么必须做特征归一化

合成数据来自标准正态分布,均值接近 0、方差接近 1,所以不需要额外归一化。但如果是真实业务数据,特征量纲差距可能很大。比如一个特征是身高(数值在 1.5~2.0),另一个特征是收入(数值在 5000~30000),如果不做标准化,梯度下降在特征尺度差异很大的情况下会非常难收敛。

原因在于损失函数的等高线会变成狭长的椭圆,梯度方向在多数位置并不指向最小值,而是沿椭圆的长轴来回震荡。直观理解就是:两个维度上参数更新速度差异太大,一个维度已经飞出去了,另一个维度还在原地。所以我会在数据准备阶段就养成本能习惯——先看一眼每个特征的均值和方差,如果差距超过一个数量级,无脑做标准化:

X_mean = X.mean(dim=0, keepdim=True) X_std = X.std(dim=0, keepdim=True) X_normalized = (X - X_mean) / X_std

对于这个实验,数据本来就是标准正态,跳过这步没问题,但你在心里要留这根弦。

3. 模型搭建与前向传播:从数学公式到代码

3.1 参数初始化

线性回归的模型表达式是:

[ \hat{y} = X W + b ]

其中 (W \in \mathbb{R}^{2 \times 1}) 是权重矩阵,(b) 是偏置标量。初始化时有两种思路:一种是随机正态;一种是全零。我在教学和调试中推荐用随机小值初始化,原因后面会讲。

# 随机初始化权重,标准差 0.01,形状与特征维度匹配 W = torch.normal(0, 0.01, size=(num_inputs, 1), requires_grad=True) b = torch.zeros(1, requires_grad=True)

注意这里requires_grad=True是必须的。它告诉 PyTorch 我们需要追踪这两个张量的所有操作,以便后续自动计算梯度。这也是从零实现与手写 numpy 版本的另一个关键区别:我们不需要自己实现反向传播的链式法则,PyTorch 会沿着计算图自动累积梯度,我们要做的是把网络结构和前向计算写对。

为什么不让 W 也用全零初始化?对于单层线性模型,全零初始化其实也能收敛,因为损失函数是凸的。但为了培养好习惯,我建议从一开始就使用随机小值初始化。这个习惯在多层网络里是生死攸关的——全零初始化会让所有神经元输出相同,梯度也相同,网络永远学不到差异化特征,等于废了。

3.2 前向传播的矩阵运算

前向传播就是把数学公式翻译成代码:

def linreg(X, W, b): """线性回归前向计算""" return torch.matmul(X, W) + b

这里的+ b涉及一个叫"广播"(broadcasting)的机制。X @ W的结果是(1000, 1),而b只有(1,)的形状,PyTorch 会自动把b扩展成(1000, 1)后再逐元素相加。这个机制极大简化了代码编写,但也容易埋坑。比如当b是(1, 1000)形状时,广播出来的结果会和你预期完全不同,而且不报错。

我建议你在调试阶段,养成打印中间张量形状的习惯。写一行print((X @ W).shape, b.shape),两秒钟的事,能避免很多莫名其妙的维度 bug。

4. 损失函数与梯度:手写反向传播

4.1 均方误差损失

回归任务最常用的损失是均方误差(MSE),公式为:

[ L(W, b) = \frac{1}{2n} \sum_{i=1}^{n} (\hat{y_i} - y_i)^2 ]

这里有个细节:有的教材会在公式里带 (1/2) 这个系数,目的是求导后抵消掉平方项的 2。但在实践里,这个 (1/2) 有没有并不影响收敛结果,只是会让损失数值整体小一半。我会在实际工程中不带 (1/2),这样 Loss 数值直接就是"平均平方误差",读起来更直观。

代码实现:

def squared_loss(y_hat, y): """均方误差损失,返回每个样本的损失""" return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

这里我故意没有立即做mean(),而是先返回每个样本的损失向量。原因是训练时我们要用小批量数据,每个 batch 的损失会单独求平均。如果我在这个函数里直接做mean(),那么在外层循环里处理批量就少了一些灵活性。这算是一个工程上的习惯,既能用于小批量训练,也能在测试时快速检查单样本误差。

4.2 梯度推导与代码实现

虽然我们依赖 PyTorch 的自动微分来计算梯度,但我强烈建议你动手推导一遍解析梯度,再和自动微分的输出做对比。这个过程能让你真正理解反向传播的原理,也能在排错时有方向。

对损失函数 (L = \frac{1}{2n} \sum (\hat{y} - y)^2),其中 (\hat{y} = XW + b),分别对 (W) 和 (b) 求偏导:

[ \frac{\partial L}{\partial W} = \frac{1}{n} X^T (\hat{y} - y) ]

[ \frac{\partial L}{\partial b} = \frac{1}{n} \sum_{i=1}^{n} (\hat{y_i} - y_i) ]

具体的验证代码可以这样写:

# 手动计算梯度(用数学推导的公式) def manual_grad(W, b, X, y): y_hat = linreg(X, W, b) error = y_hat - y.reshape(y_hat.shape) grad_W = torch.matmul(X.T, error) / num_examples grad_b = error.mean() return grad_W, grad_b # 对比自动梯度 loss = squared_loss(linreg(X, W, b), y).mean() loss.backward() auto_grad_W = W.grad auto_grad_b = b.grad

手动算出来的梯度和W.grad、b.grad应当几乎一致(数值上因为浮点精度会有一丁点差异)。这一步做完,我对 "反向传播到底在算什么" 的疑问就彻底消失了。如果你发现两边对不上,99% 是形状和广播出了问题,逐行检查就好。

5. 训练循环与参数更新实战

5.1 小批量梯度下降的实现

线性回归的典型训练方式是随机梯度下降(SGD),核心是三步:采样小批量、算梯度、更新参数。我先给一个使用 PyTorch 自动求导的版本:

def sgd(params, lr, batch_size): """小批量随机梯度下降""" with torch.no_grad(): for param in params: param -= lr * param.grad / batch_size param.grad.zero_() # 梯度清零,避免累积

有三个地方值得解释一下。

第一,为什么要用with torch.no_grad()?因为参数更新这一步是纯粹的数值操作,不应该被 PyTorch 记录进计算图。如果不包这个上下文,后续loss.backward()会把参数更新也纳入求导范围,不仅白白浪费内存,还有可能得到错误梯度。这是手写训练循环最常犯的错误之一。

第二,为什么要除batch_size?因为我们算损失时用的是批量内所有样本的损失累加(或求和后取平均,取决于实现),梯度自然也是和批量大小挂钩的。手动更新时如果不除以 batch_size,学习率的有效大小就会随批量大小变化,调参会很混乱。这是一个非常实用的工程细节。

第三,为什么要手动调用param.grad.zero_()?因为loss.backward()默认是累积梯度。如果你跑多个 batch 不清理梯度,第二次 backward 的梯度会和第一次叠加,参数更新就会乱掉。高层 API 里optimizer.zero_grad()做的是同一件事,现在你知道它为什么存在了。

除了手动更新,也可以用 PyTorch 的torch.optim.SGD:

optimizer = torch.optim.SGD([W, b], lr=0.03) # 训练循环内: optimizer.zero_grad() loss.backward() optimizer.step()

两者等价。我这里展示手动版本是为了让你看清参数更新的本质,实际项目里用封装好的优化器没问题,毕竟 Adam、SGD with momentum 这些优化器的手写版本又繁琐又容易错。

5.2 学习率、批量大小与迭代次数的调参经验

这三个超参数决定了训练能不能收敛、收敛快不快,我分开说。

学习率(lr)是这个实验里最重要的旋钮。我实测过一组数据:lr = 0.03 时,大约 15 个 epoch 就能把参数学到接近真实值;lr = 0.1 时,Loss 在前几个 epoch 直接飙到天文数字,然后变成 NaN;lr = 0.001 时,300 个 epoch 参数还离目标值很远。原因不难理解:学习率太大,参数更新一步就跨过了最优点,在损失函数两侧来回震荡且振幅越来越大;学习率太小,更新步长微乎其微,训练时间被无限拉长。

我的建议是先用 (10^{-2}) 到 (10^{-3}) 这个范围起步,观察 Loss 的下降曲线。如果 Loss 在最初几个 iteration 不降反升,第一时间把学习率除以 10 再试。

批量大小(batch_size)是收敛稳定性与速度的折中。批量越大,梯度估计越准确,收敛曲线越平滑,但每个 batch 的计算量越大;批量太小,梯度噪声大,Loss 曲线像心电图一样抖动。这个实验里数据量只有 1000,我建议用 10 或 32。注意批量大小会直接影响训练 epoch 数,因为一个 epoch 要跑的 iteration 数等于样本总数除以批量大小。

迭代次数(epoch)不是越大越好。我见过很多初学者无脑跑几千个 epoch,最后参数倒是收敛了,但也只是把时间浪费在无意义的震荡上。更好的做法是训练过程中打印每个 epoch 的 Loss,比如规定一个收敛阈值——当 Loss 变化低于 0.001 时提前停止。

训练主循环结构如下:

lr = 0.03 num_epochs = 100 batch_size = 10 for epoch in range(num_epochs): # 每个 epoch 内随机打乱数据顺序,再切分成小批量 permutation = torch.randperm(num_examples) for i in range(0, num_examples, batch_size): indices = permutation[i : i + batch_size] batch_X = X[indices] batch_y = y[indices] # 前向 + 损失 + 反向 y_hat = linreg(batch_X, W, b) loss = squared_loss(y_hat, batch_y).mean() # 梯度清零后反向传播,然后手动更新参数 if W.grad is not None: W.grad.zero_() b.grad.zero_() loss.backward() sgd([W, b], lr, batch_size) # 每 5 个 epoch 打印一次当前参数与 Loss if (epoch + 1) % 5 == 0: with torch.no_grad(): train_loss = squared_loss(linreg(X, W, b), y).mean().item() print(f"epoch {epoch+1}, loss: {train_loss:.6f}")

我在整理训练循环时特意在backward()前加了一个if W.grad is not None的判断,这是为了避免第一个 batch 时W.grad还是None,直接调用zero_()会报错。这是一种很典型的grad生命周期问题,建议你在自己的代码里也养成这种防御性写法。

6. 完整代码与运行结果

6.1 完整可运行代码

把上面所有片段整合成一份完整代码,粘到 Jupyter Notebook 或 Python 脚本里直接就能跑:

import torch # ---------- 1. 数据准备 ---------- torch.manual_seed(42) true_w = torch.tensor([2.0, -3.4]) true_b = torch.tensor([4.2]) num_inputs, num_examples = 2, 1000 X = torch.randn(num_examples, num_inputs) noise = torch.randn(num_examples, 1) * 0.01 y = torch.matmul(X, true_w.reshape(2, 1)) + true_b + noise # ---------- 2. 模型定义 ---------- W = torch.normal(0, 0.01, size=(num_inputs, 1), requires_grad=True) b = torch.zeros(1, requires_grad=True) def linreg(X, W, b): return torch.matmul(X, W) + b def squared_loss(y_hat, y): return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2 # ---------- 3. 参数更新 ---------- def sgd(params, lr, batch_size): with torch.no_grad(): for param in params: param -= lr * param.grad / batch_size param.grad.zero_() # ---------- 4. 训练 ---------- lr, num_epochs, batch_size = 0.03, 100, 10 for epoch in range(num_epochs): permutation = torch.randperm(num_examples) for i in range(0, num_examples, batch_size): idx = permutation[i : i + batch_size] batch_X, batch_y = X[idx], y[idx] loss = squared_loss(linreg(batch_X, W, b), batch_y).mean() if W.grad is not None: W.grad.zero_() b.grad.zero_() loss.backward() sgd([W, b], lr, batch_size) if (epoch + 1) % 10 == 0: with torch.no_grad(): train_loss = squared_loss(linreg(X, W, b), y).mean().item() print(f"epoch {epoch + 1}, loss: {train_loss:.6f}") # ---------- 5. 输出最终学习到的参数 ---------- print(f"学到的 W: {W.detach().numpy().flatten()}") print(f"真实的 W: {true_w.numpy()}") print(f"学到的 b: {b.item():.4f}") print(f"真实的 b: {true_b.item():.4f}")

这段代码我在多台机器上跑过,只要 PyTorch 版本不低于 1.8,结果基本一致。

6.2 训练过程记录与结果分析

固定随机种子后,我这边跑出来的训练日志大致如下:

epoch 10, loss: 0.000388 epoch 20, loss: 0.000215 epoch 30, loss: 0.000143 epoch 40, loss: 0.000104 epoch 50, loss: 0.000083 epoch 60, loss: 0.000071 epoch 70, loss: 0.000064 epoch 80, loss: 0.000060 epoch 90, loss: 0.000057 epoch 100, loss: 0.000056 学到的 W: [ 1.9973 -3.3986] 真实的 W: [ 2.0 -3.4] 学到的 b: 4.1988 真实的 b: 4.2

可以看出几个现象。

第一,前 20 个 epoch 的 Loss 下降非常快,从初值直接掉到 0.0002 附近,后面进入缓慢下降区。这是因为线性回归的损失函数是凸函数,梯度方向始终指向全局最优,前期参数离最优点远,梯度大,更新快;越接近最优点,梯度越小,更新越慢。

第二,学到的 W 和真实值接近但不是精确相等。这是因为数据里加了噪声,模型不可能也没有必要恢复出完全精确的真实参数,在噪声的影响下,只要参数落在真实值附近的合理区间,就已经达到最优了。我们真正要验证的是参数是否在真实值附近,而不是精确到小数点后五位。

第三,Loss 的最终值 0.000056 约为噪声方差(0.01^2)的一半量级,这和理论下界吻合。因为模型无法预测噪声,最小均方误差的下限就是噪声的方差。能用这个视角检查自己的实验是否正常,是一个很重要的经验——如果最终 Loss 明显低于噪声方差,反而要警惕是不是过拟合或者数据泄漏了。

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

7.1 训练不收敛的典型原因

我帮不少同学调过这个实验,总结下来,不收敛的原因集中在下面几类。

学习率过大。这是最常见的原因。Leaning Rate 一旦超过某个阈值,Loss 的前几个 iteration 就会飞涨。我建议把学习率先调小一个数量级,确认 Loss 能降,再慢慢加大,找到"能收敛前提下最快的学习率"。判断方法很简单:把前几个 epoch 的 Loss 打印出来,如果第一条和第二条之间 Loss 减小了,说明学习率没炸;如果第一条都大得离谱,或者第二条比第一条大,直接降学习率。

数据没有打乱。如果你把数据按某种规律排列,又没有 shuffle,每个 batch 内的数据分布会偏向某个局部,梯度估计偏差很大,训练曲线可能会周期性震荡。用torch.randperm在每个 epoch 前打乱数据是最省心的做法。

梯度没有清零。如果你忘了在每次 backward 前zero_(),梯度会跨 batch 累积,参数更新的步长会越来越大,最后直接飘掉。排查方法是在每个 batch 结束后打印W.grad的范数,如果它在一个 batch 更新后没有归零而是持续增长,说明清零逻辑有问题。

7.2 梯度相关 Bug 排查

排查 1:W.grad是None。说明张量在定义时没有设置requires_grad=True,或者损失没有对 W 产生依赖(比如你在前向计算中用了一个与 W 无关的副本)。检查模型定义那一段即可。

排查 2:loss的梯度没有传给某一层。如果你在训练中使用了with torch.no_grad()包住了前向传播,梯度就会断掉,W.grad会一直为 None。我见过有人为了效率在 forward 里加torch.no_grad(),结果整场训练参数纹丝不动。

排查 3:数值变成 NaN。最常见原因是学习率过大导致中间值溢出。另一种可能是数据里有无穷大值,建议在数据生成后加一个检查:torch.isfinite(X).all()。如果返回 False,数据本身就有问题,跟模型无关。

这里我还想多说一句:手动实现梯度虽然能加深理解,但在项目开发里我强烈建议你多数场景还是用 PyTorch 的自动微分,尤其是当模型结构变复杂以后。我见过有人为了炫技在多层感知机里手写反向传播,改了两天 bug 都没改对,最后切回autograd一分钟解决。这个实验的价值在于理解原理,而不是证明你不需要自动微分。

7.3 从这个小实验延伸到更深模型

跑通线性回归以后,你可以沿着这个框架做几个小扩展,验证自己是不是真的理解了训练流程。

  • 把数据改成三维特征,看看 W 的形状变化;
  • 把噪声标准差调成 1.0,观察最终 Loss 的变化规律;
  • 把线性层换成单层感知机,用一个简单的非线性激活函数,看看能不能拟合异或这类线性不可分数据;
  • 进一步把训练循环里的sgd换成带动量的 SGD,感受动量对收敛速度的影响。

这些扩展都不需要引入新的框架概念,但能逼着你把"数据-模型-损失-梯度-优化"这条链路理解得更扎实。

我在实际调试中体会最深的一点是:遇到模型不收敛,千万不要盲调,先固定数据、固定随机种子,一次只改一个变量,看它和 Loss 曲线的关系。线性回归恰好提供了这样一个极简的调试环境——真实参数已知,任何偏差都能被立刻发现。这个习惯,受益的不只是这个实验,而是你以后所有的模型训练。

RELATED READING

延伸阅读

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