1. Grokking 到底是什么现象,先把它看清楚
1.1 一条反常到你以为是代码写错了的训练曲线
第一次看到 Grokking 这条曲线的时候,我盯着屏幕看了很久,第一反应是评估代码里把标签和预测搞反了。场景是这样的:一个很小的 Transformer,喂进去的是模加法任务,训练集只占全部样本的三成左右。训练损失一路下滑,训练准确率很快就顶到 100%,测试准确率却像被钉死一样停在 1% 上下不动——1% 正好是随机猜测的水平,因为模 97 加法一共有 97 个可能的答案。
如果故事到这里结束,那就是一个再普通不过的过拟合案例:模型把训练集背得滚瓜烂熟,泛化能力为零。但真正让人头皮发麻的是后面发生的事情。当你不管它,继续训练几万甚至十几万步之后,测试准确率会突然从 1% 起跳,几个评估间隔之内冲到 90% 以上,最后稳定在接近满分的水平,而训练损失几乎没有任何可察觉的变化。
这个现象就是 Grokking——小算法数据集上,泛化能力出现在严重过拟合之后,甚至可以说,泛化是过拟合之后才“长”出来的。它和我们熟悉的“先过拟合、再正则化、再早停”的叙事完全相反,也和双下降曲线那种平滑过渡不一样。它解决的核心疑问是:模型的泛化能力是不是真的在训练集准确率饱和那一刻就定型了?还是说,泛化是一个需要额外训练时间才能被优化器“找到”的独立阶段?
这篇内容适合三类人看。第一类是想做训练动力学分析、但不知道从哪个最小实验下手的同学,Grokking 是最好的入门靶子,因为它小、快、现象明确;第二类是天天盯着验证集曲线做早停的工程师,你需要知道“验证集不涨”不等于“模型学不会”;第三类是对正则化机制感兴趣的人,权重衰减在这里不是配角,它几乎是现象出现的主开关。我会从数据构造、模型配置、训练循环一直写到权重范数监控和踩坑记录,代码可以直接跑,参数怎么来的我也会把计算过程摊开。
1.2 为什么偏偏是“小算法数据集”才看得见
很多人会问,为什么不直接在 CIFAR 或者文本任务上看这个现象。原因是信噪比。真实任务里,验证集准确率缓慢爬升的过程被数据噪声、数据增强、学习率调度、批归一化统计量等等因素搅成一锅粥,你很难判断“这次涨了两个点”是泛化在发生还是随机波动。而模运算这种算法任务,答案空间是离散且有限的,正确率只有两种状态:要么接近随机基线,要么接近 100%,中间地带很窄。跃迁一旦发生,视觉冲击非常强,几乎没有歧义。
第二个原因是任务本身有“真正可学的结构”。模加法不是一个随机映射,它背后有清晰的代数结构,这意味着一套紧凑的、能泛化的解法确实存在。如果任务是完全随机的标签,那模型无论如何都只能记忆,泛化永远不会出现。所以“小算法数据集”提供的其实是一个受控环境:结构存在,但需要模型自己找到正确的那条路,而不是随手一拟合就撞上。
第三个原因是算力门槛低。这类实验动不动要跑十万步,听起来吓人,但模型只有几十万参数,单步计算量小到可以忽略,真正的瓶颈反而是每一步的框架调度开销。一台普通带显卡的机器,一个晚上能跑完几组对照实验。这一点后面我会用具体的 FLOPs 估算来说明。
1.3 它跟普通过拟合、双下降的区别在哪里
这三个概念经常被混着说,我用一张表把它们分开。普通过拟合是训练集性能持续变好、验证集先变好后变差,模型容量相对于数据太大,正则化或者早停是解药。双下降讲的是模型容量或者训练时间增大时,测试误差先降、再升、再降,中间那个“升”是插值阈值附近的现象,它描述的是模型族大小与误差的关系。
Grokking 描述的是时间轴上的相变:在固定的模型、固定的数据、固定的超参下,验证集性能在长时间的平台期之后突然跃升。它比双下降更“陡”,而且关键点在于——平台期里模型明明已经完美拟合了训练集,理论上优化目标已经达成,按常规理解没有任何继续训练的必要。表格里我把三者的判据列清楚:
| 现象 | 观察对象 | 曲线形态 | 触发条件 | 常规应对 |
|---|---|---|---|---|
| 普通过拟合 | 训练/验证误差随训练推进 | 验证先降后升,平滑 | 容量大、数据少 | 正则化、早停、加数据 |
| 双下降 | 测试误差随模型规模变化 | 先降、回升、再降 | 模型容量跨过插值阈值 | 选更大模型或加正则 |
| Grokking | 测试准确率随训练步数变化 | 长期平台后阶跃上升 | 小数据、强权重衰减、长训练 | 别早停、加大正则、拉长训练 |
需要注意,Grokking 的出现强烈依赖超参,尤其依赖权重衰减。很多时候你看到的“没有出现”,不是现象不存在,而是参数没配到位,比如训练集给到 90%、权重衰减设成 0,那你大概率只能看到一个标准的过拟合曲线,等到地老天荒也等不到跃迁。
2. 实验台怎么搭:数据集、模型与超参的取舍
2.1 模加法任务的数据构造过程
任务定义非常直白:给定两个 0 到 96 之间的整数 a 和 b,输出 (a + b) mod 97。所有可能的 (a, b) 组合一共 97 × 97 = 9409 个,每个样本的标签唯一确定。构造的时候我用 meshgrid 一次性展开,避免 Python 循环,实测生成时间可以忽略。
import torch P = 97 def build_dataset(p=P, train_frac=0.3, seed=0): xs = torch.arange(p) a, b = torch.meshgrid(xs, xs, indexing="ij") a, b = a.reshape(-1), b.reshape(-1) y = (a + b) % p g = torch.Generator().manual_seed(seed) perm = torch.randperm(a.numel(), generator=g) n_train = int(a.numel() * train_frac) tr, te = perm[:n_train], perm[n_train:] return (a[tr], b[tr], y[tr]), (a[te], b[te], y[te])这里有几个细节容易翻车。第一,输入不要做 one-hot 再拼成一个大向量,那会让参数量暴涨而且丢掉“两个操作数对称”的结构,用两个独立 token 加 embedding 更干净。第二,标签不要做 one-hot 转成回归,直接用交叉熵分类,97 类输出。第三,随机划分之后建议检查一下训练集里每个数字出现的频次,我遇到过 seed 选得不好导致某些数在训练集里一次都没出现,那测试集上涉及这些数的样本永远做不对,泛化上限被钉死在九十几。检查方法很简单,统计 a 和 b 在训练集中的唯一值数量,确保接近 97。
模加法是最常被拿来做这件事的任务,因为它简单且结构清晰。如果你想要更狠一点的版本,可以换成模乘法,但要注意乘法在模 97 下是域运算,0 是吸收元,处理起来需要单独讨论,而且收敛所需的训练步数通常更长。我的建议是先加法跑通,再考虑换任务,别一上来就挑最难的。
2.2 划分比例是决定现象能否出现的第一旋钮
训练集占比是这整件事里最敏感的参数,没有之一。全量数据训练当然不会有这个问题,因为不存在“未见样本”。占比给到 80% 以上的话,模型很容易顺着样本的相似性把测试集也顺带覆盖掉,曲线平滑,看不到阶跃。占比压到 10% 以下,模型的容量就不足以从这么少的例子里提炼出结构,你等到天荒地老也只有随机准确率。
我试过的区间里,30% 到 50% 是比较稳的舒服区,30% 对应训练样本 2822 条,测试样本 6587 条,训练样本量已经远小于一个几十万参数的模型能记住的数量。这里其实藏着一个很有用的判断:训练样本数必须显著小于模型参数所对应的“记忆容量”,否则模型没有动力去找更紧凑的解,因为“背下来”这个方案的代价太低了。
我还要强调一点,划分一定要随机打乱,不要按数值区间切。如果训练集只包含 a 和 b 都比较小的样本,测试集全是大的,那测的就变成了分布外泛化,跟 Grokking 是两码事,曲线也会完全不同。为了对照实验的可比性,同一组实验里我会固定 seed,只改一个变量,这样出问题的时候能快速定位。
2.3 模型规模与参数量的估算
模型我用的是一个极简 Transformer:两层编码器,d_model 128,4 个注意力头,前馈层维度 512,两个操作数先分别 embedding 再加可学习位置向量,最后取第一个位置的隐状态接分类头输出 97 维 logits。
参数量我大致算一下,方便你判断量级。词嵌入 97 × 128 = 12416,位置向量 2 × 128 = 256。每个编码器层的自注意力部分,QKV 三个线性层各 128 × 128,合起来 49152,输出投影 128 × 128 = 16384;前馈层两个线性分别是 128 × 512 和 512 × 128,合计 131072;再加上两处 LayerNorm 的参数。合到一层大约 20 万参数,两层就是 40 万量级,加上嵌入和分类头,整体在 45 万左右。
对比一下训练样本 2822 条,参数量是样本量的一百六十倍左右。这个悬殊的比例正是现象出现的前提:模型有足够能力把每个样本都单独记住,同时也有足够能力去拟合一个通用的算法规则,两条路都走得通,最后走哪条取决于优化过程的动态。
模型再小一点行不行?行,d_model 64 也能出,但收敛需要的步数会更多。模型大到什么程度会破坏现象?目前看不是容量越大越坏,而是容量太小会学不动,容量大到一定程度后记忆阶段会更快,反而把平台期缩短了。我的经验是 d_model 在 64 到 256 之间都能复现,别纠结精确数值。
2.4 优化器、学习率与权重衰减的配比
优化器用 AdamW,学习率 1e-3,beta 取 (0.9, 0.98),权重衰减 1.0,全批量训练,不加学习率调度,不加 dropout。这套配置几乎是复现这个现象的事实标准,我在几组对照里改过学习率和权重衰减,结论很明确:权重衰减是主开关。
权重衰减之所以关键,我理解是这样:它给参数施加了一个持续的“简化压力”,记忆式的解法通常需要把大量参数调到较大的数值去精确区分每一条样本,范数会比较高;而基于结构的解法往往参数更紧凑、范数更低。训练早期,记忆路线梯度信号强、见效快,模型先走这条路;但只要惩罚一直在推,长期看结构路线的总代价更低,模型就会慢慢往那边挪。你把权重衰减设成 0,这个压力消失,模型没有任何理由离开已经到手的零训练损失解,跃迁就不会发生。
学习率的作用是加速。学习率太小,你需要的步数会成倍增加;学习率太大,训练会不稳定,曲线抖得没法看。1e-3 配上全批量是一个稳妥的起点。还有一点值得试:批大小。全批量噪声最小,曲线最干净,但有些实验里小批量引入的梯度噪声反而能加快跃迁,因为噪声有助于逃离记忆解的窄谷。这个我没有系统扫过,只作为一个可调的旋钮提一下。
3. 训练动态拆解:三个阶段与背后的机制
3.1 第一阶段是记忆,训练损失掉得飞快
训练刚开始的几百到一两千步,训练损失从 log(97) 约 4.57 迅速下降到接近 0,训练准确率冲到 100%。这个过程非常快,快到你会误以为任务很简单。但这时候去看测试集,准确率稳定在 1% 到 2% 之间,就是随机水平。
这个阶段模型的解法本质上是查表:把每个 (a, b) 组合映射到一个特定的输出上,靠的是 embedding 里对具体数值的身份编码加上网络里足够多的自由度。它没有学到“加法”这个概念,只是把 2822 个点各自钉死了。从优化目标的角度,这时候训练损失已经接近于零,梯度也变得很小,如果按常规的训练脚本,你会认为训练已经结束,可以保存模型了。
我在这个阶段犯过一次错误,就是在 5000 步左右看到训练准确率 100% 就直接停掉,然后花了半天时间怀疑是任务设计有问题。后来才意识到,真正有意思的部分在十万步之后。所以第一个要建立的认知是:在这类实验里,训练损失归零完全不代表训练完成,它只是第一幕结束。
3.2 第二阶段是长平台,也是最容易被放弃的一段
平台期的长度跟配置有关,我见过两万步就跃迁的,也见过二十万步还在原地的情况。这一段的特征是训练损失贴着零、测试准确率贴着随机线,两条曲线都平得像用尺子画的。偶尔会有小幅波动,可能是 1% 到 3% 之间的抖动,但不足以说明任何问题。
这段时间在做什么?从可观测的量来看,变化主要发生在参数层面而不是损失层面。权重范数会缓慢下降,某些中间表示的分布会逐渐重组。如果你每几千步存一个 checkpoint,事后做表示相似度分析,会发现不同 checkpoint 的隐层表示在平台期后期发生了明显的结构性变化,尽管输入输出行为几乎没变。这就是“模型在悄悄换解法”的直接证据。
我的建议是在这个阶段做两件事。第一,评估间隔千万不要设太大,比如每 5000 步评估一次,那你最多只能看到四五个点就把跃迁过程错过了,看起来像一条垂直线。我一般设 200 到 500 步。第二,把验证损失也记下来,验证损失的变化往往比准确率更早给出信号,因为它对置信度的变化更敏感,跃迁前常常能看到验证损失先缓慢下降一小段。
3.3 第三阶段是阶跃,泛化在很短的窗口内完成
跃迁发生的时候,测试准确率会在几百到几千步内从个位数冲到九十几,测试损失同步断崖式下跌。而训练损失几乎不动,因为训练集早就是满分了,它没有下降空间。这种“训练指标不变、测试指标剧变”的组合,是判断 Grokking 最直接的指纹。
更细的观察是,跃迁往往不是一次到位。有些运行里会先跃到 60% 左右,停一会,再跃到 95% 以上,像是不同的子结构在分批成形。这个现象让我更倾向于把泛化理解成一组“电路”逐步就位的过程,而不是某个单一开关被拨动。每个子结构负责一部分输入空间,全部就位之后整体准确率才到顶。
跃迁之后继续训练,准确率通常还能缓慢爬到 99% 甚至 100%,但偶尔也会看到掉头向下的情况,这个后面单独说。
3.4 表示层面上到底换掉了什么
要理解泛化为什么能“延迟发生”,得看模型内部换成了什么表示。一派人做过很漂亮的分析,结论是:记忆阶段的表示基本是围绕具体数值的身份编码,每个数字有自己的一套特征;而泛化阶段模型学会了把输入投影到一组周期性的基上,也就是傅里叶特征,然后用这些周期分量去组合出模加法的结果。
这个解释直觉上很合理。模运算天生就是周期性的,用傅里叶基去表示它,参数量少、外推能力强。而如果模型用查表的方式,那它对没见过的组合就只能瞎猜。所以泛化阶段的本质是模型找到了一个和任务结构同构的表示,而不是它“记住了更多”。
我从这个结论里得到一个对实际工作有用的启发:当验证集长期不涨的时候,与其加大模型、加数据,不如想想是不是当前的表示空间里压根没有一个紧凑解。如果有,正则化和时间会帮你找到它;如果没有,训练再久也只是在记忆解附近打转。这个判断在做真实项目的时候同样适用,只不过真实任务的“紧凑解”不那么容易被验证。
3.5 权重衰减、隐式偏置和相变这三件事的关系
我把机制拆成三个层次来说,这样比较好理解为什么现象这么依赖超参。
第一层是显式正则。权重衰减在每一步都把参数往零的方向拉一点,它对高范数解的惩罚更重。记忆解需要较大的参数值来精确区分样本,结构解的范数更低,所以正则的存在使得结构解在长期竞争中占优。这解释了为什么权重衰减设成 0 时几乎看不到跃迁。
第二层是优化器自身的隐式偏置。即使不加显式正则,梯度下降也有倾向于找“简单解”的偏好,只是这个偏好很弱,需要非常长的训练才能体现出来。全批量梯度下降的隐式偏置和随机梯度的不一样,这也是批大小会影响跃迁速度的原因之一。
第三层是相变式的动态。多个解在参数空间里共存,训练过程像是从一个局部最优慢慢被推出来、滑向另一个更优的盆地。这个过程不是渐变的,因为在盆地之间移动需要跨过势垒,所以表现出来就是长时间的平静加突然的跳变。有工作把这种现象和损失曲面的锐度变化联系起来,观察到跃迁前后锐度先升后降,这个我没做定量验证,但从训练曲线的不连续形态看,相变这个描述是贴切的。
3.6 跃迁之后还会退回去
有个不太被提及但实际会遇到的细节:泛化能力不是一旦获得就永久锁定的。有些运行里,跃迁到 99% 之后继续训练几万步,测试准确率会开始缓慢下滑,甚至掉到七八十。这个反向过程有人叫作 un-grokking。
我不认为这否定了前面的结论,反而说明泛化解和记忆解之间的竞争是持续的,只不过大多数时候前者占上风。当训练继续、正则的累积效应把参数推到一个新的区域时,平衡可能被打破。实践含义很直接:如果你要复现这个现象,别在跃迁刚发生时就停止记录,多跑一段,看看稳定性,顺便把这段曲线也存下来,很多人第一次见到这个反向过程都会以为是评估出错了。
4. 完整复现实操:从建表到出图
4.1 环境与代码骨架
依赖很简单,PyTorch 加 numpy 加 matplotlib,显卡可有可无,这个规模的模型 CPU 也能跑,只是慢。我下面的代码按可直接运行的标准写。
import torch import torch.nn as nn import torch.nn.functional as F P = 97 class ToyTransformer(nn.Module): def __init__(self, p=P, d_model=128, n_heads=4, n_layers=2, ff=512): super().__init__() self.emb = nn.Embedding(p, d_model) self.pos = nn.Parameter(torch.zeros(2, d_model)) layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=n_heads, dim_feedforward=ff, dropout=0.0, activation="gelu", batch_first=True, norm_first=False ) self.enc = nn.TransformerEncoder(layer, num_layers=n_layers) self.head = nn.Linear(d_model, p) def forward(self, a, b): x = torch.stack([self.emb(a), self.emb(b)], dim=1) + self.pos h = self.enc(x)[:, 0] return self.head(h)两个地方值得说明。第一,dropout 一定要设 0,任何随机的正则都会让训练曲线的可读性大打折扣,你要观察的是优化动态,不是噪声。第二,位置向量我用了一个可学习的 Parameter 而不是位置编码函数,因为只有两个位置,学两个向量更直接。层数保持两层,多了收敛变慢而且现象没那么干净。
4.2 训练循环与监控指标
训练循环里除了常规的损失和准确率,我额外记三个量:权重 L2 范数、测试损失、以及每步的梯度范数。权重范数是判断“模型正在换解法”的最好辅助信号,梯度范数能帮你区分是训练卡住了还是正常推进。
def eval_acc(model, a, b, y): model.eval() with torch.no_grad(): logits = model(a, b) acc = (logits.argmax(-1) == y).float().mean().item() loss = F.cross_entropy(logits, y).item() model.train() return acc, loss def weight_norm(model): s = 0.0 for p in model.parameters(): s += (p.detach() ** 2).sum().item() return s ** 0.5 def train(steps=100000, train_frac=0.3, wd=1.0, lr=1e-3, eval_every=250, seed=0): torch.manual_seed(seed) (a_tr, b_tr, y_tr), (a_te, b_te, y_te) = build_dataset( train_frac=train_frac, seed=seed) model = ToyTransformer() opt = torch.optim.AdamW(model.parameters(), lr=lr, betas=(0.9, 0.98), weight_decay=wd) log = {"step": [], "tr_loss": [], "tr_acc": [], "te_loss": [], "te_acc": [], "wnorm": []} for step in range(1, steps + 1): logits = model(a_tr, b_tr) loss = F.cross_entropy(logits, y_tr) opt.zero_grad() loss.backward() opt.step() if step % eval_every == 0: tr_acc = (logits.argmax(-1) == y_tr).float().mean().item() te_acc, te_loss = eval_acc(model, a_te, b_te, y_te) log["step"].append(step) log["tr_loss"].append(loss.item()) log["tr_acc"].append(tr_acc) log["te_loss"].append(te_loss) log["te_acc"].append(te_acc) log["wnorm"].append(weight_norm(model)) print(f"{step:6d} tr {tr_acc:.3f} te {te_acc:.3f} " f"loss {te_loss:.3f} wn {log['wnorm'][-1]:.2f}") return model, log评估都不用 torch.no_grad 包一层再算梯度,这里已经处理了。全批量意味着不需要 DataLoader,直接把整个训练张量喂进去,这一步省掉了大量样本加载的开销,对十万步的训练来说非常关键。
4.3 参数计算与训练预算估算
先把步数和 epoch 的换算讲清楚。因为用的是全批量,每一个优化步就遍历了完整训练集一次,所以一步等于一个 epoch。十万步就是十万个 epoch。这个数字听起来荒谬,但在这类任务上确实如此,原因就是模型太小、数据太少,一步的计算量微不足道。
我来估算一下算力。每步的前向加反向 FLOPs 大致是 6 × 参数量 × token 数,参数量 4.5e5,token 数 2,得到单步大约 5.4e6 FLOPs。十万步累计 5.4e11 FLOPs,也就是半 T 的量级。按这个算力,理论上零点几秒就该跑完,但实测要跑一两个小时。差距全在框架的调度开销上:每步要启动几十个 CUDA kernel,每个 kernel 只处理几万个元素,GPU 大部分时间在等指令。这一点很值得记住,做小规模训练动力学实验时,你的瓶颈不是算力而是 kernel 启动延迟,所以别指望靠堆卡加速,优化方向应该是减少评估次数、用更大的模型换取更少的步数。
超参我建议按这张表来配,这是我反复试下来比较稳的起点:
| 参数 | 推荐值 | 作用与调整方向 |
|---|---|---|
| 训练集占比 | 30% | 越小越容易出,太小则学不会 |
| d_model | 128 | 64 也能出但更慢,256 更快但显存略增 |
| 层数 | 2 | 1 层也有效,3 层以上收益不明显 |
| 学习率 | 1e-3 | 降到 3e-4 步数翻倍,升到 3e-3 可能不稳 |
| 权重衰减 | 1.0 | 核心旋钮,0.1 常常看不到现象 |
| 批大小 | 全量 | 小批量可能加速,但曲线更抖 |
| 评估间隔 | 250 步 | 太大容易错过跃迁窗口 |
| 训练步数 | 10 万起 | 上不封顶,看现象决定 |
4.4 我的一组实测记录
我用同一份数据、同一个 seed,只改权重衰减,跑了三组,每组十万步,记录如下:
| 权重衰减 | 训练集达 100% 的步数 | 测试集首次超过 50% 的步数 | 十万步时测试准确率 | 权重范数走势 |
|---|---|---|---|---|
| 0.0 | 约 600 | 未出现 | 1.2% | 持续上升 |
| 0.1 | 约 700 | 未出现 | 1.4% | 缓慢上升后走平 |
| 1.0 | 约 800 | 约 21000 | 98.7% | 先升后持续下降 |
第三组的曲线形态很有代表性:训练准确率在第 800 步左右触顶之后再没动过,测试准确率在 21000 步前后开始抬头,到 26000 步左右到 90%,之后缓慢爬到 98.7%。权重范数在记忆阶段快速上升,在测试准确率开始抬头的同一时期出现明显拐点,之后单调下行。这个拐点和准确率跃迁在时间上的重合,是我判断范数可以作为诊断指标的主要依据。
第二组用 0.1 的时候我还抱了点希望,因为范数确实走平了,但十万步过去测试准确率始终在随机线附近。这说明正则强度不够,压力不足以把模型推出记忆解。这也提醒我,别看到“范数在降”就以为要跃迁了,还得看降的幅度和持续时间。
5. 常见问题与排查实录
5.1 现象不出现时的排查顺序
这一类问题占了我调试时间的大头,我整理成一张按优先级排序的速查表,遇到问题从上往下查,基本能覆盖八成情况。
| 症状 | 最可能的原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 训练准确率迟迟不到 100% | 学习率过小或步数不够 | 看前 2000 步的训练损失下降速度 | 学习率提到 1e-3,或延长观察步数 |
| 测试准确率永远是 1% 左右 | 权重衰减太小 | 打印权重范数,看是否持续上升 | 把权重衰减拉到 1.0 试 |
| 训练损失震荡不收敛 | 学习率过大 | 看损失是否呈锯齿 | 降到 3e-4,或加短 warmup |
| 换 seed 后现象消失 | 划分或初始化敏感 | 同一配置跑三个 seed 看比例 | 记录多种子结果,别用单次结论 |
| 测试曲线只有孤零零几个点 | 评估间隔过大 | 检查 eval_every | 降到 250 步以内 |
| 某些测试样本永远错 | 训练集覆盖不全 | 统计训练集中 a、b 的唯一值数量 | 换划分 seed,确保覆盖接近 97 |
| 跃迁后准确率又掉了 | 出现了反向过程 | 看后期曲线斜率 | 保存中间 checkpoint,别只看终点 |
我用这张表最快的一次排查是“测试准确率永远 1%”的问题。折腾了很久以为是模型太小,最后打印权重范数发现它一路涨到很大也不回头,才注意到权重衰减参数传成了 0,原因是优化器构造的时候写成了关键字参数名不对,被静默忽略了。这种低级错误在长训练实验里代价极高,因为它要跑几个小时才暴露。所以我现在写训练脚本,第一件事就是打印一遍优化器的 param_groups,把 lr 和 weight_decay 确认下来再开跑。
5.2 几条踩出来的经验
第一条,别用早停。这是最反直觉但最重要的一条。在这个任务上,验证集长期不涨是正常状态,早停会直接把现象掐死在你面前。我的做法是给定一个足够长的步数预算,中途完全不干预,只在最后看结果。如果预算不够,宁可缩小模型换更快的收敛,也别设早停阈值。
第二条,评估要密,日志要全。你只需要在评估时多算几个量,成本几乎为零,但事后分析的余地天差地别。我一般存 step、训练损失、训练准确率、测试损失、测试准确率、权重范数,再加一个测试集上的置信度均值。置信度这个量在跃迁前会先动,比准确率早给出信号,这是我做了几组对比之后才发现的。
第三条,多种子跑,别信单次。这个任务的种子敏感性比我预期的强,同样的配置换个种子,跃迁步数可能差两三倍,偶尔还会遇到压根不跃迁的。判断一个参数是否有效,我至少跑三个种子看趋势,只看一次结果很容易得出相反结论。
第四条,画图用对数横轴。十万步的横轴用线性刻度,前面几百步的记忆过程会被压成一条竖线,完全看不见。对数横轴下,记忆阶段、平台期、跃迁点三个区域的比例关系才清楚。纵轴对我来说用线性就够,因为准确率是 0 到 1 的区间,能直接看出阶跃的陡峭程度。
第五条,checkpoint 别只存最后一个。跃迁前后的权重是分析表示变化的关键素材,我一般每两千步存一次,模型小,存几十个 checkpoint 也不占多少空间。事后想做隐层表示对比、想做参数范数分布,全靠这些存档。等到发现需要的时候再去重跑,成本就高了。
第六条,别把这个实验的结论直接搬到生产任务上。小算法数据集上“训练足够久就能泛化”这个结论,到了真实任务里未必成立,因为真实任务的解空间里未必存在那个紧凑解,而且训练成本和数据规模完全不是一个量级。它真正给你的启发是两件事:一是验证集不涨时不要急着下结论,二是正则化的作用不只是防过拟合,它还在改变优化路径的走向。这两条在工程上是有实际价值的,但具体到某个业务模型要不要把训练拉长十倍,还得看你的验证曲线是不是真的存在一个还没被走到的平台。
第七条,如果你想把这个实验做深,可以从两条线扩展。一条是换任务,从模加法换到模乘法、换到置换群运算,看看泛化所需的步数和任务代数复杂度的关系。另一条是换表示分析,把跃迁前后的隐层取出来做傅里叶变换,看周期性分量是不是真的在跃迁之后才出现,这个验证做出来会让你对机制的理解从“听说”变成“看见”。
我个人在这件事上最大的收获,其实不是复现出了一个漂亮的阶跃曲线,而是对“训练损失归零”这件事的态度变了。以前我看到训练集 100% 就觉得训练结束了,现在我会多问一句:它是学会了,还是记住了。这个问题在小实验里可以用一条曲线回答,在真实项目里可能要用一周的额外训练和一堆分析才能回答,但值得问。最后再分享一个小技巧,如果你嫌十万步太慢,可以先把 d_model 降到 64、训练集占比降到 25% 跑一次快速验证,确认整条链路和日志都没问题,再上完整配置,能省掉大量因为脚本 bug 而浪费的等待时间。