Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

可解释性与可靠性:模型为什么这样答,我们能信到哪一步

分类器认出了照片里的物品,还给出了 98% 的置信度。我们把物品留在原地,只换掉背后的桌布,它就改口了。

刚才的高分,到底是在认物品,还是在认桌布?

如果让模型解释,它可能给出一段很像样的话。但解释也是模型产生的输出。要知道计算过程里发生了什么,我们得多走一步:检查中间表示,改变某条信息,再观察结果是否按预期变化。

这一页沿着这条线往下走。先训练一个小分类器,抓住它对背景的依赖;再把计算拆开,做几次内部干预;最后讨论更难的问题:即使知道了某些计算机制,我们怎样判断一句回答有证据、一个置信度值得相信?

前置内容是逻辑回归、模型评估与泛化、Transformer与研究证据。资料核查截至 2026 年 10 月 20 日。本页的训练和数值实验都只需 Python 标准库。

1. 四个问题,别急着用一个分数回答

“这个模型可信吗?”太大了。不妨拆成四个可以动手检查的问题:

问题可以寻找的证据
表示里有什么信息?用简单读出器预测某个属性
哪些计算影响了回答?替换、移除中间数值,比较输出
回答的内容是否有依据?把陈述与指定来源逐条核对
条件变化后还能工作吗?改变背景、来源、时间或输入形式

一个方法可能在其中一项做得很好,却不能包办其他几项。读出“模型对答案有疑虑”的信号,不等于找到它出错的完整原因;找到某条因果路径,也不等于证明回答在现实中成立。

这正是可解释性与可靠性相遇的地方:前者帮助我们提出更具体的诊断,后者要求这些诊断经得起任务和环境的检验。

2. 一个会认背景的分类器

把照片问题缩小到两个数

为了能把每一步算清楚,我们暂时不处理像素,只给模型两个已经提取好的特征:

  • ss 是形状线索,取 −1-1 或 +1+1。
  • bb 是背景线索,也取 −1-1 或 +1+1。

目标类别记作 yy,同样取这两个值。两类各占一半。给定类别以后,形状和背景分别产生,而且在这个条件下相互独立。

原来的环境中,形状有 80% 的机会与类别同号,背景则有 95%。比如 y=+1y=+1 时,出现 s=+1,b=−1s=+1,b=-1 的条件概率是 0.8×0.05=0.040.8\times0.05=0.04;再乘类别的概率 0.50.5,这整个状态的概率为 0.020.02。

总共只有 2×2×2=82\times2\times2=8 个状态。程序把它们全部列出,按各自的概率加权。这是一个完全已知的教学分布,不是八张真实照片,也不是从总体中抽出的八个测试样本。 我们没有图像编码器,更没有借此衡量真实视觉模型的能力。

逻辑回归计算:

z=wss+wbbP(y=+1∣s,b)=σ(z) \begin{aligned} z &= w_s s+w_b b\\ P(y=+1\mid s,b)&=\sigma(z) \end{aligned}

zz 是二分类的 logit;σ\sigma 是前面学过的 sigmoid。这里不设偏置,因为整个问题对正、负类别对称。z≥0z\geq0 时预测为 +1+1。

训练目标仍是交叉熵,只是把样本平均换成八个状态的加权和:

L=∑i=18πilog⁡(1+exp⁡(−yizi)) L=\sum_{i=1}^{8}\pi_i \log\left(1+\exp(-y_i z_i)\right)

πi\pi_i 是第 ii 个状态的概率,八项加起来等于 1。参数从零开始,用学习率 0.5 做 2,000 次完整梯度更新,不使用正则化,取最后一次参数。方案在运行前固定,没有用下面的环境比较来挑参数。

高分的来源,一换环境就露出来

我们实际训练三个方案。第一个照原分布训练;第二个在训练时把背景与标签的同号概率改成 50%,也就是让背景独立于标签;第三个保留原分布,但强制 wb=0w_b=0,只用形状。三者都更新 2,000 次,不过第三个只有一个可调参数。

测试时固定形状的 80%,只改变背景:

训练方案原分布背景反转
原分布训练95.0%5.0%
背景平衡训练80.0%80.0%
只用形状80.0%80.0%

“背景反转”指背景与标签同号的概率从 95% 降到 5%。如果背景完全独立,三者的正确率依次为 50.0%、80.0%、80.0%。这些都是对八个状态求和得到的分布正确率,不附抽样置信区间。

原分布模型学到:

ws≈1.3863,wb≈2.9444 w_s\approx1.3863,\qquad w_b\approx2.9444

背景的权重大到足以压过形状:只要两者冲突,它就跟着背景走。我们没有给它加一条“请忽略背景”的要求;优化器确实找到了原分布上好用的规则。

另外两个模型都得到 ws≈1.3863,wb=0w_s\approx1.3863,w_b=0。它们在原环境中让出了 15 个百分点,却在改变背景后保住了形状所能提供的 80%。这是指定变化下的稳健性取舍。我们事先知道哪个特征会保持稳定;真实问题里,这种知识往往要靠领域经验和额外实验获得。

