线性回归优化算法对比:梯度下降与正态方程的原理、Java实现与陷阱
简介:这是一份面向机器学习初学者和 Java 开发者的线性回归实现示例,聚焦梯度下降与正态方程两种求解方式。项目用小型 Java 程序从一组数据点中学习最佳拟合直线,例如根据 y = Mx + B 的输入数据自动找到 M 与 B 的近似值,并支持选择高阶多项式特征来拟合非线性函数,可作为理解监督学习基础原理的入门代码。压缩包共 13 个文件,体积约 44KB,核心为 3 个 Java 源文件,另有 5 个 txt 格式的训练数据样本(含来自 Andrew Ng 课程的示例数据)、配置文件与说明文档,以及 JAMA 矩阵运算库的 jar 包,便于直接运行和学习。目前已有 252 人学习浏览。通过阅读源码和切换不同训练数据,可以对比梯度下降迭代求解与正态方程直接求解的差异,同时了解如何在 Java 项目中引入第三方矩阵库,并观察特征选择对回归结果的影响,适合教学演示、课程作业或新手自学。
1. 线性回归的同一个解:梯度下降与正态方程为什么殊途同归
很多刚接触机器学习的开发者都有同一个困惑:线性回归的最小二乘问题,梯度下降要迭代几千轮才给出一个近似解,正态方程却像变魔术一样一步解出来。更玄的是,这两条路线算出的参数几乎完全一致。想穿透这层表象,必须落回误差函数的形状本身——线性回归的成本函数是凸的,只有一个全局最低点,所以无论用负梯度路径逼近,还是用解析法一步求解,终点都是同一个参数向量。这份用 Java 实现的 Linear-Regression 资源,刚好把两条路线同时摆在面前。这里从机制层面拆解它:梯度下降为什么能收敛、正态方程为什么一步到位,以及特征缩放、矩阵求逆这些工程细节里的坑在哪。
2. 梯度下降:步长与走向决定收敛还是发散
2.1 凸地形:为什么线性回归没有局部最优陷阱
把误差函数画成曲面,梯度下降的每一步都是在问:当前这个点往哪个方向走,下降最快。对这个曲面形状的判断,决定了你对梯度下降的全部信心来源。线性回归用的成本函数 J(θ) 通常写成:
J(θ) = (1/2m) Σ(h(x_i) - y_i)²
其中 h(x) = θ_0 x_0 + θ_1 x_1 + ... + θ_n x_n,并且通常约定 x_0 = 1,这样 θ_0 就是截距项。关键点在于:h(x) 对参数 θ 是线性关系,所以 J(θ) 展开之后是 θ 的二次函数。二次函数要么开口向上,要么开口向下,这里由于平方项前面没有负系数,必然开口向上,形状像一个碗。决定碗沿曲率的是海森矩阵 XᵀX,它天然是半正定的,这意味着误差曲面上既没有局部低谷,也没有马鞍点。
这个性质比很多深度学习任务幸运得多。神经网络里非凸的误差曲面到处都是局部最优和鞍点,训练对初始化极其敏感;而线性回归的成本面是严格的凸函数,从参数空间任何一个点出发,梯度下降都只会滚向同一个碗底。初值最多影响到达速度,不影响最终落在哪里。这也是为什么线性回归常常被拿来当机器学习的第一个入门模型——它把“优化”这层黑匣子打开后,里面的结构是干净的。
2.2 学习率与更新步长:走快了发散,走慢了磨蹭
梯度方向只给出角度,不给出距离。距离由学习率 α 决定,更新规则写成:
θ_j := θ_j - α · (1/m) Σ(h(x_i) - y_i) · x_j^(i)
逐项拆开看:如果某个特征上的预测误差 (h-y) 整体偏大,说明当前 θ_j 偏低,梯度为正,减去它就上调;反过来预测偏高,梯度为负,减去它反而下调。这个负号是整条更新规则的核心,它保证每次移动都在压低成本,而不是抬升成本。梯度是当前点的最速上升方向,取负号就是局部最速下降,但这个结论只在无穷小步长下严格成立。α 一旦不是无穷小,跨出一步之后地形已经变了,所以每一步本质上都是局部近似决策,这也是梯度下降这个名字没有“精确”两个字的原因。
α 太小,几百轮之后还在碗边磨蹭,计算资源全花在无效位移上;α 太大,一步跨过碗底、冲到对面坡上,来回震荡甚至越弹越高,代价曲线肉眼可见地往上飞。经典的血泪经验是:从 0.01 起步,按十倍缩放试探,直到看到 cost 平滑下降、没有锯齿就停手。如果用的是真实业务数据,特征尺度和噪声都不确定,这个调参过程几乎无法跳过,只有特征缩放能把它压到最低(见第 4 章)。
2.3 批量梯度下降的 Java 实现:同步更新是隐形的坑
public double[] gradientDescent(double[][] X, double[] y, double[] theta, double alpha, int maxIter) { int m = y.length; int n = theta.length; for (int iter = 0; iter < maxIter; iter++) { double[] grad = new double[n]; // 第一趟:基于当前 theta 计算所有特征的梯度分量 for (int j = 0; j < n; j++) { double sum = 0; for (int i = 0; i < m; i++) { double h = predict(X[i], theta); // 当前预测值 h(x^(i)) sum += (h - y[i]) * X[i][j]; // 对 theta_j 的偏导累加 } grad[j] = sum / m; // 平均梯度 } // 第二趟:梯度全部算完,再统一更新 theta for (int j = 0; j < n; j++) { theta[j] -= alpha * grad[j]; } } return theta; }这段实现里最容易被写坏的细节是同步更新。所有 θ_j 必须基于旧参数计算梯度,全部算完之后再一次应用更新。如果边算边改,比如先更新 θ_0,再拿被改过的 θ_0 去算 θ_1 的梯度,那么每一维使用的参数状态都不一样,方法不再是沿梯度方向的整体移动,而变成了坐标下降的变体。对凸问题它未必发散,但收敛路径会扭曲,代价曲线会出现不规则抖动,排查起来很迷惑。
另外注意 grad[j] = sum / m 使用的是平均梯度。成本函数里 1/m 的求和平均在求导后仍然留下 1/m。有些实现还会在成本函数里多除以一个 2,那是为了让推导过程中二次项的 2 被抵消,对最终最优解没有任何影响,只要成本函数和梯度公式的比例保持一致即可。真正重要的是 predict(X[i], theta) 默认 X 的第一列是常数 1。你在构造特征矩阵时忘了加这一列,模型就会退化成过原点的线性映射,截距项失去存在的意义,预测值整体偏离目标均值方向。
3. 正态方程:一步求解背后的正交投影与矩阵推导
3.1 从几何视角看最小二乘:残差为什么必须垂直于特征空间
梯度下降把求最优参数变成一个走路的游戏,正态方程则绕开了走路,直接算出碗底的位置。这个“一步到位”能成立,靠的是残差与特征空间的正交关系。
把特征矩阵 X 的每一列看成一个向量。预测值 y_hat = Xθ 永远落在 X 的列空间里,也就是由特征向量张成的子空间。真实值 y 通常不落在这个子空间里,所以残差向量 r = y - Xθ 一定存在。我们要最小化的是 ||r||²,即残差的平方长度。
几何直观给出答案:向量到子空间的最短距离出现在残差向量与子空间垂直的那一刻。一旦垂直,残差与 X 的每一列都正交,于是 Xᵀ(y - Xθ) = 0。把这个条件展开,就得到 XᵀXθ = Xᵀy。这个方程的名字 normal equation 里的 “normal” 指的就是“垂直、正交”。所以正态方程不是拍脑袋凑出来的公式,而是“残差垂直”这个几何条件经过矩阵转置之后的投影。想理解它为什么不用迭代,核心就在这一句话:梯度下降是在曲面上找下降路径,正态方程是直接解出垂直条件本身满足的线性系统。
3.2 代数推导:矩阵求导那一步到底在做什么
顺着代数来推,同样能到达同一方程。成本函数写成向量形式:
J(θ) = (1/2m) ||y - Xθ||² = (1/2m)(y - Xθ)ᵀ(y - Xθ)
对它求梯度,套用矩阵求导法则:
∇J(θ) = -(1/m)Xᵀ(y - Xθ)
令梯度等于零,得到 XᵀXθ = Xᵀy,再解 θ:
θ = (XᵀX)⁻¹Xᵀy
注意中间这一步的两次运算其实就是计算残差与所有特征的内积。梯度为零恰好对应“残差与特征张成的子空间正交”,与几何视角完全一致。这一招之所以能一步到位,是因为二次函数导数唯一、极小点唯一;换成任意非凸目标函数,令梯度为零只能得到驻点,不一定是全局最小值,甚至可能落在鞍点上。
3.3 Java 里别直接求逆矩阵:高斯消元的实现思路
public double[] normalEquation(double[][] X, double[] y) { int n = X[0].length; double[][] A = new double[n][n]; double[] b = new double[n]; // 构造正规方程组:A = XᵀX,b = Xᵀy for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { double sum = 0; for (int k = 0; k < X.length; k++) { sum += X[k][i] * X[k][j]; } A[i][j] = sum; } double sum = 0; for (int k = 0; k < X.length; k++) { sum += X[k][i] * y[k]; } b[i] = sum; } // 高斯消元:选主元提升数值稳定性 for (int col = 0; col < n; col++) { int pivot = col; for (int row = col + 1; row < n; row++) { if (Math.abs(A[row][col]) > Math.abs(A[pivot][col])) { pivot = row; } } // 交换主元行 double[] tmpRow = A[col]; A[col] = A[pivot]; A[pivot] = tmpRow; double tmpB = b[col]; b[col] = b[pivot]; b[pivot] = tmpB; // 消去当前列下方的元素 for (int row = col + 1; row < n; row++) { double factor = A[row][col] / A[col][col]; for (int j = col; j < n; j++) { A[row][j] -= factor * A[col][j]; } b[row] -= factor * b[col]; } } // 回代求解 double[] theta = new double[n]; for (int row = n - 1; row >= 0; row--) { double sum = b[row]; for (int col = row + 1; col < n; col++) { sum -= A[row][col] * theta[col]; } theta[row] = sum / A[row][row]; } return theta; }这里刻意没有直接求 (XᵀX)⁻¹ 再乘 b,原因有两个。其一,显式求逆在浮点下更容易放大误差:计算逆矩阵相当于同时解 n 个线性方程组,中间数值可能很大,而且大部分计算我们根本用不到。其二,我们想要的是 θ = A⁻¹b,它本质上是一个线性方程组的解,直接对 A 做高斯消元再回代,复杂度同样是 O(n³),但常数更小,代码也更直观。
实现里有一处性能细节值得注意:构造 A 时循环顺序是 i → j → k,把 k 放在最内层。Java 的二维数组按行存储,i 和 j 确定后,内层 k 在同一行里顺序访问,缓存命中率高。如果把 k 放最外层,每个内部访问都会跨到不同行不同列,特征较多时性能肉眼可见地恶化。选主元的逻辑也不可省略,当 A 的对角元素接近于零时,直接做除法会产生天文数字级别的因子,选主元通过行交换把最大的元素挪到对角线位置,把数值风险掐在除法之前。
4. Java 实现细节:特征缩放、截距项与收敛判定
4.1 特征缩放为什么只踩在梯度下降的痛处
先摆结论:正态方程对特征尺度在原理上不敏感,梯度下降则非常敏感。原因是两者处理信息的方式不同。
梯度下降只用局部梯度做决策,而梯度方向本身不具备对坐标轴的缩放不变性。假设一个特征取值在 0.001 量级,另一个特征取值在 10000 量级,成本函数的等值线会被拉成细长的椭圆。椭圆长轴方向曲率极低,短轴方向曲率极高,梯度下降会在窄的那个维度上来回震荡,像在一条深沟里左右弹跳,迟迟不往前推进。这也是许多线性回归代码在跑梯度下降之前先做均值归一化的原因:
public double[][] normalize(double[][] X) { int m = X.length, n = X[0].length; double[][] scaled = new double[m][n]; // 第 0 列假定是截距项,不参与缩放 for (int j = 1; j < n; j++) { double mean = 0; for (int i = 0; i < m; i++) mean += X[i][j]; mean /= m; double variance = 0; for (int i = 0; i < m; i++) { variance += (X[i][j] - mean) * (X[i][j] - mean); } double std = Math.sqrt(variance / m); for (int i = 0; i < m; i++) { scaled[i][j] = (X[i][j] - mean) / std; } } return scaled; }缩放之后每个特征接近零均值、单位标准差,等值线接近圆形,梯度下降不管从哪个点出发,方向都大致指向碗底。而正态方程之所以无所谓,是因为解析解直接来自矩阵运算:对 X 做列变换等价于在特征空间里做一个线性变换,系数会相应补偿,最后得到的预测值不变。矩阵乘法天然内建了坐标变换的相容性,不需要像梯度下降那样关心路径上的坡度。
4.2 截距项:这一列到底该不该归一化
上面的 normalize 代码里,第 0 列被跳过了。这是有意设计。截距项对应的 x_0 是常数 1,它的作用是为模型提供一个基线偏移。如果把它也拿去减均值、除标准差,这一列会变成一个不为 1 的常数,模型的 θ_0 跟特征尺度纠缠在一起,预测值在训练集上也许还能拟合,但泛化时截距的语义完全错乱。
更隐蔽的问题是统计量的复用。归一化必须在训练集上算出 mean 和 std,然后拿着同一组 mean/std 去变换训练集和测试集。很多实现翻车就翻在这里:对测试集重新计算均值,再用来缩放测试数据。测试集分布本来就跟训练集不完全一致,一旦重算统计量,特征尺度就变了,等于把模型喂给分布外的输入。训练时的截距、系数都建立在训练集统计量之上,预测阶段必须保持完全相同的坐标变换。
4.3 收敛判定:三种口径各有各的盲区
迭代什么时候算数?常见三种判定方式:
- 成本函数下降幅度小于某个阈值
- 梯度范数小于某个阈值
- 最多迭代次数
只看第 1 种会出现假收敛:成本函数在接近碗底时下降本来就越来越慢,坡度平缓不代表已经到终点。第 2 种更接近数学本质,梯度为零才是真正的驻点,梯度范数小说明确实走不动了。第 3 种是保险丝,防止前两种在异常情况下永远不触发。
public boolean converged(double[] theta, double[][] X, double[] y, double eps, double[] lastGrad) { double norm = 0; for (double g : lastGrad) norm += g * g; norm = Math.sqrt(norm); return norm < eps; }这个判断只看梯度范数,不关心 cost 的绝对值,代价是每轮都要多算一次梯度。工程上可以交替着用:每隔几百轮打印一次 cost 和梯度范数,肉眼看到 cost 平滑下降到平台期、梯度范数同步萎缩,才算真正心里有底。只靠迭代次数硬闯,很可能迭代上限设小了,算法停在半山腰,输出一份看似合理但还没到最优的参数。
5. 常见问题排查:梯度下降翻车与正态方程的数值陷阱
5.1 损失曲线不降反升:学习率太大
现象:cost 打印出来一轮比一轮高,甚至直接变成 NaN,图像上是一条向上冲的曲线。
原因:学习率 α 太大,梯度下降一步跨过了碗底,冲上对面坡之后,新位置的梯度更陡,下一轮跨得更远,最终一路滚出参数空间。在浮点层面,过大的中间值还会溢出成无穷大,后面的减法全失效。
解决:把 α 往小调,按 10 倍量级试探(0.1 → 0.01 → 0.001),直到 cost 单调下降。更稳的做法是画收敛曲线,看到锯齿震荡就缩减步长,看到平滑下降就维持当前量级。
5.2 矩阵奇异:特征冗余与样本不足互相打架
现象:跑正态方程时,高斯消元在某一步出现除零,或者回代解出的 θ 数值大得离谱。
原因:XᵀX 不可逆,没有唯一解。典型场景有三个:特征之间存在线性相关(比如同时放入摄氏温度和华氏温度,两列只差一个线性变换);样本数 m 小于特征数 n;或者数据里有一列全是常数。
解决:删掉冗余维度、增加样本量,或者给 XᵀX 对角线加上一个小常数 λI,变成岭回归形式的 (XᵀX + λI)⁻¹Xᵀy。注意这里的 λ 不是学习率,正则化强度通常取 1e-6 到 1 之间的小值,目的是让奇异矩阵变得可逆,代价是参数有轻微偏差。它是数值上的后悔药,不是建模策略。
5.3 特征量纲差异大:梯度下降龟速但 cost 曲线平坦
现象:学习率已经调得很小,cost 还是下降极慢,几百轮过去没怎么动,只有调大 α 又立刻震荡。
原因:特征没有缩放,成本函数等值线被拉成狭长椭圆。梯度在短轴方向来回反弹,长轴方向缓慢推进,宏观表现就是“怎么走都不顺”。
解决:跑梯度下降前强制做均值归一化,并且只缩放非截距列。做了之后往往发现同样的 α 就能正常收敛,问题不在步长,而在地形。
5.4 浮点求和顺序:特征多时正态方程悄悄漂移
现象:正态方程解出的参数与梯度下降结果不一致,特征数量上百之后偏差明显,随着特征增多越来越大。
原因:XᵀX 每个元素都是 m 个乘积的和,特征之间数量级差异大时,某些求和项接近一正一负互相抵消,浮点在求和过程中丢失了有效位。这是数学上无懈可击的公式,落到 IEEE 754 浮点之后有了次序相关的舍入误差。
解决:先对特征做归一化再构造 XᵀX,把条件数压下来;高斯消元里选主元已经是第一道防线。若还嫌不够,可以在内层求和时改用 Kahan 补偿求和,记录每一步的舍入误差,把精度损失补回来。对绝大多数场景,特征归一化加选主元已经足够。
5.5 提前终止的假收敛:cost 平台不等于最优
现象:cost 曲线看起来已经平了,但把参数拿去验证集上评估,效果明显不对,或者再继续迭代还能继续下降。
原因:收敛条件写成了“相邻两轮 cost 差小于阈值”,而在某些地形下 cost 下降在很早期就会出现一个平缓的平台,尤其特征数量大、步长小时更明显。梯度还不够小,只是变化慢了。
解决:改用梯度范数作为终止条件,并设足够大的最大迭代次数作为保险。如果计算资源允许,保留一个验证集,把每轮的验证误差也打出来,看到验证误差还在改善就继续迭代,直到验证误差开始回升才考虑早停。
6. 复杂度与适用边界:这份实现什么时候该被替换
梯度下降与正态方程本质解同一个问题,但代价曲线完全不同。批量梯度下降每一轮需要扫描全部 m 个样本,对 n 个特征各算一次梯度,单轮复杂度 O(mn),总轮数取决于数据规模和条件数,通常在几百到几万之间。正态方程需要构造 XᵀX(复杂度 O(mn²),朴素实现下每个元素都要扫一遍 m),再解一个 n×n 的线性方程组,整体 O(n³)。两者在资源消耗上的分水岭非常清楚:
| 维度 | 梯度下降 | 正态方程 |
|---|---|---|
| 单轮/单次复杂度 | O(mn),轮数不定 | O(mn² + n³) |
| 内存需求 | 存特征矩阵即可 | 额外存 n×n 矩阵 |
| 特征数量 n | 适合 n 很大的场景 | n 过千后明显吃力 |
| 增量更新 | 支持在线学习,来一个新样本就能更新一步 | 需要全量重算 |
| 数值稳定性 | 依赖学习率与缩放 | 依赖条件数,病态时易飘 |
这张表告诉我的不是“哪个更好”,而是“数据长什么样,就应该选哪条路”。n 小(比如几十到几百)、m 再大也不怕的时候,正态方程几乎总是更省事,不用调学习率,不用养收敛曲线,一步到位。n 上了几千、甚至特征稀疏到无法直接塞进 dumux 矩阵时,梯度下降配合特征缩放和正则化更可控,而且可以通过随机梯度下降把单轮复杂度降到 O(n),牺牲精度换取速度。
这份 Java 实现把两条路线都放在一个资源里,正好适合当作对比实验台:构造一份中等规模的数据,先跑正态方程得到参考参数,再跑梯度下降,对比两者的 cost 曲线和最终 θ。我自己拿到任何线性回归数据的第一件事,就是先数清 m 和 n,再决定主线走哪条路;如果两条路线都在代码里,就强制自己把两边结果交叉比对一遍,一致才敢把模型拿去部署。这个习惯帮我拦住过好几回 beta 阶段才发现的数据拼接错误——当梯度下降和正态方程给出的参数不一致,几乎总能挖出一个底层 bug,要么特征没对齐,要么归一化泄漏,要么矩阵构造时下标越界。希望这份资源的代码也能给你同样的确定性,也希望这套“先看数据规模、再选算法路线、最后交叉验证”的思路对你接下来的项目有帮助。
本文还有配套的精品资源,点击获取