Files
ModelTrainEval/engine/shap_explain.py
T
Shaun ZhouandClaude Opus 5 9aa87d31a6 feat(localization): SHAP Partition 定位方法,并把每图框数上限默认收紧为 1
新增基于 SHAP Partition explainer 的定位,与 Grad-CAM 形成互补对照:

- engine/shap_explain.py:对目标类别求 SHAP 归因(只保留正贡献),以及方框平滑。
  遮挡用数值 masker 而非教程里的 inpaint_telea——后者依赖 opencv,而我们的图已做过
  逐图标准化,用 0(即该图均值)遮挡语义更干净
- scripts/shap_boxes.py:按类别抽样生成标签,并输出「原图 | Grad-CAM | SHAP」并排图
- shap 放入可选依赖组:uv sync --group explain

实测(225 张,每类 15):SHAP 在小目标类别上明显更准,框面积占比
blackdot 0.019 vs 0.269(14 倍)、mosquito 0.030 vs 0.173、whitedot 0.076 vs 0.253。
原因是 Grad-CAM 受限于 8x8 特征图(每格 16x16 像素),几像素的黑点根本不可分辨。
代价是单张 0.55 秒,全量 11k 张约 1.7 小时,而 Grad-CAM 全量只要 2 分钟。

调参时发现的坑:SHAP 第一版全部出 0 框,因为沿用了给 8x8 调的
min_area_ratio=0.02(在 128x128 上等于 328 像素)。参数不能跨分辨率照搬,
SHAP 默认改为 0.002。

--max-boxes 两个脚本都默认改为 1(原为 2):一张裁片通常只对应一处缺陷。
库函数 cam_to_boxes 的默认值同步收紧,相关测试改为显式传参而不依赖默认值。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:10:50 +08:00

100 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用 SHAP 的 Partition explainer 做定位归因。
与 Grad-CAM 的取舍正好互补:
- **分辨率**:Grad-CAM 受限于卷积特征图(resnet18 @128² 的 layer3 只有 8×8),
SHAP 直接在像素上做层次划分,输出与输入同分辨率。
- **代价**:Grad-CAM 一次 backward 就够;Partition explainer 要几百次前向,
单张约 1 秒,全量 11k 张需要数小时。
原理:把图像层次化地切块,用 Owen 值近似 Shapley 值,衡量每块对目标类别分数的贡献。
遮挡用数值 masker(默认 0)而不是教程里的 inpaint——我们的图已做过逐图标准化,
0 恰好是该图均值,语义上就是"抹掉这块的信息",而且不必依赖 opencv。
"""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
import torch
from torch import nn
def shap_attribution(
model: nn.Module,
image: np.ndarray,
target: int,
device: torch.device,
max_evals: int = 500,
batch_size: int = 64,
mask_value: float = 0.0,
) -> np.ndarray:
"""对单张图求目标类别的 SHAP 归因,返回与输入同尺寸的 (H, W) 图,已归一化到 [0, 1]。
Args:
image: HWC 的 float 数组,取值范围与训练时一致(这里是逐图标准化之后的)。
target: 要解释的类别下标——用图片所在目录的真实类别,而不是模型预测。
只保留**正贡献**:负贡献表示"这块像素在把分数往下拉",对定位没有意义。
"""
import shap
def predict(batch: np.ndarray) -> np.ndarray:
tensor = torch.from_numpy(batch).permute(0, 3, 1, 2).float().to(device)
with torch.no_grad():
# 只输出目标类别这一列,SHAP 就退化成对单个标量的归因,省掉其余类别的计算
return model(tensor)[:, target : target + 1].cpu().numpy()
masker = shap.maskers.Image(mask_value, image.shape)
explainer = shap.Explainer(predict, masker)
explanation = explainer(image[None], max_evals=max_evals, batch_size=batch_size, silent=True)
values = np.asarray(explanation.values[0])
if values.ndim == 4: # (H, W, C, n_outputs)
values = values[..., 0]
attribution = values.sum(axis=-1) if values.ndim == 3 else values
positive = np.clip(attribution, 0.0, None)
peak = positive.max()
return positive / peak if peak > 0 else positive
def smooth(attribution: np.ndarray, radius: int = 2) -> np.ndarray:
"""方框平滑。像素级归因偏碎,直接取连通域会切出很多小块;
轻微平滑后再阈值化,框更完整。radius=0 表示不平滑。"""
if radius <= 0:
return attribution
size = 2 * radius + 1
padded = np.pad(attribution, radius, mode="edge")
# 用积分图做 O(1) 的方框均值,避免逐像素卷积
integral = padded.cumsum(axis=0).cumsum(axis=1)
integral = np.pad(integral, ((1, 0), (1, 0)), mode="constant")
height, width = attribution.shape
total = (
integral[size:size + height, size:size + width]
- integral[0:height, size:size + width]
- integral[size:size + height, 0:width]
+ integral[0:height, 0:width]
)
smoothed = total / (size * size)
peak = smoothed.max()
return smoothed / peak if peak > 0 else smoothed
def batched_attribution(
model: nn.Module,
images: np.ndarray,
targets: list[int],
device: torch.device,
progress: Callable[[int], None] | None = None,
**kwargs,
) -> list[np.ndarray]:
"""逐张求归因。Partition explainer 本身是按单图工作的,这里只是循环 + 进度回调。"""
results = []
for index, (image, target) in enumerate(zip(images, targets, strict=True)):
results.append(shap_attribution(model, image, target, device, **kwargs))
if progress:
progress(index)
return results