线性回归双解法实战:梯度下降与正态方程的选择与避坑

发布时间:2026/11/4 7:26:20
线性回归双解法实战:梯度下降与正态方程的选择与避坑 简介这份资源面向正在入门监督学习、希望用 Java 亲手实现线性回归的开发者与机器学习初学者核心解决「最佳拟合直线」的求解问题通过梯度下降与正态方程两条路径从训练数据中学习 y Mx B 的常数项示例数据取自 Andrew Ng 的 Coursera 课程可复现出 M 1.2、B 4.7 的拟合结果。压缩包共 13 个文件约 44KB以 5 个 txt 训练数据、3 个 java 源码为主体另含 README 说明、工程配置文件及 JAMA 矩阵运算 jar 包源码与数据分离便于逐组替换样本做对比实验。资源已有 253 人学习读者可借此理解梯度下降的迭代过程与正态方程闭式解的差异并借助 JAMA 完成矩阵求逆等运算还能按描述尝试引入非线性特征拟合高阶多项式是理解线性回归底层实现的一份轻量实践材料。1. 线性回归两份解法梯度下降与正态方程到底怎么选拿到一份线性回归的源码包很多人第一反应是打开看训练循环跑一遍看到损失曲线往下走就关掉了。但这份资源真正值得拆的地方不在“能跑”而在于它把同一件事用两条完全不同的路径实现了一遍一条是迭代逼近的梯度下降一条是一步到位的正态方程。这两条路在数学上指向同一个最优解在工程上却是两种截然不同的取舍——前者能扩展到百万级特征、能加正则、能改成在线学习后者在特征维度可控时几乎不需要调参写出来就是闭式解。对刚接触机器学习的人来说这是理解“优化”这件事最直观的入口对已经写过模型的人来说这是一份可以拿来对照自己实现是否正确的基准。下面按“先看清结构、再动手复现、最后踩坑验证”的顺序拆开讲代码用 Java 落地参数怎么设、哪里容易翻车都会写清楚。2. 拆开源码包两份实现共用的数据流与矩阵约定2.1 目录结构与两条实现路径的分工这类线性回归项目通常把代码分成三层数据层负责读入特征矩阵 X 和标签向量 y并做必要的标准化算法层分成两个独立的类一个实现批量梯度下降一个实现正态方程求解入口层负责串起训练、预测和误差评估。拆包时先别急着看算法先确认三件事X 的每一行是一个样本还是一列是一个样本、y 是行向量还是列向量、截距项是单独一个参数还是拼进 X 的第一列。这三件事决定了后面所有矩阵乘法的维度能不能对上也是新手最容易在抄代码时翻车的地方。常见做法是把截距项作为 X 的第 0 列全 1 拼进去这样参数向量 θ 的第一个分量就是偏置 b后面统一用 Xθ 表示预测值公式干净、代码也短。如果源码里截距是单独维护的那梯度更新时就要多写一行对 b 的偏导容易漏。拆包第一步就是找这个约定找到了再往下读。2.2 特征标准化为什么是梯度下降的前置条件梯度下降的收敛速度对特征尺度极其敏感。假设一个特征是房屋面积几百到上千另一个是房间数个位数代价函数的等高线会被拉成极扁的椭圆梯度方向几乎垂直于最优点的方向迭代会在峡谷两侧来回震荡学习率稍大就发散。标准化的作用是把每个特征拉到均值 0、方差 1 附近让等高线接近圆形梯度方向直指最优解。// 按列做 z-score 标准化返回标准化后的矩阵和每列的均值/标准差 public static double[][] standardize(double[][] X, double[] mean, double[] std) { int m X.length, n X[0].length; double[][] Xs new double[m][n]; for (int j 0; j n; j) { double sum 0; for (int i 0; i m; i) sum X[i][j]; mean[j] sum / m; double var 0; for (int i 0; i m; i) var (X[i][j] - mean[j]) * (X[i][j] - mean[j]); std[j] Math.sqrt(var / m); // 总体标准差m 而非 m-1 for (int i 0; i m; i) Xs[i][j] (X[i][j] - mean[j]) / std[j]; } return Xs; }这段代码的关键参数是mean和std两个数组它们必须在训练集上算出来并保存下来预测新样本时用同一组均值和标准差做变换否则训练和推理的尺度不一致预测会系统性偏移。注意这里除以的是 m 而不是 m-1样本量大时差别可以忽略但训练和预测必须用同一套口径。另外如果 X 里拼了全 1 的截距列标准化时要跳过第 0 列否则全 1 列会被变成全 0截距就丢了——这是拆包时经常看到的隐藏 bug。2.3 正态方程的矩阵形态与可解条件正态方程的闭式解是 θ (XᵀX)⁻¹Xᵀy。它的推导来自对代价函数求导并令导数为零几何意义是残差向量与 X 的列空间正交。工程上不会真的去求逆而是解线性方程组 (XᵀX)θ Xᵀy数值稳定性更好。可解的前提是 XᵀX 满秩也就是特征之间不存在完全线性相关如果两列特征成比例矩阵奇异求解会失败或给出数值爆炸的结果。特征数远大于样本数时也必然不满秩这时候要么降维要么改用带正则的版本。// 用高斯消元解 (XtX)θ Xty避免显式求逆 public static double[] normalEquation(double[][] X, double[] y) { int n X[0].length; double[][] A new double[n][n]; double[] b new double[n]; for (int i 0; i n; i) { for (int j 0; j n; j) { double s 0; for (int k 0; k X.length; k) s X[k][i] * X[k][j]; A[i][j] s; } double s 0; for (int k 0; k X.length; k) s X[k][i] * y[k]; b[i] s; } // 带部分主元的高斯消元 for (int col 0; col n; col) { int pivot col; for (int r col 1; r n; r) if (Math.abs(A[r][col]) Math.abs(A[pivot][col])) pivot r; double[] tmp A[col]; A[col] A[pivot]; A[pivot] tmp; double t b[col]; b[col] b[pivot]; b[pivot] t; for (int r col 1; r n; r) { double f A[r][col] / A[col][col]; for (int c col; c n; c) A[r][c] - f * A[col][c]; b[r] - f * b[col]; } } double[] theta new double[n]; for (int i n - 1; i 0; i--) { double s b[i]; for (int j i 1; j n; j) s - A[i][j] * theta[j]; theta[i] s / A[i][i]; } return theta; }参数说明X是 m×n 的设计矩阵含截距列y是长度 m 的标签。选主元那一步是为了避免主元接近 0 时除法放大误差这是数值实现里必须有的保护。如果消元过程中主元绝对值小于某个阈值比如 1e-10说明矩阵接近奇异应该抛出异常而不是硬算否则得到的 θ 看着有值、预测却完全不可用。3. 梯度下降实现学习率、迭代次数与收敛判据3.1 批量梯度下降的更新公式与代码骨架批量梯度下降每次用全部样本计算梯度更新规则是 θ : θ − α·(1/m)·Xᵀ(Xθ − y)。它的优点是路径稳定、对学习率不那么挑剔缺点是每轮都要扫全量数据样本大时单轮开销高。代码骨架如下重点是梯度那一行的维度要对齐Xθ 是 m 维预测减去 y 得到 m 维残差Xᵀ 乘上去得到 n 维梯度正好和 θ 同维。public static double[] gradientDescent(double[][] X, double[] y, double alpha, int epochs, double tol) { int m X.length, n X[0].length; double[] theta new double[n]; double prevCost Double.MAX_VALUE; for (int e 0; e epochs; e) { double[] grad new double[n]; double cost 0; for (int i 0; i m; i) { double pred 0; for (int j 0; j n; j) pred X[i][j] * theta[j]; double err pred - y[i]; cost err * err; for (int j 0; j n; j) grad[j] err * X[i][j]; } cost / (2.0 * m); for (int j 0; j n; j) theta[j] - alpha * grad[j] / m; if (Math.abs(prevCost - cost) tol) break; // 收敛判据 prevCost cost; } return theta; }参数含义逐个说alpha是学习率标准化之后一般从 0.01 或 0.1 起试epochs是最大迭代轮数设大一点没关系靠tol提前退出tol是相邻两轮代价函数值的变化阈值取 1e-6 到 1e-8 之间比较常见。注意代价函数里除以了 2m这是为了求导后系数干净和梯度里除以 m 是配套的两者口径必须一致否则学习率的有效尺度会差一个常数调参时就会莫名其妙。3.2 学习率怎么定从发散到震荡的判断方法学习率没有万能值但有可操作的判断流程。先把epochs设成 100 左右画代价随轮数的变化如果代价单调下降但下降很慢说明学习率偏小可以乘 3 再试如果代价先降后升或者来回跳说明偏大除以 3如果第一轮就变成 NaN 或无穷大说明严重偏大直接除以 10。标准化之后0.01 到 0.3 这个区间通常能覆盖大多数数据集超出这个范围要么是没标准化要么是特征里有极端异常值。一个容易被忽略的点是判断学习率是否合适看的是代价曲线的形状而不是最终值。曲线平滑下降、尾部趋平就是合适的曲线带周期性抖动哪怕最终值还行也说明学习率踩在了稳定边界上换一份数据就可能发散。我一般会同时跑三个学习率把三条曲线画在一张图上对比比单看一个数字靠谱得多。3.3 收敛判据与早停别让迭代白跑收敛判据有两种常见写法看代价变化量或者看梯度范数。代价变化量简单但在代价曲线很平的区域容易过早触发梯度范数更本质梯度接近 0 才说明真的到了驻点。工程上可以两个都用满足其一就停。另外要设一个最大轮数兜底防止因为阈值设得太小而空转。// 梯度范数判据所有分量的平方和开根号小于 eps 即认为收敛 double norm 0; for (double g : grad) norm g * g; if (Math.sqrt(norm) / m 1e-7) break;这里的1e-7是经验值和数据尺度有关。如果特征没标准化梯度范数的量级会差好几个数量级这个阈值就失去意义——这也是为什么标准化要放在最前面。早停的另一个作用是防止过拟合虽然线性回归本身容量有限但在特征多、样本少的情况下迭代太久会把噪声也拟合进去验证集误差会先降后升盯着验证集而不是训练集来决定何时停是更稳的做法。4. 正态方程实现数值稳定性与奇异矩阵处理4.1 为什么不能直接调求逆数学上 θ (XᵀX)⁻¹Xᵀy 没问题但数值上显式求逆是下策。求逆的运算量是 O(n³)和解方程同级但求逆过程中误差会被放大尤其是 XᵀX 条件数很大时特征高度相关求出来的逆矩阵可能面目全非。解线性方程组配合部分主元消元能把误差控制在可接受范围。更稳的做法是 QR 分解或 SVD但那份源码包如果只实现了高斯消元对教学场景已经够用只是要在文档里写清楚适用边界。判断条件数的一个土办法把 XᵀX 的对角线元素和整体量级对比如果某两列的相关性接近 1或者某一列几乎全是同一个值就要警惕。更直接的办法是求解后回代检查残差 ‖(XᵀX)θ − Xᵀy‖残差相对量级超过 1e-6 就说明解不可信。4.2 特征共线性从现象到处理共线性的典型现象是模型在训练集上拟合得很好但 θ 的各个分量数值巨大且正负抵消换一份同分布的数据重训系数符号都变了。原因就是 XᵀX 接近奇异解对数据扰动极其敏感。处理方式有三条路一是删掉相关性高的特征之一最简单二是加 L2 正则把 (XᵀX λI) 变成满秩这就是岭回归三是改用梯度下降迭代过程本身对共线性有一定容忍度但收敛会变慢。// 岭回归的正态方程形式在 XtX 对角线上加 λ for (int i 0; i n; i) { A[i][i] lambda; // lambda 常取 1e-3 到 1e2需交叉验证 }注意截距项对应的对角线元素通常不加正则否则会把偏置往 0 压导致预测整体偏移。如果截距拼在 X 第 0 列循环就从 i1 开始。lambda的选择没有闭式答案常见做法是在验证集上扫一组对数间隔的值取误差最小的那个。4.3 两种解法的结果对照怎么验证实现没写错验证实现正确性最直接的办法是让两条路径解同一个标准化后的小数据集比较 θ 的差异。理论上两者应收敛到同一组参数实际差异来自梯度下降的迭代精度。如果差异在 1e-3 量级以内说明两份实现都对如果差一个数量级先查梯度下降是否收敛增大轮数、减小学习率再试再查正态方程的矩阵拼装是否漏了截距列。这个对照实验建议在拆包后第一时间做比读代码快得多。对照项梯度下降正态方程是否需要标准化必须不必须但有助于数值稳定时间复杂度O(epochs·m·n)O(m·n² n³)特征维度大时可扩展矩阵求解吃不消超参数学习率、轮数、阈值无岭回归除外共线性容忍度较高低需正则5. 避坑与排查五条血泪经验5.1 损失变成 NaN先查学习率和标准化现象训练几轮后代价变成 NaN 或 Infinity。原因通常是学习率过大导致参数爆炸或者特征没标准化、某一列量级远超其他列。解决先把学习率除以 10 重跑仍不行就检查标准化是否真的作用到了所有特征列特别是拼进去的截距列有没有被误标准化成 0。5.2 正态方程报矩阵奇异找共线特征而不是硬加扰动现象高斯消元时主元接近 0或者解出来的系数大得离谱。原因特征之间存在线性相关或样本数少于特征数。解决先算特征两两之间的相关系数矩阵找出相关性超过 0.95 的列对删掉其一如果确实需要保留全部特征改用岭回归加正则而不是在主元上加一个极小的数硬撑那样得到的解没有统计意义。5.3 训练集表现好、新数据预测偏标准化参数没保存现象训练时 R² 很高拿新样本预测系统性偏高或偏低。原因预测时用了新样本自己的均值方差做标准化或者干脆忘了标准化。解决把训练集算出的mean和std序列化保存预测流程里强制复用并在代码里加断言检查维度一致。这个坑在把训练和推理拆成两个模块时特别常见。5.4 梯度下降收敛太慢检查特征尺度和代价口径现象跑了几千轮代价还在缓慢下降参数离正态方程的解差很远。原因特征尺度差异大或者代价函数和梯度更新里的除以 m 口径不一致。解决确认标准化已生效核对代价是除以 2m 还是 m、梯度是除以 m 还是没除两者必须配套。口径错了不会报错只会让学习率的有效值差一个常数表现为“怎么调都不对”。5.5 截距项丢失拼列和标准化的顺序问题现象模型预测值整体偏移一个常数残差均值明显不为 0。原因截距列拼在 X 里后被标准化成了全 0或者正态方程求解时把第 0 列也加了正则。解决标准化时跳过截距列加正则时从索引 1 开始求解后检查 θ[0] 是否接近 y 的均值减去其他项的贡献偏离太大就回头查这两处。6. 进阶用法把两份实现变成回归问题的基准工具拆完这份源码最有价值的用法不是把它当成品模型而是把它当基准。任何新的回归实现——不管是加了正则的、换了优化器的、还是用了特征工程的——都可以拿这两份实现做对照在小数据集上新实现的 θ 应该和正态方程的解在数值精度内一致在大数据集上新实现的收敛曲线应该和梯度下降的曲线形状可比。这个对照习惯能挡掉大部分“看起来能跑但其实是错的”实现。具体做法是写一个统一的评估入口输入同一份标准化后的数据分别调用两条路径输出三样东西θ 的逐分量差异、训练集上的均方误差、以及梯度下降的代价曲线。差异超过阈值就报警逼着自己去查是数据拼装的问题还是算法本身的问题。我一般会把阈值设在 1e-3因为梯度下降在合理轮数内很难压到更低再严就会频繁误报。还有一个值得试的扩展把批量梯度下降改成小批量观察收敛曲线和最终解的变化。小批量引入的梯度噪声会让曲线带毛刺但只要学习率按批量大小适当缩小最终解仍然会收敛到正态方程附近。这个实验能直观说明“随机性不影响最优解、只影响路径”对理解后续更复杂的优化算法很有帮助。另外正态方程那份实现可以顺手改成岭回归只需在 XᵀX 对角线上加 λ代码改动不超过五行却能让你在同一份数据上对比有无正则时系数的收缩情况比看公式直观得多。从那以后我每次拿到一份回归相关的代码都会先跑一遍“正态方程对照”再谈其他——闭式解是这类问题唯一可靠的后悔药迭代实现再怎么调参最终都得回到它这里对一次账。希望帮到你。本文还有配套的精品资源点击获取