Files
ModelTrainEval/engine/shap_explain.py
T
Shaun ZhouandClaude Opus 5 65bc6b2045 feat(boxes): 新增传统 CV 定位方法(--method cv),不依赖分类模型
背景差分:大半径方框均值估背景 → 残差(可选极性)→ 平滑 → 有无判定 → 按峰值归一化,
再交给与 gradcam / shap 同一套 cam_to_boxes 出框。只依赖 numpy。

与归因方法的差别:不需要 --checkpoint,分类器还没训练出来就能先跑一版框,也不受分类
精度影响;类别只能来自目录(--target label);找的是偏离局部背景的区域,而不是模型
据以判断类别的区域,所以对局部目标好、对铺满整图的纹理类给不出有意义的框。

有无缺陷是显式判定的:残差峰值要同时高于绝对对比度下限与噪声水平(MAD 估计)的 15 倍,
否则返回全零图。这条是必需的——实测纯噪声图的"峰值/MAD"稳定在 9 附近(最大 10.9,
几乎与噪声大小无关),有 0.25 对比度黑点时是 25~330;若只按峰值归一化,正常图的噪声
会被拉到 1,满图假框。阈值 15 取在两者之间。

顺带把积分图方框均值从 shap_explain.smooth 提到 engine/classical.box_mean 共用,
避免两处实现各自漂移。

实测:单张 0.011 秒(全量 11k 约 2 分钟);真实薄膜图上框能套住黑点、虫状物与划痕。
已知局限:背景估计在图像边界处对明暗阶跃有响应,贴边的交界可能出假框。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:03:13 +08:00

91 lines
3.5 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
# 与 engine/classical.py 共用同一份积分图实现,免得两处各自漂移
from engine.classical import box_mean
smoothed = box_mean(attribution, radius)
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