模运算加法这种任务,第一次看到训练曲线的时候我以为是代码写错了:训练准确率几百步就冲到 100%,验证准确率死死卡在 0.9% 附近——113 类分类,随机猜就是 0.88%。更离谱的是,如果你有耐心让它在原地"浪费"几个小时算力,验证准确率会在某个时刻突然抬起来,一路爬到 99% 以上,而训练准确率纹丝不动。这就是 Grokking:在小数据集上先过拟合到极致,然后才泛化。这篇东西就是围绕这个现象做的一次完整拆解,从小数据集构造、过拟合与泛化的曲线形态,到权重衰减、优化器配置、进度指标监控,再到我自己复现时踩过的坑。适合已经能跑通普通 Transformer 训练、想搞清楚"泛化到底是怎么冒出来的"的人,也适合刚接触这类小算法任务、被验证集曲线搞得怀疑人生的同学。
1. Grokking 到底怪在哪:过拟合之后再泛化的三段式曲线
1.1 一个能在笔记本上跑起来的最小现场
先把场景摆清楚,不然后面全是空谈。任务选模运算加法:给定两个 0 到 p-1 之间的整数 a 和 b,让模型输出 (a+b) mod p。p 取 113,这样一共有 113×113 = 12769 个合法输入对,输出类别是 113 类。整张表可以被完整枚举,这正是"小算法数据集"的典型特征——数据量小到可以穷举,规则又强到可以被精确描述。
接着做划分:随机取 30% 作为训练集,大约 3830 条,剩下 70% 约 8939 条全部当验证集。注意这里没有"测试集"的讲究,验证集就承担了衡量泛化的全部职责。
模型用一个单层 Transformer,d_model=128,4 个注意力头,前馈层 512 维,可学习位置编码,输入是三个 token(a、b、一个分隔符),在分隔符位置的输出接一个 113 维分类头。这一套参数加起来大概二十万上下,而训练样本只有三千八百多条,参数量是样本量的五十倍往上。这个比例本身就是"必然过拟合"的结构性前提——你给模型的空间远远大于数据能约束的空间,它当然会优先选一条最省力的路走。
优化器用 AdamW,学习率 1e-3,权重衰减 1.0,batch size 512,全量数据打乱循环喂。dropout 关掉,因为在这个任务里 dropout 会把信号搅乱,反而让现象变得不稳定。整个实验在一张普通显卡上跑十万步也就是几个小时的事,这是它作为"可复现最小案例"的价值所在。
1.2 训练曲线不是两段,是三段
普通训练的经验是两段式:先是欠拟合,loss 下降;然后收敛,训练和验证一起上去。Grokking 的曲线是三段式,中间多出一段很长的"平台期"。
第一段,训练准确率快速爬升,几百到几千步内到 100%,训练 loss 迅速逼近 0。验证准确率几乎不动,就在随机水平附近抖。这一段模型在做的事很直白:把三千多条训练样本的答案背下来。因为是查表式记忆,每条样本对应一个几乎独立的输出方向,模型不需要理解任何算术结构,只要给每个输入分配一个足够独特的内部表示就行。
第二段,平台期。训练 loss 已经是 0 或者极小的数值,验证 loss 可能还在缓慢上升或者不动,验证准确率纹丝不动。很多人到这里就放弃了,觉得模型烂了、任务不可学、代码有 bug。这一段能持续几千到几万步,取决于超参。
第三段,验证准确率突然起飞。它往往不是平滑上升,而是先缓慢爬一点,然后在一个很窄的步数窗口里陡峭地拉起来,最后停在 99% 左右,少数情况能到 100%。训练准确率全程不变,因为训练集早就背熟了,泛化提升不会让训练准确率再涨。
注意:判断是否真的复现出 Grokking,看的不是最终验证准确率有多高,而是"验证准确率起飞的时间点显著晚于训练准确率饱和的时间点"。如果两者几乎同时到 100%,那只是普通的学习,不是这个现象。
这里有个很容易犯的误判:把"验证准确率低"直接等同于"过拟合"。严格说过拟合指的是训练表现优于泛化表现这个差距,而 Grokking 有意思的地方在于,这个差距在训练后期会自己合拢。模型的容量没变,数据没变,梯度更新的形式也没变,唯一在持续起作用的是正则化项在参数空间里的长期累积效应。这个细节后面第 4 节会展开。
2. 复现设计:为什么这组超参能把现象逼出来
2.1 任务与数据划分的取舍
任务选模运算加法不是随便挑的。它满足三个条件:规则精确、样本可穷举、结构性强。模 113 的加法构成一个有限循环群,这种群结构意味着存在一种"用少量参数描述全部映射"的高效解法,比如利用离散傅里叶变换的周期性质。模型最终能学会的,就是这种紧凑解法,而不是三万多条独立规则。
如果换成随机映射任务,比如给一堆随机输入配随机标签,那就永远不会出现 Grokking,因为没有可泛化的结构可学,模型只能记忆。所以任务本身必须"有规律可循",这是前提条件。
训练集比例是关键旋钮。30% 是常见起点,50% 也能出效果但平台期会短一些,因为模型看到的结构线索更多。低于 20% 时训练会变慢,平台期拉得很长,有时候十万步都等不到起飞。我的经验是先用 30% 把现象稳定复现出来,再去做比例扫描。扫描的时候发现一个规律:数据比例越高,泛化起飞越早;但比例高到一定程度(比如 80%),现象会退化成普通学习,因为模型从训练集里就能直接归纳出结构,不需要靠正则化去逼。
数据划分一定是随机打乱后切分,不能按数值区间切。如果训练集只取 a 和 b 都小于 80 的组合,验证集就会系统性地外推到分布外,那是另一类泛化问题,会干扰你对 Grokking 的观察。我第一版代码就是顺序切分的,结果验证准确率永远上不去,排查了半天才发现是数据分布偏移,不是现象没复现。
2.2 权重衰减是导演,不是配角
这个实验里最反直觉的一点:权重衰减从"防止过拟合的辅助手段"变成了"驱动泛化的主要动力"。把 weight decay 设成 0,训练准确率照样很快到 100%,验证准确率会永远停在随机水平附近,十万步也不动。设成 0.01 这种常规值,现象会变得极其缓慢甚至不出现。设成 1.0,现象稳定出现。
为什么是 1.0 这种看起来夸张的值?因为 AdamW 用的是解耦权重衰减,参数更新里有一项是 -lr × wd × θ,它和梯度项是分开算的。学习率 1e-3、权重衰减 1.0 的时候,每一步参数被收缩的比例大约是千分之一,等效时间常数在一千步量级。而 Grokking 的起飞点往往在几千到几万步,累积收缩量已经足够把高范数的记忆解压到低范数的结构解附近。换句话说,权重衰减在这里不是"轻微惩罚",而是一个持续几千步、方向恒定的搜索偏置。
我用一个不太严谨但很好懂的类比:模型一开始用"背答案"的方式解题,这种方法参数范数大、占用空间多,但到达得快;"理解规律"的解法参数范数小、更紧凑,但需要绕一段路才能找到。权重衰减就像一个持续施加的"精简压力",它不会立刻改变答案,但会慢慢把那些臃肿的记忆解挤掉,直到结构解成为更优的选择。
提示:调这个实验的时候,weight decay 应该是你第一个动的参数,而不是学习率。学习率主要影响训练段的快慢,权重衰减决定的是平台期长度和最终是否起飞。
2.3 模型容量与优化器的配置逻辑
模型不需要大。单层、128 维在这个任务上完全够用,甚至 64 维也能出效果,只是平台期会变长。用两层或者更宽不会让现象更容易出现,反而增加训练不稳定。这里的逻辑是:容量只需要"足以表达记忆解和结构解两种方案",不需要更多。容量再大,模型只是把记忆解表达得更轻松,平台期反而更久。
batch size 512 是个折中。全批量(12769)也能跑,但每步开销大、日志粒度粗;batch 太小(比如 32)会让梯度噪声变大,训练段抖得厉害,曲线不好判读。512 大概对应每步看到 512 条样本,一个 epoch 约 7.5 步,一千步约 133 个 epoch,这个粒度既能看清快速收敛段,又不会让日志刷屏。
学习率 1e-3 配 AdamW 是这个规模下的稳妥值。有人会问要不要 warmup,我的经验是单层小模型不做 warmup 也没问题,但如果加了权重衰减 1.0,前期梯度还比较大的时候,收缩项和梯度项会打架,头几百步 loss 可能会有个小波动,属于正常现象,不用急着调参。
dropout 关掉。这一点值得多说两句:dropout 会随机屏蔽一部分激活,等价于给模型加噪声,它会拖慢记忆解的收敛速度,让"先过拟合"这个阶段变得不明显,从而破坏整个对比结构。这个实验要的就是让模型干净利落地先记住,所以把随机性都关掉,只留权重衰减这一个长期作用力。
| 配置项 | 取值 | 作用定位 | 改动后的影响 |
|---|---|---|---|
| 数据集 | (a+b) mod 113 | 提供强结构 | 换成随机映射则现象消失 |
| 训练集比例 | 30% | 制造记忆压力 | 太高退化为普通学习,太低平台期过长 |
| 模型 | 单层 Transformer,d_model=128 | 容量刚好够表达两种解 | 过大不改善,过小无法记忆 |
| 学习率 | 1e-3 | 控制训练段速度 | 过大训练不稳,过小收敛慢 |
| 权重衰减 | 1.0 | 驱动解的选择 | 设 0 则永不泛化 |
| batch size | 512 | 平衡噪声与速度 | 过小噪声大,全批量日志粗 |
| dropout | 0 | 保留干净的过拟合阶段 | 开启会模糊对比 |
3. 动手实现:从数据生成到训练循环
3.1 数据管线的写法与几个坑
数据生成很直接,但有几个细节不注意就会卡住。词表要包含 0 到 p-1 的 p 个数字,再加一个分隔符 token,所以词表大小是 p+1。输入序列长度固定为 3,标签是一个 0 到 p-1 的整数。注意标签空间和数字 token 空间重合但不完全一样,分类头输出的是 p 维,不要写成 p+1,否则多出来的那一类永远不会被用到,还会稀释 softmax 的梯度。
import torch from torch.utils.data import TensorDataset P = 113 EQ = P # 分隔符 token 的编号 def build_all_pairs(p=P): a = torch.arange(p).repeat_interleave(p) b = torch.arange(p).repeat(p) y = (a + b) % p x = torch.stack([a, b, torch.full_like(a, EQ)], dim=1) return x, y def split_dataset(x, y, train_ratio=0.3, seed=0): g = torch.Generator().manual_seed(seed) idx = torch.randperm(x.size(0), generator=g) n_train = int(x.size(0) * train_ratio) tr, va = idx[:n_train], idx[n_train:] return (x[tr], y[tr]), (x[va], y[va]) x_all, y_all = build_all_pairs() (x_tr, y_tr), (x_va, y_va) = split_dataset(x_all, y_all, 0.3, seed=0) print(x_tr.shape, x_va.shape) # 约 3830 与 8939这段代码里最容易出错的地方是随机种子。每次换种子,平台期长度会有可见波动,有时候差一两万步。所以做对比实验时,种子必须固定,否则你会把随机波动误判成超参的影响。我的做法是固定三组种子各跑一遍,看现象是否在三组里都出现,只出现一组就说明配置还不够稳。
另一个坑是验证集的评估频率。如果每步都评估全量验证集,8939 条样本的前向开销会显著拖慢训练;如果评估太稀疏,比如每五千步一次,你可能正好错过起飞的陡峭段。每 100 步评估一次是个折中,起飞段的形态足够清楚。评估的时候记得切到 eval 模式,虽然这个模型没有 dropout 和 BN,但养成习惯没坏处。
3.2 模型与训练循环
模型部分没有花活,一个编码层加一个分类头。取分隔符位置的输出做分类,因为这个位置的表示同时"看过"了 a 和 b,信息最全。
import torch.nn as nn class TinyTransformer(nn.Module): def __init__(self, vocab, d_model=128, nhead=4, d_ff=512, n_layers=1, n_out=113): super().__init__() self.emb = nn.Embedding(vocab, d_model) self.pos = nn.Embedding(3, d_model) layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_ff, dropout=0.0, batch_first=True, norm_first=False ) self.encoder = nn.TransformerEncoder(layer, num_layers=n_layers) self.head = nn.Linear(d_model, n_out) def forward(self, x): pos = torch.arange(x.size(1), device=x.device).unsqueeze(0) h = self.emb(x) + self.pos(pos) h = self.encoder(h) return self.head(h[:, -1, :])训练循环的重点是全量打乱循环,而不是按 epoch 补零。因为这个任务没有"epoch 边界"的概念,样本会被反复看到上千次,写死 epoch 数会让代码逻辑变复杂。
import torch.optim as optim device = "cuda" if torch.cuda.is_available() else "cpu" model = TinyTransformer(vocab=P + 1).to(device) opt = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1.0, betas=(0.9, 0.98)) loss_fn = nn.CrossEntropyLoss() x_tr, y_tr = x_tr.to(device), y_tr.to(device) x_va, y_va = x_va.to(device), y_va.to(device) bs = 512 steps = 100000 ptr = 0 order = torch.randperm(x_tr.size(0)) for step in range(steps): if ptr + bs > x_tr.size(0): order = torch.randperm(x_tr.size(0)) ptr = 0 idx = order[ptr:ptr + bs] ptr += bs model.train() logits = model(x_tr[idx]) loss = loss_fn(logits, y_tr[idx]) opt.zero_grad() loss.backward() opt.step() if step % 100 == 0: model.eval() with torch.no_grad(): tr_acc = (model(x_tr).argmax(-1) == y_tr).float().mean().item() va_acc = (model(x_va).argmax(-1) == y_va).float().mean().item() wnorm = sum(p.norm().item() ** 2 for p in model.parameters()) ** 0.5 print(f"{step:6d} loss={loss.item():.4f} tr={tr_acc:.3f} va={va_acc:.3f} wn={wnorm:.2f}")这里有个实操细节:每 100 步算一次训练集全量准确率,3830 条的前向很快,可以接受。如果你想再省,可以用最近一个 batch 的准确率代替,但那样曲线会抖很多,判读起飞点不准确。我倾向于全量算,反正一天也就几万步。
参数量的粗略估算:注意力部分 4×128×128 约 6.5 万,前馈层 2×128×512 约 13 万,加上嵌入和输出层,总共二十万出头。训练集三千八百多条,参数量是样本的五十倍以上,这个比例直接决定了"先记忆"几乎是必然的路径。
3.3 训练日志怎么读:加三个监控指标
只看训练 loss 和验证 loss,你会错过最重要的信息。我建议在日志里加三个进度指标,它们是把黑箱变白箱的关键。
第一个是权重范数,就是上面代码里的 wnorm。它在记忆阶段会稳定上升,到平台期后期开始掉头向下,然后在验证准确率起飞前后出现明显下降。这个拐点和起飞点的时间关系,是我判断"现象是否健康"的主要依据。
第二个是"记忆损失"和"结构损失"的分离。具体做法是把训练集切成两部分:一部分是训练集独有、验证集里不存在的样本,另一部分是训练集和验证集共有分布特征的样本。更常见的做法是直接在验证集上算 loss,同时算训练集 loss,两者之差就是记忆带来的差距。真正起飞的时候,验证 loss 会先于验证准确率开始下降,这是最早的信号。
第三个是输出层的稀疏度或者 logit 分布。记忆阶段的 logit 分布很平,模型对每个输入都给出一堆差不多大的分数,只是正确答案略高一点;结构解形成之后,logit 会出现明显的周期性模式,正确答案的分数高得干脆。这个指标不太容易量化,但如果你把几个样本的 logit 画出来看,变化非常直观。
提示:起飞前的一两千步,验证 loss 往往先降,准确率后升。如果你只盯准确率,会觉得变化来得毫无征兆;盯住 loss 就能提前看到趋势。
4. 机制拆解:模型在"中途"到底发生了什么
4.1 范数、几何与解的选择
把参数空间想象成一片地形,解就是低谷。记忆解是一个窄而深的坑,每个训练样本都要被单独容纳,需要的参数组合很分散,整体范数大;结构解是一个宽而浅的盆地,用一套规则覆盖全部输入,参数紧凑,范数小。
优化器在训练初期走的是最陡的方向,梯度指向哪里就去哪里,记忆解离起点近,所以先到。到了记忆解之后,训练 loss 已经接近 0,梯度项变小,但权重衰减项没有变小,它的大小只和参数本身有关。于是每一步更新里,收缩项的相对权重越来越大,参数被慢慢往原点方向拉。这个拉力对所有参数一视同仁,但对大范数的记忆解影响更大——因为它离原点更远,被拉动的绝对距离更多。
拉到某个临界点,记忆解开始崩塌,模型的输出开始变得"模糊",训练准确率可能会出现短暂的小幅下跌,然后迅速被一个新的、低范数的解接管。这个新解就是结构解,它在训练集上表现一样好,但在验证集上因为抓住了共同规律而大幅提升。
这个过程的本质是"解的选择"问题,而不是"学的多少"问题。模型从头到尾都能完美拟合训练集,变的是它在众多能拟合训练集的解里,被正则化项推向了哪一个。
4.2 从记忆电路到算法电路
如果去拆开模型内部看,记忆阶段和解阶段用的完全是两套机制。
记忆阶段,模型倾向于用少数几个方向来"标注"每个输入。你可以理解成给每条训练样本分配一个专属的钥匙,钥匙之间没有关系,只是刚好能开对应的锁。这种方案在训练集上完美,但它不携带任何关于模运算的知识,遇到没见过的组合就抓瞎。
结构阶段,模型会形成类似三角函数的周期性表示。因为模 p 加法在数学上可以借助离散傅里叶变换来表达:把输入映射到一组频率上,相加对应相位相加,再变换回来就得到结果。模型不是被教了这个公式,而是在权重衰减的长期压力下,自己收敛到了这个参数配置,因为它是描述这个映射最紧凑的方式。
这两套机制不是渐变的,而是竞争的。在平台期里,两套表示可能同时以微弱的形式存在,谁也没占上风,所以验证准确率不动。随着正则化持续施压,紧凑的那一套逐渐占据主导,一旦过了某个阈值,输出行为会整体切换到新机制上,这就是曲线陡峭的原因。
我在复现时做过一个观察:在起飞点附近,模型对训练集的预测虽然还是对的,但 logit 的数值结构变化很大,从"一条高、其余低"变成"有明显周期规律的一排高"。这说明切换不是输出层的微调,而是内部表示的重新组织。
4.3 权重衰减的真实角色:一个带时间常数的筛选器
把上面两节合起来看,权重衰减在这里的作用可以描述成一个筛选器:它不直接告诉模型"应该学什么",只是持续地、均匀地提高大范数解的代价。这个筛选器有时间常数,大约等于 1/(lr × wd)。学习率 1e-3、衰减 1.0 的时候,时间常数在一千步量级,但真正要等到累积效应大到足以翻转解的选择,往往需要十倍以上的时间常数,这就解释了为什么平台期经常是几千到几万步。
这个模型还有一个可验证的推论:如果你把学习率调大,时间常数变小,起飞应该更早。实测确实如此,但学习率太大会让训练段本身不稳,得不偿失。反过来,把权重衰减调大,时间常数也变小,起飞同样提前,但衰减太大会让模型连记忆解都维持不住,训练准确率都上不去。所以这是一个有窗口的参数,不是越大越好。
注意:不要把这个解释当成唯一正确的理论。这只是一个目前解释力比较强、且和实验现象吻合得不错的图景。换个任务、换个架构,细节可能不同,但"正则化压力在长时间尺度上改变解的选择"这个核心思路,在不少类似现象里都能对上。
5. 常见问题排查实录
5.1 跑不出 Grokking 的六种原因
复现失败太常见了。我整理了一张排查表,按出现频率排序,基本都是我在不同阶段踩过的。
| 现象 | 最可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 验证准确率始终在随机水平,十万步不动 | 权重衰减设成了 0 或过小 | 打印优化器参数确认 wd | 调到 1.0 重跑 |
| 训练准确率都上不去 | 学习率过小或模型容量不足 | 看前一千步 loss 是否明显下降 | 学习率调到 3e-3 试,或加宽 d_model |
| 训练和验证同时到高位 | 训练集比例过高 | 检查切分比例 | 降到 30% 甚至 20% |
| 曲线剧烈震荡,看不出平台期 | batch 太小或学习率过大 | 看 loss 的方差 | batch 加到 512,lr 降到 1e-3 |
| 平台期特别长,几十万步还没动 | 种子运气差或 wd 偏小 | 换三个种子各跑一遍 | 换种子,或把 wd 提到 2.0 |
| 验证准确率起飞后又掉回去 | 权重衰减过大,结构解被继续压缩 | 看起飞后的 wnorm 是否还在掉 | 把 wd 降到 0.5 附近 |
表格里最后一条值得单独说。起飞后掉回去这种情况我遇到过一次,当时 wd 设成了 3.0,验证准确率冲到 98% 之后又慢慢滑回 60%。原因是收缩压力没有停,结构解虽然紧凑,但也扛不住无限压缩,最后模型退化成了一个表达能力不足的状态。教训是:权重衰减是持续作用的,它在你想要的方向上推,推过头也会坏事。
5.2 曲线诡异时的排查路径
除了上面这些典型情况,还有一些边角问题会让人误判。
第一种是评估代码写错。验证集的标签和输入对不上,或者分类头的输出维度和标签范围不匹配,都会让验证准确率永远上不去。排查方法很简单:用一个故意过拟合到极致的小模型(比如只训十条样本),如果它在训练集上到不了 100%,那就是代码问题不是现象问题。
第二种是数据泄漏的错觉。有人会担心训练集和验证集有重复样本,导致验证准确率虚高。这个任务里所有输入对都是唯一的,不会有重复,但如果你自己造任务,记得先去重。还有一种隐性泄漏是随机种子控制不严,切分索引和初始化用了同一个种子,虽然概率低,但排查起来很费劲。
第三种是把学习率调度的效果误认为 Grokking。如果用了余弦退火或者 step 衰减,验证准确率的跳升可能只是学习率降到某个点之后的结果,和权重衰减无关。做这个实验的时候,我建议学习率保持恒定,把所有变量收窄到一个,现象归因才清楚。
第四种是硬件和精度问题。用混合精度训练时,小模型在这个任务上可能出现数值不稳,尤其是权重衰减乘出来的收缩项在 fp16 下精度不够。我的做法是全程 fp32,虽然慢一点,但曲线干净。
还有一个经常被忽略的点:验证集大小。8939 条验证集算出来的准确率很稳定,如果你只留几百条验证,曲线会抖得看不出起飞点。验证集要么留足,要么就多评估几次取平均。
6. 我在多次复现中的几点体会
6.1 关于"泛化"这个词的重新理解
做完这组实验之后,我对"泛化好"这个说法的理解变了不少。以前我会默认泛化能力是模型容量和正则化的平衡结果,是一种静态属性;现在更倾向于把它看成训练过程中的一个阶段性产物,模型在参数空间里移动,不同阶段落在不同的解上,泛化表现只是当前所处位置的副产品。
这个视角的实用价值在于:当验证指标不动的时候,不一定是模型没学到东西,可能是它还没走到那个该到的位置。当然这个说法有边界,如果任务本身没有可泛化的结构,等多久都等不来。判断有没有结构,就看任务规则能不能用比样本数少得多的参数描述清楚。
还有一点,Grokking 里的"过拟合"和教科书里的过拟合不是一回事。教科书里的过拟合是模型把噪声也学了,泛化变差;这里模型学的是真规则,只是先学了笨办法。把它叫"延迟泛化"可能更准确,但社区已经习惯了这个叫法,改口也没意义,理解清楚就行。
6.2 这套实验还能怎么扩展
如果你跑通了基础版,有几个方向值得继续折腾。
一个是换任务。模乘法、模幂、多项式求值、排序、置换群运算,这些都能做成类似的小数据集。不同任务的平台期长度和起飞难度差别很大,模乘法明显比模加法难,起飞点更晚;排序任务因为结构更弱,有时候干脆不出现。做横向对比能帮你建立"什么样的结构容易被学出来"的直觉。
另一个是换正则化方式。除了权重衰减,还可以试参数范数约束、梯度裁剪、标签平滑,看哪些手段能替代权重衰减驱动起飞。我试过 L2 正则直接加到 loss 里,效果不如 AdamW 的解耦衰减稳定,因为前者会和学习率的缩放耦合在一起,调参时容易乱。
再一个是做表示分析。把模型的嵌入矩阵做傅里叶变换,看在起飞前后频率成分怎么变化;或者看注意力图,在结构解阶段模型是不是会自发地去读两个输入位置。这些分析不一定有明确结论,但能让你对"模型内部发生了什么"有更具体的印象,比只看一条准确率曲线有意思得多。
最后一个提醒:做这类实验的时候,日志一定要存全。我最早只存了准确率,后来想回头分析起飞点的 loss 变化,发现数据没了,只能重跑。现在我的习惯是每 100 步存一行,包含步数、训练 loss、验证 loss、两个准确率、权重范数、学习率,CSV 存下来。跑完几十组实验之后,这些日志才是真正值钱的东西。