实验目标
大模型少样本学新东西,现在有两条热门路线各走各的路:一条是测试时训练(Test-Time Training,TTT)——推理时拿几个示例做几步梯度更新,让知识真正进权重;另一条是定位选层——用探针或激活信号找出"信息在哪一层",微调时只动那几层。后者在常规微调里已有不少工作,但"定位指导 TTT"这个交叉点,截至本文写作时检索不到公开实验。
这个交叉点值得问一个非常实际的问题:TTT 只更新定位出来的那几层,会不会比随机选层、比全层摊开更新更好?如果定位有用,少样本适配就能用更小的改动、更小的遗忘代价学到新规则;如果没用甚至反作用,那"先定位再微调"这条直觉路线就要打问号。
实验预期(先立预言)
- P1:探针定位选出的层上做 TTT,好于随机选层(定位有信息)。
- P2:定位最差的层上做 TTT,差于定位最好的层(梯度存在性)。
- P3:不做任何更新的纯上下文学习(ICL)是全场最弱下界。
- P4:选层更新比全层摊开更新的遗忘更小(定向更新的卖点)。
实验环境卡
| 项 | 值 |
|---|---|
| GPU | RTX 5070 Ti Laptop 12.8GB |
| 软件 | PyTorch 2.8.0 cu128 / transformers / Qwen3-0.6B-Base(bf16 全冻结) |
| 种子 | 单种子 20261022(全程确定性;随机选层臂跑 3 个抽取,方差通道单独明示) |
| 任务 | 60 个数字映射密码(40 个规则映射 + 20 个随机双射对照组),每密码 5 示例 + 5 个 held-out 查询 |
| TTT | 每密码临时注入 LoRA(只包 q/v 投影),40 步 AdamW lr 3e-4,用完拆除 |
| 预算 | 可训参数全臂严格相等 286,720;全程墙钟约 25 分钟 |
| 日期 | 2026-10-22 |
任务设计
主裁决场:规则映射密码。每个密码是 0-9 的一种映射规则——位移(d → (d+s) mod 10,s 随机)或反射(d → 9-d)。给模型看 5 行示例(如7 -> 0、2 -> 5……),再用 5 个示例里没出现过的数字考它。规则映射的关键性质:示例足够推出完整规则,held-out 答案理论上限 100%——学没学进权重,一测便知。
对照组:随机双射。映射完全随机、无规律可学,held-out 的正确答案根本不在上下文里。这种任务无论怎么学,最优策略也只是"猜没出现过的 5 个输出",理论上限 20%。它用来对照:TTT 学到的是"规则"还是"把见过的答案焊进权重"。
八臂对照(等参):ICL(不更新)/ 全层 LoRA(28 层 rank 2)/ 透镜 top-7 层(rank 8)/ 激活 top-7 层(rank 8)/ 随机 7 层×3 抽 / 透镜 worst-7 层(rank 8)。选层臂 7 层×r8 与全层臂 28 层×r2 的可训参数严格相等,排除"参数多少"的干扰。
实验过程(含三次设计期转折,如实记录)
第一次冒烟就暴露了任务设计的坑:最初用纯随机双射做主任务,跑出来 ICL 25%、TTT 各臂 5-15%——反复核对才意识到随机双射的 held-out 答案不在上下文里,任务本身不可学,ICL 那 25% 已经贴着"猜未用输出"的理论天花板。于是把主任务换成规则映射,随机双射降级为对照组。
第二个坑在探针。原设计用闭式岭回归探针从各层隐状态里解码答案,冒烟发现 60 个密码上 margin 全程贴着随机概率(0.088-0.090,零层间分离)——5 个样本在 1024 维空间里做留一回归,统计上就是死的。转向零训练的logit-lens:直接用模型自带的输出头读每一层残差流里"正确答案 token"的概率,不需要从 5 个样本里拟合任何东西。
第三个坑最有意思:固定示例顺序做 TTT,怎么调学习率、步数,held-out 成绩都和 ICL 一模一样(22/40 分毫不差)。排查发现 TTT 把答案绑死在上下文的具体位置上——示例换序就失效,等于什么都没学进权重。解法是每步训练前打乱示例顺序(位置增强),逼模型学规则而不是背位置。加上顺序增强后,中段层 TTT 立刻从 55% 跳到 85%。正式批全部采用顺序增强。
评测函数先过了人工验证再采信:恒等映射示例下 ICL 对查询 5/5 全对、TTT 拟合概率 1.000、LoRA 拆除后参数树与注入前完全一致(无残留)。
实验结果
主表(规则映射,200 个查询点):
| 臂 | held-out 准确率 | 遗忘账单* |
|---|---|---|
| ICL 不更新 | 47.0% | — |
| 全层 r2 | 77.0% | +0.56% |
| 透镜 top7 r8 | 44.5% | +1.25% |
| 激活 top7 r8 | 34.5% | +1.60% |
| 随机#0/#1/#2 r8 | 73.5 / 77.0 / 67.0% | +0.91 / 1.05 / 0.80% |
| 透镜 worst7 r8 | 64.0% | +1.91% |
*遗忘账单 = TTT 后固定通用文本窗的交叉熵涨幅(对 ICL 基线 4.666 nats/token)。
P1 证伪,而且反向得干脆:透镜定位选层 44.5%,比随机选层三抽均值 72.5% 低 28 个百分点(双比例 z = −7.2)。定位不但没帮忙,还把 TTT 拉回了 ICL 的水平(44.5% vs 47.0%,z = −0.5 持平)。P2 同步反向:定位"最差"的层反而做出 64.0%,显著好于定位"最好"的层(z = +3.9)。
为什么?看层曲线就明白了:
logit-lens 读出的正确答案概率在前 19 层几乎贴着随机(≈0.10),从第 22 层起陡升到 0.19-0.25——"信息在哪"的答案是:输出端的最深几层。激活选层(按隐状态范数)选的也是同一个区域(第 20-26 层,60 密码无一例外)。而恰恰是这几层,用 rank 8 的 LoRA 去动,破坏性最大:模型本来就在那里把答案算出来了,厚改等于把现成的答案通路砸了。透镜 margin 最平的浅层区反而最"耐改"。
"广撒薄改"赢了:等参数预算下,全层 rank 2(77.0%)不输任何选层臂(对随机均值 z = +1.3,不显著;对 ICL z = +4.1 显著),而且遗忘账单全场最小(+0.56%)。集中 7 层厚改没有一层能同时打平成绩和遗忘。
对照组给出机制的另一半:
随机双射上,ICL 12%(连 20% 的"猜未用输出"上限都没摸到,说明 0.6B 基座连"答案在未出现过的输出里"这个约束都用不利索),而所有 TTT 臂塌到 0-4%——比盲猜还低。无规则可学时,TTT 把示例里出现过的答案焊进权重,查询时反而优先吐这些已被排除的数字。这坐实了顺序增强 TTT 学的是规则而非答案集合:规则存在时 +30 个百分点,规则不存在时 −10 个百分点,同一枚硬币的两面。
结论
- "信息在哪层"回答不了"该更新哪层"。透镜和激活两种定位信号一致指向输出端重层,而在那些层上做 TTT 恰恰伤害最大。定位峰值层是答案的必经之路,不是空白的施工区。
- 等参预算下,低秩摊满全层是少样本 TTT 的默认好选择:成绩最好(77% vs ICL 47%)、遗忘最小(+0.56%)。本文场景里没有任何一种"聪明选层"打过它。
- TTT 要配上位置增强才真学规则:固定示例顺序时 TTT 等价于背上下文位置(成绩与 ICL 逐分不差);无规则可学时 TTT 全线塌到盲猜以下——用之前先确认任务里真有规则。
- 副产物:k=5 的少样本场景里,从零拟合的线性探针统计上读不出任何层间信号,logit-lens 是唯一可用的零训练定位信号。
局限
单种子单基座(Qwen3-0.6B-Base),随机选层臂的 3 个抽取是全部方差证据;任务族是数字等变映射(位移/反射),秩=1 的简单规则,对更复杂规则(多步组合、自然语言概念)结论是否外推未验证;定位信号只试了 logit-lens 与范数两种,按"注意力贡献"或"梯度敏感度"定位未测;遗忘账单只用单一通用文本窗度量。
附录:核心代码摘录
每步打乱示例顺序的 TTT(位置增强,本文最关键的一行是torch.randperm):
for step in range(steps): order = torch.randperm(len(pairs_all), generator=gen).tolist() prs = [pairs_all[i] for i in order] # 每步重排示例 ids, ans_pos = build_ids(tok, head_ids, prs) # 重建 prompt ai = torch.tensor([dids[b] for _, b in prs], device="cuda") h = model.model(ids).last_hidden_state pre = torch.tensor([p - 1 for p in ans_pos], device="cuda") loss = F.cross_entropy(model.lm_head(h[0, pre]).float(), ai) loss.backward() opt.step(); opt.zero_grad(set_to_none=True)logit-lens 逐层定位(用模型自带输出头读残差流,零训练):
for l in range(1, n_layers + 1): X = hs[l][0, pos].float() # 答案前一位置的层输出 logits = model.lm_head(X.to(model.lm_head.weight.dtype))[:, dt].float() probs = torch.softmax(logits, -1) # 10 个数字 token 内 margins.append(probs[range(5), ans_ids].mean().item())等参 LoRA 注入(7 层×r8 与 28 层×r2 参数严格相等,用完原样拆回):
def enter(self, layers, r): for i in layers: attn = self.model.model.layers[i].self_attn for name in ("q_proj", "v_proj"): orig = getattr(attn, name) setattr(attn, name, LoRALinear(orig, r=r, alpha=2 * r).cuda()) self.touched.append((attn, name, orig)) # 记住原件,exit 时拆回