☰
线性回归三种解法对比:OLS、批量梯度下降与SGD的实战差异
2026/10/18 17:46:16 网站建设 项目流程

简介:一份基于红酒数据集完成线性回归建模的完整Python实战资源,非常适合正在学习机器学习基础、想掌握回归分析与数据建模流程的开发者。资源围绕“特征(化学成分)对酒质量评分的影响”这一真实案例展开,完整覆盖数据加载、缺失值处理、可视化探索、特征选择、模型训练、R²评分与残差检验等环节。资源压缩包共6个文件,大小仅89KB,包含3个Python脚本(分别为普通最小二乘、批量梯度下降和随机梯度下降三种实现)、2个CSV数据文件(红、白葡萄酒质量数据)以及1个交换文件,脚本可直接运行对照学习。已有1715人学习使用该资源。借助这套资源,读者既能清晰理解线性回归的数学原理与参数含义,又能获得一套可复用的代码模板,帮助自己独立完成从数据清洗到模型预测的完整分析任务,也可将同样的流程快速迁移到其他回归类数据集上。

1. 三种解法一条回归线:同一份红酒数据集,为什么三种实现给出的系数不一样

同一份红酒数据集,跑三个手写的线性回归脚本,系数在小数点后第三位就开始分家:OLS 给的是一个闭式解,GD 给的是一个迭代逼近,SGD 给的甚至每次运行都不完全一致。同一个算法家族,为什么答案会漂移?这份资源把线性回归拆成了三条路径——解析解的linear_regression_ols.py、批量梯度的linear_regression_gd.py、随机梯度的linear_regression_sgd.py——刚好把「什么叫解、什么叫逼近、什么叫估计」三个层次摆在了一起。搞懂这三者,不只是看懂一个酒质量预测模型,而是把线性回归背后的矩阵求解、梯度地形、采样噪声一次性看穿。

2. 解析解 OLS:正规方程为什么能一步到位,又为什么会悄悄翻车

2.1 最小二乘的本质:求系数其实是找残差的垂足

线性回归的目标是找一组权重 w,让预测值 Xw 与真实值 y 的误差平方和最小。写成损失函数就是:

J(w) = ||y - Xw||²

从几何上看,X 的每一列是一个特征向量,所有特征向量张成一个线性子空间,Xw 是这个子空间里的一个点。我们要找的点,是子空间里离 y 最近的那个点——也就是 y 在该子空间上的正交投影。投影点有一个天然性质:残差向量 y - Xw 与子空间垂直,也就是与 X 的每一列都正交。写成矩阵形式:

Xᵀ(y - Xw) = 0

这就是正规方程。把它展开,得到 XᵀXw = Xᵀy,只要 XᵀX 可逆,一步就能解出 w。这就是 OLS 为什么「不做迭代」:它不是去逼近最优解,而是直接把最优解应该满足的代数条件写出来再求值。

这个几何视角不是装饰。它解释了为什么 OLS 对异常值的反应那么敏感——平方损失把残差放大,而投影点会被远离主体的小部分样本整体拽偏。后面第 5 章讲红酒数据清洗时,这个性质会直接变成翻车现场。

2.2 正规方程的数值软肋:共线性与条件数

正规方程看着漂亮,但inv(XᵀX)是个隐患。XᵀX 的奇异值等于 X 奇异值的平方,求逆时奇异值会取倒数,等于把原本就小的奇异值二次方地放大。当两个特征高度相关,X 近似奇异,XᵀX 的条件数会大到离谱,微小的浮点舍入误差都会被放大成系数上的明显偏差。

winequality 数据集里有没有这个问题?看列名:密度(density)、酒精(alcohol)、残糖(residual sugar)之间确实存在相关性,但 1599 行、12 列的数据规模下,正规方程条件数一般可控,直接求逆结果依然可信。真正危险的是特征数接近样本数,或者人为加进一个与其他列线性相关的哑变量。那种情况下np.linalg.inv会给你一个「能算出来但不靠谱」的答案,而不是直接报错。