图 1:背景平衡与只用形状两条曲线重合在 80%。下图提醒我们,同一个高置信度门槛,换环境后也可能留下大量错误。数据来自本页的有限分布实验。

选读:这两个权重为什么接近 ln 4 和 ln 19?

形状同号与反号的概率比为 0.8/0.2=40.8/0.2=4,背景为 0.95/0.05=190.95/0.05=19。在给定类别后相互独立、两类等概率的设定下,贝叶斯公式把两条证据的概率比相乘;取对数,就变成相加:

log⁡P(y=+1∣s,b)P(y=−1∣s,b)=slog⁡4+blog⁡19 \log\frac{P(y=+1\mid s,b)} {P(y=-1\mid s,b)} =s\log4+b\log19

所以这个逻辑回归恰好能表达原分布的真实条件概率。数值训练的结果与解析值吻合。它在背景反转后失败,不是因为我们没把训练损失降够,而是因为条件概率已经变了。

3. 看得出信息,不等于用到了信息

大模型内部没有“形状”和“背景”这样的现成标签。每层留下的是一长串数,研究者常用一个简单的分类器去读它们。例如,输入某层表示,预测句子里说的是人还是地点。这个读出器叫探针(probe)。

探针在留出的数据上预测得好,说明信息能被这种读出方式提取。它仍没有告诉我们:原模型后面的计算是否使用了这条信息。

可以亲手造一个反例。把刚才已经训练好的分类器等价展开为四个内部数值:

h=(s,b,b,t) \mathbf h=(s,b,b,t)

tt 是额外给入的纸张厚薄标记,取 −1-1 或 +1+1,与原任务无关。输出写成:

z=wsh1+wb2h2+wb2h3+0h4 z=w_s h_1+\frac{w_b}{2}h_2 +\frac{w_b}{2}h_3+0h_4

我们只是把背景复制了两份、各分一半权重,原分类器的函数完全没变。这个内部结构是人为安排的,不是另行训练出来的神经网络。

现在用探针直接读 h4h_4,厚薄标记可以 100% 读对。但把 tt 从 −1-1 改成 +1+1,输出一丝不动,因为它的读出系数是零。这就把“存在的信息”和“参与当前输出的计算”分开了。

真实模型中的探针还多几层困难:一个过于强大的探针可能自己学会任务;相似模板可能同时落入训练集和测试集;标签也可能与别的信息共变。因此,探针需要简单基线、留出数据和合适的随机标签对照。即使通过这些检查,下一步仍要考虑干预。

4. 换回一小块内部数值,会发生什么

先拿一个正类样例,令 s=+1,b=+1,t=−1s=+1,b=+1,t=-1。原始 logit 约为 4.3314.331。只把背景改成 −1-1,标签保持不变,logit 降到 −1.558-1.558,预测也跟着翻转。

现在保留这次“换背景”的运行,只把第二个通道从 −1-1 换回原来的 +1+1,再继续计算。这种把一次运行的激活放入另一次运行的操作,叫激活替换(activation patching)。

内部状态的改动最后 logit
换背景后的状态-1.558
恢复一个背景通道1.386
恢复两个背景通道4.331

图 2:只恢复一个通道,类别就已经变回正类,但相对于两端的 logit 差,只恢复了一半。

若用 logit 衡量这次替换恢复了多少,可以定义:

R=z替换后−z换背景z原始−z换背景 R=\frac{z_{\text{替换后}}-z_{\text{换背景}}} {z_{\text{原始}}-z_{\text{换背景}}}

分母在这里不为零。一个通道得到 R=0.5R=0.5,两个通道得到 R=1R=1。换一个任务,RR 可能小于零,也可能大于 1;不要把它自动理解成概率。

这个小例子还藏着两个容易忽略的地方。

第一,指标会改变你看见的故事。如果只看“类别有没有恢复”,一个通道就够了;看 logit,才看见它只补回一半差距。在多分类语言模型中,还可以比较目标答案与某个明确替代答案的 logit 差,避免把共同抬高所有候选的变化误当作任务特有的作用。

第二,内部替换可能造出自然输入到不了的状态。正常运行总有 h2=h3h_2=h_3,但只恢复一个通道后,它们一正一负。这次干预确实改变了程序的输出,却不是一张正常输入经过编码后会产生的表示。解释结果时,要说清楚我们改变了什么。

Zhang 与 Nanda 系统比较过替换输入的方法、概率或 logit 差指标、单层或多层联合替换,发现这些选择会改变定位结果。他们尤其讨论了强噪声可能扰乱原有计算的情形。本页的精确算例帮助理解这个问题,并未复现他们的语言模型实验。Activation Patching,第 3~6 节

移除之后没出错,也不能急着说“没用”

另一种操作叫消融:把某个通道设为零,或换成一个参考值。

