Files
ModelTrainEval/tests/test_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

81 lines
3.0 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 归因定位。"""
import pytest
torch = pytest.importorskip("torch")
pytest.importorskip("shap")
import numpy as np # noqa: E402
from torch import nn # noqa: E402
from engine.gradcam import cam_to_boxes # noqa: E402
from engine.shap_explain import shap_attribution, smooth # noqa: E402
class _Detector(nn.Module):
"""把左上角亮度当成第 1 类的证据——归因应当集中在那里。"""
def __init__(self) -> None:
super().__init__()
self.bias = nn.Parameter(torch.zeros(2), requires_grad=False)
def forward(self, x):
corner = x[:, :, :8, :8].mean(dim=(1, 2, 3))
return torch.stack([-corner, corner], dim=1)
def test_smooth_spreads_and_renormalizes():
peak = np.zeros((16, 16), dtype=np.float32)
peak[8, 8] = 1.0
smoothed = smooth(peak, radius=2)
assert smoothed.shape == peak.shape
assert int((smoothed > 0).sum()) == 25 # 5x5 方框
assert float(smoothed.max()) == pytest.approx(1.0) # 重新归一化到 1
assert np.allclose(smooth(peak, radius=0), peak)
def test_smooth_keeps_flat_map_flat():
flat = np.full((8, 8), 0.5, dtype=np.float32)
assert np.allclose(smooth(flat, radius=2), 1.0) # 归一化后整体为 1,不产生伪峰
def test_attribution_lands_on_the_evidence():
"""模型只看左上角,SHAP 的正贡献就该集中在左上角。"""
model = _Detector().eval()
image = np.zeros((16, 16, 3), dtype=np.float32)
image[:8, :8] = 1.0
attribution = shap_attribution(
model, image, target=1, device=torch.device("cpu"), max_evals=200, batch_size=32
)
assert attribution.shape == (16, 16) # 与输入同分辨率,不是特征图分辨率
assert 0.0 <= attribution.min() and attribution.max() == pytest.approx(1.0)
assert attribution[:8, :8].mean() > attribution[8:, 8:].mean()
def test_attribution_is_non_negative():
"""只保留正贡献:负贡献表示"这块在把分数往下拉",对定位没有意义。"""
model = _Detector().eval()
image = np.random.default_rng(0).random((16, 16, 3)).astype(np.float32)
attribution = shap_attribution(
model, image, target=0, device=torch.device("cpu"), max_evals=200, batch_size=32
)
assert (attribution >= 0).all()
def test_full_resolution_beats_feature_map_for_small_targets():
"""SHAP 与输入同分辨率,小目标不会被格子吞掉——这是它相对 Grad-CAM 的核心优势。
一个 10x10 的小目标在 128x128 归因图上占 100 像素:
按 gradcam 那套 min_area_ratio=0.02(=328 像素)会被整个滤掉,
换成 0.002(=33 像素)才留得住——所以 SHAP 必须用小得多的阈值。
"""
attribution = np.zeros((128, 128), dtype=np.float32)
attribution[60:70, 60:70] = 1.0
assert cam_to_boxes(attribution, threshold=0.4, min_area_ratio=0.02) == []
(box,) = cam_to_boxes(attribution, threshold=0.4, min_area_ratio=0.002)
x1, y1, x2, y2 = box
assert (x2 - x1) < 0.15 and (y2 - y1) < 0.15 # 框紧贴小目标