提示:工程上判断 XᵀX 是否病态,不靠肉眼,靠np.linalg.cond(X)输出条件数。条件数超过 1e12 时,闭式解的每一位有效数字都可能被污染。

更稳的替代方案是用 SVD 直接求伪逆:np.linalg.pinv(X)或np.linalg.lstsq(X, y)。SVD 把奇异值截断到安全阈值,即便 X 严格奇异也能给出最小范数解。正规方程适合教学和理解,真到了 p 接近 n 的场景,优先选 SVD 路线。

2.3 资源里的 OLS 脚本对应着哪一段逻辑

这类手写脚本的常见实现思路是:先把特征矩阵增广一列 1 来吸收截距项,然后直接套正规方程。核心代码通常长这样:

import numpy as np def ols_fit(X, y): # 增广一列 1,把截距 b 当作普通权重 w0 Xb = np.c_[np.ones((X.shape[0], 1)), X] # 正规方程:w = (X^T X)^{-1} X^T y XtX = Xb.T @ Xb w = np.linalg.inv(XtX) @ Xb.T @ y return w

这段代码里有两处值得停下来看。第一,np.c_拼接的作用是把截距从「特殊参数」降级为「普通权重」,这样求解器只需要处理一个矩阵方程,不用单独给截距开小灶。第二,np.linalg.inv(XtX) @ Xb.T @ y是正规方程的教科书写法,但它默认了 XtX 满秩——如果数据里有重复或线性相关的列,这里直接埋雷。

对应到红酒数据集,X 是 1599×11 的化学特征矩阵(固定酸度、挥发酸度、柠檬酸、残糖、氯化物、游离二氧化硫、总二氧化硫、密度、pH、硫酸盐、酒精),y 是 1599 维的质量评分(3 到 8 的整数)。OLS 在这个规模上毫秒级出结果,权重可以直接解释:每个系数代表该特征每增加一个单位,质量评分平均变化多少。这正是摘要里「参数解读」那一步的底层来源——解析解给出了一个标准化、无随机性的答案,后面 GD 和 SGD 都要向它看齐。

3. 批量梯度下降:为什么朝着负梯度走就能走到同一个山头

3.1 损失函数的地形:凸二次型只有一个谷底

梯度下降的思路是完全另一条:不求方程,而是从某个初始点出发,每步朝损失下降最快的方向挪一点。损失 J(w) = ||Xw - y||² 展开后是 w 的二次型,二次型的等高线是椭圆,且因为 XᵀX 半正定,J(w) 是凸函数——没有局部极小值,唯一的谷底就是正规方程的解。

所以梯度下降和 OLS 在数学上求解的是同一个 w,只是路线不同。梯度表达式为:

∇J(w) = (2/m) Xᵀ(Xw - y)

除以样本数 m 是为了让梯度不随数据量膨胀。每一步迭代 w ← w - α∇J(w),其中 α 是学习率。α 太小,走得像蜗牛;α 太大,可能越过谷底跑到对面山坡上震荡,典型表现是损失曲线先下降然后反弹,甚至直接溢出成 NaN。

红酒数据集上,GD 收敛到的地方和 OLS 几乎一样,但有个前提:特征尺度要一致。密度列的量级是 0.98~1.00,二氧化硫列的量级是几十到几百,量级差几百倍。此时损失椭圆的离心率极大,梯度方向严重偏向大尺度特征,下降路线会在山谷两侧来回弹跳。这也是 5.1 节要专门处理标准化的原因。

3.2 学习率不是玄学:它对应着特征矩阵的最大特征值

收敛性分析告诉我们,GD 的稳定步长上限是 2/L,其中 L 是 XᵀX 的最大特征值(或最大奇异值的平方)。超过这个上限,迭代发散;接近这个上限,会震荡;取值的 1/10 到 1/100,才走得稳。

