ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

模型可解释性从入门到落地:Grad-CAM、SHAP与工程评估

模型可解释性从入门到落地:Grad-CAM、SHAP与工程评估 1. 0.98 的准确率为什么还是让人心里没底模型可解释性这个词我最早是在啃入门教材的时候看到的那时候我的理解非常朴素把特征权重画成条形图把卷积核打印出来就算做过可解释性了。后来带过几个真正要交付的项目才发现入门阶段建立的这套认知有个巨大的漏洞——它只教了你怎么看图没教你这张图凭什么可信。这篇东西算是我自己的一次复习把可解释性从入门概念到工程落地的链路重新捋一遍。适合两类人看一类是刚学完 CNN、Transformer 这些结构想搞清楚模型到底学到了什么的初学者另一类是模型已经能跑但被业务方追问为什么给这个样本打了高分而答不上来的工程同学。前半部分讲清概念分层中间讲方法原理和我自己的实现细节最后是踩过的坑和一套能直接搬进项目的检查清单。1.1 可解释性要回答的不是模型准不准而是它凭什么先说一个我印象很深的事。之前做一个工业质检的项目模型在验证集上准确率 0.98F1 也挺好看大家都觉得可以上线了。结果试运行第一周产线反馈说模型把一批有明显划痕的件判成了合格。我们把那批图片捞出来重新看发现一个很尴尬的事实模型根本没有在看划痕它在看图片右下角的一个工装夹具边缘——因为训练集里所有不合格的样本恰好都是在另一台设备上拍的那台设备的夹具位置稍微偏了一点。这就是典型的伪相关spurious correlation。模型在训练集上的表现无可挑剔因为它找到了一个比划痕更好用的捷径特征。而准确率、AUC 这类指标对这种事情是完全无感的——你给它一个捷径它照样能刷到 0.98。可解释性要解决的正是这一类问题不是告诉你模型有多准而是告诉你这个准确率是靠什么换来的。所以我现在看可解释性的价值会拆成三个具体问题模型主要依赖哪些输入区域或特征、这些依赖在同类样本上是否稳定一致、以及当输入发生微小变化时判断会不会突然翻转。前两个问题关系到你敢不敢上线第三个问题关系到上线之后会不会被打脸。1.2 三类读解释的人要求完全不一样入门材料里很少强调这一点但它是决定你用什么方法的关键前提解释是给人看的而不同的人要的东西根本不一样。第一类是算法同学自己。我们要的是调试信息关心的是这一层的激活是不是塌了这个通道是不是在学背景噪声所以更偏好细粒度、可以逐层拆开看的工具比如激活最大化、逐层相关性传播、通道级别的可视化。这类解释不追求好看追求信息量大。第二类是业务方。他们要的是决策依据比如这个客户为什么被拒贷这张片子为什么被判为阳性需要的是原图上的高亮区域加上一句人话结论。这时候 Grad-CAM 那一类热力图就比逐层激活图有用得多因为它直接叠在原图上认知成本低。第三类是审核或者质量同学。他们要的是可复现同一张图今天跑和下周跑要给出同样的解释还要能存档、能对比。这就把要求从方法好不好变成了流程稳不稳随机种子、推理模式、预处理版本这些工程细节反而成了重点。我踩过的一个坑就是拿第一类工具去应付第二类人。当时给业务方展示了一组卷积核可视化对方看了两分钟问所以我这张图到底哪里有问题那一刻我才意识到解释的受众匹配比解释的技术先进性重要得多。1.3 别把可视化当成可解释性还有一层区分入门的时候特别容易混淆可视化不等于可解释可解释也不等于可视化。可视化只是把张量变成人能看的图它可能什么都证明不了。比如你把第一层卷积核画出来看到一堆边缘检测器这只能说明网络学到了类似 Gabor 滤波的东西跟你这个任务的决策逻辑没有直接关系。而可解释性方法是有明确目标的它要给出输入特征对输出预测的贡献分配这个分配还要经得起检验——换一个样本、换一个随机种子、换一批数据结论不能乱跳。我习惯用一个类比可视化像体检时拍的 X 光片可解释性像医生根据片子给出的诊断结论。片子本身没有对错诊断才有对错。很多人包括当年的我拍了一堆片子就宣布模型可解释性工作已完成其实诊断那一步一步都没走。2. 先把方法分堆一张不用背的分类表可解释性的方法名字特别多SHAP、LIME、Grad-CAM、IG、LRP、TCAV……初学者很容易被这一堆缩写劝退。其实它们都能落到三个正交的维度上把这三个维度理清楚方法名就不是需要背的东西了而是可以推导出来的。2.1 内在可解释与事后解释第一个维度是解释发生在建模的哪个阶段。**内在可解释intrinsic**指的是模型结构本身就是人能读懂的。线性回归的系数、决策树的切分路径、广义加性模型GAM里每个特征的形状函数、注意力机制里显式的权重矩阵都属于这一类。它的好处是解释和模型是同一套东西不存在解释方法本身也不可信的问题坏处是表达能力受限你很难在 ImageNet 上要求一个线性模型给出 SOTA。**事后解释post-hoc**则是模型当黑盒训练完之后再想办法反推它在想什么。Grad-CAM、LIME、SHAP、积分梯度全在这个范畴里。工程上绝大多数场景用的是事后解释因为我们训练的是 ResNet、ViT 这类复杂结构不可能为了可解释性把它们换成线性模型。这里有个我后来才想明白的点事后解释永远只是近似。它不是模型的真实推理过程而是在某个假设下对模型行为的一种拟合。承认这一点你才不会对解释结果有过度信任。2.2 全局解释与局部解释第二个维度是解释覆盖的范围。全局解释回答整体上模型依赖哪些特征。特征重要性排序、部分依赖图PDP、置换重要性都是这类。它适合用来做特征筛选、给业务方讲模型的大致逻辑。局部解释回答对这一个样本为什么给出这个预测。LIME、SHAP 的单样本值、Grad-CAM 的单张热力图都是局部解释。它在个案申诉、异常复核这类场景里不可替代。这两者经常打架。一个特征可能在全局重要性里排得很低但对某个具体样本起到了决定性作用反过来某个全局最重要的特征在这个样本上可能完全没参与。我在写解释报告时会把两者并排放先给一张全局重要性图定基调再挑两三个典型案例做局部解释读者接受度高很多。2.3 模型特定与模型无关第三个维度是方法需不需要碰模型内部。模型特定方法要知道网络结构比如 Grad-CAM 需要拿到目标层的特征图LRP 需要逐层定义传播规则。它的优势是计算便宜、结果贴合模型本身代价是换个结构就得重写。模型无关方法只要求你能调用模型的predict接口SHAP 的 KernelExplainer、LIME 都是这一类。好处是通用同一套代码能套在 CNN、Transformer、甚至梯度提升树上坏处是慢而且对采样策略敏感。维度选项 A选项 B工程上的选择倾向建模阶段内在可解释事后解释复杂任务几乎都选事后解释覆盖范围全局局部两者结合先全局后局部依赖模型模型特定模型无关有实现条件优先模型特定追求通用性选模型无关计算成本一次前向或反向大量采样多次前向在线服务选便宜的离线分析可以奢侈一点结果稳定性较高依赖采样波动较大需要存档对比的场景要做多次平均这张表我自己是当决策工具用的。新项目要加解释能力时先在这三个维度上各勾一个剩下的就是在对应格子里挑实现。3. 手搓一遍最常用的几种解释方法概念讲完了接下来是动手部分。我会按从简单到复杂的顺序走一遍每一段都给出核心代码和我自己在实现时补的细节。这些代码不追求最短追求的是你能直接抄进自己的脚本里跑通。3.1 从线性模型和树模型开始权重与置换重要性如果你刚开始接触可解释性我强烈建议先拿表格数据配上逻辑回归或者梯度提升树练手别一上来就搞图像。原因很实际这两个模型有标准答案可以对照你能验证自己理解得对不对。逻辑回归的系数就是最直接的全局解释但有个前提——特征必须做过标准化。如果特征量纲不同系数的大小根本不可比。我见过有人直接拿原始特征训练然后按系数绝对值排出最重要的特征是年龄其实只是因为年龄的数值范围比其他特征大两个数量级。置换重要性permutation importance是另一件便宜好用的工具逻辑也简单把某一列特征的值随机打乱看模型性能掉多少掉得越多说明这一列越重要。import numpy as np from sklearn.inspection import permutation_importance def perm_importance(model, X_val, y_val, n_repeats10, seed42): rng np.random.default_rng(seed) base model.score(X_val, y_val) scores np.zeros((X_val.shape[1], n_repeats)) for j in range(X_val.shape[1]): for r in range(n_repeats): X_perm X_val.copy() rng.shuffle(X_perm[:, j]) # 只打乱这一列 scores[j, r] base - model.score(X_perm, y_val) return scores.mean(axis1), scores.std(axis1) mean_imp, std_imp perm_importance(clf, X_val, y_val)这里有个细节值得注意打乱相关特征会引入不存在的样本。如果两列特征高度相关你打乱其中一列生成的是现实中不可能出现的组合模型在这个虚假分布上的表现没有意义重要性会被严重低估。我一般的做法是先把相关系数超过 0.9 的特征聚成一组整组一起打乱这样得到的排序更靠谱。还有n_repeats这个参数很多人设成 1 就跑了。因为置换本身有随机性单次结果抖动很大我一般至少跑 10 次取均值和标准差。标准差同样有价值——如果某个特征的重要性均值很高但标准差也很大说明这个结论不稳别急着拿去做业务结论。3.2 卷积网络的第一层和最后一层能看出什么轮到图像模型了。这里我想先把预期降下来逐层可视化能告诉你网络学到了什么特征但告诉不了你这个样本为什么被判成这个类。第一层卷积核通常能看出明显的边缘、颜色梯度这是因为输入空间本身是像素最底层的滤波器只能做这么简单的事。中间层的可视化会开始出现纹理、局部部件比如车灯、眼睛。到了靠后的层感受野变大单个通道对应的语义越来越难用一句话概括可视化出来往往是一团看不出所以然的图案。激活最大化activation maximization是另一条路固定住某个通道反过来在输入空间做梯度上升让这个通道的响应尽可能大看最后生成什么样的图。这个方法很吸引人但实操中如果不加正则生成的图会充满高频噪声看起来像雪花——这些对抗样本式的图案其实在自然图像里根本不会出现硬解读会得出很荒谬的结论。我的建议是第一层可以放心看中间层选择性看深层不要试图用单个通道解释语义。想看深层直接跳到下面的 Grad-CAM 更省事。3.3 Grad-CAM 的三行核心逻辑Grad-CAM 是我在项目里用得最多的方法理由很简单它不需要改模型、计算开销小、结果直接叠在原图上业务方一看就懂。它的核心逻辑其实只有三步第一步对目标类别分数注意不是 loss关于目标卷积层的特征图求梯度第二步把梯度在空间维度上做全局平均得到每个通道的权重第三步用这些权重对特征图加权求和过一层 ReLU再上采样到原图尺寸。import torch import torch.nn.functional as F class GradCAM: def __init__(self, model, target_layer): self.model model self.acts None self.grads None target_layer.register_forward_hook(self._save_act) target_layer.register_full_backward_hook(self._save_grad) def _save_act(self, module, inp, out): self.acts out.detach() def _save_grad(self, module, grad_in, grad_out): self.grads grad_out[0].detach() def __call__(self, x, class_idxNone): self.model.eval() self.model.zero_grad() logits self.model(x) if class_idx is None: class_idx logits.argmax(dim1).item() score logits[0, class_idx] score.backward() # 通道权重梯度在空间维度上的平均 weights self.grads.mean(dim(2, 3), keepdimTrue) cam F.relu((weights * self.acts).sum(dim1, keepdimTrue)) cam F.interpolate(cam, sizex.shape[-2:], modebilinear, align_cornersFalse) cam cam - cam.min() cam cam / (cam.max() 1e-8) return cam.squeeze().cpu().numpy(), class_idx代码不长但有几个地方特别容易出错。self.model.eval()这行必须加否则 BatchNorm 会用当前 batch 的统计量、Dropout 会随机丢神经元你每次跑出来的热力图都不一样排查半天以为是方法不稳定其实是忘了切推理模式。还有zero_grad()如果不清零上一次的梯度会累加进来。F.relu的作用是只保留对目标类别有正向贡献的区域负值直接压成 0。这一步有人会去掉觉得负贡献也是信息去掉之后热图会变得很花到处都是高亮反而没法解读。我一般保留 ReLU。3.4 显著图与积分梯度一阶梯度为什么会骗人显著图saliency map是最简单的梯度方法——直接取输入层的梯度绝对值看每个像素动一点输出变多少。它便宜但问题很明显梯度饱和。想象一个 ReLU 网络某个像素的值已经很大把 ReLU 推到了线性区这时候再增大它对输出的影响也很平缓梯度接近 0显著图就会认为这个像素不重要。但它实际上可能是决定性特征只是已经饱和了。这就是所谓梯度消失导致的解释消失。积分梯度Integrated Gradients是为了解决这个问题提出的。它不去看单点的梯度而是沿着从基线通常是全黑图到原图的直线路径上把梯度积分起来def integrated_gradients(model, x, baselineNone, steps64, class_idxNone): model.eval() if baseline is None: baseline torch.zeros_like(x) # 在基线和原图之间插值得到 steps 个采样点 alphas torch.linspace(0, 1, steps, devicex.device).view(-1, 1, 1, 1) path baseline alphas * (x - baseline) grads [] for i in range(steps): p path[i:i1].clone().requires_grad_(True) out model(p) if class_idx is None: class_idx out.argmax(dim1).item() out[0, class_idx].backward() grads.append(p.grad.detach()) avg_grad torch.stack(grads).mean(dim0) # 乘以 (x - baseline)得到每个像素的贡献 return (x - baseline) * avg_grad两个参数决定结果质量baseline和steps。基线选全零图是很常见的做法但对图像来说全黑不是一个有意义的分布内样本容易引入伪影。我试过用训练集的像素均值、用高斯噪声图、用模糊后的原图当基线结果确实不一样。稳妥的做法是在报告里写清楚你用的是什么基线别让读者以为这是唯一答案。steps太小比如 8时积分近似误差大热图会很粗糙我在实践中一般用 32 到 64再大收益就有限了但计算时间是线性增长的。3.5 SHAP 与 LIME把黑盒当函数来采样这两个是模型无关方法的代表。LIME 的思路是在待解释样本附近撒一堆扰动样本用原模型给它们打标签然后在这个局部邻域上训练一个简单的可解释模型比如带 L1 正则的线性模型用这个简单模型的系数当作解释。SHAP 则借用了博弈论里的 Shapley 值把每个特征看作一个玩家计算它对预测结果的边际贡献。SHAP 理论上更漂亮因为它满足一系列公理局部准确性、缺失性、一致性解释结果有明确的数学含义。代价是计算量随特征数指数增长所以实践中用的是各种近似版本KernelSHAP 用采样近似DeepSHAP 用反向传播近似TreeSHAP 针对树模型做了多项式时间优化。import shap masker shap.maskers.Image(inpaint_telea, X_train[0].shape) explainer shap.Explainer(model_wrapper, masker, output_namesclass_names) shap_values explainer(X_test[:8], max_evals500, batch_size50, outputsshap.Explainer.label(class_id))这段代码有个坑我踩过max_evals控制采样次数设小了结果不稳定设大了跑得极慢。我一般先用 100 次快速看一眼大概确认方向没错再调到 500 次出正式结果。batch_size也別设太大图像模型在 CPU 上很容易把内存吃满。另外要提醒一句SHAP 给的正负号含义是相对于平均预测的偏移不是相对于零。很多人第一次看到全正的值会困惑原因就在这里。3.6 Transformer 的注意力权重到底能不能当解释现在做视觉或者 NLP 的项目Transformer 占比越来越高很自然的一个想法是注意力矩阵不是现成的权重吗直接拿它当解释不就行了这个做法有个著名的争论。反方观点是注意力权重只是中间量它后面还要经过多层非线性变换、残差连接、多层堆叠高注意力不等于高贡献。已经有实验表明在某些任务上随机打乱注意力权重模型输出几乎不变——这说明注意力权重和最终决策的因果关系很弱。我在实践中的态度是注意力可视化可以用来观察模型在处理什么位置的信息但不要把它当成因果性的解释。如果一定要给出解释我会在注意力图上再叠一层梯度方法比如对注意力输出做梯度加权或者干脆用统一的 Grad-CAM 风格方法覆盖所有结构保证解释口径一致。4. 三条技术路线背后的原理梯度、扰动、代理前面讲的是怎么用这一段我想退一步讲为什么这些方法能work。理解这一层你才能在面对新场景时自己设计方法而不是到处找现成实现。4.1 梯度路线本质是一次一阶泰勒展开梯度类方法显著图、Grad-CAM、积分梯度、LRP 的某些变体共享同一个数学基础把模型在输入点附近做一阶泰勒展开。$$f(x \delta) \approx f(x) \nabla_x f(x)^\top \delta$$梯度 $\nabla_x f(x)$ 就是输入每个维度对输出的局部敏感度。它成立的前提是 $\delta$ 足够小、函数在这个邻域内近似线性。神经网络恰恰是高度非线性的所以这个近似只在小范围内有效——这就是梯度类方法的根本局限。Grad-CAM 在这里做了一次巧妙的改写它不去解释输入像素而是解释中间层的特征图。因为中间层的感受野更大、语义更抽象一阶近似反而更稳定。这是我理解 Grad-CAM 为什么比其他梯度方法更鲁棒的关键——它换了一个解释的对象。积分梯度则是绕开近似的另一条路既然单点梯度不可靠那就沿着一条路径把梯度都积起来用路径上的平均梯度近似全局的贡献。它满足完整性公理——所有特征的归因之和等于模型输出减去基线输出这给了结果一个可验证的约束。4.2 扰动路线用遮挡做受控实验扰动类方法LIME、Occlusion、RISE、SHAP 的部分实现把解释变成一个受控实验遮住输入的某个部分看输出掉多少。掉得多说明重要。听上去很直观但实现上有两个必须做的决策而且没有标准答案。第一个是怎么遮。填零、填均值、填随机噪声、用模糊替代、用图像修复inpainting——这几种做法给出的解释可能完全不同。填零会让图片出现大量不属于自然图像分布的黑色区域模型的反应可能是异常而非理性。我个人的偏好是用模糊或者均值填充因为它对分布的破坏最小。第二个是遮多大。格子太小采样次数爆炸格子太大解释粒度太粗可能出现整块都重要的无效结论。我常做的是先跑一个粗略版本比如 8×8 的格子看整体趋势定位到大致的区域后再在那个区域上做细粒度16×16的二次分析。这样比一上来就全局细粒度高效得多。4.3 代理路线在局部用一个简单模型逼近LIME 的核心假设叫局部线性意思是虽然模型整体很复杂但在我关心的这一个样本附近它可以用一个线性模型很好地近似。这个假设在多大程度上成立取决于你选取的邻域半径。半径太大用线性去拟合一个弯曲的面误差很大半径太小采样点都挤在一起得到的系数方差极大不具代表性。我实测下来LIME 在图像上的结果对num_samples和sigma控制邻域范围的高斯核宽度非常敏感。同一条狗的照片换个参数高亮区域可能从狗脸变成背景草地。所以我在报告里从来不会只放一张 LIME 图至少放三组不同参数的结果让人看到稳定性边界在哪。4.4 三条路都躲不开的坑相关不是因果这是我认为入门阶段最需要建立的一个意识所有可解释性方法给出的都是相关性信息不是因果信息。梯度大不代表特征在因果意义上重要可能只是模型在这个方向上特别敏感。遮挡后掉分不代表这个区域是决策原因可能只是因为遮挡破坏了图像的统计特性模型识别不出这是自然图像于是降低了置信度。举个具体的例子。有人做过实验在图像上放一个和任务完全无关的水印训练时水印和某个类别绑定测试时模型看到水印就预测那个类。这时候所有可解释性方法都会把水印区域高亮出来——它们确实解释对了模型确实在依赖它。但如果你问为什么这张图是猫答案是因为水印这在语义上是荒谬的可在统计上是完全正确的。所以解释结果的正确定位是帮你发现模型在依赖什么然后由你来判断这个依赖是否合理。后面这一步任何算法都替你做不了。5. 解释本身也要被评估入门材料通常到画出热力图就结束了但真正让解释可靠的是下一步评估解释的质量。这块内容相对少被提到我花了挺长时间才把几个评估维度理清楚。5.1 忠实性、稳定性、稀疏性三者很难同时满足评价一个解释方法我通常看三个指标。**忠实性faithfulness**指解释是否真实反映了模型行为。常用的度量方式是把解释给出的重要特征删掉看模型性能掉多少掉得越多越忠实。注意这里是删掉解释认为重要的特征而不是删掉你认为重要的特征。**稳定性stability**指相近的输入应该得到相近的解释。给同一张图加一点几乎看不见的噪声热力图不应该剧烈变化。这一点上梯度方法通常比采样类方法稳因为采样本身引入随机性。**稀疏性sparsity**指解释是否聚焦。一张全亮的热力图等于没给信息人眼需要一个明确的关注区域。这三个指标存在张力。为了忠实可能需要更多采样结果就变噪、稀疏性下降为了稳定需要平滑平滑又会牺牲局部细节的忠实度。所以没有最好的方法只有在你的场景下权衡合适的方法。5.2 Deletion / Insertion 曲线怎么读这是评估图像解释最常用的定量方法实现也不复杂。Deletion 的做法是按解释给出的重要性从高到低逐步把像素替换成基线值记录模型置信度的下降曲线。Insertion 反过来从基线图开始按重要性从高到低逐步把原图像素填回去记录置信度的上升曲线。一个好的解释两条曲线都应该表现得很极端Deletion 下降得快Insertion 上升得快。def deletion_curve(model, x, cam, class_idx, steps20, baseline0.0): flat cam.flatten() order flat.argsort()[::-1] # 从最重要到最不重要 n_pix flat.numel() chunk n_pix // steps scores [] x_mod x.clone() for s in range(steps): idx order[s*chunk:(s1)*chunk] x_mod.view(-1)[idx] baseline with torch.no_grad(): prob torch.softmax(model(x_mod), dim1)[0, class_idx].item() scores.append(prob) return scores读这条曲线的时候有个常见误判把曲线下降快直接等同于解释正确。其实如果解释图是一张随机噪声图Deletion 曲线也可能下降得很快因为随机删除大量像素同样会破坏图像。所以一定要有对照组至少跑三条曲线正常解释、随机解释、反序解释按最不重要的先删。只有正常解释明显优于两个对照结论才站得住。5.3 一致性检验换一个随机种子解释还一样吗这是我给自己定的硬规矩任何要交给别人看的解释图至少跑三次不同随机种子看结果的相似度。如果三次的 IoU交并比低于 0.5我会认为这个解释不成熟不会写进报告。做法上把热力图按 0.5 阈值二值化两两计算交并比取平均。这个指标很粗糙但足够用能快速筛掉那些看起来很美但纯属偶然的解释。对于采样类方法LIME、KernelSHAP种子影响尤其大。我的做法是至少跑 10 次取平均输出的热力图是这 10 次的均值同时在报告里注明标准差。均值热图看起来会比单次结果糊一些但它更诚实。5.4 那些看起来很美但毫无意义的解释我整理了几种假解释的典型形态你对号入座一下看看自己有没有中招。第一种是中心偏置。不管什么模型、什么输入得到的热力图永远集中在图像中央。这通常说明上采样或者归一化环节出了问题热力图其实反映的是插值核的形状不是模型的行为。第二种是全图均匀高亮。这八成是因为取了梯度绝对值再归一化没有做 ReLU正负贡献被混在一起。也可能是 BatchNorm 层在梯度回传时把量级放大了需要检查钩子挂的层对不对。第三种是只高亮背景。这个不一定是 bug可能模型真的在用背景做判断——那这就是一个需要严肃处理的发现而不是解释方法的问题。这时候别急着换方法先去看数据分布是不是背景和标签存在伪相关。6. 我在项目里真实踩过的几个坑前面讲的都是方法论这一段全是血泪。这些东西在教科书里基本不会写但每一条都让我debug过不止半天。6.1 预处理把热图坐标搞错了有一次热力图叠在原图上明显偏了——高亮区域整体向右上方平移了十几个像素。查了很久才定位到问题为了输入模型我对图片做了 resize 加 center crop然后把热力图按 resize 的比例映射回原图。但是这个映射忽略了 crop 引入的偏移导致坐标整体错位。修正的办法是把预处理参数完整记录下来按反向顺序做逆变换先逆 crop在热图上补零还原到 crop 前的尺寸再按 resize 的缩放比例还原。这件事之后我把所有预处理都改成一个显式的 transform 对象每一步都能拿到参数能反向执行再也不手写散装的 resize 代码。6.2 logits 还是 softmax结果差很多Grad-CAM 对目标类别分数求梯度这个分数用 logits 还是 softmax 之后的概率结果是不一样的。用 logits 的好处是梯度不会因为 softmax 的饱和而消失尤其当模型非常确信的时候概率接近 1softmax 的梯度会非常小热力图会变得很暗很模糊。用 softmax 的好处是数值范围明确跨样本对比更直观。我现在的默认做法是用 logits 求梯度画热图用 softmax 概率做置信度标注两个混着用各取所长。这个选择一定要在代码注释里写清楚否则过两个月你自己都忘了当时用的是哪个。6.3 忘记切换 eval 模式解释图每次都不一样前面提过一次这里再强调因为它太容易犯了。model.train()状态下Dropout 会随机关闭神经元BatchNorm 用的是当前 batch 的统计量。你跑第一次得到一张热图跑第二次得到完全不同的另一张第一反应肯定是这个方法不稳定然后去调参数越调越乱。正确的姿势是任何解释脚本的第一行就是model.eval()并且配一个torch.no_grad()求梯度的部分除外。我甚至在 Grad-CAM 类的构造函数里加了一句断言检查model.training是不是 False不是就直接报错强制自己在调用前切模式。6.4 上采样插值把 7x7 的热图美化成假细节Grad-CAM 输出的原始热图尺寸很小比如 ResNet 的最后一层是 7×7。要叠到 224×224 的原图上必须上采样。问题来了双线性插值会把 7×7 的粗糙网格渲染成看起来很平滑、很有细节的图让人误以为模型关注的是一个精确的轮廓。实际上这个轮廓是插值算法造出来的模型眼里只有 7×7 的格子。我的处理方式有两种。一是在图上标注原始分辨率明确告诉读者热图有效分辨率是 7×7。二是换更靠前的层比如用 layer3 而不是 layer4原始尺寸变成 14×14粒度细一倍代价是语义抽象程度低一些。到底用哪层取决于你是想看关注哪个物体还是关注物体的哪个部位。6.5 多标签场景选错了目标类别多分类场景下argmax拿目标类别没问题。但多标签场景一张图同时有多个标签就不一样了模型的输出不是一个概率分布而是一组独立的 sigmoid 值。我踩过的坑是在多标签模型上直接用了argmax得到的热图解释的是分数最高的那个标签但业务方问的是为什么这张图被判为有裂缝而裂缝那个标签的分数可能排在第三位。解释的对象整个都错了。修正方式很简单把目标类别作为参数显式传入别依赖自动推断。我在所有解释函数上都加了class_idx参数默认值改成 None 并且不自动推断强制调用方明确指定。这种多写一行代码少犯一类错误的设计后来帮我省了很多时间。7. 把解释能力接进日常工程流程可解释性如果只停留在临时写个脚本跑一张图价值很有限。真正有用的是把它变成一个可以重复执行、有版本记录、能对比的流程。7.1 训练阶段就埋好的几个钩子我现在写训练代码的时候会把解释需要的接口一起预留出来。具体来说有三件事第一固定并记录预处理。把所有的 resize、crop、normalize 参数写进配置文件跟模型权重一起存档。解释脚本读同一个配置文件保证输入和训练完全一致。第二暴露目标层。在模型定义里给需要解释的层加上名字比如self.gradcam_target self.layer4[-1]这样解释脚本不用去猜测层名直接通过属性访问。这个习惯能省掉大量到底该挂哪一层的试错时间。第三在每个 epoch 的验证阶段顺便采样几张图存下来。不需要每个 epoch 都画热图只是把固定的一小批验证样本的原图、预测、置信度存成一个文件。等到模型训完直接拿这批固定样本跑解释跨 epoch 对比热力图的变化能很直观地看到模型关注区域是怎么演化的。我做过一次前 10 个 epoch 模型在关注背景第 20 个 epoch 之后才逐渐转移到目标物体上这个观察对我理解训练动力学帮助很大。7.2 给业务方看的解释报告长什么样技术上的解释图和业务能看懂的结论之间还需要一层翻译。我摸索出来的报告结构是这样的第一页放整体情况全局特征重要性排序、模型在验证集上的关键指标、以及一句风险提示比如模型对背景纹理有一定依赖已在 v2 版本中通过数据增广缓解。第二页开始放典型案例每个案例一张原图加一张热图配一句结论比如该样本被判为不合格主要依据是左上区域的纹理异常。案例要挑有代表性的正常判对的、判错的、置信度低的各来一个。最后一页放方法的说明和局限用的什么方法、什么参数、原始热图分辨率多少、跑了几次取平均。这一页很多人会省略我觉得最不该省。它既是自我保护也是给读者建立正确预期的必要信息。写结论的时候有个语言习惯要注意说模型关注而不是因为。比如模型主要关注左上区域而不是该样本不合格是因为左上区域纹理异常。后者把相关性说成了因果性一旦被追问会很难解释。7.3 工具选型的取舍最后聊一下工具。我不太推荐一上来就上重型框架除非你要做的是标准化的解释平台。快速验证阶段自己写 50 行代码实现 Grad-CAM 是最快的可控性也最好。需要多样化的方法时可以用成熟的解释库它们的实现经过社区验证能省掉很多边界情况的处理。不过要注意不同库对同一方法的名字和默认参数可能不一样尤其是归一化和预处理的部分混用的时候容易出问题——我遇到过一次用两个库分别算 Grad-CAM结果差异很大最后发现是其中一个库默认对输入做了归一化另一个没有。如果是做正式产出、要长期维护的我会把这些解释逻辑封装成项目内的一个独立模块输入输出接口固定内部的实现可以随时替换。这样即使将来换库、换方法上层的报告生成和存档流程都不用动。我个人的体会是可解释性这件事最贵的从来不是算法实现而是一致性和纪律。方法本身都不难难的是每次都记得切 eval 模式、每次都记得跑三个种子、每次都记得把参数记进报告。这些琐碎的坚持才决定了你的解释到底能不能被人信任。最后分享一个小技巧我会给解释脚本加一个--dry-run参数只跑两个样本、只跑一次用来快速检查流程通不通确认没问题再跑全量。这个习惯让我在调试阶段节省的时间比任何算法优化都多。
返回列表