🤖 第3章 最小二乘学习法
📄 一句话概括

4000 个样本、10 维:单样本梯度走 8 遍,参数误差从 0.0438 降到 0.0143,中间还反弹过一次。 小批 32 只更新了 1000 次,误差从 0.0203 单调降到 0.0022。 差距不在次数,在步长:小批敢用 0.02,单样本只敢用 0.003。批越大,梯度方差越小, 步长能开得越大。 两条路都在逼近闭式解——因为最小二乘是凸的,只有一个最低点。

📝 本节和前面几节的关系

3.1 的闭式解要算 A 转置 A,这要扫全部 n 条样本、并且把它们同时放进内存。 n 到千万级、或者数据是流式到达时,这条路就走不通了。 这一节换成迭代法:每次只看一小部分样本,估一个粗糙的下降方向。 代价是要调步长,收益是内存和时间都变成线性。

一、闭式解卡在哪里

两道坎。第一道是内存:A 是 n 乘 b,A 转置 A 是 b 乘 b。b 不大时 A 转置 A 很小, 但算它要遍历整个 A,数据必须能一次读完。 数据在磁盘上、在数据库里、在别的机器的日志流里,就读不完。 第二道是时间:闭式解一次要花 O(n b 平方) 的乘法。梯度法每一步只要 O(b) 或 O(批大小 乘 b), 可以随时停下来看效果。 所以迭代法的真正优势不是「更快到终点」,是「随时可以停」。 数据源源不断进来时,你根本不需要一个终点。参数随着数据慢慢漂移,这本身就是想要的行为。 这也是第 15 章在线学习的前提:模型要能一条一条地更新。最小二乘做得到这件事,闭式解做不到, 因为每来一条新数据就要重算全部。

二、三种批大小

全量(批量)梯度:每步用所有样本算梯度。方向最准,但每步最贵, 而且一步都不能在数据没读完时迈出去。 单样本(随机)梯度:每步只用一个样本。方向噪声极大,但一步极便宜,n 个样本就走 n 步。 这就是「随机梯度下降」里「随机」的来源。 小批:每次用 32 到几百个样本。这是实践中的默认选择,原因有两个: 一次算一批能用上矩阵乘法的并行性;批内的平均把方差压下来了,步长可以开大。 Demo 把差别量成了一个数:同样的 8 遍数据,单样本误差 0.0143,小批 32 误差 0.0022。 而小批的更新次数只有单样本的 32 分之一。 「多走几步」不等于「走得更准」。

三、步长:这一节唯一的真功夫

步长的量纲要和梯度对上。对平方损失,梯度里有个 2 倍的因子,步长要相应缩小。 批大小变大时梯度是平均值、量级不变,但方差小了,所以步长可以开大。 这条规律在 Demo 里写得很直白:单样本 0.003,小批 32 用 0.02,差 6.7 倍。 所以「小批为什么好」的标准答案不是「省时间」,是「稳,所以敢走大步」。 步长还要衰减。固定的步长在最优点附近只能抖:梯度不为零(因为有噪声),参数就一直被推来推去。 衰减到零,抖动幅度才收敛。Demo 用的是 1 除以 (1 加 0.8 倍遍数),简单、够用。 工程上还有几个必做的动作:打乱顺序(不然样本的相关性会让梯度系统性偏斜)、 特征缩放(不然不同方向的条件数差太远,一个步长伺候不了所有方向)。 这两条不做,调步长会调到你怀疑人生。

四、实测:两条曲线

4000 个样本、10 维,闭式解的训练 MSE 是 0.0938。

遍历遍数 单样本更新 参数误差 小批 32 更新 参数误差
1 4000 0.0438 125 0.0203
2 8000 0.0479 250 0.0144
4 16000 0.0196 500 0.0052
8 32000 0.0143 1000 0.0022
看第二行:单样本的误差从 0.0438 涨到 0.0479。这不是 bug,是单样本梯度的噪声:
一条样本代表不了整体,它给的方向时准时歪。
小批那一列从头到尾单调下降,8 遍误差 0.0022,比单样本小 6 倍。而它的更新次数只有 1000 次,
是单样本的 32 分之一。
两条路都在往 0.0938 那个训练 MSE 靠,但这里比的是「参数误差」不是「损失」。
参数误差能到 1e-3 量级,损失早就看不出差别了,和 3.1 那个现象一样。