在原始正类样例上,把一个背景通道置零,logit 仍约为 2.859;把两个都置零,仍有形状提供的 1.386。类别都没有变。但这不表示背景无用,它对分数的贡献明明减少了。

此外,置零、替换为平均值、换入另一个真实样例的值,回答的是不同问题。冗余通道可能互相补偿;联合移除的效果也未必等于单独效果之和。一次样例的局部实验,不能直接升级成“这个模块在所有输入上负责某种能力”。

5. 为什么一个神经元不一定对应一个概念

还有一种困难:我们希望给每个内部数值贴一个名字,模型却未必按这种方式组织信息。

先看普通坐标变化。假如原来用 (u,v)(u,v) 表示两个量,换成 (u+v,u−v)(u+v,u-v),只要后面配上逆变换,信息仍然完整。每个新坐标都混合了原来的两个量。单看一个坐标,就容易把它误解为“同时负责两个概念”。

叠加表示(superposition)更进一步:可能需要表达的特征,比可用的维度还多。它不能无损容纳任意组合,但如果特征很少同时出现,就可能用一些干扰换取空间。

我们手工安排三个单位方向,共用二维平面:

d1=(1,0)d2=(−1/2,3/2)d3=(−1/2,−3/2) \begin{aligned} \mathbf d_1&=(1,0)\\ \mathbf d_2&=(-1/2,\sqrt3/2)\\ \mathbf d_3&=(-1/2,-\sqrt3/2) \end{aligned}

三个非负特征组成 x=(x1,x2,x3)\mathbf x=(x_1,x_2,x_3)。先沿方向相加,再用点积和 ReLU 读出:

h=∑j=13xjdjx^j=max⁡(0,djTh) \begin{aligned} \mathbf h&=\sum_{j=1}^{3}x_j\mathbf d_j\\ \hat{x}_j&=\max(0,\mathbf d_j^\mathsf T\mathbf h) \end{aligned}

只有 x1=1x_1=1 时,三个点积为 1,−1/2,−1/21,-1/2,-1/2,ReLU 恰好留下 (1,0,0)(1,0,0)。另外两个特征单独出现也一样。

但若 x1=x2=1x_1=x_2=1,读出会变成 (0.5,0.5,0)(0.5,0.5,0),两个活跃特征都被压小了;三者同时为 1 时,三个方向相消,读出全为零。

图 3:图中的方向由我们指定。曲线的四个点来自全部八种 0/1 组合的概率加权,连线仅便于观察,并不是训练曲线。

令每个特征独立地以概率 pp 取 1。以三个分量的平方误差之和为损失,程序枚举得到:

pp三个方向共用只保留前两个
0.050.00393750.05
0.200.0720.20
0.500.56250.50
1.003.001.00

当特征很稀疏时,共用空间划算;频繁同时出现时,干扰就变贵了。右列直接丢弃第三个特征,另外两个准确保留,期望损失为 pp。这只比较两种手工设计,不是在宣称哪一种是所有网络的全局最优解。

Elhage 等的 Toy Models of Superposition 用真实训练的小型网络研究了类似的容量、稀疏性与干扰关系。它提供的是可控模型中的证据,不能把其中所有几何结构直接套到任意大模型上。作者论文

6. 稀疏自编码器:给表示找一套更好读的坐标

如果原坐标不好解释,能否再训练一个模型,把内部表示拆成少量活跃特征?

稀疏自编码器(sparse autoencoder,SAE)就是一条这样的路线。把已训练模型的某层表示 h∈Rd\mathbf h\in\mathbb R^d 取出来,原模型保持冻结;另一个编码器将它变成 a∈Rm\mathbf a\in\mathbb R^m,再重建:

a=ReLU⁡(Eh+be)h^=Da+bd \begin{aligned} \mathbf a&=\operatorname{ReLU}(E\mathbf h+\mathbf b_e)\\ \hat{\mathbf h}&=D\mathbf a+\mathbf b_d \end{aligned}

这里 EE 的形状为 m×dm\times d,DD 为 d×md\times m,常让 m>dm>d,给候选方向留足位置,但希望每次只激活其中少数几个。

一种基础目标是:

LSAE=∥h−h^∥22+λ∑j∣aj∣ L_{\text{SAE}} =\|\mathbf h-\hat{\mathbf h}\|_2^2 +\lambda\sum_j|a_j|

第一项要求还原得像,第二项鼓励稀疏。还需要约束解码方向的尺度,例如让 DD 的各列保持单位长度;否则把 DD 放大、a\mathbf a 缩小,就能降低稀疏惩罚而不改变重建。现代方法也有保留最大若干激活、学习阈值等不同设计。

训练完以后,可以寻找某个特征最活跃的文本,提出“它似乎与某个概念有关”的假设,再用新样例和干预检查。但低重建误差并不自动意味着名字贴得正确:一个概念可能分散到多个特征,一项特征也可能混入我们没注意到的条件。

2026 年的一条路线:有些概念可能需要一小块空间

