可解释性与可靠性:模型为什么这样答,我们能信到哪一步
分类器认出了照片里的物品,还给出了 98% 的置信度。我们把物品留在原地,只换掉背后的桌布,它就改口了。
刚才的高分,到底是在认物品,还是在认桌布?
如果让模型解释,它可能给出一段很像样的话。但解释也是模型产生的输出。要知道计算过程里发生了什么,我们得多走一步:检查中间表示,改变某条信息,再观察结果是否按预期变化。
这一页沿着这条线往下走。先训练一个小分类器,抓住它对背景的依赖;再把计算拆开,做几次内部干预;最后讨论更难的问题:即使知道了某些计算机制,我们怎样判断一句回答有证据、一个置信度值得相信?
前置内容是逻辑回归、模型评估与泛化、Transformer与研究证据。资料核查截至 2026 年 10 月 20 日。本页的训练和数值实验都只需 Python 标准库。
1. 四个问题,别急着用一个分数回答
“这个模型可信吗?”太大了。不妨拆成四个可以动手检查的问题:
| 问题 | 可以寻找的证据 |
|---|---|
| 表示里有什么信息? | 用简单读出器预测某个属性 |
| 哪些计算影响了回答? | 替换、移除中间数值,比较输出 |
| 回答的内容是否有依据? | 把陈述与指定来源逐条核对 |
| 条件变化后还能工作吗? | 改变背景、来源、时间或输入形式 |
一个方法可能在其中一项做得很好,却不能包办其他几项。读出“模型对答案有疑虑”的信号,不等于找到它出错的完整原因;找到某条因果路径,也不等于证明回答在现实中成立。
这正是可解释性与可靠性相遇的地方:前者帮助我们提出更具体的诊断,后者要求这些诊断经得起任务和环境的检验。
2. 一个会认背景的分类器
把照片问题缩小到两个数
为了能把每一步算清楚,我们暂时不处理像素,只给模型两个已经提取好的特征:
- 是形状线索,取 或 。
- 是背景线索,也取 或 。
目标类别记作 ,同样取这两个值。两类各占一半。给定类别以后,形状和背景分别产生,而且在这个条件下相互独立。
原来的环境中,形状有 80% 的机会与类别同号,背景则有 95%。比如 时,出现 的条件概率是 ;再乘类别的概率 ,这整个状态的概率为 。
总共只有 个状态。程序把它们全部列出,按各自的概率加权。这是一个完全已知的教学分布,不是八张真实照片,也不是从总体中抽出的八个测试样本。 我们没有图像编码器,更没有借此衡量真实视觉模型的能力。
逻辑回归计算:
是二分类的 logit; 是前面学过的 sigmoid。这里不设偏置,因为整个问题对正、负类别对称。 时预测为 。
训练目标仍是交叉熵,只是把样本平均换成八个状态的加权和:
是第 个状态的概率,八项加起来等于 1。参数从零开始,用学习率 0.5 做 2,000 次完整梯度更新,不使用正则化,取最后一次参数。方案在运行前固定,没有用下面的环境比较来挑参数。
高分的来源,一换环境就露出来
我们实际训练三个方案。第一个照原分布训练;第二个在训练时把背景与标签的同号概率改成 50%,也就是让背景独立于标签;第三个保留原分布,但强制 ,只用形状。三者都更新 2,000 次,不过第三个只有一个可调参数。
测试时固定形状的 80%,只改变背景:
| 训练方案 | 原分布 | 背景反转 |
|---|---|---|
| 原分布训练 | 95.0% | 5.0% |
| 背景平衡训练 | 80.0% | 80.0% |
| 只用形状 | 80.0% | 80.0% |
“背景反转”指背景与标签同号的概率从 95% 降到 5%。如果背景完全独立,三者的正确率依次为 50.0%、80.0%、80.0%。这些都是对八个状态求和得到的分布正确率,不附抽样置信区间。
原分布模型学到:
背景的权重大到足以压过形状:只要两者冲突,它就跟着背景走。我们没有给它加一条“请忽略背景”的要求;优化器确实找到了原分布上好用的规则。
另外两个模型都得到 。它们在原环境中让出了 15 个百分点,却在改变背景后保住了形状所能提供的 80%。这是指定变化下的稳健性取舍。我们事先知道哪个特征会保持稳定;真实问题里,这种知识往往要靠领域经验和额外实验获得。
图 1:背景平衡与只用形状两条曲线重合在 80%。下图提醒我们,同一个高置信度门槛,换环境后也可能留下大量错误。数据来自本页的有限分布实验。
选读:这两个权重为什么接近 ln 4 和 ln 19?
形状同号与反号的概率比为 ,背景为 。在给定类别后相互独立、两类等概率的设定下,贝叶斯公式把两条证据的概率比相乘;取对数,就变成相加:
所以这个逻辑回归恰好能表达原分布的真实条件概率。数值训练的结果与解析值吻合。它在背景反转后失败,不是因为我们没把训练损失降够,而是因为条件概率已经变了。
3. 看得出信息,不等于用到了信息
大模型内部没有“形状”和“背景”这样的现成标签。每层留下的是一长串数,研究者常用一个简单的分类器去读它们。例如,输入某层表示,预测句子里说的是人还是地点。这个读出器叫探针(probe)。
探针在留出的数据上预测得好,说明信息能被这种读出方式提取。它仍没有告诉我们:原模型后面的计算是否使用了这条信息。
可以亲手造一个反例。把刚才已经训练好的分类器等价展开为四个内部数值:
是额外给入的纸张厚薄标记,取 或 ,与原任务无关。输出写成:
我们只是把背景复制了两份、各分一半权重,原分类器的函数完全没变。这个内部结构是人为安排的,不是另行训练出来的神经网络。
现在用探针直接读 ,厚薄标记可以 100% 读对。但把 从 改成 ,输出一丝不动,因为它的读出系数是零。这就把“存在的信息”和“参与当前输出的计算”分开了。
真实模型中的探针还多几层困难:一个过于强大的探针可能自己学会任务;相似模板可能同时落入训练集和测试集;标签也可能与别的信息共变。因此,探针需要简单基线、留出数据和合适的随机标签对照。即使通过这些检查,下一步仍要考虑干预。
4. 换回一小块内部数值,会发生什么
先拿一个正类样例,令 。原始 logit 约为 。只把背景改成 ,标签保持不变,logit 降到 ,预测也跟着翻转。
现在保留这次“换背景”的运行,只把第二个通道从 换回原来的 ,再继续计算。这种把一次运行的激活放入另一次运行的操作,叫激活替换(activation patching)。
| 内部状态的改动 | 最后 logit |
|---|---|
| 换背景后的状态 | -1.558 |
| 恢复一个背景通道 | 1.386 |
| 恢复两个背景通道 | 4.331 |
图 2:只恢复一个通道,类别就已经变回正类,但相对于两端的 logit 差,只恢复了一半。
若用 logit 衡量这次替换恢复了多少,可以定义:
分母在这里不为零。一个通道得到 ,两个通道得到 。换一个任务, 可能小于零,也可能大于 1;不要把它自动理解成概率。
这个小例子还藏着两个容易忽略的地方。
第一,指标会改变你看见的故事。如果只看“类别有没有恢复”,一个通道就够了;看 logit,才看见它只补回一半差距。在多分类语言模型中,还可以比较目标答案与某个明确替代答案的 logit 差,避免把共同抬高所有候选的变化误当作任务特有的作用。
第二,内部替换可能造出自然输入到不了的状态。正常运行总有 ,但只恢复一个通道后,它们一正一负。这次干预确实改变了程序的输出,却不是一张正常输入经过编码后会产生的表示。解释结果时,要说清楚我们改变了什么。
Zhang 与 Nanda 系统比较过替换输入的方法、概率或 logit 差指标、单层或多层联合替换,发现这些选择会改变定位结果。他们尤其讨论了强噪声可能扰乱原有计算的情形。本页的精确算例帮助理解这个问题,并未复现他们的语言模型实验。Activation Patching,第 3~6 节
移除之后没出错,也不能急着说“没用”
另一种操作叫消融:把某个通道设为零,或换成一个参考值。
在原始正类样例上,把一个背景通道置零,logit 仍约为 2.859;把两个都置零,仍有形状提供的 1.386。类别都没有变。但这不表示背景无用,它对分数的贡献明明减少了。
此外,置零、替换为平均值、换入另一个真实样例的值,回答的是不同问题。冗余通道可能互相补偿;联合移除的效果也未必等于单独效果之和。一次样例的局部实验,不能直接升级成“这个模块在所有输入上负责某种能力”。
5. 为什么一个神经元不一定对应一个概念
还有一种困难:我们希望给每个内部数值贴一个名字,模型却未必按这种方式组织信息。
先看普通坐标变化。假如原来用 表示两个量,换成 ,只要后面配上逆变换,信息仍然完整。每个新坐标都混合了原来的两个量。单看一个坐标,就容易把它误解为“同时负责两个概念”。
叠加表示(superposition)更进一步:可能需要表达的特征,比可用的维度还多。它不能无损容纳任意组合,但如果特征很少同时出现,就可能用一些干扰换取空间。
我们手工安排三个单位方向,共用二维平面:
三个非负特征组成 。先沿方向相加,再用点积和 ReLU 读出:
只有 时,三个点积为 ,ReLU 恰好留下 。另外两个特征单独出现也一样。
但若 ,读出会变成 ,两个活跃特征都被压小了;三者同时为 1 时,三个方向相消,读出全为零。
图 3:图中的方向由我们指定。曲线的四个点来自全部八种 0/1 组合的概率加权,连线仅便于观察,并不是训练曲线。
令每个特征独立地以概率 取 1。以三个分量的平方误差之和为损失,程序枚举得到:
| 三个方向共用 | 只保留前两个 | |
|---|---|---|
| 0.05 | 0.0039375 | 0.05 |
| 0.20 | 0.072 | 0.20 |
| 0.50 | 0.5625 | 0.50 |
| 1.00 | 3.00 | 1.00 |
当特征很稀疏时,共用空间划算;频繁同时出现时,干扰就变贵了。右列直接丢弃第三个特征,另外两个准确保留,期望损失为 。这只比较两种手工设计,不是在宣称哪一种是所有网络的全局最优解。
Elhage 等的 Toy Models of Superposition 用真实训练的小型网络研究了类似的容量、稀疏性与干扰关系。它提供的是可控模型中的证据,不能把其中所有几何结构直接套到任意大模型上。作者论文
6. 稀疏自编码器:给表示找一套更好读的坐标
如果原坐标不好解释,能否再训练一个模型,把内部表示拆成少量活跃特征?
稀疏自编码器(sparse autoencoder,SAE)就是一条这样的路线。把已训练模型的某层表示 取出来,原模型保持冻结;另一个编码器将它变成 ,再重建:
这里 的形状为 , 为 ,常让 ,给候选方向留足位置,但希望每次只激活其中少数几个。
一种基础目标是:
第一项要求还原得像,第二项鼓励稀疏。还需要约束解码方向的尺度,例如让 的各列保持单位长度;否则把 放大、 缩小,就能降低稀疏惩罚而不改变重建。现代方法也有保留最大若干激活、学习阈值等不同设计。
训练完以后,可以寻找某个特征最活跃的文本,提出“它似乎与某个概念有关”的假设,再用新样例和干预检查。但低重建误差并不自动意味着名字贴得正确:一个概念可能分散到多个特征,一项特征也可能混入我们没注意到的条件。
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. 高置信度,为什么还能错得很坚定
回到小分类器。我们把它给自己所选类别的概率作为置信度:
只接受 的预测,其余交给人工或另一个流程。这叫选择性预测。要同时报告两项:
是门槛。若一个预测都没接受,错误率就没有定义,不能记成“零错误”来庆祝。
在原分布上,门槛 0.9 保留 77.0% 的状态概率,留下的错误率只有约 1.3%。背景反转后,同一个门槛保留 23.0%,错误率却达到 82.6%。
怎么会这样?因为形状与背景一致时,模型最有信心。反转后,这种一致经常来自“形状恰好错了,背景也错了”:
这个模型甚至能准确表达旧分布的条件概率,仍挡不住新分布中的自信错误。校准值得做,但它的证据总依赖评价分布。选择门槛时应使用与部署有关的独立数据,并在环境变化后重新检查;不能把一个固定门槛当成永久保证。
除了总体正确率,也可以看失败集中在哪些组。本例按真实类别与“背景是否同号”分组,原模型在背景不同号的组里正确率为零;只用形状的方案在四组里都为 80%。最差组指标能把平均分遮住的问题摆出来,但真实数据中还要考虑组的定义和样本量。
9. 四种说法,可能只是一种意思
语言模型还有一层特殊困难。下面四个回答,字符串不同,意思却相同:
二楼。在二楼。2 楼。第二层。
直接按字符串数类别,会把表达变化也当成答案的不确定性。语义熵先把相同意思的回答归为一组,再衡量这些组有多分散。
若一共取得 份回答,人工或判断模型把它们分成 组,第 组有 份,一个基于频数的估计为:
这里用自然对数,单位为 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 节
本页只用三个结构化字段做一次精确对照,支持比例为 。自然语言的拆分、检索、蕴含判断都没有在这个小程序里实现,因此它不是 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. 动手想一想
- 为什么原分布模型在背景反转后只有 5% 正确率,而不是 20%?
- 把训练背景的同号概率改成 50%,为什么最优背景权重变成零?
- 探针能完美预测纸张厚薄,为什么不能说明分类器用到了厚薄?
- 只恢复一个背景通道时,为什么类别恢复了,logit 恢复比例却只有一半?
- 本例哪一种内部状态不能由正常输入产生?这给干预解释带来什么限制?
- 三方向算例中,恰有两个特征出现时的平方误差之和是多少?试着推导期望损失。
- SAE 为什么需要约束解码方向的尺度?只增大字典宽度,能保证特征名称更准确吗?
- 置信度门槛为 0.99 时,本实验一个状态都不接受。错误率应记成多少?
- 四份回答意思一致,为什么仍不能保证事实正确?若改变语义分组,会影响哪个量?
- 设计一个与“保持形状、改变背景”类似的文档问答实验。写清什么保持不变,什么变化,以及怎样判断答案是否应保持不变。
参考答案与提示
- 原模型的背景权重大于形状权重,所有输入都跟着背景分类;反转环境中,背景只有 5% 的机会与标签同号。
- 给定形状后,背景不再提供类别信息。此时类别的条件对数几率只有形状一项;训练得到的背景权重为零。
- 后续输出中厚薄的系数为零。读取能力属于探针,使用关系还要看原模型的计算。
- 类别只看 logit 是否跨过零,而恢复比例比较两个端点之间的连续差值。二者衡量的对象不同。
- 两个背景副本一正一负的状态。它能用于检查程序对内部数值的响应,但不能直接当作正常输入上的行为频率或完整机制证据。
- 。恰有两个出现的概率为 ,三个全出现的概率为 ,其损失为 3。因此期望为 。
- 否则可放大解码矩阵、缩小激活,保持重建不变却减少稀疏惩罚。更多方向还可能拆分特征,不能保证解释名称正确。
- 覆盖率为零,选择后错误率未定义;记录中用
null,而不是零。 - 模型可能重复同一个错误。分组影响频数 和语义熵;事实正确性仍需另外核对。
- 例如保留一份有效公告的全部内容和问题,只调整无关文档的排列。先人工确认有效公告没有冲突、问题答案没有改变,再比较答案与引用。若把旧公告换成新公告导致答案改变,就不是同一项不变性测试。
如果继续读论文,可以按这条路线走:
- 表示为何难读: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。注意支持比例与内容覆盖不是同一项指标。