五、什么时候不该用迭代法

b 小、n 中等、数据能一次读进内存时,闭式解永远优先。它没有步长要调、没有随机性、 结果完全可复现,跑一次就知道答案。 迭代法要调步长、要调批大小、要定衰减策略、还要设随机种子,这些都是成本。一个粗略的分界: b 在几千以内、n 在几十万以内,闭式解够用。 还有一类情况必须用迭代:模型根本不是线性的。层级模型、逻辑回归、支持向量机,全都没有闭式解, 只能迭代。 所以这一节讲的不只是「一种可选方案」,而是后面所有章节的通用解法。第 4 章往后的每一个算法, 本质都是「定一个损失,然后梯度下降」。

六、动手 Demo:小批梯度下降:单样本 8 遍误差 0.0143,小批 32 是 0.0022

4000 个样本、10 维(第 0 维是常数 1),真实参数随机生成,噪声 0.3。 先用闭式解算出标准答案,再跑两条随机梯度路线:批大小 1(步长 0.003)和批大小 32(步长 0.02), 都按 1 除以 (1 加 0.8 倍遍数) 衰减,每遍结束记录参数与闭式解的距离。

w_star = np.linalg.solve(X.T @ X, X.T @ y)     # 闭式解当标准答案

def sgd(bs, epochs, lr0, seed=1):
    r2 = np.random.default_rng(seed)
    w = np.zeros(b)
    for ep in range(epochs):
        lr = lr0 / (1.0 + 0.8 * ep)            # 步长衰减
        order = r2.permutation(n)              # 每遍重新打乱
        for i in range(0, n, bs):
            idx = order[i:i + bs]
            g = 2.0 * X[idx].T @ (X[idx] @ w - y[idx]) / len(idx)
            w -= lr * g
    return w

h1 = sgd(1, 8, 0.003)      # 单样本:步长必须小
h32 = sgd(32, 8, 0.02)     # 小批:方差小了,步长可以开大

实跑输出:

--------------------------------------------------------------------
小批梯度下降:单样本 8 遍误差 0.0143,小批 32 是 0.0022
--------------------------------------------------------------------
  数据                     4000 个样本,10 维(第 0 维是常数 1)
  闭式解                   解一个 10 乘 10 的方程组,训练 MSE 0.0938
  全量梯度                 每步要扫 4000 个样本,算 X 转置乘 X
  小批梯度                 每步只看 1 个或 32 个样本,内存 O(b)
  初始步长                 单样本 0.003,小批 32 是 0.02

  遍历遍数   单样本更新次数   参数误差   小批 32 更新次数   参数误差
  -----------------------------------------------------------------
         1             4000     0.0438                125     0.0203
         2             8000     0.0479                250     0.0144
         4            16000     0.0196                500     0.0052
         8            32000     0.0143               1000     0.0022

  **第一行是 1 遍之后:单样本更新了 4000 次,误差 0.0438。**
  **一遍之内就更新了 4000 次——方向很糙,但架不住次数多。**
  **8 遍之后误差 0.0143,趋势向下,但第二遍时反弹过。**
  **反弹是单样本梯度自带的噪声:一条样本代表不了整体,它给的方向时准时歪。**
  **所以步长必须小、还要衰减,不然到最优点附近只能抖。**
  **小批 32 那边 8 遍只更新了 1000 次,误差 0.0022,一路单调下降。**
  **次数少了 32 倍,最后的误差反而小 6 倍——因为每次的方向准得多。**
  **更关键的是步长:小批 32 敢用 0.02,单样本只敢用 0.003。**
  **批越大,梯度方差越小,步长就能开得越大,末端抖得也越轻。**
  **这就是小批在实践中几乎总赢的原因——不只是省时间,是稳。**
  **闭式解的 MSE 是 0.0938,两条路都在逼近它。**
  **因为目标函数是凸的,最小二乘只有一个最低点,没有局部极小可以卡住。**
  **所以「梯度法能不能到全局最优」在这类问题上是「能」,而深度学习里就不是了——那才是它真正难的地方。**
  要说明:数据是我按线性模型**造的**;两条曲线的初始步长不同(0.003 与 0.02),都按 1 除以(1 加 0.8 倍遍数)衰减;
  **我没测实际耗时**——这个规模下 Python 循环的开销会盖过算法差别,量出来的时间不可信。**这一节比的是「更新次数」这个与机器无关的量。**