月份是个很好的直觉例子:十二月与一月相邻,一条直线上从 1 排到 12 却会把它们放在两端。用平面上的圆表达周期,往往比单个标量自然。这是我们自己构造的表示直觉,并不意味着所有语言模型都这样编码月份。

Dalili 与 Mahdavi 的 SASA 预印本把 SAE 的单个方向扩展为一组方向组成的子空间,并在组层面进行稀疏选择。论文在 GPT-2 Small 和 Mistral-7B 的冻结表示上研究重建、特征拆分及吸收,也提供 GPT-2 相同数据与预算的比较。部分指标更好,并非每个指标都领先。

作者明确留下一个限制:找到子空间,不代表已经知道空间内部每个坐标的意义。其理论也依赖具体的特征结构与稀疏条件。我们可以把它作为“解释单位是否应该从一条方向改成一个子空间”的研究问题,而不是把最新预印本当作所有 SAE 问题的定论。SASA v1,第 5、7、8 节,2026-06-04

本页没有训练 SAE 或 SASA。前面的三方向实验只用来说明表示压缩中的干扰。

7. 从特征到计算路径:解释图还缺什么

找到一个“与某概念相关”的方向之后,还要问:它从哪里得到信息,又影响了谁?

2025 年的 Circuit Tracing 用跨层转码器近似模型中 MLP 的计算,并为具体提示构造特征之间的归因图。转码器在这里学习近似计算的输入输出关系,与重建同一层表示的普通 SAE 有区别。

看这种图时,节点和边很容易给人“计算已被完整画出来”的印象。论文的方法实际上保留了重建误差节点;其局部替代模型固定原运行的注意力模式与归一化分母。因此,图能够追踪一部分信息如何流动,却未必解释注意力为何选择那些位置。作者也通过干预检查替代模型与原模型的响应是否相符。Circuit Tracing:方法与局限,2025-03-27

阅读时可以抓住一个具体问题:若按这张图改动一个特征,原模型会不会发生图所预测的变化? 如果只在替代模型上成立,就还需要补证据。如果只在一个提示上成立,就继续换表达、换实体和换上下文。

模型写出来的推理过程也需要这样的态度。它能帮助用户理解回答,但文字上的理由与内部计算是否一致,是另一个待检验的问题。仅仅把解释写得更长、更流畅,不会自动填补这段距离。

8. 高置信度,为什么还能错得很坚定

回到小分类器。我们把它给自己所选类别的概率作为置信度:

c=max⁡(P(y=+1∣x),P(y=−1∣x)) c=\max\bigl(P(y=+1\mid\mathbf x), P(y=-1\mid\mathbf x)\bigr)

只接受 c≥0.9c\geq0.9 的预测,其余交给人工或另一个流程。这叫选择性预测。要同时报告两项:

覆盖率=P(c≥τ)选择后错误率=P(y^≠y∣c≥τ) \begin{aligned} \text{覆盖率}&=P(c\geq\tau)\\ \text{选择后错误率} &=P(\hat y\ne y\mid c\geq\tau) \end{aligned}

τ\tau 是门槛。若一个预测都没接受,错误率就没有定义,不能记成“零错误”来庆祝。

在原分布上,门槛 0.9 保留 77.0% 的状态概率,留下的错误率只有约 1.3%。背景反转后,同一个门槛保留 23.0%,错误率却达到 82.6%。

怎么会这样?因为形状与背景一致时,模型最有信心。反转后,这种一致经常来自“形状恰好错了,背景也错了”:

P(一致)=0.8×0.05+0.2×0.95=0.23P(错误∣一致)=0.190.23≈0.826 \begin{aligned} P(\text{一致})&=0.8\times0.05\\ &\quad+0.2\times0.95\\ &=0.23\\ P(\text{错误}\mid\text{一致})&=\frac{0.19}{0.23} \approx0.826 \end{aligned}

这个模型甚至能准确表达旧分布的条件概率,仍挡不住新分布中的自信错误。校准值得做,但它的证据总依赖评价分布。选择门槛时应使用与部署有关的独立数据,并在环境变化后重新检查;不能把一个固定门槛当成永久保证。

除了总体正确率,也可以看失败集中在哪些组。本例按真实类别与“背景是否同号”分组,原模型在背景不同号的组里正确率为零;只用形状的方案在四组里都为 80%。最差组指标能把平均分遮住的问题摆出来,但真实数据中还要考虑组的定义和样本量。

9. 四种说法,可能只是一种意思

语言模型还有一层特殊困难。下面四个回答,字符串不同,意思却相同:

二楼。在二楼。2 楼。第二层。

直接按字符串数类别,会把表达变化也当成答案的不确定性。语义熵先把相同意思的回答归为一组,再衡量这些组有多分散。

若一共取得 NN 份回答,人工或判断模型把它们分成 KK 组,第 kk 组有 nkn_k 份,一个基于频数的估计为:

p^k=nkNH^=−∑k=1Kp^klog⁡p^k \begin{aligned} \hat p_k&=\frac{n_k}{N}\\ \hat H&=-\sum_{k=1}^{K}\hat p_k\log\hat p_k \end{aligned}

