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>
This commit is contained in:
Shaun Zhou
2026-09-10 16:10:50 +08:00
co-authored by Claude Opus 5
parent fc7136fddf
commit 9aa87d31a6
9 changed files with 2173 additions and 1344 deletions
+99
View File
@@ -0,0 +1,99 @@
"""用 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