
第 2 课我们把数据洗干净、切好了训练集和验证集。这一课在这份数据上做第一件像机器学习的事拟合一条这里是超平面y Xw b。全程不装 PyTorch、不装 sklearn只用 numpy 把梯度算出来、把 loss 曲线跑出来再和闭式解对一遍看手写的代码差多少。为什么在框架已经很成熟的时候还要手推因为求解器的收敛行为是长期主题——scikit-learn 官方站显示 1.9.1 在 2026 年 9 月发布、1.10 开发版的 changelog 一直更新到 2026 年 10 月改动清单里仍然有线性模型求解器的线搜索优化和 Lasso/ElasticNet 路径计算提速。不懂梯度在干什么这些调参项就只是玄学。环境准备只需要 numpy。本机版本Python 3.13.14 / Windows 10 / numpy 2.5.1。python -c import numpy, sys, platform; print(sys.version.split()[0], platform.platform(), numpy.__version__) pip install numpy2.5先造一批「不好解」的数据用随机数造数据但故意留一手让第二列跟第一列高度相关相关系数接近 1。这是现实数据里最常见的情况也是后面所有奇怪现象的根源。import numpy as np rng np.random.default_rng(42) n 500 x1 rng.normal(sizen) x2 x1 * 0.98 rng.normal(scale0.05, sizen) # 故意跟 x1 高度相关 x3 rng.normal(sizen) X np.column_stack([x1, x2, x3]) w_true np.array([3.5, -2.0, 0.8]) b_true 1.2 y X w_true b_true rng.normal(scale0.5, sizen) Xb np.hstack([X, np.ones((n, 1))]) # 补一列 1把截距并进权重向量 print(cond , round(float(np.linalg.cond(Xb)), 2), Xb.shape)输出cond 37.07 (500, 4)条件数 37 不算大但已经足够让不同参数、同样 loss的现象出现了。闭式解lstsq 和正规方程对一遍先算标准答案。两条路np.linalg.lstsq奇异值分解数值稳和正规方程w (XᵀX)⁻¹Xᵀy推导简单但要先求逆。theta_star, residuals, rank, sv np.linalg.lstsq(Xb, y, rcondNone) print(lstsq :, np.round(theta_star, 4), residuals:, residuals, rank:, rank) theta_ne np.linalg.solve(Xb.T Xb, Xb.T y) print(normal:, np.round(theta_ne, 4)) print(max |diff| , float(np.max(np.abs(theta_star - theta_ne))))输出lstsq : [ 4.1367 -2.6754 0.7915 1.1187] residuals: [124.53111447] rank: 4 normal: [ 4.1367 -2.6754 0.7915 1.1187] max |diff| 5.617728504603292e-13两条路的差在 1e-13 量级说明这份数据下正规方程完全可用。注意rcondNone要显式写上NumPy 2.0 之前这个参数的默认值是-1并会发 FutureWarning2.0 起默认值改成机器精度 × max(M, N)老教程里的写法在新版本上语义已经变了。另外看一眼权重真值是[3.5, -2.0, 0.8]闭式解给出[4.14, -2.68, 0.79]。前两维偏得挺多这不是算错了是 x1 和 x2 高度相关导致的参数不可识别——把 3.5·x1 − 2.0·x2 稍微改写成别的系数组合对 y 的预测几乎一样。手写全批量梯度下降均方误差 loss ‖Xw − y‖²/n对 w 求导得 grad 2·Xᵀ(Xw − y)/n。整个训练循环就这么三行。def loss_fn(th): r Xb th - y return float((r r) / n) print(lstsq loss:, round(loss_fn(theta_star), 6)) th np.zeros(4) lr 0.1 for i in range(1, 301): grad (Xb.T (Xb th - y)) * 2.0 / n th - lr * grad if i in (1, 2, 5, 10, 20, 50, 100, 200, 300): print(fiter {i:4d} loss {loss_fn(th):.6f}) print(GD theta:, np.round(th, 4)) print(gap to lstsq:, round(float(np.max(np.abs(th - theta_star))), 4))实测输出lstsq loss: 0.249062 iter 1 loss 2.369513 iter 2 loss 1.399652 iter 5 loss 0.498003 iter 10 loss 0.300016 iter 20 loss 0.279378 iter 50 loss 0.278677 iter 100 loss 0.277907 iter 200 loss 0.276426 iter 300 loss 0.275021 GD theta: [1.0282 0.4931 0.7976 1.1258] gap to lstsq: 3.1685loss 曲线是对的2.37 → 0.50 → 0.28 → 0.275前 10 轮掉得最快之后是一条很长的尾巴。但权重差得离谱——梯度下降收敛到[1.03, 0.49, 0.80, 1.13]和闭式解的[4.14, -2.68, ...]差了 3.17而两边的 loss 只差 0.026。原因就是前面那条x1 与 x2 相关参数在这个方向上可以随便滑梯度下降停在哪个点取决于起点和学习率loss 却几乎不变。换成 mini-batch全批量每次都算 500 条样本。改成每 32 条更新一次epoch 内先打乱顺序th_mb np.zeros(4) for epoch in range(1, 31): perm rng.permutation(n) for s in range(0, n, 32): idx perm[s:s 32] grad (Xb[idx].T (Xb[idx] th_mb - y[idx])) * 2.0 / len(idx) th_mb - 0.1 * grad if epoch in (1, 2, 5, 10, 30): print(fepoch {epoch:3d} loss {loss_fn(th_mb):.6f}) print(MB theta:, np.round(th_mb, 4))输出epoch 1 loss 0.290387 epoch 2 loss 0.283768 epoch 5 loss 0.281025 epoch 10 loss 0.278352 epoch 30 loss 0.274106 MB theta: [1.1607 0.3286 0.8248 1.1219]一个 epoch 就到 0.29比全批量 300 轮的 0.275 差一点点但更新次数只有它的 1/10 左右。这就是 mini-batch 的全部秘密单步质量差步数多总账更划算。三个真实的坑学习率太大直接发散。把 lr 从 0.1 换成 2.0同一份数据iter 1 loss 131.1284 iter 2 loss 5804.7643 iter 3 loss 279925.2999 iter 4 loss 13722612.0304 iter 20 loss 1.6185783571901943e3420 轮之后权重已经是 -6.3e16 量级。判断标准很简单如果前几轮 loss 不降反升直接把 lr 除以 10 重来不要等它自己跳出来。学习率太小看着像收敛其实没到。lr 0.001 时3000 轮 loss 是 0.279001还不如 lr 0.1 跑 50 轮的 0.278677。特征量纲差太多loss 会炸。把第一列乘 1000模拟金额和年龄混在一起条件数从 37 跳到 18867同样的 lr 0.1iter 1 loss 3.8088696757870656e17 iter 2 loss 1.2889668030118792e28 iter 3 loss 4.362016977972201e38 iter 19 loss 1.2906760751510606e207再跑下去 numpy 会抛RuntimeWarning: overflow encountered in matmul接着是invalid value encountered in matmulloss 变成 nan。修法只有一句话训练前先做标准化(X - X.mean(0)) / X.std(0)别指望调学习率救回来。看 loss 还是看参数这一课最容易产生误解的地方loss 已经降到 0.275 了参数却和真值、和闭式解都不一样。判断训练是否成功应该看验证集上的指标而不是逐个权重去对答案。pred Xb th ss_res float(((y - pred) ** 2).sum()) ss_tot float(((y - y.mean()) ** 2).sum()) print(R2 , round(1 - ss_res / ss_tot, 4), RMSE , round((ss_res / n) ** 0.5, 4)) print(lstsq R2 , round(1 - float(((y - Xb theta_star) ** 2).sum()) / ss_tot, 4))输出R2 0.913 RMSE 0.5244 lstsq R2 0.9213两者 R² 差 0.008参数却差 3.17。噪声标准差我设的是 0.5RMSE 0.5244 基本就贴着这个下限——说明模型已经把能学的都学到了剩下的就是噪声。特征共线时想要正确的权重得靠第 4 课要讲的特征处理或者加正则化硬调梯度下降是调不出来的。小结闭式解给的是标准答案手写梯度下降给的是同一条路的不同走法两边对不上时先查特征共线和量纲再怀疑代码。把上面三段代码存成一个脚本跑一遍改改 lr 和 batch size 看 loss 曲线怎么变比读十遍公式管用。下一课在这个基础上加 L2 正则、讲清楚训练集和验证集上的 loss 什么时候该开始分岔。