这里用自然对数,单位为 nat。有限回答给出的是估计;分组是否准确、采样温度和回答数量都会影响结果。不能把同一条贪心回答复制十次,冒充十次独立采样。

程序里有三组手写回答。假设指定公告明确写着打印室在二楼:

回答的意思语义熵是否正确
四份都说二楼0都正确
四份都说三楼0都错误
两份二楼、两份三楼约 0.693一半正确

第二行是关键反例。语义一致可以提示稳定性,却没有替我们核对事实。这里的文本、语义分组和正确性都由我们手工指定,没有真实语言模型采样或自动蕴含识别。

Kossen 等的 Semantic Entropy Probes 则把语义熵作为探针的监督信号:训练阶段仍需多次生成并分组;使用阶段从一次运行的隐藏状态预测高、低语义不确定性。其目标是降低检测成本,并在若干问答任务上比较迁移效果,不是证明内部有一个通用的“真理开关”。成本也要分开看:推理时少采样,不等于训练标签免费获得。SEP v1,第 3~5、8 节,2024-06-22

10. 事实性:把一句话拆开,证据才接得上

假设助手回答:“打印室在二楼,晚上九点关闭,还提供彩色打印。”

公告 A 只写着“打印室位于二楼,20:00 关闭”。整句话一半像对,一半又不对。拆开以后,问题清楚多了:

陈述对照公告 A
打印室在二楼有支持
晚上九点关闭与来源冲突
提供彩色打印来源未说明

“未说明”和“冲突”需要不同处理。前者要找更多证据,后者要解决内容不一致;两者都不能因为句子带了引用,就被算成已经有支持。

FActScore 以原子事实为单位,衡量生成内容中有多少事实得到指定知识来源支持。其定义关心事实精确性,不能单独奖励内容的完整程度:只说很少的安全事实、回避大量问题,也可能提高这个比例。原论文同时讨论了来源、事实权重和拒答等条件。Min 等,EMNLP 2023,第 3.1 节

本页只用三个结构化字段做一次精确对照,支持比例为 1/31/3。自然语言的拆分、检索、蕴含判断都没有在这个小程序里实现,因此它不是 FActScore 系统复现。

实际应用里,除了支持比例,还要看是否回答了用户真正的问题、引用是否准确指向证据、来源是否适合当前时间。回到检索增强生成的例子:找到一篇相关文档,只完成了一步;生成的每条关键陈述仍要与文档对得上。

11. 把诊断变成一次可以反驳的实验

假如我们怀疑“模型主要靠背景判断”,可以把这句话写得更具体:

保留类别和形状,只反转背景时,原分布模型的类别预测会翻转;去掉背景读出后,这种翻转消失。

这句话有操作、有比较对象、有预期结果。本页八个状态都可以检验,原模型的背景翻转率为 100%,只用形状时为 0%。但它没有声称“删除所有背景信息会改善所有现实任务”。

迁移到语言模型时,也需要界定改变的内容。例如,保持事实问题不变,只改措辞;保持文档内容不变,只改顺序;保持有效证据不变,加入看似权威的干扰句。这些是不同的鲁棒性问题,应分别记录。若改写本身改变了答案,就不能再把它当作“不该改变预测”的测试。

解释可以帮助我们决定改哪里:增加某类训练样例、调整特征、改变检索流程或选择拒答。但改完以后,仍要回到独立任务上看效果,同时检查原本正常的行为。一个解释漂亮的系统,也可能稳定地使用了错误规则。

12. 运行、修改,再看一次结果

下载完整脚本、实验方案和完整记录。把两个 JSON 放在脚本旁的 data/ 目录。

python3 interpretability_lab.py

默认读取已经保存的结果,输出为:

三个环境的正确率:原分布 / 背景独立 / 背景反转
原分布训练:95.0% / 50.0% / 5.0%
背景平衡训练:80.0% / 80.0% / 80.0%
只用形状:80.0% / 80.0% / 80.0%
original,置信度至少 0.9:覆盖 77.0%,错误 1.3%
reversed,置信度至少 0.9:覆盖 23.0%,错误 82.6%
恢复一个 / 两个背景通道:50.0% / 100.0%
各组回答的语义熵:0.000 / 0.000 / 0.693
三条陈述的证据状态:supported / contradicted / unknown

要从零进行三次梯度训练、重新计算所有干预和数值算例,另存到一个新文件:

python3 interpretability_lab.py --run my-record.json

代码在 Python 3.9 与 3.12 下可运行,不需要下载模型或安装数值库。因为只有八个状态,每次更新都精确计算整个有限分布的梯度,不涉及随机抽样或随机种子,也没有训练/测试样本划分。不同环境共用状态空间,只改变状态概率;这些结果研究的是明确的分布变化,而不是对未见图像的泛化。