这个结论听着理论,实则是排错清单。跑linear_regression_gd.py时,如果损失曲线一路飙升,第一反应不是「数据有问题」,而是「α 大于 2/L」。把 α 从 0.01 降到 0.001 往往立刻见效。如果想精确预测临界值,可以在跑回归前先算一步np.linalg.eigvalsh(Xb.T @ Xb).max(),用这个值倒推 α 的安全区间。

这也是 GD 对比 OLS 的代价:OLS 一步到位但要做 n×n 矩阵求逆,复杂度 O(n³);GD 每步只做矩阵乘法,复杂度 O(mn),但需要迭代很多步。当 n 上百万而 m 适中时,GD 反而更快,因为它根本不需要构造 XᵀX。

3.3 手写 GD 的收敛判断:损失曲线和权重变化缺一不可

红酒数据集的 GD 脚本通常会记录每一轮的损失值,常见的实现框架是这样:

def gd_fit(X, y, alpha=0.01, epochs=300, tol=1e-6): Xb = np.c_[np.ones((X.shape[0], 1)), X] w = np.zeros(Xb.shape[1]) losses = [] for i in range(epochs): grad = Xb.T @ (Xb @ w - y) / y.size # 批量梯度:全部样本参与 w -= alpha * grad loss = np.mean((Xb @ w - y) ** 2) losses.append(loss) if len(losses) > 1 and abs(losses[-2] - losses[-1]) < tol: break return w, losses

判断收敛不能只看损失绝对值。损失从 100 掉到 0.5 和从 0.5 掉到 0.49 都是「下降」,但后者可能只是数字抖动。两个可靠信号:一是损失差值的绝对值连续多轮小于阈值;二是权重的 L2 范数变化趋于零。前者反映整体拟合稳定,后者反映参数本身不再漂移。

批量梯度还有个容易被忽略的细节:它每一轮都要把所有 1599 个样本算一遍。epochs=300 意味着 300 次全量遍历,每轮做一次 1599×12 的矩阵乘法,这在红酒数据上无所谓,但放到百万级样本上就会慢得让人怀疑人生。这正是下一章 SGD 登场的理由——它把每步成本从 O(mn) 砍到 O(n),只用一个样本来估计梯度。

4. 随机梯度下降:一个样本的梯度为什么也算数

4.1 单样本梯度的期望等于全量梯度

SGD 的核心反直觉点在于:用一个个样本的梯度代替全量梯度,方向不准,但长期期望是对的。随机抽一个样本 i,它的梯度是:

(xi·w - yi) · xi

对 i 求期望:E[(xi·w - yi)·xi] = (1/m) Xᵀ(Xw - y) = ∇J(w)

也就是说,单样本梯度的期望恰好等于批量梯度。严谨地说,批量梯度是单样本梯度的均值,均值的期望等于真值,所以抽样梯度是无偏估计。方向上有噪声,但噪声围绕真值波动,不会系统性跑偏。这就是随机近似的核心思想。

代价是每一步都不精确。用红酒数据来感受这个差异:批量 GD 每步是 1599 个样本的平均「民意」,方向稳定但有延迟;SGD 每步只问一个样本,方向可能完全跑偏,但每步便宜 1599 倍。同样是 300 个 epoch,GD 做了 300 次全量更新,SGD 做了 300×1599 次单样本更新——后者实际更新的步数多得多,震荡着也就晃到谷底附近了。

4.2 学习率衰减与洗牌:为什么 SGD 必须配这两个动作

如果 SGD 用固定学习率,长期来看权重会在最优解附近做随机游走,无法真正停住。靠近谷底时,梯度变小,但随机噪声不缩小,权重就在一个球状邻域里抖动。解决办法是让 α 随迭代递减:

α_t = α_0 / (1 + decay · t)

