线性回归训练从零开始:手写梯度下降与loss排查
很多人第一次接触线性回归训练都是调一行现成函数直接出结果。我也一样直到有一次训练循环怎么都收敛不了loss曲线一路冲到几千才意识到自己根本不了解模型在底层到底发生了什么。把线性回归训练从零开始完整跑一遍不是为了重新发明轮子而是为了让损失函数、梯度下降、参数更新这些概念变成看得见摸得着的代码。这篇博文就带你从造数据、写训练循环到调参、排查翻车点最后验证模型效果全流程走一遍。哪怕你之前只写过Python基础语法也能跟下来。这篇文章适合三类人刚入门机器学习、想搞明白“训练”这个词具体在做什么的初学者已经会用现成库但遇到loss异常不知道从哪排查的实践者以及准备面试、想对经典模型原理有真正把握的同学。我会把每一步为什么这么做、不这么做会怎样都讲清楚保证你读完能独立实现一个能用的线性回归训练流程。1. 为什么要把现成的模型拆开重写一遍1.1 fit背后的三件事调用库里的模型完成回归训练通常就是构造一个模型对象然后调用fit。看起来只有一行但这一行背后至少藏了三件核心工作定义损失函数用某个公式衡量“当前预测值离真实值有多远”。优化参数根据损失函数计算梯度沿梯度反方向更新模型参数。判断是否结束设定迭代次数或收敛阈值决定训练循环什么时候停。如果你一直使用封装好的接口遇到“模型训练好了但效果差”“loss不降反升”“训练速度慢”这类问题脑子里是没有排查路线的因为你连参数是怎么被更新的都不清楚。手写一遍之后所有变量都是你自己定义和打印的问题自然被拆成了一项项可检查的指标。1.2 手写训练到底在训练什么训练的本质只有一句话寻找一组参数让预测函数在训练数据上的损失最小。线性回归的预测函数特别简单就是一条直线参数就是斜率和截距。从零开始训练时你需要手动管理的东西包括模型参数的初始值通常设为0或很小的随机数每一次迭代中梯度的计算方式梯度往参数上减去多少也就是学习率训练数据遍历的顺序和次数损失值、梯度和参数的可视化或打印。这些内容在封装库里都是隐藏起来的而它们恰恰是理解机器学习最关键的部件。我常说看一遍讲梯度下降的博客不如自己打印一百行训练日志印象深。因为当你亲眼看到参数从0慢慢靠近真实值的轨迹看到loss曲线从高处平滑下降才算真正对“训练”有了手感。1.3 什么时候没必要手写手写线性回归训练也不是万能的。如果业务里面特征有上万维、数据量上百万或者你需要和深度学习网络混合建模那直接用成熟的工具库更合理别人已经做了大量工程优化。手写版本的定位是实验、教学和补盲区解决那些“一行代码跑通但换个数据就不会调”的问题。理解了手写流程之后再去用现成模型你会更容易理解它提供的参数应该怎么设置报错信息也更有头绪。2. 训练前必须算清的三笔账损失、梯度与更新规则2.1 均方误差为什么是默认选择线性回归最常用的损失函数是均方误差公式是L (1 / 2m) * Σ(ŷ_i - y_i)²其中m是样本数ŷ_i是第i个样本的预测值y_i是真实值。这里有几个新手容易问的问题。第一为什么用平方而不是绝对值和三次方因为平方对大误差更敏感差2倍就产生4倍的损失这符合我们通常认为“坏得离谱的预测应该被重点纠正”的直觉。绝对值也能用但在误差为0的地方不可导写训练循环时会多出不少麻烦。三次方以上的损失对异常值又过于敏感所以实际中最常用的还是平方。第二公式里的1/2是干嘛用的纯粹是为了求导方便。均方误差对预测值求导后会产生一个系数2提前乘以1/2正好抵消让梯度表达式更干净。很多人第一次看到这个1/2会以为它和“均方”有关其实不是。2.2 梯度误差对参数变化的敏感度梯度描述的是“参数动一点点损失会变多少”。线性回归的预测值是ŷ w*x b用链式法则对w求导会得到∂L/∂w (1 / m) * Σ(ŷ_i - y_i) * x_i∂L/∂b (1 / m) * Σ(ŷ_i - y_i)这个结果的意义很直观每个样本的预测误差乘以该样本的特征值然后求平均就是斜率w应该调整的方向和幅度。误差为正说明预测低了梯度为正更新参数时需要减小w误差为负则相反。b的梯度更简单直接就是平均误差。我建议不要死记这个公式而是自己推导一遍过程就是损失对预测值求导预测值再对参数求导两条链相乘再求和。我在教别人的时候常把梯度理解成“下山时脚下的坡度”坡度越陡一脚下地走的方向越明确坡度接近0说明快到底了。2.3 学习率步子太大容易扯到蛋参数更新规则是w w - lr * ∂L/∂w b b - lr * ∂L/∂b学习率lr表示每次沿梯度反方向走多远。如果lr太小训练要很久才能收敛如果太大参数可能越过最低点来来回回震荡甚至直接发散。常见的翻车现场是loss在几百次迭代之后变成NaN就是因为学习率过大导致数值溢出。这里有个判断技巧训练初期loss如果快速下降但之后一波动就越增越大大概率是学习率偏大如果loss一直平缓下降但速度极慢可能是学习率偏小。调试时可以先从0.01这种保守值开始看曲线走势再调整而不是凭空拍脑袋定一个数。2.4 梯度下降路线选择梯度下降的实现方式还分三种批量梯度下降、随机梯度下降和小批量梯度下降。它们的区别在于每一步用多少样本计算梯度。批量梯度下降每次用全部数据计算梯度稳定但数据大时每一步都很慢。随机梯度下降每次只用一个样本更新快但路径很颠簸。小批量梯度下降每次用一小批数据比如32或64个样本折中方案。手写示例大多用批量方式方便观察和理解。实际工程里则多用小批量。这三者不是线性回归专属的概念神经网络训练里也完全一样现在理解了后面会省很多力。3. 亲手跑通一次线性回归训练代码逐步拆解3.1 先造一份带噪声的数据训练的第一步是准备数据。为了把问题聚焦在训练流程上我通常先合成一份“线性关系加噪声”的数据。下面这段代码会生成80个样本真实关系是y 2.5*x 1.2再加上标准差为0.4的高斯噪声。import numpy as np def generate_data(n80, w_true2.5, b_true1.2, noise0.4): rng np.random.default_rng(42) x rng.uniform(0, 3, sizen) y w_true * x b_true rng.normal(0, noise, sizen) return x, y x, y generate_data()为什么要加噪声因为完全没有噪声的数据会让线性回归训练过于理想化模型很快就能逼近真实参数看不出泛化的概念。有了噪声我们才能讨论“模型学到的是趋势而不是死记每一个点”。3.2 训练主循环前向计算、反向更新、loss观测训练的循环逻辑可以分成四步前向计算预测值计算损失计算梯度更新参数。下面是一个最小实现。def train_linear_regression(x, y, epochs300, lr0.05): w 0.0 b 0.0 m len(x) history [] for epoch in range(epochs): # 前向计算 y_pred w * x b # 损失对应 (1/2m) * sum((y_pred - y)^2) loss np.sum((y_pred - y) ** 2) / (2 * m) # 梯度 grad_w np.sum((y_pred - y) * x) / m grad_b np.sum(y_pred - y) / m # 更新 w - lr * grad_w b - lr * grad_b history.append((w, b, loss)) if epoch % 50 0: print(fepoch {epoch:3d}, loss {loss:.6f}, w {w:.4f}, b {b:.4f}) return w, b, history w, b, history train_linear_regression(x, y) print(ffinal: w {w:.4f}, b {b:.4f})这段代码里最重要的一行是更新参数那两步。grad_w和grad_b是当前梯度lr控制步长计算顺序不能乱。每条样本的梯度方向可能不同求和取平均后得到的是一个整体方向。运行之后你会看到loss从最初的某个值一路下降w和b逐渐接近真实值2.5和1.2。3.3 loss曲线能看到什么训练过程中把history里的loss画出来会看到它们呈一条快速下降后趋于水平的曲线。不同阶段要关注的点不一样初期loss下降很快说明梯度方向基本正确参数正在快速靠近较好区域中期下降变缓说明已经接近局部最优点每一步修正量变小后期近乎水平但还有极小波动这是噪声数据带来的正常现象。我建议在调试任何训练代码时都把loss曲线打出来看。只看最终loss可能让你错过一个重要信息模型可能在第10次迭代就已经收敛了后续200次迭代只是浪费时间。训练成本高的时候观察曲线能帮你早点止损。3.4 训练得到的模型怎么用训练完成后w和b就是模型的核心。预测新样本时只需要一行def predict(x_new, w, b): return w * x_new b print(predict(1.5, w, b))因为线性回归本身是直线方程所以它面对新数据的计算成本极低。即便样本量上千万预测时也只是一次乘加运算。这也是线性回归至今仍然广泛用于某些低延迟场景的原因。4. 训练翻车集中营发散、走样与NaN排查4.1 学习率过大的现场还原把学习率改成1.0再训练你会看到很有意思的现象loss先下降一点接着猛涨到几百然后又突然跳到几万最后变成NaN。我复现过这个现象原因是参数更新步长太大w和b直接跳过了最优区域冲到一个很远的点梯度又因此变得更大形成正反馈循环。这就是发散。判断发散有三个信号loss不降反升、参数绝对值在几轮内暴增、日志里开始出现inf或NaN。遇到这种情况不要急着调代码结构先把学习率缩小10倍或100倍再试。实践里我经常用这样的策略质量数据不明确时用0.001起步确认能下降之后再用指数倍递增尝试。4.2 特征尺度不一致引发的“隐性爆炸”假设你的x特征不是0到3之间的数而是收入水平动辄几万几十万。这时计算grad_w时会乘上x本身梯度幅度会被放大成千上万倍。即使学习率设得很小参数更新也可能异常剧烈。我遇到过一个类似场景一个样本的特征有两个一个范围0.01到1一个范围1000到100000模型训练之后两个权重无法同时收敛。根源就是特征尺度差异太大。解决办法是特征标准化对每个特征减去均值并除以标准差让所有特征变成均值为0、方差为1的分布。标准化之后梯度计算各特征的贡献就不会被量级吞掉训练会稳定很多。mean x.mean() std x.std() x_norm (x - mean) / std # 训练完成后用新样本预测时需要先做同样的变换 x_new_norm (x_new - mean) / std注意一个坑对特征做标准化时均值和标准差要从训练数据计算不能在新数据上重新算否则会造成数据泄露影响模型在未知数据上的表现。4.3 最便宜的排查手段打印loss很多人写训练循环时喜欢调完参数直接看结果中间什么都不打印。当模型出问题时只能瞎猜。我的习惯是在训练循环里每固定轮次打印一次loss、w、b并保存到history变量里随时可以后续分析。打印内容不需要太多带上关键指标就行。从经验看绝大多数线性回归训练问题都能通过观察loss曲线定位到原因loss一直很高可能数据没有线性趋势或者损失函数与模型不匹配loss持续波动学习率偏大或者使用了随机梯度下降但批次太小loss逐渐下降但速度很慢学习率偏小或特征需要标准化loss出现NaN学习率过大、数据中存在无穷值或数值计算溢出。4.4 判断训练收敛的正确姿势训练结束的判定标准不是“迭代完所有epoch”而是看参数和损失的变化量是否足够小。实际代码可以这样写tolerance 1e-4 max_epochs 1000 for epoch in range(max_epochs): # ... 前向、梯度、更新 if abs(w - prev_w) tolerance and abs(b - prev_b) tolerance: break这里不是比较loss直接比较参数更新量更稳定。因为loss可能已经很小但参数还在缓慢漂移参数不再动基本说明模型已经收敛。另一个经验是不要追求训练loss等于0线性回归在带噪声数据上不可能也不应该做到零损失。强拟合每一个数据点意味着模型被噪声牵着走后面的预测效果反而会变差。5. 多元线性回归把代码从“一个x”改成“一百个x”5.1 向量化改写代码反而更短现实问题很少只有一个特征。当特征变成n个时原来的标量公式要改成矩阵形式。预测值写成ŷ X·w b其中X是形状为(m, d)的特征矩阵w是长度为d的权重向量b是偏置。梯度表达式也需要向量化grad_w X.T·(ŷ - y) / m grad_b mean(ŷ - y)使用NumPy可以很简洁地实现代码看起来甚至比一元版本更整齐def train_multiple(X, y, epochs500, lr0.1): m, d X.shape w np.zeros(d) b 0.0 for epoch in range(epochs): y_pred X w b loss np.sum((y_pred - y) ** 2) / (2 * m) grad_w X.T (y_pred - y) / m grad_b np.mean(y_pred - y) w - lr * grad_w b - lr * grad_b if epoch % 100 0: print(fepoch {epoch}, loss {loss:.6f}) return w, b这里的X.T ...是矩阵乘法表示把所有样本的误差乘以对应特征后求和和一元时的∑(ŷ-y)*x完全一致只是写成了整体运算。5.2 高维权重的梯度长什么样很多人以为多变量就是重复做几次一元训练其实不是。每个特征的梯度是同时计算、同时更新的权重之间会互相影响。因为预测值由所有特征共同决定一个权重的调整会改变整体的误差进而影响其他权重的梯度方向。这也是手写一遍最有价值的收获之一你会发现梯度下降不是对每个参数单独优化而是在一个联合的参数空间中搜索。f特征之间有相关性时搜索路径会呈现螺旋或倾斜的状态训练可以更快也可能更慢这取决于数据本身。5.3 别让不同特征量纲抢走了话语权多元场景下特征标准化从“建议”变成了“必须”。比如预测房价时面积动辄上百平方米卧室数量只有1到5如果直接用原始数据训练面积对应的梯度会非常大卧室数量的权重会很难被正常学到。标准化之后每个特征的量纲被拉齐权重的大小才能真实反映特征对目标的贡献。需要补充的是标准化之后的权重解释会变化此时w表示的是“该特征变化一个标准差时目标平均变化多少”。做业务分析时如果你的目的是解释特征影响建议保留一个未标准化的模型作对照。我自己通常两个模型都训练一个用于预测一个用于解释。6. 训练结束后的验证怎么判断模型是真的会了6.1 切分数据集仿造一场“闭卷考试”拿着训练时见过的数据说模型好就像开卷考试说自己考满分一样没有说服力。要判断模型的真实表现必须把数据切成训练集和测试集训练时只用训练集测试时只让模型碰测试集。常见做法是80%训练、20%测试并且先做一次随机打乱。rng np.random.default_rng(7) idx rng.permutation(len(x)) split int(len(x) * 0.8) train_idx, test_idx idx[:split], idx[split:]切分之后在测试集上计算MSE这个值才是你对外说“模型效果如何”的底气。如果测试集误差远大于训练集误差说明模型过拟合了需要加入正则化或者换更简单的模型。6.2 MSE与R²两个完全不同视角MSE给出了误差的绝对值量级但单独看不够直观。R²则把模型表现和数据本身的波动放在一起比较公式是R² 1 - SS_res / SS_tot其中SS_res是残差平方和SS_tot是目标值围绕均值波动的平方和。R²为1说明模型完美解释所有变化为0说明模型和“直接用均值预测”差不多为负说明比用均值还差。实际场景中R²在0.8以上通常算不错但需要结合领域判断。工资预测能到0.5已经很好物理实验数据可能要到0.99才算合格。6.3 残差图比指标更诚实指标会骗人残差图不会。所谓残差就是每个样本的真实值减去预测值。画残差图时x轴可以是预测值y轴是残差。一个健康模型的残差应该随机分布在0附近没有明显的形状或趋势。如果残差出现喇叭状说明预测值越大误差越分散模型在同方差假设上失效如果残差呈现弯曲弧线说明数据里有线性模型未能捕捉的非线性关系。我处理过很多所谓“模型效果很好”的案例看一眼残差图就发现里面藏着明显模式这种模式意味着数据里还有值得挖掘的结构或者模型本身就选错了。因此我现在的习惯是任何回归模型训练完先画残差图再决定要不要调整模型。6.4 手写训练留给我的一些小习惯从零手写线性回归之后我养成了几个固定习惯先打印loss曲线再调参先做特征标准化再训练先用小学习率起步再逐步调大最后一定要看测试集上的表现和残差图。这些习惯让后面接触更复杂的模型时排查问题省了无数时间。线性回归虽然结构上是最简单的模型但训练过程涉及的概念和工程细节和它在实际业务中发挥的作用并不成正比。真正理解了一遍你会发现再去看其他训练任务不过是换了一个模型表达式、换了一套梯度计算方法而已。