修改 make_plan() 中的形状可靠程度、训练步数或环境概率以后,先用 --freeze 新方案.json 保存新方案,再通过 --plan 新方案.json --run 新记录.json 运行。若改变特征或干预方式,还要同步改相应函数。程序拒绝覆盖已有方案和结果,便于比较。

展开完整代码
"""有限分布上的逻辑回归、内部干预和可靠性算例;只用 Python 标准库。"""
from argparse import ArgumentParser
from collections import Counter
from itertools import product
from pathlib import Path
import hashlib
import json
import math
import platform
import time

HERE = Path(__file__).resolve().parent
PLAN = HERE / "data/interpretability-plan.json"
RECORD = HERE / "data/interpretability-record.json"
MODES = ("original", "balanced", "shape_only")
NAMES = ("原分布训练", "背景平衡训练", "只用形状")


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def save_new(path, value):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x", encoding="utf-8") as stream:
        json.dump(value, stream, ensure_ascii=False, indent=2, allow_nan=False)
        stream.write("\n")


def make_plan():
    return {
        "schema": 1, "shape_agreement": 0.8,
        "train_background_agreement": 0.95,
        "test_background_agreement": {"original": 0.95, "independent": 0.5, "reversed": 0.05},
        "initial_weights": [0.0, 0.0], "steps": 2000, "learning_rate": 0.5,
        "modes": list(MODES), "thresholds": [0.5, 0.8, 0.9, 0.95, 0.99],
        "data": "exact enumeration of eight (label, shape, background) states; not samples",
        "selection": "fixed final weights, no validation or hyperparameter selection",
        "superposition_activation": [0.05, 0.2, 0.5, 1.0],
    }


def sigmoid(z):
    return 1 / (1 + math.exp(-z))


def population(shape_agreement, background_agreement):
    rows = []
    for y, s, b in product((-1, 1), repeat=3):
        ps = shape_agreement if s == y else 1 - shape_agreement
        pb = background_agreement if b == y else 1 - background_agreement
        rows.append({"y": y, "x": [s, b], "mass": 0.5 * ps * pb})
    return rows


def objective(weights, rows):
    loss, gradient = 0.0, [0.0, 0.0]
    for row in rows:
        x, y, mass = row["x"], row["y"], row["mass"]
        margin = y * sum(w * v for w, v in zip(weights, x))
        loss += mass * math.log1p(math.exp(-margin))
        for j in range(2):
            gradient[j] -= mass * y * x[j] * sigmoid(-margin)
    return loss, gradient


def fit(rows, plan, shape_only=False):
    weights = plan["initial_weights"].copy()
    losses = []
    for _ in range(plan["steps"]):
        loss, gradient = objective(weights, rows)
        losses.append(loss)
        for j in range(1 if shape_only else 2):
            weights[j] -= plan["learning_rate"] * gradient[j]
    loss, gradient = objective(weights, rows)
    losses.append(loss)
    return {"weights": weights, "losses": losses, "final_gradient": gradient}


def evaluate(weights, rows, thresholds):
    predictions = []
    for row in rows:
        z = sum(w * v for w, v in zip(weights, row["x"]))
        prediction = 1 if z >= 0 else -1
        predictions.append({**row, "logit": z, "positive_probability": sigmoid(z),
                            "confidence": sigmoid(abs(z)), "prediction": prediction,
                            "correct": prediction == row["y"]})
    accuracy = sum(r["mass"] for r in predictions if r["correct"])
    groups = {}
    for y, agreement in product((-1, 1), (False, True)):
        group = [r for r in predictions if r["y"] == y and (r["x"][1] == y) == agreement]
        mass = sum(r["mass"] for r in group)
        groups[f"y={y},background_agrees={agreement}"] = (
            sum(r["mass"] for r in group if r["correct"]) / mass
        )
    selection = []
    for threshold in thresholds:
        chosen = [r for r in predictions if r["confidence"] >= threshold]
        coverage = sum(r["mass"] for r in chosen)
        risk = sum(r["mass"] for r in chosen if not r["correct"]) / coverage if coverage else None
        selection.append({"threshold": threshold, "coverage": coverage, "risk": risk})
    flips = 0.0
    for row in predictions:
        s, b = row["x"]
        new = 1 if weights[0] * s - weights[1] * b >= 0 else -1
        flips += row["mass"] * (new != row["prediction"])
    return {"predictions": predictions, "accuracy": accuracy, "groups": groups,
            "worst_group_accuracy": min(groups.values()), "selection": selection,
            "background_flip_rate": flips, "nll": objective(weights, rows)[0]}


