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:
co-authored by
Claude Opus 5
parent
fc7136fddf
commit
9aa87d31a6
+15
-3
@@ -61,15 +61,27 @@ def test_peak_ratio_drops_weak_secondary_blobs():
|
||||
cam[1:3, 1:3] = 1.0 # 主区域
|
||||
cam[5:7, 5:7] = 0.45 # 弱响应区
|
||||
|
||||
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, peak_ratio=0.0)) == 2
|
||||
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, peak_ratio=0.75)) == 1
|
||||
kwargs = {"threshold": 0.4, "min_area_ratio": 0.0, "max_boxes": 5} # 显式放开数量上限
|
||||
assert len(cam_to_boxes(cam, peak_ratio=0.0, **kwargs)) == 2
|
||||
assert len(cam_to_boxes(cam, peak_ratio=0.75, **kwargs)) == 1
|
||||
|
||||
|
||||
def test_max_boxes_defaults_to_one():
|
||||
"""默认只留最强的一个框——一张裁片通常只对应一处缺陷。"""
|
||||
cam = np.zeros((12, 12), dtype=np.float32)
|
||||
cam[1:4, 1:4] = 1.0
|
||||
cam[7:10, 7:10] = 0.95 # 峰值够高,不会被 peak_ratio 滤掉
|
||||
|
||||
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0)) == 1
|
||||
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, max_boxes=2)) == 2
|
||||
|
||||
|
||||
def test_min_area_and_max_boxes_limits():
|
||||
cam = np.zeros((10, 10), dtype=np.float32)
|
||||
cam[0:1, 0:1] = 1.0 # 1 格,面积很小
|
||||
cam[4:8, 4:8] = 1.0
|
||||
assert len(cam_to_boxes(cam, threshold=0.5, min_area_ratio=0.05, peak_ratio=0.5)) == 1
|
||||
assert len(cam_to_boxes(cam, threshold=0.5, min_area_ratio=0.05, peak_ratio=0.5,
|
||||
max_boxes=5)) == 1
|
||||
|
||||
many = np.zeros((12, 12), dtype=np.float32)
|
||||
for offset in (0, 4, 8):
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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 # 框紧贴小目标
|
||||
Reference in New Issue
Block a user