或更常用的 α_t = α_0 / √t。前者的衰减是线性的,适合样本量可控的离线训练;后者衰减更慢,适合长期流式训练。红酒数据集脚本里一般会选第一种,epoch 数固定,decay 取 0.01 上下。

洗牌则是为了打破原始数据顺序里的周期性。winequality-red.csv 的行并不是随机排列的,不同批次可能在连续行里聚集。如果按原始顺序遍历,相邻样本高度相似,SGD 会被局部模式牵着走。np.random.permutation每次 epoch 前打乱行序,保证抽样近似独立同分布。

提示:洗牌和衰减都是 SGD 的「必需品」而不是「优化项」。少任何一个,你都会看到收敛曲线尾段拖着一条长尾巴,或者权重在最后几十轮反复横跳。

4.3 收敛噪声是特性不是缺陷:SGD 的最后一个隐藏优点

红酒数据上的 SGD 脚本通常会这样组织:

def sgd_fit(X, y, alpha=0.01, epochs=20, decay=0.01): Xb = np.c_[np.ones((X.shape[0], 1)), X] w = np.zeros(Xb.shape[1]) m = y.size for epoch in range(epochs): order = np.random.permutation(m) # 每个 epoch 先洗牌 for i in order: xi, yi = Xb[i], y[i] grad = (xi @ w - yi) * xi # 单样本梯度,期望等于全量梯度 lr = alpha / (1 + decay * epoch) w -= lr * grad return w

注意学习率的更新挂在 epoch 级别而不是 step 级别,这保证了同一个 epoch 内步长一致、整体回退,避免半路突然减速导致序列不均匀。

最后那个「噪声」其实有正面价值:批量梯度一旦被离群样本带偏方向,恢复起来很慢;SGD 本身的随机性反而给了它摆脱局部误导的能力。这在大规模数据集上尤其重要——从一个坏的初始点出发,GD 可能沿着椭圆的长轴慢慢磨,SGD 因为噪声天然带一点探索性,反而更容易钻回谷底。当然,红酒数据只有 1599 行,SGD 的震荡更多是视觉效果,真正的好处要到几十万行数据上才体现出来。

到这里三条路径就齐了:OLS 给精确值,GD 给稳定逼近,SGD 给廉价且带探索性的逼近。三者的差异会在同一份红酒数据上直接显形——接下来看实战里最常见的坑。

5. 红酒数据实战排查:标准化、离群值、R² 的真实含义

5.1 特征尺度不一致:为什么同一份数据 GD 发散而 OLS 没事

现象:把 winequality-red.csv 直接喂给linear_regression_gd.py,α 取 0.01,跑几百轮,损失曲线像过山车,甚至直接变 NaN。换成 OLS 脚本,结果正常。

原因:特征列量纲悬殊。密度集中在 0.99 附近,数值变化在小数点后第三位;总二氧化硫从几十到几百;酒精含量从 8 到 14。梯度计算里 Xᵀ 与残差相乘时,大数值特征会支配梯度方向,导致小数值特征对应的权重更新几乎停滞,同时大数值特征在 α 稍大时直接爆炸。

解决:训练前对特征做标准化,(x - mean) / std,让每列都落在零均值、单位方差附近。标准化之后,α=0.01 这类常规取值就能安全收敛。OLS 不需要标准化的原因在于闭式解对每个系数单独求解,线性变换只会让系数本身重新缩放,不改变拟合优度。

5.2 离群值:平方损失被极端值绑架

现象:把训练数据里的若干极端样本删掉,重跑 OLS,系数变化明显;不删,模型预测值被拉向某个怪异的方向。

原因:MSE 对残差取平方,残差 3 的样本贡献 9,残差 10 的样本贡献 100。一个极端样本的损失可以抵几十个正常样本,它的梯度在 GD 和 SGD 中同样被放大。winequality 数据的质量评分集中在 5~6,但某些化学指标列右尾很长,个别样本的残差会显著大。

