从liner到线性回归:手写最小实现与梯度下降踩坑笔记

发布时间:2026/10/6 3:53:38
从liner到线性回归:手写最小实现与梯度下降踩坑笔记 说个挺有意思的事我第一次系统性学回归分析的时候看了不少“最简单回归代码”的教程当时觉得自己全会了公式也背得下来可是一关编辑器手放在键盘上脑子就一片空白。后来我才明白问题不在于“会不会背公式”而在于大脑里没有一条从数学符号到代码行为的清晰映射。这篇学习笔记是系列的第4篇前几篇我整理过回归的数学基础、损失函数的来龙去脉还有数据清洗阶段踩过的坑这篇反过来不聊推导只聊代码——准确说是“liner”这标题下面那份最精简的线性回归实现它为什么能跑、每行代码在干什么、把它改坏过多少次之后我才真正看懂了它。如果你的状态跟我当时一样能看懂公式但写不出完整代码或者拿过别人的回归脚本跑通了却不敢说理解又或者正准备从零手写一份最小实现来加深印象——那这篇笔记应该能帮上忙。我会按下面这几块走先把这个标题里的“liner”到底是什么、为什么“最简”反而最容易学偏说清楚然后把最小二乘解和梯度下降这两条主线的代码逐行拆开再讲数据进出代码前后的那些隐藏操作最后把我反复踩过的几个报错和逻辑错误整理成一份排查清单。1. 标题里的“liner”到底是什么先把这个系列的学习脉络理清1.1 一个容易被忽略的拼写细节标题写的是“liner”搜热词榜上配的也是“liner”和“回归代码”。我第一眼看到就知道这多半是 linear regression线性回归的手误。这种拼写在代码学习笔记里非常常见搜索“liner 回归”出来的结果有一大半其实都在讲线性回归。顺带说一句如果你搜英文资料正确关键词是 linear regression 或者 ordinary least squares而“liner”这个写法在数学语境里几乎不会有结果。先把这个概念锚定住线性回归解决的是“用一条直线超平面来描述特征和输出之间的关系”。最简场景就是一元线性回归也就是 y kx b代码里最常见的变量名是 w权重和 b偏置。数据再往多了走就变成多元线性回归y w₁x₁ w₂x₂ ... b。所谓“最简单的回归代码”通常就是这份东西构造数据、搭模型、算损失、更新参数循环若干轮。它没有正则化、没有特征交叉、没有交叉验证纯粹把回归最核心的骨架摆在桌面上。1.2 为什么“第4篇”才写最简单的代码我这个系列前面三篇分别聊了回归的数学背景、损失函数的设计逻辑和数据预处理的实操坑。可能有人会觉得把“最简单代码”放在第4篇是不是太晚了。事实上我复盘下来这个顺序恰好是合理的你直接看代码看到的是一个结果——w 和 b 在一轮一轮循环里变小但你看不懂“为什么减小这个方向”以及“为什么学习率会给成 0.01”。有了前面的数学基础代码里的每一行才有了落点。更现实的原因是真正的“最简单代码”往往被过度封装了。你用 sklearn 的 LinearRegression 三行就能出结果但那三行背后是 LAPACK 里几十年的数值计算积累根本不是“最简单”而是“最复杂”。我理解的“最简单”是指代码行数少、依赖少、逻辑直白让读者能一眼看穿所有步骤——这类实现用 numpy 手写是首选。1.3 这篇笔记的约定手推优先调包不优先我给自己定了个规矩不理解数学就先不碰封装库。学习笔记的阶段目标是把 numpy 手写代码吃透达到“丢一行代码就能发现哪里不对”的程度。如果你已经是老手大可直接用 sklearn完全没问题但对还在学习阶段的人我还是建议走一遍手写路线。这篇笔记里的所有代码均以 numpy 为主不引入任何机器学习框架数据量也是玩具级别的目的是把原理放大给你看。2. 最小二乘法的代码灵魂矩阵运算为什么能一行搞定2.1 从损失函数到正规方程代码里只留了最后一步线性回归最经典的参数求解方式是最小二乘法。它的思路是我们要找一组参数 θ让预测值 y_hat 和真实值 y 的平方误差和最小。写成函数就是损失函数 J(θ) (1/m) * Σ(y_i - x_i·θ)²其中 m 是样本数。这个损失函数关于 θ 是开口向上的二次函数既然是二次函数那就必然存在一个全局最小值点。求这个最小值点不需要迭代可以直接令导数等于零解出来的闭式解就是正规方程θ (XᵀX)⁻¹Xᵀy我第一次看到这个公式的时候觉得很难记但把它拆成几步就好理解了XᵀX 得到的是特征之间的协方差结构矩阵对这个矩阵求逆相当于做了一个“解相关”的归一化Xᵀy 度量的是每个特征与输出之间的相关性两者一乘就得到了每个特征对输出贡献的最优权重如果这个过程一开始没转过来可以先跳过数学只要记住代码里这一行是精确解不是逼近解。这对于后续判断梯度下降的收敛是否到位是一个非常重要的金标准。2.2 用 numpy 复现正规方程闭式解的完整代码下面是我自己维护的一个最小实现每次需要验证算法正确性的时候我都会回头跑一下这份代码import numpy as np # 构造一组带噪声的线性数据 np.random.seed(42) X np.random.rand(100, 1) * 10 # 特征0~10 之间的 100 个点 true_w 2.5 # 真实斜率 true_b 1.2 # 真实截距 y true_w * X.squeeze() true_b np.random.randn(100) * 1.5 # 关键步骤给特征矩阵加一列全 1用来表示偏置 b X_with_bias np.c_[np.ones((X.shape[0], 1)), X] # 正规方程θ (XᵀX)⁻¹Xᵀy theta np.linalg.inv(X_with_bias.T X_with_bias) X_with_bias.T y print(拟合斜率:, theta[1]) print(拟合截距:, theta[0])输出大概会是这样因为噪声是随机生成的每次可能略有浮动拟合斜率: 2.63 拟合截距: 0.97当初构造数据用的是真实斜率 2.5、截距 1.2拟合结果误差都在噪声合理范围之内。再解释一下np.c_[]这个操作它是把两列数组按列拼在一起左边一列全是 1右边一列是原始特征。这样做的目的是把原来 y wx b 的不齐次方程变成 y w·x b·1 的齐次形式统一到矩阵乘法里。从代码角度看此时特征矩阵从 (100,1) 变成了 (100,2)θ 也从两个标量变成了一个长度为 2 的向量。2.3 为什么特征矩阵要手动加一列 1这个坑我栽过一次加一列 1 这件事我刚学的时候真的跳过。当时想当然地认为既然 numpy 用 算矩阵乘法那么 X.T X 直接算就行偏置项单独用标量处理。结果就是拟合出来的曲线总是不过 y 轴的正确位置因为我把 b 忘了参与整体优化。后来才意识到如果不加这一列 1模型被迫过原点也就是 b0线性回归的表达能力直接砍掉一半。正确的做法是让偏置作为一项“特征”它的取值恒定不变为 1权重就是截距。这样 θ 里的第一个分量就是 b第二个分量才是 w。如果你后续用 sklearn 对照结果它的intercept_对应我这里的theta[0]coef_对应theta[1:]顺序是一致的。还有一个数值层面的细节XᵀX求逆在特征维度低时非常快但一旦特征维度过高或者特征之间存在强共线性矩阵就会接近奇异数值上逆矩阵会变得极不稳定。这也是为什么在真正的复杂场景里大家更愿意用梯度下降而不是死磕闭式解——不是闭式解不对而是数值不稳定和计算量爆炸。我见过有人在 10000 维特征上硬算正规方程机器直接内存溢出。这不是代码写错是选错了工具。3. 梯度下降版本把“学习率”调明白才算真正学会3.1 从教科书公式到手写实现的完整映射正规方程是一步到位的精确解但它没让我真正理解回归的“学习”过程。理解参数如何一轮一轮被调整还是得靠梯度下降。梯度下降在回归里的逻辑用大白话说就是往损失函数的山坡下走每次走一小步方向是当前点的负梯度方向步长由学习率控制。在普通的线性回归里梯度的数学形式恰好有极为简洁的闭式表达预测值 y_hat X θ 误差向量 error y_hat - y 梯度 grad (1/m) * X.T error 参数更新 θ θ - learning_rate * grad全部堆到循环里就是最精简的梯度下降版回归代码。注意这里的梯度公式里没有“损失对参数求导”的复杂链条它被矩阵运算压缩成了三个表达式这也是线性回归作为入门模型最友好的地方。下面是我手写时用的模板import numpy as np # 继续沿用上面的数据 X_with_bias np.c_[np.ones((X.shape[0], 1)), X] m len(X) # 初始化参数 theta np.zeros(2) # [b, w] 初始化为 0 learning_rate 0.01 n_iterations 1000 loss_history [] for i in range(n_iterations): y_hat X_with_bias theta error y_hat - y grad (1 / m) * (X_with_bias.T error) theta theta - learning_rate * grad loss np.mean(error ** 2) # 均方误差 loss_history.append(loss) print(梯度下降拟合斜率:, theta[1]) print(梯度下降拟合截距:, theta[0])这段代码每次循环里做了四件事算预测、算误差、算梯度、更新参数。循环终止方式用的是固定轮数这适合入门更严谨的做法是同时监控损失变化当两次迭代之间损失变化小于阈值时提前停止后面的章节会提到。3.2 收敛判断和损失曲线怎么用很多初学者只知道跑完循环输出参数但从没看过损失曲线。这个习惯非常不好。损失曲线是第一手诊断工具——它直接告诉你训练过程是否健康。我通常会这样使用它import matplotlib.pyplot as plt plt.figure(figsize(8, 4)) plt.plot(loss_history[:200]) plt.xlabel(iteration) plt.ylabel(MSE loss) plt.title(Loss Curve of Gradient Descent) plt.show()观察要点很简单曲线平滑下降并最终稳定一切正常曲线剧烈震荡学习率偏大参数来回跨越最低点曲线下降极慢学习率偏小或者特征没有做标准化曲线先降后升往往意味着学习率偏大且已经到了发散边缘我在实验里故意把学习率调到 0.5结果损失在 30 轮内冲到了 10 的几十次方量级直接溢出成 inf。那一刻我才真正体会到为什么大家都在强调学习率是梯度下降最敏感的超参数。3.3 学习率调参踩坑记录一个曾经让我熬夜的 Bug说一个我印象特别深的经历。有一版代码我加了特征标准化标准化后所有特征均值归零、方差归 1这时候学习率用 0.01 跑得非常稳。后来我偷懒换了另外一组没有标准化的原始数据忘了改学习率结果损失曲线一路爬升。当时我盯着代码看了快两个小时从梯度公式看到矩阵形状都没发现问题。最后打印出梯度范数才发现梯度数值已经巨大参数更新的步长直接飞出去了——不是代码逻辑变错了而是数据尺度变了学习率不匹配了。这个坑的教训我总结成三条换数据之后务必从头确认特征尺度如果特征值范围差异很大优先进行标准化而不是硬调学习率损失出现 inf 或 nan 时先检查学习率再检查数据里是否存在缺失值标准化放前面还是放后面顺便说一下拆训练集和测试集之后再标准化。先在全数据集上算出均值和标准差、再拆分会造成信息泄露后面第4章细讲。4. 数据进出代码前后的隐藏关键标准化、拆分与评价指标4.1 特征标准化一场被大多数人低估的操作如果你的代码只是拿玩具数据跑着玩标准化似乎可有可无。但真实数据完全不是这个节奏。比如某个特征是人年龄20~60另一个特征是收入3万~200万两者量纲差出万倍。此时梯度下降的更新会被大尺度特征主导表现为损失表面是一个极狭长的椭圆梯度方向在谷底两侧来回震荡收敛速度奇慢。标准化并非线性回归独有的操作但它对梯度下降版本的影响是决定性的。最常用的方法是 z-score 标准化mean X.mean(axis0) std X.std(axis0) X_scaled (X - mean) / std得到的新特征均值为 0标准差为 1。好处有两个一是损失函数的等高线变得更接近圆形梯度下降走捷径二是学习率的可用范围大大增加0.01 这个默认值在大多数场景下都能跑。需要注意的是训练模型时记录的 mean 和 std 要保存下来推理时对新的数据用同样的 mean 和 std 做变换而不是重新计算。很多初学者会在这个细节上犯错——训练时用了标准化预测时却忘了对输入做同样的预处理结果预测结果完全不在量级上。这是很典型的“训练-推理预处理不一致”问题。4.2 训练集测试集拆分与随机种子别让“模型作弊”了数据拆分的意义不用我强调。但实际操作里有几个细节新手特别容易踩第一拆分的时机第二随机种子第三是否打乱数据。正确顺序是先打乱数据再拆分再做标准化。打乱是为了避免数据里存在的天然顺序影响训练分布。随机种子固定下来是为了保证实验可复现——不然你每次跑结果都不一样根本没法判定是改进了还是随机波动。# 打乱与拆分的固定流程 idx np.random.permutation(len(X_scaled)) split int(len(X_scaled) * 0.8) train_idx, test_idx idx[:split], idx[split:] X_train, X_test X_scaled[train_idx], X_scaled[test_idx] y_train, y_test y[train_idx], y[test_idx]拆分比例我一般默认 8:2 或 7:3。数据量小的时候可以多留一点训练数据数据量大则测试集足够大才有统计意义。需要注意的是训练集测试集拆分之后标准化所需 mean 和 std 只能从训练集计算测试集使用训练集的 mean、std 来变换。这样才能模拟出“面对全新数据”的真实场景。4.3 评价指标 R² 和 MSE 怎么读先看爆炸方向再看数值模型训练完不能只盯着损失函数。损失函数是训练过程的指标评价模型最终效果要用另外一套指标。最常用的两个是均方误差MSE和决定系数R²。# 测试集上的预测与评价 y_pred X_test theta mse np.mean((y_pred - y_test) ** 2) ss_res np.sum((y_test - y_pred) ** 2) ss_tot np.sum((y_test - y_test.mean()) ** 2) r2 1 - ss_res / ss_tot print(MSE:, mse) print(R²:, r2)R² 的含义是“模型解释掉的方差占总体方差的比例”。R²1 说明完美预测R²0 说明模型预测效果等同于直接用均值预测R²为负则说明你的模型比“摆烂”更差——到了这个程度通常意味着预处理或者模型本身出了大问题。我第一次跑出手写回归 R² 是负数的时候还很困惑后来发现是训练集测试集标准化不一致导致。MSE 的问题在于它的量纲是 y 的平方不好直观判断。所以实际使用中我更习惯看 RMSE根号 MSE它和 y 保持在同一个量纲便于评估误差典型大小。而 R² 是相对指标不受量纲影响在跨数据集对比时更有参考价值。5. 最容易跑偏的五个细节我的排查笔记5.1 矩阵维度不匹配几乎所有 numpy 报错的根源手写回归代码时最常见的报错就是ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0。这个报错看着吓人本质上就是一个问题两个矩阵做乘法的内维不一致。出现这种问题不要瞎猜直接在报错前加一行打印X.shape、theta.shape、y.shape一目了然。我在笔记里给自己的规则是任何涉及矩阵运算的代码写完之后先打印一遍所有参与矩阵的 shape再跑训练循环。一个额外的提示如果X_with_bias是 (m, n1)那么theta必须是 (n1,)两者做乘法结果是 (m,)。如果 theta 初始化成了 (n1, 1) 这种二维矩阵计算结果就会变成 (m, 1)后面再和 y形状 (m,)做运算就会出问题。这个“多一个尾巴维度”的问题极易被忽略。5.2 数据泄露手指一滑测试集就脏了数据泄露这个词听起来很高级但实践中往往就是多做了一个极其简单的操作。比如在拆分训练集测试集之前就先用整份数据计算了标准化参数。标准化用了测试集的均值和标准差模型在训练阶段就已经“见过”测试集的信息测试集就失去了模拟未来的意义。这种错误在代码上非常隐蔽因为它不会报错也不会让损失变大——恰恰相反它会让测试表现异常地好好到你以为自己写出了天才模型。等部署上线面对真正的新数据效果立刻现出原形。所以我现在写学习代码的习惯是先把拆分写进代码的前三行再考虑标准化。5.3 返回值类型心里想着标量实际拿到向量在矩阵运算环境里y 的 shape 有时候是 (m,)有时候是 (m,1)。如果你用np.random.randn(m, 1)生成 y那 y 就是列向量计算 MSE 的时候(y_pred - y_test)的广播行为会跟标量场景不一样得到的可能是一个矩阵而不是一个数。我身边的同学不止一次在这里翻车他们打印loss发现是一个向量就以为梯度下降出了 bug其实只是 y 的维度多了一维。解决方式很简单统一在数据生成阶段就把 y 压成一维数组y.reshape(-1)或者全程坚持带列向量的语义一个脚本里不要混用。5.4 数值稳定性闭式解为什么也不是万能的正规方程公式看着优雅但np.linalg.inv在矩阵接近奇异时会给出完全离谱的结果。特征高度相关、或者特征数量超过样本数量都会导致 XᵀX 不可逆或病态。此时正规方程的解在数值上极不稳定。一个更稳妥的替代做法是使用np.linalg.pinv伪逆或者np.linalg.solve(A, b)而不是inv(A) b。solve走的是 LU 分解比显式求逆更快也更稳。示例如下# 不推荐 theta np.linalg.inv(X.T X) X.T y # 推荐 theta np.linalg.pinv(X) y # 或者 theta np.linalg.solve(X.T X, X.T y)这里面的差别不明显但当数据规模变大或共线性变强之后差距会非常致命。我建议手写学习阶段就养成用solve的肌肉记忆。5.5 过拟合玩具数据看不见真实数据满地爬最简单的线性回归代码在玩具数据上几乎不会触发过拟合——因为特征数量通常只有一两个。但只要你把代码往多特征数据上一搬情况就完全变了。特征越多模型在训练集上表现得越夸张测试集上却一塌糊涂。手写回归版本里没有正则化项所以对过拟合几乎是裸奔的。学习阶段可以先把X的特征数固定在个位数等真正需要处理高维问题时再用岭回归或 Lasso 这类带约束的变体。这个认知我在系列第1篇里提到过这里再提醒一次最简单版回归代码的价值在于理解原理不在于解决生产问题。5.6 用 sklearn 对照验证手写代码跑得对不对一测便知手写模型最大的隐忧是“跑步跑得对”。有一个非常有效的自查手段用 sklearn 的LinearRegression跑同一份数据把两边的系数和截距直接对比。手写值和 sklearn 值在数值上几乎一致在可以接受的数值误差范围内就说明你的手写逻辑完全正确。下面是典型的对照脚本from sklearn.linear_model import LinearRegression model LinearRegression().fit(X_train, y_train) print(sklearn 截距:, model.intercept_) print(sklearn 系数:, model.coef_)如果手写值和 sklearn 值相差巨大那几乎可以肯定是数据预处理步骤存在不一致而不是梯度下降公式有问题。这个对照思路可以应用到后续所有的模型实现中先用成熟库跑出标准答案再逐步换成手写实现并保持输出不变。这既是对自己代码的验证也是熟悉成熟库行为的好机会。回到代码本身一份练习了无数遍的最小实现不知不觉这篇笔记又写长了。最后再把整份最简实现汇总一下方便你直接复制运行然后在这个基础上自己做实验import numpy as np # 1. 生成带噪声的数据 np.random.seed(42) X np.random.rand(100, 1) * 10 y 2.5 * X.squeeze() 1.2 np.random.randn(100) * 1.5 # 2. 拼接一列 1 用于拟合截距 X_b np.c_[np.ones((len(X), 1)), X] # 3. 拆分训练集和测试集 idx np.random.permutation(len(X_b)) train_idx, test_idx idx[:80], idx[80:] X_train, X_test X_b[train_idx], X_b[test_idx] y_train, y_test y[train_idx], y[test_idx] # 4. 手写梯度下降 theta np.zeros(X_train.shape[1]) learning_rate 0.01 for _ in range(1000): y_hat X_train theta error y_hat - y_train grad (1 / len(y_train)) * (X_train.T error) theta - learning_rate * grad # 5. 在测试集上评估 y_pred X_test theta mse np.mean((y_pred - y_test) ** 2) print(theta:, theta) print(MSE:, mse)我自己在练习这个版本的代码时最喜欢做三组实验第一组把学习率从 0.001 换到 0.1观察损失曲线的变化第二组把初始参数从零向量换成随机向量观察最终收敛位置的稳定性第三组把训练集测试集拆分比例改成 5:5观察评价指标随数据量减少如何波动。这三组实验做完你对线性回归代码的控制感会明显提升一个台阶。这篇笔记写到这里基本把我对“最简单回归代码”的理解框架都倒出来了。它看起来只有十几行但每一行背后都有值得展开的潜台词。系列后面的几篇我打算继续沿着这个思路把正则化项加进最小实现、把多特征场景下的可视化诊断补齐再从线性回归走向逻辑回归。到时候这些笔记大概会更加零散但每一篇都会保持这个定位用最少的代码讲清楚核心机制到底发生了什么。