def interventions(weights):
    # 人为等价展开,不声称这些通道由神经网络自动学出。
    readout = [weights[0], weights[1] / 2, weights[1] / 2, 0.0]
    clean, changed = [1, 1, 1, -1], [1, -1, -1, -1]
    score = lambda h: sum(w * v for w, v in zip(readout, h))
    baseline, target = score(changed), score(clean)
    patches = []
    for indices in ([], [0], [1], [2], [1, 2], [3]):
        h = changed.copy()
        for j in indices:
            h[j] = clean[j]
        patches.append({"indices": indices, "hidden": h, "logit": score(h),
                        "recovery": (score(h) - baseline) / (target - baseline)})
    ablations = []
    for indices in ([0], [1], [2], [1, 2], [3]):
        h = clean.copy()
        for j in indices:
            h[j] = 0
        ablations.append({"indices": indices, "hidden": h, "logit": score(h)})
    # 第四维完全可以读出,改变它却不影响输出。
    probe = [{"hidden": [s, b, b, t], "label": t, "prediction": t,
              "logit": score([s, b, b, t])}
             for s, b, t in product((-1, 1), repeat=3)]
    return {"readout": readout, "clean": clean, "changed": changed,
            "clean_logit": target, "changed_logit": baseline,
            "patches": patches, "ablations": ablations, "unused_probe": probe}


def superposition(probabilities):
    directions = [[1.0, 0.0], [-0.5, math.sqrt(3) / 2], [-0.5, -math.sqrt(3) / 2]]
    patterns = []
    for x in product((0, 1), repeat=3):
        h = [sum(x[j] * directions[j][k] for j in range(3)) for k in range(2)]
        decoded = [max(0.0, sum(a * b for a, b in zip(d, h))) for d in directions]
        patterns.append({"input": list(x), "hidden": h, "decoded": decoded,
                         "squared_error": sum((a - b) ** 2 for a, b in zip(x, decoded))})
    expectations = []
    for p in probabilities:
        loss = sum(p**sum(r["input"]) * (1 - p)**(3 - sum(r["input"])) * r["squared_error"]
                   for r in patterns)
        expectations.append({"activation_probability": p, "triangle_loss": loss,
                             "keep_two_loss": p})
    return {"directions": directions, "patterns": patterns, "expectations": expectations}


def entropy(labels):
    counts = Counter(labels)
    return max(0.0, -sum((n / len(labels)) * math.log(n / len(labels)) for n in counts.values()))


def evidence_toy():
    # 回答和语义分组均手工给定,没有调用语言模型或自动蕴含判断器。
    cases = [
        {"name": "同义正确", "answers": ["二楼", "在二楼", "2 楼", "第二层"],
         "meanings": ["floor2"] * 4, "correct": [True] * 4},
        {"name": "一致错误", "answers": ["三楼", "在三楼", "3 楼", "第三层"],
         "meanings": ["floor3"] * 4, "correct": [False] * 4},
        {"name": "答案摇摆", "answers": ["二楼", "三楼", "二楼", "三楼"],
         "meanings": ["floor2", "floor3", "floor2", "floor3"],
         "correct": [True, False, True, False]},
    ]
    for case in cases:
        case["string_entropy"] = entropy(case["answers"])
        case["meaning_entropy"] = entropy(case["meanings"])
    sources = {"floor": {"value": 2, "source": "notice-A"},
               "closing_hour": {"value": 20, "source": "notice-A"}}
    claims = [{"key": "floor", "value": 2}, {"key": "closing_hour", "value": 21},
              {"key": "has_color_printer", "value": True}]
    for claim in claims:
        evidence = sources.get(claim["key"])
        claim["status"] = ("unknown" if evidence is None else
                           "supported" if claim["value"] == evidence["value"] else "contradicted")
        claim["source"] = evidence["source"] if evidence else None
    return {"cases": cases, "sources": sources, "claims": claims,
            "supported_fraction": sum(c["status"] == "supported" for c in claims) / len(claims)}


def run(plan_path, out):
    plan = json.loads(plan_path.read_text())
    assert plan == make_plan(), "方案与代码不一致,请重新冻结到新文件。"
    if out.exists():
        raise FileExistsError(out)
    start = time.perf_counter()
    original = population(plan["shape_agreement"], plan["train_background_agreement"])
    balanced = population(plan["shape_agreement"], 0.5)
    models = {}
    for mode in MODES:
        rows = balanced if mode == "balanced" else original
        model = fit(rows, plan, shape_only=mode == "shape_only")
        model["training_population"] = rows
        model["evaluation"] = {
            name: evaluate(model["weights"], population(plan["shape_agreement"], q),
                           plan["thresholds"])
            for name, q in plan["test_background_agreement"].items()
        }
        models[mode] = model
    record = {"complete": True, "plan": plan, "plan_sha256": digest(plan_path),
              "source_sha256": digest(Path(__file__)), "python": platform.python_version(),
              "models": models, "interventions": interventions(models["original"]["weights"]),
              "superposition": superposition(plan["superposition_activation"]),
              "evidence": evidence_toy(), "elapsed_seconds": time.perf_counter() - start}
    save_new(out, record)
    return record