解决:训练前画残差图,按残差绝对值降序看前几个样本的真实值和预测值,判断是录入错误还是真实的极端组合。录入错误可以剔除;真实极端值则考虑 Huber 损失这类对离群鲁棒的目标函数。线性回归脚本里没有 Huber,但替换损失函数只改三行代码。

5.3 R² 只有 0.3:模型失败了吗

现象:OLS 的 R² 算出来只有 0.3 出头,新手第一反应是模型废了。

原因:红酒质量评分是 3~8 的离散整数,而且来自专家主观打分;化学指标只能解释其中一部分方差,剩下的既有人工评分的噪声,也有非线性交互效应。R² = 0.3 的意思是「模型解释了 30% 的方差」,这在这个数据集上其实是一个正常水平,不代表回归无效。

解决:把 R² 跟基线对比。基线是「全部预测为均值」,R² = 0。模型到 0.3 说明化学特征确实携带了质量信息。再配合看每个特征的系数方向和显著性:挥发酸度为负、酒精为正,这两个方向的化学常识是吻合的。真正要警惕的不是 R² 低,而是 R² 很高但残差图有明显的非线性形态——那才说明模型形式错了。

5.4 三个高频翻车现场

  • 现象:三个脚本的输出系数在小数点后对不上。原因:OLS 是解析解的精确值,GD 在达到容差阈值时提前停机,SGD 收敛到的是带噪声的随机邻域。解决:以 OLS 结果为基准,检查 GD 的损失曲线是否平坦、SGD 的尾部是否还在抖动,而不是怀疑代码写错。
  • 现象:模型预测出 5.43 分这种小数,而真实质量评分全是整数。原因:线性回归输出的是条件均值,它不会知道目标变量其实是离散序数。解决:如果要交「分数预测」,把预测值四舍五入并说明这是近似;更严谨的做法是换成有序逻辑回归,但那不是这份资源讨论的范围。
  • 现象:解压后看到.winequality-red.csv.swp这类文件,误当成数据读入。原因:这是文本编辑器在编辑 CSV 时留下的交换文件,不是数据本身。解决:加载数据时按.csv后缀精确匹配,或删除以.swp结尾的残留文件。

6. 三次手写教会我的取舍:什么时候别用正规方程,什么时候必须用正则

三种实现都跑通后,对比着看会有一个更清楚的取舍框架:

方法单步成本内存收敛性质适合场景
OLS 解析解O(mn²) 构造 + O(n³) 求逆O(mn)一步到位特征数小、样本量适中的离线分析
批量 GDO(mn)O(mn)确定收敛,需调 α中等规模、可全量内存加载
SGDO(n)O(n)(流式更低)随机收敛,带噪声大规模数据、在线学习

OLS 的优雅在它「一步到位」,但这一步的复杂度被矩阵求逆卡死;SGD 每一步都像在猜方向,但累计成本低得多,而且天然适配数据不断流入的场景。红酒数据太小,三种方法都秒级完事,真正的差别要到数据规模放大几个数量级才显形。

真正让我改变习惯的是从 OLS 到岭回归的一行改动:在正规方程里加一个 λI。

w = (XᵀX + λI)⁻¹Xᵀy

这行改动的设计动机是:当 XᵀX 接近奇异时,给它的对角加一个「地板」,让求逆不发散。副作用是系数整体向零收缩,方差变小、偏差变大——这正是偏差-方差权衡的直接体现。理解了正规方程,就能理解为什么岭回归叫「正则化最小二乘」:它不是在 OLS 上打补丁,而是换了求逆的路径。

从那以后,我每次拿到新数据集都强制走一遍:先标准化,再画残差,最后才谈 R² 和系数方向。这套流程在红酒数据上救过我三次,在别的项目上也救过我——三份脚本的价值不在脚本本身,而在于把「为什么这套流程是必要的」全部暴露在了明处。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询