资讯动态

简单线性回归底层代码实现:从梯度下降到损失函数的完整解析

发布时间:2026/10/19 17:26:51 来源:尧图企业网站定制
简单线性回归模型底层代码实现——这是我入行多年后依然觉得最值得反复手写一遍的内容。很多朋友习惯了直接调用现成工具包一两条命令就把线性模型训练出来了可问到“系数到底是怎么算出来的”“学习率为什么调不好”“梯度下降和最小二乘到底啥关系”往往就卡住了。这篇文章不谈调库就用 Python 和 numpy 从零开始把一个简单线性回归模型的数学推导、损失函数构造、梯度下降优化、多元扩展、常用评估、常见坑位全部拆开来走一遍。新手读完能上手老手也可以拿来对照自查。1. 项目概述与核心需求解析1.1 这个标题背后真正考验的是什么“简单线性回归模型底层代码实现”这个说法里简单线性回归指的是一个自变量、一个因变量的场景模型表达式就是 y w·x b。难点不在“知道这个公式”而在“底层代码实现”这五个字。它要求你抛开现成工具包自己去实现拟合过程这意味着你至少要把以下问题想清楚模型的参数 w 和 b 是什么含义误差怎么度量“拟合”在数学上等价于优化什么问题梯度怎么算参数怎么更新收敛条件怎么判断结果怎么验证。我在指导新人做这个项目时最看重的是能否从零写出训练循环而不是复制粘贴现成代码。因为只有亲手写了循环你才会真正理解学习率、初始化、特征缩放这些看似琐碎的操作为什么如此重要。1.2 为什么一定要做底层实现有人会问现成库一行代码就能解决为什么还要费劲写底层我的回答很直接因为调库只能告诉你结果不能告诉你过程。实际生产环境中模型效果不好时你需要诊断问题面试算法岗时手推线性回归是高频考点想去改模型、加正则、换优化器时不懂底层就只能瞎猜参数。无论是为了理解原理、应对面试还是为了具备定制化能力手写一遍都是性价比最高的投资。况且线性回归是一切复杂模型的基石。你把梯度下降、损失函数、过拟合处理这些概念在最小模型上彻底吃透后面看逻辑回归、神经网络就不会觉得那么玄因为底层逻辑高度一致。1.3 写代码前需要明确的几个基本概念动手之前先明确三个基本约定。第一损失函数这里用均方误差 MSE也就是所有样本预测值与真实值差值的平方的平均值它的几何直觉是惩罚大误差数学上又天然可导非常适合做优化目标。第二梯度它告诉我们在当前参数点哪个方向能让损失下降得最快所以参数更新就往负梯度方向走一步。第三学习率这个“一步”迈多大直接决定训练是稳定收敛还是直接爆炸。这三个概念贯穿整个项目。后续所有代码都是在围绕它们做具体化表达。2. 核心拆解从数学公式到代码思路2.1 把线性模型和损失函数先写出来对于一个样本 (x_i, y_i)模型预测值是y_pred_i w * x_i b误差就是 y_pred_i - y_i。为了让误差可微、正负不抵消我们取平方再求平均得到整个训练集上的损失Loss(w, b) (1 / n) * Σ (w * x_i b - y_i)^2这里的 n 是样本个数。我见过不少初学者忘记除以 n其实不除以也能收敛但损失值会随样本量变化不好横向对比学习率是否合适。统一除以 n 会让损失反映的是“平均每个样本的误差平方”直觉更清晰调参也更方便。2.2 损失函数为什么要用平方误差而不是绝对值很多人会问误差绝对值不是更直观吗绝对值损失在零点不可导梯度下降走到最优解附近时会来回震荡收敛慢。而平方误差处处光滑可导导数是一个线性表达式数学性质非常好。更关键的是在误差服从正态分布的假设下最小化平方误差等价于极大似然估计这就是它在统计学里的正统地位。我给读者的建议是刚开始做实验就用 MSE把它吃透绝对值损失 MAE 的鲁棒性问题等你对优化器有感觉了再去研究也不迟。2.3 两种求解路线解析解与迭代解线性回归这条线上有两个求 w 和 b 的方向。一个是直接求解析解核心思路是让损失函数对 w 和 b 的偏导等于 0然后解方程组。对于简单线性回归存在完全封闭的公式w Σ ((x_i - mean_x) * (y_i - mean_y)) / Σ ((x_i - mean_x)^2) b mean_y - w * mean_x另一个是迭代解也就是梯度下降从随便一组参数出发反复按照负梯度方向调整参数直到损失不再下降。两种路线各有适用场景。解析解算得快、不涉及调参但要求特征矩阵满秩数据量一大矩阵运算内存也扛不住。梯度下降更通用扩展到逻辑回归、神经网络都还是这套逻辑。我建议你把两个都实现一遍。先用解析解求得一个确定答案作为对照基准再来调试梯度下降这样你能很直观地看到迭代解逼近解析解的过程。3. 实操过程解析解与梯度下降的底层实现3.1 环境准备与模拟数据构造写代码前先说环境。这个项目我把依赖缩到最小Python 3numpymatplotlib。不需要安装任何机器学习库这样你能确认“模型是纯手工造出来的”。我习惯先造一组“暗含真实答案”的数据来做验证。假设真实模型是 y 3·x 7我给它加上一点随机噪声这样我们知道理想参数应该接近 3 和 7。如果代码最后算出 w 和 b 明显偏离真实值那一定是实现有问题而不是数据本身的错。下面是用 numpy 生成模拟数据的代码import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 生成60个样本x在0到10之间均匀分布 x np.linspace(0, 10, 60) # 真实参数w_true3, b_true7加上高斯噪声模拟真实场景 true_w 3.0 true_b 7.0 y true_w * x true_b np.random.normal(0, 2, sizex.shape)这里的 np.random.seed(42) 是为了让实验可复现。我做任何对内实验都会固定随机种子否则每次跑结果不一样根本无法判断改动代码是变好了还是变坏了。3.2 解析解实现过程解析解公式看起来只有两行但实现时有个细节容易错公式里的除号分母不能接近 0。分母其实是特征 x 的方差再乘以 n只要 x 不是常数问题不大但养成检查分母的习惯是好事情。先写出均值和求和形式的解析解def solve_by_closed_form(x, y): mean_x np.mean(x) mean_y np.mean(y) numerator np.sum((x - mean_x) * (y - mean_y)) denominator np.sum((x - mean_x) ** 2) w numerator / denominator b mean_y - w * mean_x return w, b w_closed, b_closed solve_by_closed_form(x, y) print(f解析解: w{w_closed:.4f}, b{b_closed:.4f})在我的实际运行结果里得到的 w 大概在 3.03 附近b 在 6.8 附近。因为数据里带了噪声不可能精确等于 3 和 7但只要噪声是零均值长期来看估计值会在真实值附近波动这正是统计里的无偏性体现。3.3 梯度下降实现的核心流程梯度下降才是这次实现的重头戏。按照我们前面写的损失函数先对 w 和 b 分别求偏导。推导的结果很干净dL/dw (2 / n) * Σ ((w * x_i b - y_i) * x_i) dL/db (2 / n) * Σ (w * x_i b - y_i)可以看出两个梯度的共同核心是残差 (pred - y)。这个模式在后面所有模型里都会反复出现——误差回传的起点就是残差。实现时我的建议是把它拆成三个部分前向计算、梯度计算、参数更新。这样后续加正则项、换损失函数改哪个模块一目了然。下面是完整代码实现def gradient_descent(x, y, lr0.01, epochs500): n len(x) w 0.0 b 0.0 history [] for epoch in range(epochs): # 前向计算得到全部预测值 y_pred w * x b # 计算损失和梯度 loss np.mean((y_pred - y) ** 2) dw (2.0 / n) * np.dot(x, y_pred - y) db (2.0 / n) * np.sum(y_pred - y) # 参数更新沿着负梯度方向走一步 w - lr * dw b - lr * db history.append((w, b, loss)) return w, b, history # 训练 w_gd, b_gd, history gradient_descent(x, y, lr0.01, epochs500) print(f梯度下降结果: w{w_gd:.4f}, b{b_gd:.4f})这里面有个细节值得注意dw 用了 np.dot(x, residual)也就是对残差向量和 x 向量做点积再除以 n这等价于逐元素相乘再求和。如果写成 np.dot(residual, x)结果一样因为两个一维向量点积是对称的。3.4 三个容易踩坑的代码细节第一处是否除以 n。梯度里的 2/n 是必须的如果哪天你发现 loss 下降特别慢或者数值特别大先检查这里。第二处参数更新用的是 - 而不是 。减号代表沿负梯度方向移动方向反了会导致 loss 不断变大这是新手最常见的低级错误。第三处epochs 到底要设多少。这个没有标准答案更好的做法是保存历史损失当损失变化小于某个阈值就提前停止而不是硬跑固定轮数。我在最初实现时就吃过没做收敛判断的亏epochs 设太小参数没到位设太大看着 loss 几乎不动白白浪费时间。后来改成记录历史损失每次迭代后比较新旧损失差小于 1e-6 就退出速度明显变快训练过程也透明多了。3.5 梯度下降与解析解的对比验证写完两种求解方式最后必须做个对拍确认梯度下降确实收敛到了和解析解一致的结果。print(f解析解误差: w差距{abs(w_closed-w_gd):.6f}, b差距{abs(b_closed-b_gd):.6f}) print(f最终梯度下降损失: {history[-1][2]:.6f})正常情况梯度下降的 w 和 b 应该与解析解极为接近比如相差 1e-4 甚至更小。如果差距很大多半是学习率太小收敛不够或 epochs 不够多。我通常会在调试时把 loss 曲线打印出来肉眼确认曲线是否平滑下降这一步对新手来说比直接看数值更有帮助。4. 从一元到多元底层代码怎么扩展4.1 为什么需要矩阵化真实场景里谁会用只有一个特征的模型房价预测要考虑面积、朝向、楼层销量预测要考虑价格、季节、促销投入。所以学会了简单线性回归下一步必做的就是多元线性回归。多元线性回归的模型写成向量形式就是y_pred X · W这里的 X 是形状为 (n, d) 的特征矩阵W 是长度为 d 的系数向量。注意为了把截距 b 也合并进去我习惯在 X 左边加一列全 1这样 b 就变成了 W 的第一个分量不用单独维护整个计算全部统一成矩阵乘法。提示这种“用一列 1 吸收截距”的做法是很多底层实现的通用技巧写逻辑回归、神经网络时也会见到。理解了它你再看开源库源码时就不会发怵。4.2 多元场景下的梯度下降代码把一元代码改成矩阵化版本逻辑其实没变只是把标量运算换成了向量和矩阵运算。代码如下def gradient_descent_multivariate(X, y, lr0.01, epochs1000): # X: (n, d), y: (n,) # 在X前面加一列1用于拟合截距 ones np.ones((X.shape[0], 1)) X_b np.hstack([ones, X]) n X_b.shape[0] W np.zeros(X_b.shape[1]) history [] for epoch in range(epochs): y_pred X_b W residual y_pred - y loss np.mean(residual ** 2) # 梯度: (2/n) * X_b.T residual gradient (2.0 / n) * X_b.T residual W - lr * gradient history.append((W.copy(), loss)) return W, history这里最关键的是梯度那行X_b.T residual。为什么这样写因为我们要对每个维度求偏导而 X_b 每一列正好对应一个特征的所有样本值转置后点乘残差得到的就是每个特征的梯度。这个写法可以直接套用到任意维数的特征矩阵而不需要像一元情况那样单独算 w 和 b。4.3 特征缩放为什么能救梯度下降矩阵化实现跑通后你多半会碰到一个状况某些特征数值范围特别大比如面积是几十到几百而楼层数只有几层。数值范围大的特征在梯度中会占据主导地位导致参数更新振荡收敛极慢。解决方法也简单训练之前做标准化让每个特征都具有近似零均值和单位方差。标准化是很多机器学习代码里“模块化”的组成部分需要把训练集的均值和标准差保存下来测试集用同一份统计量转换。def standardize(X, meanNone, stdNone): if mean is None or std is None: mean np.mean(X, axis0) std np.std(X, axis0) std[std 0] 1.0 return (X - mean) / std, mean, std这里的 std[std 0] 1.0 的意图是防止某一列全为常数时除零报错。加了标准化之后你通常可以把学习率调得更大收敛速度会明显提升。这是我强烈建议你亲自动手验证的一步直观感受一下同样的学习率在标准化前后效果差距有多大。5. 模型评估与验证判断代码写得对不对5.1 训练集与测试集划分代码写出来了loss 降下去了不代表模型就真的精确。我们尝试验证效果时必须把数据划分为训练集和测试集。若用全部数据去做训练又用同一批数据做评估模型记住噪声都能得高分这属于数据泄漏很能迷惑新手。我用最简单的方式按 8:2 比例乱序划分数据集因为数据是自己生成的字段本身没有时间顺序可以直接打乱。如果用的是真实业务数据务必检查数据是否按时间排序时间序列数据不能随意打乱否则会引入未来信息。下面的代码是划分函数的示例def train_test_split(x, y, test_size0.2, random_state42): rng np.random.default_rng(random_state) n len(x) indices rng.permutation(n) split int(n * (1 - test_size)) train_idx, test_idx indices[:split], indices[split:] return x[train_idx], x[test_idx], y[train_idx], y[test_idx]5.2 评估指标的计算与解读线性回归里我最常用的评估指标有三个每个都有自己的侧重。第一个是MSE均方误差也就是我们训练的损失函数本身单位是目标变量的平方直观性差一点。第二个是MAE平均绝对误差单位与原数据一致比如房价预测误差 5 万意思就是平均偏差 5 万业务人员听起来更直观。第三个是R²决定系数取值范围通常在 0 到 1 之间表示模型解释了目标变量方差的百分之多少比如 R²0.85 就说明特征能解释 85% 的波动。下面是一个手写指标计算的函数def evaluate(y_true, y_pred): n len(y_true) mse np.mean((y_true - y_pred) ** 2) mae np.mean(np.abs(y_true - y_pred)) # R^2计算1 - SS_res / SS_tot ss_res np.sum((y_true - y_pred) ** 2) ss_tot np.sum((y_true - np.mean(y_true)) ** 2) r2 1 - ss_res / ss_tot return mse, mae, r2需要注意 R² 的计算里如果 ss_tot 接近 0也就是目标变量几乎没有方差R² 会没有意义。这种情况在你的模拟数据里不太会出现但处理真实数据时一定要有所警惕。5.3 用评估指标反推底层实现的正确性我自己做实验时会把从零实现的模型和开源工具库的标准结果做对拍比较两者在测试集上的 R² 和系数通常误差应在小数点后 4 位以内。如果超过了那就要回去查前面说的三个细节数据是否标准化、梯度是否除以 n、学习率是否太大导致发散。对拍这一步被杀我很推荐每个学习者做。它不是让你依赖工具库而是给你一个“参考答案”。就像数学题做完之后对一下答案确认思路没有跑偏。你模拟出来的结果越接近标准答案说明你的梯度计算、损失函数、参数更新这些底层实现基本可信。6. 常见问题与排查技巧实录6.1 学习率太大损失不降反升这个问题在我反复训练时太常见了。具体表现是 loss 一开始就飙到一个天文数字然后越滚越大。原因很简单步子迈太大直接越过最优点的区域。排查方式来说先看损失曲线的第一个点。如果第一次迭代 loss 就直接爆炸大概率是学习率过大。把学习率调到 1e-3 再试一遍如果还炸继续降到 1e-4直到 loss 能顺利下降为止。你也可以画一张“学习率-最终损失”的曲线能直观看到存在一个安全区间低于区间收敛慢高于区间直接崩。注意学习率小并非绝对好它可能带来收敛极慢的观感这会让你误以为“卡住”了。我习惯用前几百轮的 loss 下降速度作为判断标准而不是只看最后的数值。6.2 特征范围差异太大导致的收敛慢之前提过特征缩放但实际项目里大家经常会因为“忘记标准化”而踩坑。症状是训练很多轮后损失下降非常缓慢或者个别特征对应的系数忽大忽小。修复方法是标准化并重新训练效果通常立竿见影。我还遇到过一种让人困惑的情况标准化做完了损失曲线依然不平滑看起来总是在小幅度振荡。后来发现是训练集和测试集混用了不同的标准化统计量。记住一条原则标准化参数只能用训练集拟合然后原样套用到测试集。6.3 接近线性相关的特征导致矩阵无法求逆解析解在多元场景下会碰到矩阵求逆的问题。当两个特征几乎完全线性相关时比如一个特征是面积另一个特征是“面积×2”特征矩阵的秩不足逆矩阵不存在或者数值极不稳定解析解直接失效。面对这种情况优先考虑去掉冗余特征或者换用梯度下降。梯度下降即使在高相关特征下也能得到一个结果只是训练过程会变慢、参数方差变大。理解这个现象的核心在于如果两个特征表达的信息高度重叠模型对参数的惩罚不够明确参数可以在这两个方向上来回摊。6.4 浮点数精度与数值稳定性小坑写底层代码离不开对浮点数计算的理解。一个常见问题是直接用解析解公式时如果特征数值特别大比如几万几百万计算 (x - mean_x) 的平方可能超出普通浮点数的精度范围导致算出的 w 偏差很大。对此有两个解法。第一是标准化后再求解析解但注意要把系数还原为原始尺度第二是使用数值稳定算法比如基于矩阵分解的求解方式。对于学习入门级别的模拟数据标准化加上分解就足够稳定不需要过度设计。另外一个小坑是如果有特征方差恰好为 0所有样本值相同梯度下降求梯度时对应维度的梯度永远为 0参数不动这本身没问题但标准化代码里要防止除以 0。提前做一次数值检查比运行时崩溃之后再去排查体验好得多。6.5 遇到“loss 下降缓慢”的通用排查思路我把这种问题归类为“看起来在下降但速度令人绝望”。常见原因有三个学习率太小、特征没标准化、初始值选得太差。应对方法是先做标准化再学习率从小到大扫三档测试比如 1e-4、1e-3、1e-2观察哪一档在固定轮数内让 loss 降得最多。如果这三档都不理想再检查特征是否有效——总有一列特征本身和目标几乎无关甚至纯噪声训练轮次再多也没意义。在实际教学场景中我曾用一个小技巧加速排查先把训练轮数固定为 100打印每个轮次的 loss用肉眼判断曲线的形状。如果前 20 轮是快速下降而后 80 轮趋平说明终止时机可以更早如果曲线全程近似直线缓慢下滑那么这轮训练基本没价值要从初始化或缩放处入手。最后再分享一点经验最后再分享一个小技巧。这个手写线性回归的项目做完之后不要急着丢掉。我习惯把它当做一个“测试平台”每次学一个新概念比如 L2 正则、动量法、Batch 大小影响先回到这个最简单的模型上做实验效果非常直观。比如说加 L2 正则就是在梯度上多加一个 2·λ·w 的项模拟代码不超过三行但你能立刻看到系数被压小的过程。加动量法就是记录历史梯度更新时混合一点上一次的方向模拟起来也就多一个变量。这样一步步扩展你手写模型的复杂度会自然上升而你对底层原理的掌控力也会越来越稳。我见过太多人花时间背概念却很少亲手把梯度推导和代码对起来。真正动手写完一遍梯度下降再回头去看那些讲解优化算法的文章你会发现一切都顺了。这个项目不只是一道练习题它是理解整个机器学习体系的一个绝佳起点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