简介:这份资源面向正在入门监督学习、希望用 Java 亲手实现线性回归的开发者与机器学习初学者,核心解决「如何用梯度下降与正态方程拟合最佳直线」这一基础问题。压缩包共 13 个文件,约 44KB,以 5 个 txt 训练数据、3 个 java 源码为主,另含 README 说明、工程配置文件及 JAMA 矩阵运算 jar 包,源码与数据分离,便于直接导入 IDE 运行调试。资源以 y = Mx + B 为例,通过训练数据学习出 M = 1.2、B = 4.7,示例数据取自 Andrew Ng 的 Coursera 机器学习课程,并说明选取非线性特征后也可拟合高阶多项式。已有 253 人学习下载,适合对照代码理解梯度下降迭代过程与正态方程闭式解两种求解思路的差异,是入门线性回归的轻量实践样例。
1. 线性回归的两条路:为什么同一个模型会有两种完全不同的解法
很多人第一次写线性回归,都是从一行theta = theta - alpha * grad开始的。跑通了,损失降下去了,就以为这件事到此为止。但如果你翻过任何一份正经的实现,会发现同一个模型往往还挂着另一个入口:正态方程(Normal Equation),一行theta = (X^T X)^{-1} X^T y直接把参数算出来,没有迭代、没有学习率、没有收敛判断。这两条路算的是同一个东西,却在数值行为、复杂度、适用规模上完全不同。搞懂它们为什么能同时存在、各自在什么条件下会翻车,比会调sklearn的 API 有价值得多。这份以 Java 实现的线性回归资源,把梯度下降和正态方程放在同一套代码骨架里对照,正好是拆解这个问题的好样本。
线性回归是整个机器学习里最像“物理定律”的模型:假设明确、解唯一、误差可解释。但恰恰因为它简单,很多人跳过了它背后的数值细节,等到上真实数据、特征一多、量纲一乱,就开始遇到玄学问题——损失不降、参数爆炸、矩阵求逆报奇异。这些坑不是调参能救的,得回到机制层面看。下面从假设函数讲到两种解法的推导,再落到 Java 实现里那些容易被忽略的细节。
2. 假设函数与损失:最小二乘到底在最小化什么
2.1 从一条直线到矩阵形式
线性回归的假设很朴素:输出是输入的线性组合加上一个偏置。单特征时是y = w*x + b,多特征时写成向量内积h(x) = theta^T x,其中x的第一维习惯性补一个 1,把偏置吸收进权重向量,这样公式里就不用单独处理截距。整个训练集堆成矩阵X(m 行样本、n 列特征,含常数列),预测就是X * theta。
这个“补 1”的操作看着是小事,实际决定了后面所有矩阵运算能不能统一写。如果忘了补常数列,模型就永远过不了原点,拟合出来的线在数据均值附近系统性偏移,而损失曲线看起来还挺正常——这是典型的静默错误。常见的实现思路是在数据预处理阶段就把常数列拼进去,而不是在每次预测时临时加。
2.2 均方误差与它的矩阵展开
损失函数选均方误差(MSE):J(theta) = (1/2m) * sum((h(x_i) - y_i)^2)。前面的 1/2 是为了求导时把平方的 2 约掉,纯粹是书写便利,不影响最优解位置。写成矩阵形式是J(theta) = (1/2m) * (X*theta - y)^T (X*theta - y)。
把它展开:(X*theta)^T(X*theta) - 2*(X*theta)^T y + y^T y。对theta求梯度,用到两个矩阵求导恒等式:d(a^T theta)/d theta = a,d(theta^T A theta)/d theta = 2A theta(A 对称时)。得到:
grad J(theta) = (1/m) * X^T (X*theta - y)这个式子是全篇的核心。它说明梯度就是“残差向量被特征矩阵转置加权后的平均”。残差X*theta - y是每个样本的预测误差,X^T把误差按特征维度重新分配——某个特征在误差大的样本上取值大,它分到的梯度就大。理解这一点,后面梯度下降为什么会震荡、为什么要特征缩放,就都有了解释。
提示:这里的 1/m 是“平均”而非“求和”。用求和形式时学习率要相应缩小 m 倍,很多实现之间学习率不可直接照搬,根源就在这里。
2.3 为什么最小二乘有闭式解
均方误差是关于theta的二次函数,二次函数的等高线是椭圆,只有一个全局最小值,没有局部极小。令梯度为零:X^T(X*theta - y) = 0,即X^T X theta = X^T y。这就是正态方程。只要X^T X可逆,解就是theta = (X^T X)^{-1} X^T y。
这个推导的价值不在于背公式,而在于它揭示了一个前提:X^T X必须可逆。X^T X是 n×n 的方阵,可逆的充要条件是X的列满秩,也就是特征之间线性无关。一旦有两个特征完全共线(比如“面积平方米”和“面积平方英尺”同时入模),X^T X就奇异,求逆直接失败或者数值上给出荒谬的巨大参数。这是正态方程最硬的一道门槛,也是后面避坑章节要重点说的。
3. 梯度下降:迭代逼近背后的步长博弈
3.1 更新规则与批量梯度下降
梯度下降不去解方程,而是沿着负梯度方向一步步走:theta = theta - alpha * grad J(theta),其中alpha是学习率。把第 2 章推出来的梯度代进去:
theta_j = theta_j - alpha * (1/m) * sum_i (h(x_i) - y_i) * x_ij每个参数theta_j的更新量,等于所有样本在该特征上的“误差 × 特征值”之和再乘学习率。这就是批量梯度下降(BGD):每走一步都要扫全量数据。它的优点是方向准、路径平滑,缺点是数据一大,单步成本线性上升。
Java 实现里这一步通常是两层循环:外层遍历参数维度,内层累加样本误差。写成代码大致是这样:
// 批量梯度下降的一步更新,X 为 m×n 矩阵(含常数列),y 为 m 维向量 public double[] gradientStep(double[][] X, double[] y, double[] theta, double alpha) { int m = X.length; int n = X[0].length; double[] grad = new double[n]; // 先算残差,避免在参数循环里重复计算预测值 double[] residual = new double[m]; for (int i = 0; i < m; i++) { double pred = 0.0; for (int j = 0; j < n; j++) { pred += X[i][j] * theta[j]; } residual[i] = pred - y[i]; // 预测减真实,符号决定下降方向 } // 按特征维度累加梯度 for (int j = 0; j < n; j++) { double sum = 0.0; for (int i = 0; i < m; i++) { sum += residual[i] * X[i][j]; } grad[j] = sum / m; // 取平均,与损失函数的 1/m 对应 } // 沿负梯度更新 double[] next = new double[n]; for (int j = 0; j < n; j++) { next[j] = theta[j] - alpha * grad[j]; } return next; }这段代码有两个值得说的设计点。第一,残差先单独算一遍存起来,而不是在参数循环里每次重算预测值,把复杂度从 O(m·n²) 降到 O(m·n)。第二,grad[j] = sum / m里的除以 m 必须和损失函数的定义严格对应,否则学习率的量纲就错了,换个数据集就得重新调参。我见过不少实现把这两处的系数写岔了,结果在小数据上能收敛、大数据上直接发散,排查半天以为是学习率问题。
3.2 学习率:为什么大了会炸、小了会磨
学习率alpha控制每步走多远。从二次函数的几何看,梯度下降的收敛条件是alpha < 2 / L,其中L是损失函数 Hessian 矩阵的最大特征值(对 MSE 来说就是X^T X最大特征值的 1/m 倍)。特征值越大,允许的学习率上限越小。
这就解释了一个常见现象:特征量纲差异大时,X^T X的特征值跨度极大,最大特征值被大尺度特征撑得很大,学习率上限被压得很低,于是小尺度特征方向上的收敛慢如蜗牛,大尺度方向上一不小心就震荡。血泪经验是:不做特征缩放就直接上梯度下降,基本等于给自己埋雷。标准化(减均值除标准差)能把各维特征拉到同一量级,X^T X的条件数大幅下降,学习率就好调了。
3.3 收敛判据与迭代终止
梯度下降没有“算完”的时刻,只有“够近了”。常见判据有三种:损失变化小于阈值、梯度范数小于阈值、参数变化小于阈值。三者各有盲区——损失变化小可能是陷入了平坦区而非最优点,梯度范数小在高维下计算成本不低,参数变化小则对学习率敏感。
Java 实现里通常组合使用:每轮记录损失,连续若干轮相对变化低于1e-6就停,同时设一个最大迭代次数兜底,防止死循环。这个最大次数不是形式主义,学习率设错时它就是唯一的后悔药。另外要注意,损失是震荡下降的,单轮比较可能误判,用滑动窗口或者连续 N 轮判断更稳。
4. 正态方程:一步到位的代价与前提
4.1 从正规方程到矩阵求逆
正态方程theta = (X^T X)^{-1} X^T y的推导在第 2 章已经给出。它的计算分三步:算X^T X(n×n)、算X^T y(n 维)、解线性方程组。注意最后一步不必真的求逆再相乘,数值上更稳的做法是解(X^T X) theta = X^T y这个线性系统,用 Cholesky 分解(X^T X对称正定时)或 LU 分解,比显式求逆少一半运算量、误差也更小。
Java 里没有内置的线性代数库,实现这份资源时要么手写高斯消元,要么依赖外部矩阵库。手写高斯消元带部分主元选择(列主元)是常见做法,主元选择能缓解除以小数的数值不稳定:
// 高斯消元解 A * x = b,A 为 n×n,带列主元选择 public double[] solve(double[][] A, double[] b) { int n = b.length; double[][] M = new double[n][n]; double[] rhs = b.clone(); for (int i = 0; i < n; i++) M[i] = A[i].clone(); for (int col = 0; col < n; col++) { // 选当前列绝对值最大的行作为主元,减小舍入误差 int pivot = col; for (int r = col + 1; r < n; r++) { if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r; } if (Math.abs(M[pivot][col]) < 1e-12) { throw new ArithmeticException("矩阵接近奇异,无法求解"); } // 交换行 double[] tmpRow = M[col]; M[col] = M[pivot]; M[pivot] = tmpRow; double tmpVal = rhs[col]; rhs[col] = rhs[pivot]; rhs[pivot] = tmpVal; // 消元 for (int r = col + 1; r < n; r++) { double factor = M[r][col] / M[col][col]; for (int c = col; c < n; c++) { M[r][c] -= factor * M[col][c]; } rhs[r] -= factor * rhs[col]; } } // 回代 double[] x = new double[n]; for (int i = n - 1; i >= 0; i--) { double sum = rhs[i]; for (int j = i + 1; j < n; j++) sum -= M[i][j] * x[j]; x[i] = sum / M[i][i]; } return x; }主元阈值1e-12是个经验值,用来在矩阵接近奇异时提前报错,而不是让除法产生Infinity再污染整个结果。这个检查很关键:没有它,共线特征会悄悄算出天文数字的参数,预测值看着还能跑,但模型已经完全不可信。
4.2 复杂度对比:n 与 m 的博弈
梯度下降每轮 O(m·n),跑 T 轮就是 O(T·m·n);正态方程构造X^T X是 O(m·n²),求解是 O(n³)。关键差异在 n:特征数几百以内,正态方程一次算完很划算;特征上万时,n³ 直接不可接受,梯度下降反而更合适。样本数 m 很大而 n 不大时,正态方程依然有优势,因为它对 m 只是线性。
| 维度 | 梯度下降 | 正态方程 |
|---|---|---|
| 时间复杂度 | O(T·m·n) | O(m·n² + n³) |
| 需要学习率 | 是 | 否 |
| 需要特征缩放 | 强烈建议 | 不需要 |
| 特征共线 | 可勉强收敛 | 矩阵奇异,直接失败 |
| 适用特征规模 | 大(上万) | 小到中等(几千以内) |
这张表里“特征共线”那一行最容易被低估。梯度下降在共线时不会报错,它会在一条平坦的“山谷”里慢慢挪,损失能降但参数不唯一,换个随机种子结果就变;正态方程则是硬失败。两种失败方式都不好受,但至少正态方程的失败是显式的,容易发现。
4.3 数值稳定性:条件数与正则化
X^T X的条件数是X条件数的平方,这意味着即使X只是轻度病态,X^T X也会严重病态。特征高度相关时,求出来的参数对输入扰动极其敏感,训练集稍微变一点,权重就大幅跳动。这是正态方程在真实数据上的主要软肋。
缓解手段是加正则项,把X^T X换成X^T X + lambda * I,这就是岭回归的闭式解。加了对角项之后矩阵一定正定可逆,条件数也被压下来。代价是引入了偏差,lambda越大参数越往零收缩。这份资源如果只做纯线性回归,至少应该在文档里点明这个退化路径,否则用户拿共线数据一跑就懵。
5. 避坑与排查:那些让模型静默出错的细节
5.1 现象:损失不降反升,参数越来越大
原因通常是学习率超过收敛上限,或者梯度符号写反。梯度更新是theta - alpha * grad,如果误写成theta + alpha * grad,就是沿梯度上升方向走,损失必然爆炸。另一种情况是残差定义成y - pred而非pred - y,符号整体翻转,效果等同于加号。
解决:先用一个极小的学习率(如1e-6)跑几十轮,确认损失单调下降,再逐步放大到开始震荡为止,取震荡前的一半。同时打印前几轮的梯度范数,正常应该逐轮减小。
5.2 现象:正态方程抛奇异异常,但特征看着不重复
原因往往是常数列重复。预处理时补了一次 1,某个特征又恰好是常数(比如全为 1 的哑变量),两列完全共线,X^T X奇异。也可能是特征数超过样本数(n > m),此时X^T X必然不满秩。
解决:入模前检查每列方差,方差为零的列直接剔除;n > m 时改用梯度下降或加正则项。别指望求逆库能兜底,奇异矩阵求逆在数值上就是没有意义的。
5.3 现象:梯度下降收敛极慢,几千轮损失还在缓慢下降
原因是特征量纲差异大,条件数高,学习率被最大特征值卡死。典型场景是一个特征取值在 0 到 1 之间,另一个在几万量级。
解决:做标准化,每个特征减均值除标准差。注意预测时要用训练集的均值和标准差做同样的变换,否则训练和推理的尺度不一致,这是上线后精度骤降的常见原因。
5.4 现象:两种方法算出的参数对不上
原因通常是梯度下降还没收敛,或者两边的数据预处理不一致(一边补了常数列一边没补),又或者损失函数的 1/m 系数在两边定义不同导致学习率量纲错位。
解决:用同一份预处理后的数据,先跑正态方程拿到“标准答案”,再让梯度下降跑足够多轮,比较两者参数的相对误差。如果误差在1e-3以内,说明实现正确;差得远就逐项核对预处理和系数。
5.5 现象:预测结果系统性偏高或偏低
原因是常数列缺失或偏置项被正则化误伤。如果补了常数列又对它做了标准化,常数列会被减成零向量,偏置就消失了,模型被迫过原点。
解决:常数列不参与标准化,或者干脆把偏置单独拿出来不放进正则项。这个坑在加了正则化之后尤其隐蔽,因为损失曲线看起来完全正常。
6. 设计取舍:什么时候该用哪条路,以及怎么验证
两条路的选择不是风格问题,是规模、数值条件和可解释性的三方权衡。特征数在几千以内、数据干净、需要一次算准,正态方程更省心,没有学习率这个玄学参数,结果可复现。特征上万、样本流式到达、需要增量更新,梯度下降是唯一现实的选择,因为 n³ 的求逆成本无法承受。真实项目里更常见的做法是:小规模原型用正态方程快速验证特征工程是否有效,规模上来后切到梯度下降或随机梯度下降,用正态方程的结果作为正确性基准。
验证方法上,我习惯做一个“双解对拍”:同一份标准化后的数据,正态方程算一遍,梯度下降跑一万轮算一遍,比较参数的 L2 相对误差。这个对拍能同时抓出预处理不一致、梯度符号错误、系数错位三类问题,比单看损失曲线可靠得多。损失曲线会骗人——它下降不代表参数正确,尤其在共线数据上,损失可以很低而参数完全跑偏。
还有一个容易被忽略的取舍是数值精度。Java 的double是 64 位,对大多数回归够用,但构造X^T X时如果特征值跨度超过 1e15,累加误差就会显现。这时候要么用 Kahan 求和补偿,要么老老实实做标准化把量级拉平。我一般会这么理解:正态方程的优雅建立在“矩阵良态”这个前提上,前提不成立时,它的失败是断崖式的,不像梯度下降那样还能给你一个“差不多”的结果。
从那以后我每次实现线性回归,都会先跑一遍正态方程当基准,再让梯度下降去对拍,两个结果对不上就先查预处理而不是调学习率。希望帮到你。
本文还有配套的精品资源,点击获取