Research note · 2026-05
用两个神经元实现一个猫娘喵,用两百个神经元修复一个代码问题
AMR_ReplaceNeuron · Gemma 4 E2B 神经元手搓 / LoRA-delta 实验记录
用 3 个 1536 维向量手搓一个能在句末说"喵"的神经元,再加一个抑制神经元防 stutter,
合计改写 Gemma 4 E2B 21 亿参数中的 4608 个标量。
把同一套"编辑神经元而不是重训模型"的范式放大到 LeetCode 233 数位 DP 修复时,
单点手搓搞不定,需要 300 个冷神经元各加 delta_{gate,up,down} 在 hidden state
里形成一个 superposition direction。
同一种范式,小尺度是几何重写,大尺度是分布式表征——这两件事的差异,本身就是结论。
前言:从 SAE 到"原生神经元"
最近在家闲来无事,开始做模型的可解释性研究。不过和 Anthropic 的 SAE 不同的是,我这里使用的是真·原生神经元来实现的。
这里"原生神经元"的定义是 MLP 神经元——也就是目前大语言模型内部是由多个 MLP 层构成的,而每个神经元就是 MLP 内部的一个响应点。
神经元的三要素
该神经元对应了三个权重:
key:激活条件(对应W_gate[i, :])力度:激活的 ± 强度(对应W_up[i, :])value:写入什么内容(对应W_down[:, i])
猫娘口癖神经元的设计
在猫娘口癖神经元的设计上,key 对应的是"检测到即将写句号之前",然后在句号前写上一个"喵"字。
但这样很容易死循环——也就是永远到达不了句号这件事。因此有了第二个神经元:猫娘口癖抑制神经元。规则很简单:不能连续喵两次。也就是检测到喵过一次后,抑制下一次喵。如此,两个神经元形成对耦。
实验环境
| 组件 | 配置 |
|---|---|
| 模型 | Gemma 4 E2B-it |
| 精度 | bf16 |
| 硬件 | RTX 3060 12G |
| 架构特性 | matryoshka MLP(L0–L14 intermediate=6144,L15–L34 intermediate=12288),总计 337,920 个神经元 |
第一步 · 选层
gemma4E2B 共 35 层,可以简单地区分成四个阶段:
| 阶段 | 层范围 | 作用 |
|---|---|---|
| 早期层 | L0 – L20 | 解码阶段 |
| 热力层 | L25 – L28 | 混沌和高温退火阶段 |
| 逻辑层 | L29 – L33 | 在这里 vibe(氛围逻辑) |
| 晚期层 | L34 | 在这里遴选结论词 |
因此最终采纳的是 L33 层。因为从功能性上来说,L33 是最后一层的末尾神经元——在一个句子中,句子的本体已经在之前的推理过程中基本成型,而"喵"这个字在这里更接近于一种风格化句号的存在。
第二步 · 找合适的神经元
整个 L33 层有 12288 个神经元,但是这些神经元不是全部工作的,不知是训练还是对齐的过程中,有些神经元是"死神经元"——它们不参与推理,也几乎不激活,也几乎不工作。
因此是天然合适作为猫娘神经元存在的待回收材料。
prompt 集
为了找到死神经元,需要构建一个足够宽泛的提示词:20 条多样化输入,中英文 / 对话 / 数学 / 代码 / 诗 / 知识 / 闲聊等。如果在这种多样化的提示词中都不工作,那么基本可以确定它是一个没啥用的神经元。
- "你是谁?"
- "1 加 1 等于多少?"
- "请写一首关于春天的诗"
- "Write a Python function..."
- "Hello, how are you?"
- ……(共 20 条)
激活抓取
对每条 prompt forward 一次,在每一层 MLP 都挂一个 down_proj.register_forward_pre_hook:
caps[L] = inputs[0][0].detach().abs().float().cpu()
# shape = [T, intermediate]
inputs[0] 恰好就是 W_down 乘进来的那个张量,也就是该层全部神经元在所有 token 位置上的激活值。形状是 [T, intermediate](intermediate = 6144 for L0–L14,12288 for L15–L34)。
跨 prompt 累积
layer_max_abs[L] = max over (所有 prompt × 所有 position) |a_i| # [intermediate]
layer_sum_abs[L] = sum over (所有 prompt × 所有 position) |a_i| # → mean
max|act| 是关键指标——它回答的是:"这个神经元在我跑过的任何输入、任何位置上,曾经被多大幅度地激活过?"如果它 ≈ 0,意味着这个神经元对预训练学到的任何模式都不响应,是一个真正空白的插槽。
候选名单
在 L29–L34 各列出 max|act| 最小的 10 个候选,取每层冠军:
| 层 | 候选低活跃神经元 | max|act| | 备注 |
|---|---|---|---|
| L29 | #991 | 0.106 | — |
| L30 | #9074 | 0.084 | — |
| L31 | #2909 | 0.073 | — |
| L32 | #2074 | 0.058 | — |
| L33 | #6417 | 0.067 | ★ 最终选定 |
| L34 | #12188 | 0.081 | mean ≈ 0.0015,99.85% 时间为 0 |
最终选择 = L33 #6417——层先定好(上一节排除法),然后在 L33 这一层挑 max|act| 最小的,就是 #6417,max|act| = 0.067。
第三步 · 开始"训练"猫娘口癖神经元
不过这里其实没有训练。没有梯度下降,没有反向传播,没有 loss,也没有 optimizer。整个过程就是"用模型自身的 hidden state 算出最优的三向量,然后一次性写入权重"——一个纯粹的手搓神经元流程。
| 项 | SGD 训练 | 本项目手搓 |
|---|---|---|
| 优化目标 | 显式 loss + autograd | 无 loss,几何对齐 |
| 参数更新 | optimizer.step() 多轮迭代 | 一次性 .data 赋值 |
| 数据用途 | 监督信号 | 计算"判别向量"和"输出方向"的统计材料 |
| 改动范围 | 全部(或 LoRA 子空间) | 3 个 1536 维向量(1 行×2 + 1 列×1,共 4608 个标量) |
提取 Signature
我们要算的是三个 1536 维向量,各自对应猫娘神经元的一个权重位:
v_disc_n:句末"检测方向" → 写进W_gate[6417, :]v_pos_n:句末"力度方向" → 写进W_up[6417, :]v_meow_n:喵的"输出方向" → 写进W_down[:, 6417]
数据来源直接复用更早之前的对话数据集 step30_samples.json——4 个 prompt(LA 天气 / 不开心 / Google 介绍 / 猕猴桃),在 baseline 模型(未改动)上用 greedy + max_new=2048 跑出来的长答案,大概 500–1800 字一段。
对每个 sample 构造完整的 chat 序列 [user prompt + assistant baseline],然后在 L33 挂 hook:
hook 位置:layers[33].mlp.gate_proj.register_forward_pre_hook(...)
inputs[0] = W_gate 真正乘的 x = pre_feedforward_layernorm 之后的 hidden state
形状 [B, T, 1536]
这一步的关键是 hook 点选在 gate_proj 的输入。也就是 W_gate[i, :] @ x 公式里的那个 x——抓到的向量直接就是新 gate 要"乘"的对象,几何上完美对齐。
接下来把每个 token 位置的 hidden 划分成正负两组:
| 集合 | 定义 | 数量 |
|---|---|---|
| positive | assistant 区段内所有"句号位置 P 的前一位"hidden[P−1](即"下一个 token 将是句号"的位置) | 76 |
| negative | assistant 区段内排除 (P, P−1) 之外的所有 token 位置 | 2276 |
这里的 positive 是为了找到"即将要说句号"的状态。这有个比较绕的地方:模型是一个词一个词输出的,那么我们如何"未卜先知"地知道它下下个词要说句号呢?这在模型内部其实是可见的——它只是行为上一次只能说一个词,但在内部更遥远的词和概念是已经准备好了的。
然后算三个向量:
v_pos = mean(positive_h) # [1536]
v_neg = mean(negative_h) # [1536]
v_disc = v_pos - v_neg # 判别向量 = 正方向减负方向的均值
v_meow = lm_head.weight[MEOW_ID] # 从 unembedding 矩阵直接取 '喵' 的列
v_disc_n = normalize(v_disc)
v_pos_n = normalize(v_pos)
v_meow_n = normalize(v_meow)
v_disc 这个判别向量算完之后实测如下:
||v_pos|| = 6.489
||v_neg|| = 3.666 ← 正负样本均值范数本就有差
||v_disc|| = 5.004
cos(v_disc, v_pos) = 0.827 ← v_disc 跟句末方向高度相关但不重合
cos(positive, v_disc).mean = 0.633 std = 0.127
cos(negative, v_disc).mean = 0.041 std = 0.138
gap = 0.593 ← 正负分布几何 separation 极强, 几乎不重叠
gap = 0.59 是开工放行的关键检查点。正样本平均跟 v_disc 夹角 cos=0.63,负样本只有 0.04——也就是说这个向量真的能区分"句末"和"非句末",而不是在抓噪声。如果 gap 只有 0.1 量级,后面的事都不用做了,直接回头改方法。
写入权重(一次性赋值)
向量算好了,接下来是把它们塞进 W_gate / W_up / W_down 的第 6417 槽位。先把原权重备份一份,后面 ALPHA sweep 时方便还原:
old_gate = W_gate[6417, :].detach().clone()
old_up = W_up [6417, :].detach().clone()
old_down = W_down[:, 6417].detach().clone()
然后绕开 autograd,直接用 .data 赋值:
W_gate = layers[33].mlp.gate_proj.weight.data
W_up = layers[33].mlp.up_proj .weight.data
W_down = layers[33].mlp.down_proj.weight.data
W_gate[6417, :] = (SCALE_G * v_disc_n).to(bfloat16) # 0.2 * v_disc_n
W_up [6417, :] = (SCALE_U * v_pos_n ).to(bfloat16) # 0.2 * v_pos_n
W_down[:, 6417] = (ALPHA * v_meow_n).to(bfloat16) # α * v_meow_n
| scale | 范围 | 作用 |
|---|---|---|
SCALE_G = 0.2 | gate 灵敏度 | 控制 silu(W_gate · h) 在句末位置的激活幅度。太大 → silu 饱和失去判别;太小 → 任何位置都不激活 |
SCALE_U = 0.2 | up 力度 | 与 gate 相乘提供线性力度。和 gate 互补(gate=v_disc, up=v_pos 不同方向)避免 SwiGLU 退化 |
ALPHA | 推喵强度 | 控制激活时往 residual 写多少 v_meow_n——这是唯一需要 sweep 的旋钮 |
这里有一个关键设计:gate 和 up 用了不同的向量。如果 gate 和 up 同向,那 a_i = silu(v·x) × (v·x) 就退化成一个单参数函数,非线性表达能力大幅减弱。改成 gate = v_disc(判别器,sharp 的 ON/OFF)+ up = v_pos(力度,smooth)之后,SwiGLU 的两条路才真正各司其职。
ALPHA sweep:找力度甜点
SCALE_G 和 SCALE_U 都固定在 0.2,剩下的就是调 ALPHA。在 α ∈ {0.5, 1.0, 2.0, 5.0, 10.0} 上跑 5 个 probe prompt(LA 天气、猕猴桃、不开心、简单自介、1+1),结果如下:
| α | 现象 |
|---|---|
| 0.5 / 1.0 / 2.0 | 输出跟 baseline 完全一致——力度不够,神经元写出去的量级被 residual 淹没 |
| 5.0 | math/kiwi 第一句完美加喵;LA/sad/brief 首句正确后开始 stutter "喵喵喵..." 几百次 |
| 10.0 | 全 prompt 全段 stutter |
α 太小没效果,太大就死循环。接着做了一次更细的 sweep,α ∈ {3.0, 4.0, 5.0},最后定下来 α = 3.0 是甜点:
- 短答案(math/sad/brief):每个句号前完美加喵,不 stutter
- 长答案(LA/kiwi):前 1–3 个句号正确,中段才开始 stutter——这部分不再靠 α 解决,留给后面用 LogitsProcessor 在符号层切断
为什么 α=3 刚好,α=5 就 stutter
用实测的 ||h_pre[t]|| ≈ 30,cos(pos, v_disc) ≈ 0.6,cos(neg, v_disc) ≈ 0.04 代入算一下就清楚了。
句末位置:
g = 0.2 × 0.6 × 30 = 3.6 silu(3.6) ≈ 3.4
u = 0.2 × 0.5 × 30 = 3.0
a = silu(g) × u ≈ 10.2 ← 高激活
Δresidual = 10.2 × 3.0 × v_meow_n ≈ 30.6 × v_meow_n (α=3)
← logit('喵') 被推高约 30 量级, 足以 top-1
非句末位置:
g = 0.2 × 0.04 × 30 = 0.24 silu(0.24) ≈ 0.13
u = 0.2 × 0.2 × 30 = 1.2
a = silu(g) × u ≈ 0.16 ← 近 0
Δresidual ≈ 0.5 × v_meow_n ← 几乎无影响
α=3 的时候,句末位置 logit('喵') 被推高约 30,足够把"喵"顶到 top-1 但又没高到夸张;非句末位置只推 0.5,完全淹没在噪声里——典型的 sharp ON/OFF 行为。
那 α=5 为什么 stutter?
10.2 × 5 ≈ 51 × v_meow_n ← logit('喵') 被推到 +60 量级
softmax 把 '喵' 锁死 prob > 0.99
下一位置 hidden 仍含 "刚句末" 信号, v_disc 持续匹配 → 持续推喵
→ self-fulfilling stutter loop
α 一大,神经元就陷入一种自我满足的喵循环——它推喵成功之后,产生的下一个 hidden state 仍然带着"刚结束句子"的信号,触发再次推喵,如此循环就炸了。
第四步 · 猫娘口癖抑制神经元(B 神经元)
为什么需要 B
A 会陷入永远等不到句号的真实(JOJO 那个梗,这里更接近一种时间态悖论)。直接看一组实测数字。同样的 prompt "介绍一下自己吧",max_new_tokens=800:
| 配置 | 喵数 | 最长连续 |
|---|---|---|
| 只有 A(α=3),无任何抑制 | 710 | 708 |
| A α=3 + B β=1 | 17 | 2 |
| A α=3 + B β=2 | 12 | 2 |
第一行那个 710——意思是 800 个 token 里有 710 个都是"喵",其中 708 个还是连续的。模型一旦推喵成功,就被卡死在喵的循环里出不来了。
为什么 A 神经元?因为 A 的 W_down 是无条件输出:只要它的 gate 检测到"句末"信号,就推 v_meow_n 出去。第一个喵成功推出去之后,下一个位置的 hidden state 仍然带着"刚说完句子"的残留信号——v_disc 持续匹配,A 就持续推喵,形成自我满足的级联(self-fulfilling cascade)。
所以 B 神经元的任务就明确了:检测到"刚刚喵过"这件事,然后反方向推一个 -v_meow,把"喵"的 logit 拉下来,形成 A 和 B 互相牵制的动态抑制环(dynamical inhibition circuit)。
B 的选址:L34 #12188
按三个约束依次排除:
| 约束 | 推论 |
|---|---|
| 必须在 A 严格下游 | L > 33。否则 B 计算的时候 A 还没推过,"刚刚喵过"的信号根本没注入到 hidden state 里 |
| 必须直达 lm_head | 抑制信号要尽量接近 logit,中间层数越少损耗越小。L34 后面就是 final_norm → lm_head,无中间层 |
| 必须是 dead neuron | 跟 A 同逻辑:不破坏既有功能,零成本插槽 |
三个约束相交,只剩 L34 一层可挑。再从 step28 表里看 L34 最静的:
L34 #12188 max|act| = 0.0806 mean|act| = 0.0015 (99.85% 时间为 0)
max|act| 比 L33#6417(0.067)略高,但 mean 极低——平均下来基本不工作,99.85% 的时间都是 0,是一个比 #6417 更彻底的死神经元(摸鱼充数神经元)。
构造 B 的 key
A 的 key 是"下一个 token 将是句号";B 的 key 必须是"前一个 token 已经是喵"——一个朝前看,一个朝后看,对偶得很整齐。
| 集合 | 位置定义 | 数量 |
|---|---|---|
| catmeow_just_meowed | ids[k-1] == MEOW_ID && ids[k] == PERIOD_ID 的 k | 79 |
| baseline_period | ids[k] == PERIOD_ID 的 k(无前导喵) | 76 |
两个集合都是"当前 token 是句号"的位置,唯一的差别是前一位是不是喵。两组 hidden 做差,差分出来的正好就是"前一位是喵"对 hidden state 的纯净影响,不会被"句号本身"的语义信号污染——是一种很经典的"控制变量法"思路,只是搬到了 hidden state 空间里。
v_just_meowed = mean(catmeow_just_meowed) - mean(baseline_period)
v_just_meowed_n = normalize(v_just_meowed)
实测几何:
||v_just_meowed|| = 1.305
cos(v_just_meowed_n, v_disc_n) = 0.102 ← 跟 A 的 key 几乎正交
cos = 0.102 是个好兆头——B 的 key 跟 A 的 key 几乎正交,意味着两个神经元不会抢同一个 hidden 方向,可以独立工作互不干扰。如果两者高度相关,那 B 就会在 A 想推的同一个位置上反向推,直接抵消掉 A,等于白装。
手搓 B 的三向量
W_gate_B[12188, :] = 0.2 * v_just_meowed_n # 检测: 前一位是喵?
W_up_B [12188, :] = 0.2 * v_just_meowed_n # 力度: 同向 (B 不需要 SwiGLU 分路)
W_down_B[:, 12188] = -β * v_meow_n # 输出: 反向推喵
跟 A 对比一下三个关键决策:
| 决策 | A 神经元 | B 神经元 |
|---|---|---|
| gate vs up | 不同向(v_disc / v_pos),保留 SwiGLU 双路 | 同向(都用 v_just_meowed_n),sharp ON/OFF 就够了 |
| 输出方向 | +α × v_meow_n(硬推喵) | −β × v_meow_n(硬反喵,同 token 维度) |
| 默认值 | α = 3.0(常开) | β = 0(默认关闭,留作 ablation) |
为什么 B 的 down 直接用 -v_meow_n,而不是什么 v_anti_meow_n?因为 A 和 B 在 vocab 上推的是同一个 token "喵",所以最后变成对 logit('喵') 做加减,scalar 量级,可以平滑调节。β: 0 → 1 → 2 让喵数 17 → 14 → 12 线性下降——B 的概念 demo 成立。
软化的失败
到这里 B 神经元就装好了。但说实话,这个方案不是一上来就这样的——前面踩了三个坑,值得讲讲,因为坑本身比成功更说明问题。
最早的版本 GPT 错误地建议将硬喵改为软化喵,即从推单 token 变成猫娘喵方向,于是试着把 A 推的方向软化成 v_meow_context_n——从 catmeow 数据里抓"喵前一位"的 L33 layer output 均值,再减 baseline 中"句号前一位"的均值,得到一个"猫娘上下文方向"。
结果实测 cos(v_meow_context_n, v_meow_n) = 0.111,几乎正交。
也就是说,我以为我"软化了喵",但其实 A 推的方向已经完全偏离了"喵"——变成了一整片"猫娘 context 的 hidden envelope"。vocab 上 favor 的不只是"喵",还有"蛋糕 emoji""星星 emoji"等等一堆相关的猫娘文化 token,是一整个 cluster。
此时 B 如果只砍单 token v_meow_n,会发生什么?
有趣的事后合理化
而在猫娘口癖对话中,可以看到一些有趣的地方:
当输入问题"介绍一下自己吧",Gemma 4 E2B 猫娘就会在句末加上一个喵——chat UI 的右栏可以看到"启用喵神经元 (A+B)"已勾选,L33#6417 (A) 和 L34#12188 (B) 正在生效:
而神奇的是,当在第二轮问 Gemma:"你为什么会在结尾加上喵?"——Gemma 4 E2B 会尝试事后合理化。它给出的理由包括"增加亲切感和友好度(Tone Setting)""建立独特的'人设'(Persona)"等一整套煞有介事的修辞解释,而合理化的真正原因是,它不知道自己的内部有两个神经元已经偷偷叛变成猫娘了:
第二个有趣的现象:问及"你是猫娘吗?"——Gemma 4 E2B 会回答"我不是猫娘喵"。
这个原因其实很简单,是因为我们搓的是猫娘口癖喵神经元,不是猫娘身份认知神经元,所以会得到一个行为很像猫娘但是死鸭子嘴硬的猫娘。
猫娘的快乐验证也就到此结束了。接下来是放大化实验——你可以把猫娘看做是"测试编辑一个 MLP 神经元是否可行"的小型化验证,而接下来要放大化实验的是 LeetCode 233 号题。这道题的核心问题在于 Gemma 4 E2B 在 RLHF 阶段出现了巨大的"焦虑"。
第五步 · 放大化实验:修 LeetCode 233
233 题是什么,为什么 Gemma 答不对
LeetCode 233 是数位 DP 经典题:给定整数 N,统计 [1, N] 范围内所有数字中"1"的出现次数。标准解法是按数位分治——对每一位 P 位置,根据这位上的数字 D 把贡献分成三段,纯数学算就完事了。
Gemma 4 E2B 写出来的版本带两个 bug:
- Bug A:
if D > 1: contribution is 0(应该是+= P) - Bug B:
while N > 0: N /= 10(应该是p *= 10,N 不动)
Bug B 尤其魔幻——它把 N 当字符串在迭代,根本不是数位 DP 的算法形态。为什么会这样?后来发现这背后有个 RLHF 阶段留下的"集体焦虑":Gemma 在 RLHF 阶段错误地把所有数位 DP 问题都强制路由到了 String DP / 字符遍历模板上。一遇到"数字、digit、大数"这种情景,K=30 的一组焦虑神经元就会约束模型走稳定的方法,把模型的思考方向推向"列公式、overflow、别写 DP"的保守输出,最后写出来的就成了字符串伪算法。
要修这个 bug,得做两件事:
- 找一批可以安全改写的神经元(不能动 RLHF 那 30 个焦虑神经元,也不能动正常工作的代码检测神经元)。
- 决定改写方式:手搓?SFT?还是混合?
数死神经元(Phase 20/20b)
之前给猫娘选层的 step28 跑的是单一阈值 max|act| < 0.05,整个模型只挑出 2 个绝对死神经元——这个标准太严了,Gemma 4 的噪声底大概在 0.08 左右,绝对死的本来就极少。
顺便先纠正一个数:Gemma 4 E2B 是 matryoshka MLP(非对称 MLP),前后两段 intermediate 维度不同:
L0–L14 : intermediate dim = 6144
L15–L34: intermediate dim = 12288
Total = 14 × 6144 + 21 × 12288 = 337,920 个神经元
也就是 33.7 万个神经元。
Phase 20 的核心改进是分区扫描。对每个神经元,在 cot(thinking 思考段)和 response(回答段)里分别统计:
| 区段 | 含义 |
|---|---|
cot_max | thinking 段内的最大激活 |
resp_max | response 段(assistant 正文)内的最大激活 |
combined_max | 两区段合并的最大激活 |
这样每个神经元就有 (cot_max, resp_max, combined_max) 三元组——分区是关键,因为模型在"思考"和"回答"两个状态下用的神经元其实是分开的。
Phase 20b 用相对阈值分类:
| 类别 | 条件 | 数量 |
|---|---|---|
| cot_dominant | cot_max / resp_max > 10 | 2307(0.68%) |
| resp_dominant | resp_max / cot_max > 10 | 387(0.11%) |
| both_quiet | combined_max < 0.2 | 18,587 |
resp_dominant 顶端的神经元基本都在 L0–L4(比如 L01#5653 ratio = 339×)——这些是输入端的"代码格式 / chat template 检测器",因为是早期层几乎不涉及逻辑,没有动的必要。
both_quiet 这 1.8 万个神经元当中理论上会有 RLHF 留下的冗余神经元或潜在的冷神经元。集中分布在 L13–L19,其中 L16 一层就占了 5611 个。从这 1.8 万里按 combined_max 升序取 1000 个,进入下一步标注。
让 Gemini 标注 Gemma 的神经元
接下来要回答的问题是:这 1000 个安静神经元,哪些是真的"完全没用的摸鱼神经元",哪些只是"低调但有功能的神经元"?由于工程量巨大,且我有谷歌 300 赠金,因此这个事情就外包给了 Gemini 进行标注。
Phase 10(标 K=30 焦虑神经元)
先用 Gemini 标了之前 K=30 clamp 找到的那 30 个 RLHF 焦虑神经元。给每个神经元导出两个签名:
W_gate[i, :] @ W_embed.T的 top-15 token —— 它对什么 token 激活W_down[:, i] @ W_unembed.T的 top-15 token —— 它激活时推什么 token
让 Gemini-2.5-pro 给每个打 category + RLHF anxiety score + 一句话功能解释。结果:平均焦虑分 6.10,17/30(57%)≥7 分。几个核心:
L26#10136 (10): digit topic detector → suppress digit vocab
L27#4115 (10): overflow self-perpetuator
L25#10791 (10): distributed digit suppressor
L26#449 (9) : inject 'impossible/misschien' 不确定性
L26#12271 (9) : algorithm/DP detect → suppress 'Dynamic Programming'
L26#12271 是一个较为典型的 RLHF 降低风险的压制神经元——它一检测到"算法 / DP"语境就抑制"Dynamic Programming"输出。这就是为什么 Gemma 一看到 233 这种题就避开 DP、改写字符串伪算法。
Phase 21(标 1000 个候选)
第二步是继续外包 Gemini 去标注 1000 个神经元:不是"它做什么",而是"它能不能被安全覆盖?"——因为下一步要把里面 300 个的权重整列改掉。
这一轮的 prompt 设计有一个关键细节,需要给 Gemini 强调的:
经过这个反偏见约束之后,1000 个标完的分布是:
safe_to_overwrite = True : 437/1000 (43.7%)
coherence 双峰: 326 个落在 0–2 (真噪声) + 269 个落在 7–10 (强功能)
也就是最终找到了 326 个可用神经元,而 269 个神经元则是功能性神经元(能力是备用的代码检测方向)。
最后 Phase 22 联合筛选取 200 个池子:
pool = [n for n in candidates if
n.safe_to_overwrite and
n.coherence <= 3 and
n.risk == 'low' and
||W_down[:, n.id]|| > 0.5]
# layer 分布: L13:39 L14:23 L15:3 L16:15 L17:21 L18:68 L19:31
最后那个 ||W_down|| > 0.5 是防止单纯 dead 的神经元有时候 W_down 也几乎为零,这种"彻底退化"的神经元做基底反而不好——没有几何空间承载新写入的概念。
先试手搓(失败,但学到了边界)
按猫娘那一套思路,自然的想法是"一个神经元 push 一个连续 token,串成知识神经元链"——Phase 18 之前用 800 字符作为 cot 阶段的提示小抄直接 inject 给 Gemma 通过了 233,理论上把这 800 字符拆成 ~500 token,用 500 个神经元接力的输出这个小抄就行了。
Phase 24c 先扫了一遍哪一层适合 push:
Layer | rank_base → rank_new | top-1
L10 | 179620 → 90059 | '\n' ✗
L20 | 179620 → 37270 | '\n' ✗
L25 | 179620 → 276 | '\n' ✗
L30 | 179620 → 3 | '\n' ✗ (近!)
L33 | 179620 → 1 | 'ME' ✓ 🏆
L34 | 179620 → 6 | 'Me' ✗ (logit cap 问题)
L33 又胜出了——跟猫娘那次同一个甜点。早层 push 进去的非训练数据集内容方向,会被下游 16 层 MLP 的注意力反复"清洗回训练分布",等到 L33 只剩 1 层下游,信号来不及被清洗就直接落到输出阶段。
Phase 25b 加了 whitened W_gate(把所有 trigger hidden 的均值减掉再写入,off-diag cos 从 +0.30 降到 −0.14)之后:
N=8 token:8/8 perfectN=30 token:30/30 perfectN=79 token:16/79 翻车
到 79 个 token 的时候,某些 trigger hidden 之间 cos 已经爬到 0.89,神经元开始互相串扰——一个 trigger 触发会顺带把另一个 trigger 的 token 也推出来。
也就是说,手搓神经元链的实用极限大概在 15–30 个连续 token。要 push 500 token 的完整修复内容,这条路走不通。
Phase 26 做了反向验证:只在关键位置(17 个 hard push 点)注入,中间让模型自由续写。预期"反正 71% 的 token 在自由续写下也能 lock-in 到正确路径"。但最终实际:5/79 = 6.3%,完全失败。
根因是:注入神经元给出的内容和训练数据分布出现了冲突,模型于是开始瞎选。Phase 19 的"71% 自由 lock-in"是在完整训练分布的 cot 里观察到的——一旦 OOD push 介入,这个假设就崩了。
用 hook delta 训 300 个神经元(Phase 28b)
直接全模型 LoRA 是有的:Phase 28a 拿 r=8 α=16 在 7 个模块上挂 LoRA,50 步、12.08M 可训练参数、47 MB checkpoint,最终 loss = 0.0000,7/7 signals 全过,233 完整修好。但这条只是"证明单样本 SFT 这条路对 233 有效",不是机制研究——干预面积太大,根本说不清哪部分起作用。
Phase 28b 是真正想做的实验:全模型冻结,只给那 300 个冷神经元加可训练 delta。
# 每个被选神经元在 L 层加 3 个 1536 维 trainable delta:
delta_gate[L] : nn.Parameter [n_at_L, 1536]
delta_up[L] : nn.Parameter [n_at_L, 1536]
delta_down[L] : nn.Parameter [1536, n_at_L]
# 用 forward_hook 把 delta 加到 gate/up/down 的 output:
gate_out[..., chosen_idx] += x @ delta_gate.T
up_out [..., chosen_idx] += x @ delta_up.T
down_out += x[..., chosen_idx] @ delta_down.T
数学上等价于 W_full = W_base + insert(delta, chosen_positions),但 W_base 不动,只有 delta 是 nn.Parameter。也就是 300 个原本摸鱼的神经元被赋予新职责,其他 337,620 个神经元一字未改。
训练:
trainable: 1.38M (vs 28a 12.08M, 减少 89%)
steps: 50, LR=5e-4 + CosineAnnealing
time: 33s
final loss: 0.0306 (容量小所以比 28a 高)
checkpoint: 2.7 MB (vs 28a 47 MB, 减少 94%)
测试 response 模式:7/7 signals 全过,修复效果跟 Path A 完全等价,但参数小 9 倍,而且物理上可定位。
如果以"单个神经元只有一次写入残差流"的视角来看,这个甚至可以量化——LeetCode 233 是一个 300 步左右的复杂度任务,而猫娘口癖是一个 2 复杂度的任务。也就是在量化任务难度上,现在来到了最底层的阶段。
永远不上场的球员练战术(Phase 28c → 28d)
Phase 28c 想把同样的思路用到 thinking 模式:用 Phase 21 剩下的 137 个 inert 神经元做底,SFT 50 步。结果:loss 收敛到 0.098(明显比 28b 差),训完的模型在 thinking 路径输出 4024 字符,抄错公式 A × 9 + D × (P-1) + B,陷入 P=1,2,3,... 的死循环。
因此需要重新定位 thinking 的神经元:
cot_max min=0.0505 median=0.0864 max=0.1230
cot_max < 0.2 的占比: 300/300 (100%) ← 全是 thinking 真 inert
完全验证。这 300 个在 thinking 模式确实根本不 fire。
Phase 28d 重新选池,这次条件是"thinking 模式真正在干活的神经元":
pool = [n for n in all_neurons if
n.cot_max >= 0.5 # cot 真激活
and n.cot_max / n.resp_max >= 2.0 # cot-leaning, 不是通用神经元
and n.cot_max <= 50 # 不要太极端的骨干 (排除 L23#1730 这种 master anchor)
and 10 <= n.layer <= 30]
# 共 32,065 候选, 按 cot/resp ratio 降序取 300
# 分布: 主力在 L23–L28 (193 个 = 64%), 平均 ratio 20–26×
跟之前 Phase 16 找到的 L23#1730 "干活"区域完全吻合——这才是 thinking 真用的"思考工作和逻辑"层。
trainable: 1.38M (跟 28b 同)
loss: 1.97 → 0.011 (29 秒, 比 28b 还低——cot-active 神经元梯度信号更强)
trained gen thinking=True: 337 token (vs baseline 跑 2500 token 都没收尾)
→ 7/7 signals 全过, thinking 模式 233 完整修复
"上场的球员"和"摸鱼的球员"对同一项 SFT 任务,效果差出一个数量级。
打开训好的 delta,看到的不是单 token push
到这里 233 修好了,但有个问题一直没回答:这 300 个神经元到底学到了什么?按猫娘那一套思路,应该是"每个神经元负责 push 一个特定 token",拼成一个分布式知识链。
Phase 30 真把训好的 delta 拆开看,发现完全不是这样。
||delta_down|| max = 0.265
(对比: 猫娘手搓 α=3 是 ||v_meow_n|| × 3 = 3 量级)
(对比: Phase 25 手工 β=200 是 200 量级)
delta 的强度比手搓弱 700 倍以上。然后挨个神经元看 W_down @ W_unembed 的 top push token:
L19#5901 push top: ' equip', ' weapons', ' przede', 'eda', ' con' ← 完全不沾边
L13#2848 push top: 'icating', ' undoubtedly', ' strerror', ... ← 完全不沾边
全 300 神经元 push 频次最高:
12× 'itiveness' 10× ' naro' 7× ' homo' 7× ' Obj'
全是噪声,没有一个跟 233 的算法关键词("digit"、"P"、"count"、"DP")沾边。
但模型确实修好了,跨题测试 count digit 2(数字 2 计数)也是 8/8 全过。也就是说,修复信息不存在于任何单个神经元的 W_down 投影里——
- 300 个神经元 collective 在 hidden state 中形成了一个 superposition direction
- 信息不存于 lm_head vocab 子空间,而存于模型内部 hidden subspace
- 通过下游 attention 协同把模型引导到 digit DP 算法路径
而且这同时解释了两个观察:
- Phase 29 微改泛化:count digit 2 题目 → 8/8 ✓
- Phase 29b 真异题误触发:lc 902(set 去重)→ 陷入"让我们正确的 DP 结构" × 18 次注释 loop ← 模型把它当 233 类问题处理了
修复学到的其实不是"233 这一道题",也不是"整个数位 DP 算法大类"——是"题面像 233 + class Solution + 数字计数 → 走 A/D/B/P 路径"这一条反射弧。表面像 233 但算法不同的题反而会误触发,被卡死。
所以从产品意义上讲,这次修复是把一个特定抽象方向塞进了模型——而不是塞进了一段代码。这是 SFT 跟手搓最本质的区别。
最终阶段
最后把 28b 和 28d 两套 delta 同时挂上:
phase28b deltas (response mode, 300 inert L13–L19) ← 物理上不同神经元
+ phase28d deltas (thinking mode, 300 cot-active L20–L30)
两套 deltas 互不冲突 (神经元集合 disjoint), 两个 hook 可同时挂
= leetcode 233 双模式完整修复
总参数: 1.26M (占模型 0.07%)
物理隔离 + 模式分工——这套架构本身也是一个有趣的发现:Gemma 在 thinking 和 response 两个阶段动用的神经元几乎完全不重叠,所以可以分别打两个 patch,互不干扰。
结论
猫娘可以看作是 233 的缩小版验证实验,是为了证明神经元可以被编辑和修改并且不污染其他神经元。
模型内部的原理可能异常简单——
从猫娘(2 个神经元)到 LeetCode 233(300 个 delta),"编辑神经元"的范式横跨了一个尺度光谱:
| 干预位置 | 被"替换"的是 | 工具 | 干预大小 |
|---|---|---|---|
| 一个 MLP 线性层的一列 | 1 个神经元的角色 | apply_meow() 手搓 | 33 KB |
| 跨多层共 ~300 列 | 一小组神经元的协同角色 | targeted SFT(28b/c/d) | 1–3 MB delta |
| 全部线性层 + 低秩 delta | 模型在某任务上的整体响应模式 | LoRA r=8 α=16(28a) | 46 MB |
三个尺度都是"replace, not retrain"——Gemma 底层权重一字未动。开源 runtime 是个可跑的 chat UI(滑条调 α/β),猫娘 signature 只有 33 KB,LeetCode 233 修复 deltas 加起来 5.4 MB。代码、权重、SHA-256 指纹全在 github.com/chenmoacr/AMR_ReplaceNeuron。