注意 2.0 那个系数。平方损失的梯度是 2 乘 (预测减真值),漏掉它,实际步长就只有一半。 这不是理论洁癖:漏掉之后你会觉得「步长怎么调都慢」,然后把步长调到发散。 order = r2.permutation(n) 要在每一遍里重做。如果只打乱一次、后面按同样顺序遍历, 批内样本的构成会固定下来,梯度就带上系统性偏斜。这个小细节在真实项目里影响很明显。 两条曲线的初始步长不同,这是本节最容易被忽略的一点。很多人拿同一个步长比单样本和小批, 然后得出「小批更差」的结论。 那是因为步长是按单样本调的,公平的比较必须让两边各自取到合适的步长。 误差用「与闭式解的距离」而不是损失,是为了看得清。损失早就落在 0.09 附近,两条曲线重在一起; 参数距离还差着 6 倍,这个差才能说明谁更接近正确答案。 要说明:数据是我按线性模型造的,两条曲线的步长不同(0.003 与 0.02)。 我没测实际耗时,这个规模下 Python 循环的开销会盖过算法差别。 这一节比的是「更新次数」这个与机器无关的量。

30 秒实验

  1. 把单样本的步长从 0.003 改成 0.01,看第二遍那个反弹会不会更夸张,甚至发散。 这就是「步长过大」的形态:误差先降后炸。
  2. 把小批从 32 改到 256,步长从 0.02 提到 0.1。 8 遍之后误差会更小还是更大?想想每遍只剩 15 次更新这件事。
  3. 把打乱顺序去掉,再跑单样本 8 遍。 数据本身没有排序,所以差别不大;但如果先把 X 按第一列排序,差别就出来了。

常见误区

  1. 「随机梯度下降是近似算法,结果不如闭式解」。 对凸问题,它收敛到同一个最优点,只是慢。Demo 里 8 遍就到 1e-3 量级。 「近似」指的是中间过程的抖动,不是最终答案。
  2. 「批越大越好」。 批大到一定程度,每遍的更新次数就少到不够用; 而且梯度的方差早就降到底了,再大只是浪费算力。实践中几十到几百是常见区间, 超大模型才用几千。
  3. 「步长可以随便试」。 步长的量纲和问题强相关:特征缩放没做、梯度公式差了系数, 都会让「别人能用的步长」在你这儿发散。正确的做法是先算梯度范数,再按范数定步长。
  4. 「闭式解已经过时了」。 小规模问题上它仍然是最优选择:无超参、可复现、一次到位。很多论文的「新方法不如基线」, 就是因为基线用了最优的闭式解、而新方法还在调步长。
自测三问(点开对答案) 1. 为什么小批的步长可以比单样本大好几倍? —— **因为批内平均把梯度的方差压小了。**单样本梯度的噪声和真实梯度同量级,步长一大就被噪声带跑; **批平均之后噪声按批大小的平方根缩小,步长就有了余量。**这是小批在实践中几乎总赢的根本原因。 2. 闭式解的复杂度是 b 的三次方,那 b 大就不行了吗? —— **不一定。**b 的三次方在 b 到几千时还能接受,而且 n 那一头是线性的、可以用并行和分块解决。 **真正致命的是「数据读不完」**,那是内存和 I/O 的限制,不是算力的限制。 所以选闭式解还是迭代,先看数据能不能一次读进来,再看 b 有多大。 3. 如果训练损失曲线一直很平,怎么判断是收敛了还是卡住了? —— **看参数的变化量,不要看损失。** **目标函数在最优点附近是平的,损失曲线的分辨率不够。**另一个办法是拿一小批留出数据看误差, 它比训练损失敏感。3.1 那个「200 步损失已到位、参数还差 0.23」就是这个问题。

第 3 章结束,最小二乘这条线走完了:有闭式解、有性质、有大规模版本。下一章开始给它加约束: 先限制解只能落在某个子空间里,再限制系数不能太大,最后讨论怎么选约束的强度。

相关笔记