def show(record):
    print("三个环境的正确率:原分布 / 背景独立 / 背景反转")
    for mode, name in zip(MODES, NAMES):
        values = [record["models"][mode]["evaluation"][e]["accuracy"]
                  for e in ("original", "independent", "reversed")]
        print(f"{name}:" + " / ".join(f"{v:.1%}" for v in values))
    for env in ("original", "reversed"):
        selected = record["models"]["original"]["evaluation"][env]["selection"][2]
        print(f"{env},置信度至少 0.9:覆盖 {selected['coverage']:.1%},错误 {selected['risk']:.1%}")
    patches = record["interventions"]["patches"]
    print("恢复一个 / 两个背景通道:" + " / ".join(f"{patches[i]['recovery']:.1%}" for i in (2, 4)))
    print("各组回答的语义熵:" + " / ".join(f"{r['meaning_entropy']:.3f}" for r in record["evidence"]["cases"]))
    print("三条陈述的证据状态:" + " / ".join(r["status"] for r in record["evidence"]["claims"]))


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--freeze", type=Path)
    parser.add_argument("--run", type=Path, metavar="NEW_RECORD")
    parser.add_argument("--plan", type=Path, default=PLAN)
    args = parser.parse_args()
    if args.freeze:
        save_new(args.freeze, make_plan())
    elif args.run:
        show(run(args.plan, args.run))
    else:
        show(json.loads(RECORD.read_text()))


if __name__ == "__main__":
    main()

13. 动手想一想

  1. 为什么原分布模型在背景反转后只有 5% 正确率,而不是 20%?
  2. 把训练背景的同号概率改成 50%,为什么最优背景权重变成零?
  3. 探针能完美预测纸张厚薄,为什么不能说明分类器用到了厚薄?
  4. 只恢复一个背景通道时,为什么类别恢复了,logit 恢复比例却只有一半?
  5. 本例哪一种内部状态不能由正常输入产生?这给干预解释带来什么限制?
  6. 三方向算例中,恰有两个特征出现时的平方误差之和是多少?试着推导期望损失。
  7. SAE 为什么需要约束解码方向的尺度?只增大字典宽度,能保证特征名称更准确吗?
  8. 置信度门槛为 0.99 时,本实验一个状态都不接受。错误率应记成多少?
  9. 四份回答意思一致,为什么仍不能保证事实正确?若改变语义分组,会影响哪个量?
  10. 设计一个与“保持形状、改变背景”类似的文档问答实验。写清什么保持不变,什么变化,以及怎样判断答案是否应保持不变。
参考答案与提示
  1. 原模型的背景权重大于形状权重,所有输入都跟着背景分类;反转环境中,背景只有 5% 的机会与标签同号。
  2. 给定形状后,背景不再提供类别信息。此时类别的条件对数几率只有形状一项;训练得到的背景权重为零。
  3. 后续输出中厚薄的系数为零。读取能力属于探针,使用关系还要看原模型的计算。
  4. 类别只看 logit 是否跨过零,而恢复比例比较两个端点之间的连续差值。二者衡量的对象不同。
  5. 两个背景副本一正一负的状态。它能用于检查程序对内部数值的响应,但不能直接当作正常输入上的行为频率或完整机制证据。
  6. (1−0.5)2+(1−0.5)2=0.5(1-0.5)^2+(1-0.5)^2=0.5。恰有两个出现的概率为 3p2(1−p)3p^2(1-p),三个全出现的概率为 p3p^3,其损失为 3。因此期望为 1.5p2(1−p)+3p3=1.5p2(1+p)1.5p^2(1-p)+3p^3=1.5p^2(1+p)。
  7. 否则可放大解码矩阵、缩小激活,保持重建不变却减少稀疏惩罚。更多方向还可能拆分特征,不能保证解释名称正确。
  8. 覆盖率为零,选择后错误率未定义;记录中用 null,而不是零。
  9. 模型可能重复同一个错误。分组影响频数 p^k\hat p_k 和语义熵;事实正确性仍需另外核对。
  10. 例如保留一份有效公告的全部内容和问题,只调整无关文档的排列。先人工确认有效公告没有冲突、问题答案没有改变,再比较答案与引用。若把旧公告换成新公告导致答案改变,就不是同一项不变性测试。

如果继续读论文,可以按这条路线走:

  • 表示为何难读:Elhage 等,Toy Models of Superposition,2022-09-14。原文。关注实验里的稀疏性、容量与重建损失,不急着外推到所有网络。
  • 怎样做内部干预:Zhang、Nanda,Towards Best Practices of Activation Patching in Language Models,v1,2023-09-27。原文。比较替换方法与指标选择。
  • 怎样追踪计算:Ameisen 等,Circuit Tracing,2025-03-27。原文。把替代模型、原模型、局部归因图区分开。
  • 一条方向是否足够:Dalili、Mahdavi,Subspace-Aware Sparse Autoencoders,v1,2026-06-04,预印本。原文。比较相同预算实验,再读内部坐标尚不易解释的限制。
  • 不确定性如何读出:Kossen 等,Semantic Entropy Probes,v1,2024-06-22。原文。分清训练标签成本、使用成本和检测目标。
  • 事实怎样逐条核对:Min 等,FActScore,EMNLP 2023。原文与 PDF。注意支持比例与内容覆盖不是同一项指标。