A M R M A T R I X \ \

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#9910.106
L30#90740.084
L31#29090.073
L32#20740.058
L33#64170.067★ 最终选定
L34#121880.081mean ≈ 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 划分成正负两组:

集合定义数量
positiveassistant 区段内所有"句号位置 P 的前一位"hidden[P−1](即"下一个 token 将是句号"的位置)76
negativeassistant 区段内排除 (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.2gate 灵敏度控制 silu(W_gate · h) 在句末位置的激活幅度。太大 → silu 饱和失去判别;太小 → 任何位置都不激活
SCALE_U = 0.2up 力度与 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_GSCALE_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.0math/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),无任何抑制710708
A α=3 + B β=1172
A α=3 + B β=2122

第一行那个 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_meowedids[k-1] == MEOW_ID && ids[k] == PERIOD_ID 的 k79
baseline_periodids[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) 正在生效:

AMR 神经元开关聊天 UI:模型回答'介绍一下自己吧',末尾自然带'一员喵!'。右侧控制面板显示喵神经元 A+B 已启用、ALPHA=7.0、β=0.0。
UI:模型回答末尾自然带"喵",右侧控制面板可看到 A/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,得做两件事:

  1. 找一批可以安全改写的神经元(不能动 RLHF 那 30 个焦虑神经元,也不能动正常工作的代码检测神经元)。
  2. 决定改写方式:手搓?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_maxthinking 段内的最大激活
resp_maxresponse 段(assistant 正文)内的最大激活
combined_max两区段合并的最大激活

这样每个神经元就有 (cot_max, resp_max, combined_max) 三元组——分区是关键,因为模型在"思考"和"回答"两个状态下用的神经元其实是分开的。

Phase 20b 用相对阈值分类:

类别条件数量
cot_dominantcot_max / resp_max > 102307(0.68%)
resp_dominantresp_max / cot_max > 10387(0.11%)
both_quietcombined_max < 0.218,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 perfect
  • N=30 token:30/30 perfect
  • N=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


mech-interp neuron-edit gemma-4 hand-crafted lora superposition rlhf-debug draft

Cite as: Indexguc (2026). 用两个神经元实现一个猫娘喵,用两百个神经元修复一个代码问题 · AMR_ReplaceNeuron 实验记录. amrmatrix.top/reports/report_ReplaceNeuron. 首发于知乎,2026-05-12。代码与产物:github.com/chenmoacr/AMR_ReplaceNeuron。