线性回归从零实现:PyTorch梯度下降与自动求导实战
线性回归从零实现PyTorch实战详解线性回归可能是很多人接触机器学习的第一个模型但“会用sklearn.linear_model跑通一条.fit()”和“亲手把梯度下降写出来”完全是两码事。这个标题看着基础实际操作中牵扯到的自动求导机制、参数初始化、损失函数梯度流、训练循环的写法甚至学习率调试的细节都是后续学习复杂模型时必须跨过的坎。这篇文章就用PyTorch从零手写一遍线性回归不调用nn.Linear这类封装层把每一行计算都摆在明面上适合刚学完Python基础、想在深度学习框架下把数学公式变成代码的初学者也适合那些已经会用高级API但想回头补一补底层的同学。全文我会按“数学准备→数据构造→模型实现→训练调试→坑点排查”这条主线走代码可以直接复制运行所有解释都尽量贴近实际跑代码时遇到的问题而不是教科书里的推导姿势。1. 从数学公式到反向传播为什么“从零实现”值得做1.1 线性回归的数学本质线性回归的公式大家都见过y w * x b但真正写代码时这一步就隐含了好几个容易忽略的点。首先是“×”和“”到底是标量乘法还是矩阵乘法其次是w和x哪个在左哪个在右最后是损失函数该用mean()还是sum()。从零实现的过程本质上就是在把这些“默认大家都懂”的细节逐个敲定。模型的目标是找到一组参数w和b让预测值y_pred尽可能逼近真实标签y。在PyTorch里w通常是一个形状为[n_features, 1]的矩阵b是一个标量或[1]的张量特征x是[batch_size, n_features]的矩阵。于是预测公式写成矩阵乘法就是y_pred x w b这里的是矩阵乘结果形状是[batch_size, 1]。损失函数选择均方误差MSE公式是loss mean((y_pred - y_true) ** 2)。这里有一个很微妙的点为什么不直接累加所有误差的平方而是求平均因为求平均之后无论batch_size是8还是1024损失值的量级范围是稳定的学习率的可调范围也就比较稳不会出现batch大一点就把梯度撑爆的情况。这个细节在从零实现时特别重要因为我们会手动控制梯度不会像nn.Module那样有各种隐藏的归一化策略。1.2 PyTorch自动求导背后的操作逻辑PyTorch对神经网络的支持核心就是自动求导。当我们把w和b设置成requires_gradTrue时PyTorch会自动构建一张计算图。每一次对张量做运算比如y_pred x w b这个运算的前向结果会连带一个“反向传播的规则”记录在计算图上。执行loss.backward()时PyTorch从loss出发沿着计算图逆推把梯度存进每个叶子张量的.grad属性里。这个机制的价值在于我们完全不用手动推导d(loss)/dw的公式更不用自己实现链式法则哪怕计算图复杂一万倍只要用的是PyTorch基础运算算子梯度都能算对。但这里有个新手很容易误解的点w.requires_grad_()之后对w的任何原地操作比如w - lr * w.grad是会破坏计算图的。因为我们用了w的梯度去更新w本身而w又参与过前向计算这个原地修改会让后续的.backward()报错或者梯度错乱。所以从零实现时正确写法是w.data w.data - lr * w.grad或者用torch.no_grad()包裹更新操作后面我会专门演示这两种写法。2. 环境准备与数据生成拿到一份能训练的数据2.1 依赖安装与版本验证工欲善其事必先利其器。先把PyTorch环境准备好。创建虚拟环境并安装PyTorchCPU版本就足够跑这个项目因为线性回归的计算量不大不需要GPU。python -m venv linreg_env source linreg_env/bin/activate # Windows下用 linreg_env\Scripts\activate pip install torch numpy matplotlib安装完成后验证一下版本和自动求导是否可用import torch import numpy as np import matplotlib.pyplot as plt print(torch.__version__) # 例如 2.3.0 print(torch.cuda.is_available()) # 一般是 FalseCPU训练也足够注意如果你照着教程跑出来的代码报错优先检查PyTorch版本。1.x版本的API和2.x有细微差别2.x的torch.autograd接口更严格一些。2.2 合成数据生成给模型一个已知答案的“考题”为了保证训练过程可验证我们用人工生成的数据做实验。给定真实的w和b生成一组带噪声的样本这样训练结束后我们就可以拿学到的参数和真实参数对比误差一目了然。# 生成合成数据 n_samples 800 n_features 2 # 真实参数作为“标准答案” true_w torch.tensor([[2.5], [-1.3]]) true_b torch.tensor([0.8]) # 随机生成特征 X X torch.randn(n_samples, n_features) # 生成标签 y X true_w true_b 噪声 noise torch.randn(n_samples, 1) * 0.3 # 噪声标准差0.3 y X true_w true_b noise这里为什么要设置噪声如果完全没有噪声模型求解出的w和b在数学上可以完美还原真实参数但实际数据几乎都有噪声。加入噪声之后模型学到的参数会和真实参数有细微偏差这种偏差恰好能检验梯度下降是否真的在朝着损失下降的方向走。把数据可视化一下虽然一次画两维特征有点挤但只看单维特征和标签的关系也能直观感受分布形态plt.scatter(X[:, 0], y, alpha0.5) plt.xlabel(X0) plt.ylabel(y) plt.title(Synthetic data distribution) plt.show()3. 模型主体搭建与梯度下降训练循环3.1 模型定义与损失函数不用nn.Linear手动前向使用nn.Module但不同nn.Linear的好处是既能借助nn.Module管理参数又能完整看到前向计算每一步做了什么。import torch.nn as nn class LinearRegressionManual(nn.Module): def __init__(self, n_features): super().__init__() # 手动定义参数requires_grad默认True self.w nn.Parameter(torch.randn(n_features, 1)) self.b nn.Parameter(torch.zeros(1)) def forward(self, x): # 前向计算y_pred x w b return x self.w self.b这里有几个值得留意的设计选择。第一w为什么要初始化成随机数而不是全0全0初始化的对称性问题在线性回归里其实不存在因为损失是凸函数但使用随机初始化可以避免在后续神经网络场景里踩坑而且随机初始化也便于观察梯度更新路径。第二b初始化为0是惯例因为偏置项的对称性不影响凸优化收敛。第三w的形状是[n_features, 1]而不是[n_features]这是为了让矩阵乘法的输出保持[batch_size, 1]的二维结构后续计算损失时维度不会出现意外广播。损失函数不调用nn.MSELoss直接手写均方误差这也是“从零”的一部分def mse_loss(y_pred, y_true): return ((y_pred - y_true) ** 2).mean()mean()和sum()的区别值得展开说。sum()在小批量训练时梯度数值会随batch_size线性增大。比如batch_size16和batch_size128使用相同的学习率128的梯度大约是16的8倍很容易导致梯度爆炸而mean()把梯度缩放到了样本平均级别学习率对批量大小不敏感。这个细节在从零实现中尤其关键因为手动设置学习率时没有现成的调度器帮你兜底。3.2 梯度下降训练循环手动更新参数的正确姿势核心训练循环如下这里面有不只一个值得我专门标注的细节model LinearRegressionManual(n_features2) optimizer torch.optim.SGD(model.parameters(), lr0.05) losses [] epochs 500 for epoch in range(epochs): # 前向 y_pred model(X) # 计算损失 loss mse_loss(y_pred, y) losses.append(loss.item()) # 梯度清零 optimizer.zero_grad() # 反向传播自动计算每个参数的梯度 loss.backward() # 手动更新参数这是关键 with torch.no_grad(): for param in model.parameters(): param.sub_(param.grad * lr) # 打印信息 if (epoch 1) % 100 0: print(fEpoch [{epoch1}/{epochs}], Loss: {loss.item():.6f})这里用的optimizer虽然只是用来“存参数和梯度”真正的更新是我在no_grad()下手动完成的param.sub_(param.grad * lr)。这样就完全复现了最原始的SGD随机梯度下降更新公式同时避开了param.data赋值方式破坏autograd追踪的风险。为什么param.sub_()而不用param param - lr * param.grad因为param ...会创建一个新的张量这会破坏nn.Parameter的引用关系模型内部的参数就被替换了优化器持有的引用也指向旧参数后续梯度更新全乱套。sub_是原地减法参数对象还是同一个只是数值变了这样模型、优化器、计算图三者的引用关系保持一致。精心设计训练循环还有一种更简洁的方式就是直接用optimizer.step()替代手动更新。但既然标题是“从零实现”我还是更推荐把手动更新的那几行代码保留一段时间。当你真正理解了optimizer.step()底层就是拿着梯度乘学习率做原地更新之后再回到step()你会发现一切都透明了。3.3 训练效果检验误差分析与可视化训练完之后比较学到的参数和真实参数print(f真实 w: {true_w.view(-1).numpy()}) print(f学习 w: {model.w.view(-1).numpy()}) print(f真实 b: {true_b.numpy()}) print(f学习 b: {model.b.item()})以一次典型运行为例输出可能如下每次运行因随机种子不同会有细微波动真实 w: [ 2.5 -1.3] 学习 w: [ 2.4899998 -1.2900002] 真实 b: [0.8] 学习 b: 0.8139994可以看到学习到的参数和真实参数偏差很小这证明梯度下降确实收敛到了最优解附近。损失下降曲线也应当是一条单调递减后趋于平稳的曲线。plt.plot(losses) plt.title(Loss curve) plt.xlabel(Epoch) plt.ylabel(MSE Loss) plt.show()4. 真实数据下的预处理与特征缩放从合成到实际的鸿沟合成数据里我用的是torch.randn生成的标准正态分布特征均值为0、方差为1。但真实数据几乎不可能是这个形态。真实数据的特征可能量纲差异很大比如房价预测中面积是几十到几百平而房龄是几年到几十年两个特征的数值范围相差一到两个数量级。如果不做任何处理直接训练线性回归会发生一类很典型的问题——梯度下降过程失衡。特征x1的数值范围是[50, 300]特征x2的数值范围是[1, 50]那么让损失函数下降同样的幅度w1需要的更新步长可能只要w2的几十分之一。在同样的学习率下大数值特征对应的梯度往往也更大参数更新就会来回震荡收敛速度非常慢甚至不收敛。4.1 特征缩放的标准做法最常用的方式是标准化Z-score归一化公式是x_scaled (x - mean) / std。保存训练集的均值和标准差在预测新样本时使用相同的统计量进行转换这一点容易被忽略——如果每来一个新样本都重新计算全量数据的均值和标准差数据分布就被泄漏了模型的预测结果会失真。def standardize(X, meanNone, stdNone): if mean is None: mean X.mean(dim0) if std is None: std X.std(dim0) X_scaled (X - mean) / std return X_scaled, mean, std # 对训练集做标准化 X_scaled, feat_mean, feat_std standardize(X)做了标准化之后即使真实数据中两个特征数值范围相差百倍也能把两个特征拉到差不多的分布区间梯度下降的等值线会更接近“圆形”收敛路径也更加笔直。4.2 学习率和迭代次数的联动调整很多人调参时把学习率和迭代次数当两个独立变量犯了错就只动其中一个。实际上它们是一对非常强的联动变量。学习率太小迭代N次可能才走了整个损失曲面的一小段损失曲线是一条斜率很缓的下坡肉眼看着像损失还在减少但模型参数还没到最优附近学习率太大损失曲线可能先降一点然后急剧上升甚至出现NaN。结合我自己的调参经验一个比较实用的流程是先把学习率从0.001起步以10倍步长往上试0.001、0.01、0.1、0.5观察损失曲线的形态。如果损失在前50次迭代断崖式下降然后平台说明学习率可以接受如果损失一路发散说明学习率太大如果损失下降得非常平滑但500次迭代后还离真实参数很远说明学习率偏小。有个量化判断的小技巧观察损失下降的斜率。同一个实验配置lr0.05时100轮从12.7降到1.2而lr0.001时100轮只从12.7降到11.8这个差距一眼就能看出来。遇到后者优先把学习率往上提而不是盲目增加迭代次数。5. 从零实现最常见的坑与调试方法5.1 梯度爆炸导致参数变成NaN做实验时我故意把学习率调成0.5跑了一遍大概30轮之后损失直接变成nan。这背后发生了什么前几轮梯度还算正常但因为学习率过大参数更新一步就跨过了最优点太远损失函数值迅速变大梯度也跟着变大再用同样的大学习率去更新梯度就被“放大”了这就是典型的梯度爆炸。遇到NaN要分情况排查。最常见的是学习率过大其次是数据里有NaN值或极大离群值还有就是损失函数写错导致梯度传播失效比如计算loss之前用了detach()或者原地修改参与前向的张量。写程序时给训练循环加一个“告警”机制非常有用for epoch in range(epochs): y_pred model(X) loss mse_loss(y_pred, y) if not torch.isfinite(loss): print(fWarning: loss became non-finite at epoch {epoch}) break optimizer.zero_grad() loss.backward() with torch.no_grad(): for param in model.parameters(): param.sub_(param.grad * lr)有了这个提前熔断Debug时不用等500轮跑完才知道炸了直接在第一处NaN出现的地方打断点定位问题根因。5.2 参数更新了但损失纹丝不动我曾经在手动实现线性回归时遇到过一个极其隐蔽的问题训练循环跑完了打印损失也下降到了很小的值但拿学到的参数去预测时结果完全不对。后来发现我把w初始化为全0而b初始化为0理论上这种初始化在线性回归的凸优化下不会失败但我用的梯度更新方式是param param - lr * param.grad这行代码创建了新的张量导致model.w的requires_grad标志变成了False后续反向传播虽然计算了梯度但梯度并没有真正挂到nn.Parameter的.grad属性上更新也就失效了。排错方法很简单在每轮训练后打印参数值如果一轮过后参数没变那么要么是requires_gradFalse要么是梯度清零错了地方。实际跑代码时在前面说的no_grad块里正确使用sub_这个问题就消失了。这也印证了一个原则永远不要用param param - something这种方式更新nn.Module里的参数要么用原地操作要么用optimizer.step()。5.3 维度匹配与广播维度错位最隐蔽的错误PyTorch的广播机制很容易让代码“看着能跑结果全错”。假设我把w初始化成[n_features]而不是[n_features, 1]那么X w的形状是[batch_size]而不是[batch_size, 1]此时和y[batch_size, 1]相减会发生广播形状变成[batch_size, batch_size]损失成了一个矩阵的平均值——这个值不为0而且能正常反向传播训练过程看起来“没崩”但损失数值的含义完全被偷换了学到的模型参数也必然是错的。排查维度问题的一个高效手段是在数据构造之后立刻用断言检查形状assert y_pred.shape y.shape, fShape mismatch: {y_pred.shape} vs {y.shape}如果每个批次训练前都能过这一关基本上排除了90%以上的广播错误。5.4 预测新样本时的维度陷阱我在真实项目里遇到过这样的问题训练时X是二维的预测时来了一个[n_features]的一维张量直接塞进模型矩阵乘法的维度对不上。PyTorch会报错但初学者往往会被错误信息绕晕。解决方案是显式地把一维张量扩展成二维new_sample torch.tensor([2.3, -1.7]) new_sample new_sample.unsqueeze(0) # 形状变成 [1, n_features] pred model(X_scaled_new_sample)另外预测时还要“喂”经过标准化后的样本不是原始数据——把这一步忘了预测值就会整体偏移。6. 扩展思考从零实现带来的后续能力储备把线性回归从头写一遍本质上不是在重复造轮子——它更像是拆一台机器弄清楚齿轮怎么咬合之后再去操作整机时心里有底。这个过程中积累的手动更新参数的经验以后迁移到逻辑回归、多层感知机时会非常有用。想继续进阶的话建议按这样的顺序做练习首先把特征从2个扩展到10个验证代码是否依然工作接着把损失函数从MSE换成交叉熵体会一下不同任务里损失函数的特性然后尝试一个真正的回归数据集比如Scikit-learn自带的房价数据集用标准化、训练/测试集拆分、学习率搜索这三个操作组合出一套完整的流程最后比较你手写的SGD和PyTorch自带的optimizer.SGD确认最终的梯度更新数值是一致的。我身边有朋友做深度学习做了一年多平时写模型直接用高级API有一次被问到“你的模型参数量大的时候梯度是怎么存储的”他愣了一下。不是说他能力不行而是他没经历过底层实现。从零实现一遍线性回归花不了多少时间但它带来的收益会长期伴随你——当你以后调试复杂的Transformer和CNN时那些因为参数更新方式、梯度清零、维度广播而积累下来的直觉比任何现成框架的高级封装都更可靠。训练循环跑完的那一刻看着控制台打印出的参数和真实参数几乎重合那种“亲手把数学变成了代码”的感觉是直接用一行nn.Linear永远体会不到的。