ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

线性探针实验指南:读取模型表示不等于揭示真实决策逻辑

线性探针实验指南:读取模型表示不等于揭示真实决策逻辑 最近有个做NLP的师弟来问我说自己读可解释性论文时看到一堆Probing实验结论写得一个比一个花什么“BERT内部编码了句法树”“词向量里藏着性别偏见”但他始终没想明白一个问题——线性探针能把一个属性解出来是不是就代表模型在做预测时真的用了这个属性这问题问到点子上了。说实话我自己刚接触Probing时也天真地以为探针是“读心术”后来踩了不少坑才意识到探针读到的信息跟模型实际用到的信息中间隔着一条很宽的河。这篇文章就把这条河讲清楚也顺手聊聊线性探针实验从设计到落地那些容易翻车的地方。1. 先搞清楚线性探针到底在干什么1.1 探针实验的基本玩法如果你还没接触过探针我先给个最简单的定义线性探针是在某个模型中间层的隐藏表示上训练一个线性分类器或者线性回归头用来判断这个表示里是否“藏”着某个属性。例如在词向量中判断词的时态、情感极性在图像特征中判断物体的颜色、形状在语音特征中判断说话人身份。它的原理很朴素如果某一层表示的某个方向上存在与属性相关的线性模式那么一个简单的线性分类器就能利用这个方向把不同类别分开。换句话说线性可分性意味着这些信息在表示中是“摆在明面上”的。我们经常在论文里看到这样的结论某层隐藏状态经过逻辑回归后情感分类准确率能到90%于是认为该层编码了情感信息。为什么大家都用线性探针而不是多层感知机探针这是很多新手容易忽略的点。非线性探针的拟合能力太强它可以任意重排特征组合把一个只在非常复杂的非线性路径上存在的信息也硬“解”出来。这样一来即使探针分数很高我们也不清楚信息是以什么形式存储的——是简单线性方向还是经过了多次特征交互线性探针限制在一个超平面上如果它能成功至少说明这些信息在表示空间里具备线性可分的结构。这种结构在后续模型中更容易被直接消费也更接近我们直觉上的“编码”。1.2 线性探针为什么是“线性”的打个比方一个仓库堆满了货物你能在仓库侧面开一扇小窗户看到里面有啤酒瓶但不能因此断定仓库里的传送带分拣货物时真的把啤酒瓶当作判断依据。你可能只是从窗户缝里看到了堆在外面的几个瓶子而已。线性探针就是这个窗户它只能看到窗口对准的方向上的东西而且看到的仅仅是“有没有”不是“用不用”。在表示空间里每个词或者每个样本都被映射成一个高维向量。线性探针做的事情就是在这个高维空间里画一个超平面把不同类别的点分开。如果分布在超平面两侧的点能很好地对应到不同标签那说明这个表示空间里存在一个线性方向与标签是相关的。但注意关键词是“相关”不是“因果”。实操中探针通常会做成一个不带隐藏层的逻辑回归或者一个单层线性层加Softmax。如果需要在PyTorch里快速验证我一般直接用torch.nn.Linear(hidden_size, num_labels)配合交叉熵损失训练几十个epoch。另一种更省事的做法是把中间层特征导出来用sklearn.linear_model.LogisticRegression直接拟合省去自己写训练循环。这里有一个容易被忽视的细节在训练线性探针时要不要冻结骨干模型答案是必须冻结。探针实验的目的是分析已经训练好的模型内部表示如果骨干模型还在更新探针组合骨干一起训练最后得到的可能是一套新的联合表示这就失去了“探测”的意义。所有探针实验都应该在torch.no_grad()下提取特征再单独训练探针。2. 核心误区探针检测到的信息不等于模型真正依赖的信息2.1 一个直观反例颜色特征与形状分类假设你在训练一个区分猫和狗的图像分类模型。训练数据里所有猫的照片都是红色背景狗的照片都是蓝色背景。模型很可能学到一个捷径根据背景颜色分类而不是真正的猫狗形状特征。这时你在最后一层卷积特征上训练一个线性探针它能非常轻松地区分红蓝背景进而区分猫和狗准确率可能接近100%。那么这个探针读到的“颜色信息”能说模型“用”了吗确实能模型在训练集分布内就是靠颜色在做判断。但是如果我们换一组背景色反转的数据模型马上崩盘。这个例子说明探针检测到了“模型在数据上可以利用的特征”但并没有揭示模型是否学会了我们真正关心的泛化性特征。更麻烦的是探针检测到的信息可能根本不在模型的决策路径上。比如一个情感分类模型可能依赖关键词“好”“差”而探针也能从中间表示中解码出这些关键词的存在但模型真正用于情感判断的可能是更深层的语义结构。探针读到的关键词信息只是相关性不是因果性。我在实际项目中遇到过类似情况。有一次想验证对话模型是否“记住”了用户性别于是在最后一层特征上训练性别探针准确率很高。但后来做干预实验把性别相关的特征方向进行翻转模型的对话输出几乎没有变化。这说明性别信息虽然存在于表示里但模型在做回复生成时并没有把它作为关键依赖。这种情况下如果只汇报探针结果就会给读者造成严重误导。2.2 信息存储与信息使用的“两条路”要理解这个问题得先接受一个事实现代神经网络的中间表示是一个“信息杂货铺”。训练任务会促使模型保留很多与任务相关的、有区分力的信息因为它不知道下游哪些细节有用。于是每一层都塞满了各种属性句法、语义、词序、词性甚至输入文本的长度、标点习惯都可能被编码。这些信息不一定都会被后续模块利用有些可能只是作为中间计算的副产物被保留下来。举一个生活化的类比一个人的日记里写了很多想法、情绪、对未来的计划但你读出这些内容只能说明它们“存在于日记中”不能直接断定这个人的下一个行动完全受这些文字驱动。可能他行动时只看了其中一条备忘录剩下的都是背景噪音。模型内部也是类似表示空间中的“可用信息”和“实际利用信息”是两回事。探针测量的主要是前者——表示中是否编码了某个属性。后者需要用更严格的因果干预来判断比如激活替换、消融某个方向、logit lens或者用扰动测试观察模型输出是否显著改变。这些方法各有局限但结合起来才能逼近“模型是否真的使用”这个结论。2.3 探针容量陷阱线性与非线性之间的灰色地带线性探针只能测试线性可分的属性非线性属性会被它漏掉。但这并不意味着非线性属性不存在。反过来当表示维度非常高时线性探针也可能过拟合在训练集上表现得非常好但在测试集上一塌糊涂。更隐蔽的问题是高维空间中的线性分类器有时可以“近似”某些非线性决策边界从而造成一种“信息可以被线性解码”的假象。有一个典型现象在BERT的768维表示上你用2000个样本训练线性探针即便标签是随机分配的训练集准确率也能轻松超过90%。这就是高维空间过拟合的威力。所以做探针实验时不能只看训练集分数也不能只看测试集分数还要和随机标签的基线做对比。我自己常用的一个基线方法是把标签随机打乱后重新训练一个同样结构的线性探针重复多次取平均准确率。如果真实标签的准确率只是比随机基线高几个点那这个探针结论基本没有说服力。更严谨的做法是做置换检验计算p值但工程实践中随机标签基线已经能过滤掉大部分噪声。3. 手把手跑一个线性探针实验附PyTorch代码3.1 实验设计思路实验目的很明确验证BERT某个隐藏层中是否包含情感信息同时用控制变量的方法证明这个结论不是靠过拟合或数据泄露得到的。用情感分类数据集取每一句的[CLS]向量作为句子表示训练一个线性逻辑回归探针。之所以用[CLS]是因为BERT在预训练时把这个位置的输出设计成聚合整个序列的信息很多下游分类任务都直接用它。实际操作步骤加载预训练模型和分词器。对输入句子做分词、padding、truncation统一长度。前向传播时设置output_hidden_statesTrue取出某一层的隐藏状态。取[CLS]位置的向量作为整句特征。将所有特征拼接成矩阵划分训练集和测试集。用逻辑回归训练探针评估准确率。整个过程不需要更新BERT参数所以用torch.no_grad()包住即可显存占用也小。3.2 完整代码与逐步解读下面这段代码可以直接在Jupyter Notebook里跑通依赖torch、transformers、datasets、scikit-learn。import numpy as np import torch from transformers import AutoTokenizer, AutoModel from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score from datasets import load_dataset model_name bert-base-uncased tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModel.from_pretrained(model_name).to(cuda) model.eval() # 加载一个小型情感数据集这里以 imdb 的前400条为例 dataset load_dataset(imdb, splittrain[:400]) texts dataset[text] labels dataset[label] def get_cls_embedding(texts, layer12): feats [] with torch.no_grad(): for text in texts: encoded tokenizer( text, return_tensorspt, truncationTrue, max_length64, paddingmax_length, ).to(cuda) out model(**encoded, output_hidden_statesTrue) # hidden_states 是一个元组包含每一层的输出 hidden out.hidden_states[layer] # [B, L, D] # 取 [CLS] token 对应的向量 feats.append(hidden[:, 0, :].squeeze(0).cpu().numpy()) return np.array(feats) X get_cls_embedding(texts) y np.array(labels) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) probe LogisticRegression(max_iter1000, C1.0) probe.fit(X_train, y_train) y_pred probe.predict(X_test) print(linear probe acc:, accuracy_score(y_test, y_pred))这里有两个细节值得展开说。第一max_length64不是拍脑袋定的情感分类的句子一般不会太长64足够覆盖大部分样本同时能减少padding带来的干扰。如果你处理长文档可以调大到512但要注意BERT有位置编码上限。第二C1.0是逻辑回归的正则化强度值越小正则越强。如果样本量少或维度高建议把C调小到0.1甚至0.01能有效缓解过拟合。跑出来的准确率通常在90%以上因为IMDB情感分类任务比较简单BERT表示里的情感信息本身就非常显著。但你可能会问这个结果能说明“BERT内部编码了情感”吗如果只看这个单一实验我倾向于说“BERT的最后一层表示中存在可与情感标签线性对齐的方向”但不会直接说“模型做预测时依赖这些方向”。后者需要用因果探针或者特征干预才能下结论。3.3 探针的四种常见变体除了上面这种最简单的逻辑回归做研究时还会用到其他变体。线性层加交叉熵在PyTorch里构造一个无隐藏层的分类头和骨干模型一起做梯度评估不更新但探针本身可以训练。这种方式适合大批量数据而且可以方便地加入正则化。最小描述长度探针MDL Probe通过在线编码方式计算用少量比特就能编码标签得分越低说明信息越容易被压缩提取。MDL的好处是天然防止过拟合因为它惩罚了过于复杂的探针。迭代剪枝探针反复训练探针后把注意力权重接近零的特征剪掉再重新训练直到准确率下降。它可以在一定程度上去除掉冗余特征告诉你哪些维度真正贡献了分类能力。CKA线性探针用中心核对齐计算表示与标签之间的相似度适合比较不同层或不同模型之间的表示差异。实际写代码时迭代剪枝会稍微麻烦一点但很值得尝试。我常用它来回答“模型到底用了哪几个维度”的问题。做法是在逻辑回归权重上取绝对值把权重最小的维度删掉重训重复多次准确率曲线下降越慢说明信息分布越冗余如果删了少数维度准确率就崩了说明关键维度很集中。3.4 让结果更可信的控制变量设计单次探针实验很容易被审稿人或者同事质疑所以我在正式实验里一定会做三组对照。第一组是随机标签基线。把训练和测试标签同时打乱重新训练探针得到一组随机分数。如果真实标签的探针分数只是略高于随机基线就别再讨论“编码了”什么了。第二组是随机初始化模型对比。用同一个架构但权重完全随机初始化提取特征训练探针。这能排除“模型结构本身导致输出特征容易被线性分离”的可能性。第三组是跨领域验证。在训练集上拟合探针在另一个来源不同的测试集上评估。如果准确率大幅下降说明探针可能学到了数据集的表面特征而不是通用属性。这三组对照不需要花很多代码但对结论的可信度提升是决定性的。现在很多探针论文被吐槽就是因为只报了一个漂亮ACC没有做任何基线控制。4. 常见翻车现场与排查技巧4.1 特征泄露探针学到了不该学的东西文本探针最经典的特征泄露是句子长度。假设在做情感分类负面评论普遍偏长模型表示里可能包含了长度信息线性探针能通过长度方向来分类准确率还很高。但那不是情感信息而是长度信息。类似的情况还有很多比如某些数据集里某个类别的样本都集中在特定主题探针实际上学的是主题分类而不是你声称的属性。怎么排查把探针的输入特征降到二维或者三维可视化一下看类别之间是不是有明显距离。更定量的方法是看探针权重向量与可能的混淆属性方向向量之间的余弦相似度。如果相似度很高说明探针确实在用混淆属性。预防手段则是做特征白化或控制协变量让探针无法依赖这些混杂特征。4.2 标签泄漏与数据划分错误数据划分错误在探针实验里特别隐蔽。最常见的翻车是同一个原始句子的多种数据增强版本同时出现在训练集和测试集。比如你用回译做了数据增强删除重复时会保留多个相似句子如果随机划分模型就会在训练时“见过”测试句子的某种变体探针准确率虚高。解决方法是按原始句子ID分组划分保证任何组内句子只出现在训练集或测试集之一。另一个容易踩坑的地方是特征提取和探针训练共用同一个模型状态。比如你提取特征时开启了dropout那每次前向传播得到的特征都不同探针会不稳定。务必在特征提取阶段设置model.eval()并且固定种子。4.3 探针太强或太弱怎么判断训练集准确率接近100%测试集准确率却比随机基线高不了多少这是典型的探针过拟合。常见原因有三个表示维度太高、样本太少、正则化太弱。第一步加L2正则化把C调小如果还不行就对特征做PCA降维保留能解释95%方差的前几十个主成分再不行就增加样本量或者换成MDL探针。相反的情况是训练集准确率也很低说明这个属性在当前层确实不是线性可分的。但这不代表表示里没有这个信息。建议先用非线性探针复测比如一个两层MLP如果非线性探针能到高分那说明信息以非线性方式存在。然后再考虑是不是需要换一层因为不同层的信息抽象程度不同。这里放一个简单的排查速查表现象可能原因处理方式训练ACC高测试ACC低探针过拟合减小C加PCA增加数据训练ACC低测试ACC也低信息线性不可分换非线性探针或换层真实标签与随机标签ACC接近探针无区分能力检查特征提取改变层位置换测试集后ACC大跌数据分布相关探针学到表面特征做跨域验证控制混杂变量多次运行结果波动大未固定种子或特征提取未关dropout固定种子设置model.eval()4.4 探针实验的黄金组合我的习惯是线性探针只做初筛发现某个属性ACC高时绝不去直接写“模型编码了这个属性”而是补三样东西随机化特征扰动、因果干预、跨域测试。随机化特征扰动是把特征某一维或多维打乱看ACC变化如果变化很小说明探针对依赖的特征不敏感因果干预是在模型前向传播时替换或者消除对应方向上的激活值观察预测结果是否改变跨域测试就是换一个数据集验证探针是否抓住了通用规律。这三样都做下来才敢说“模型在推理时使用这个信息”。虽然在论文里全做完很费时间但如果目标是真正理解模型行为这个成本是必须的。我在实际项目中靠着这套流程至少避免了两三次“假阳性结论”带来的误导少走了很多弯路。最后分享一个小技巧做探针实验时一定要保存特征矩阵和标签不要只保存ACC。这样后续做消融、可视化、换探针模型时都不用重新跑一遍骨干模型的前向传播省时省力。我自己吃过亏原本只存了ACC后来审稿人要求补充特征可视化只能把几千条数据重新过了一遍模型浪费了不少时间。特征矩阵通常不会太大存成.npy文件就好。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进