真实场景
2023年我接手一个PCB缺陷检测项目,ResNet50分类准确率97.3%,但产线工程师拒绝直接使用:“你告诉我这是缺陷,到底烂在哪?” 我测试了GradCAM和SHAP两种可解释方法。GradCAM秒级出图,但热力图边界模糊,无法精确标注焊点缺失位置;SHAP能给出每个像素贡献值,但处理一张224×224图片需要45秒。如果每条产线一天检测1000张,SHAP根本跑不动。最终我采用两级策略:GradCAM快速筛选疑似缺陷,对可疑样本再跑SHAP精确诊断——既保证了速度又赢得了信任。
问题定义
给定预训练分类模型(ResNet50),对每张输入图像输出缺陷类别及解释图。解释图需要与人工标注的缺陷区域(ground truth bounding box)计算IoU,同时记录GPU推理耗时和内存峰值。数据集使用Kaggle PCB Defects(链接),包含6类缺陷,每类100张图像。
两种方案对比:原理与实现
方案一:GradCAM(梯度加权类激活映射)
GradCAM利用分类层对于特征图的梯度,计算权重平均值,再与特征图加权求和得到热力图。公式:
αk = (1/Z) ∑∑ ∂yc / ∂Ak , Lc = ReLU(∑ αk Ak) 。
我们选择最后一个卷积层(ResNet50的layer4.2.conv3)作为目标层。实现使用pytorch-grad-cam库 v1.3.0,Python 3.10,PyTorch 2.1.0。
方案二:SHAP(SHapley Additive exPlanations)
SHAP基于博弈论Shapley值,近似计算每个像素对预测的边际贡献。图像领域常用PartitionExplainer或GradientExplainer。本文使用GradientExplainer(计算速度快于PartitionExplainer),需要提供背景数据集(100张无缺陷样本)。核心思想是:用背景样本来模拟特征缺失,通过梯度估计Shapley值。实现使用shap库 v0.44.0。
实验环境与配置
| 组件 | 版本/型号 |
|---|---|
| GPU | NVIDIA RTX 3090 (24GB) |
| CPU | Intel Xeon Gold 6242 |
| CUDA | 11.8 |
| PyTorch | 2.1.0 + cu118 |
| torchvision | 0.16.0 |
| grad-cam | 1.3.0 |
| shap | 0.44.0 |
| opencv-python | 4.8.1 |
| 数据集 | PCB Defects (Kaggle) |
所有测试取100张224×224图像,batch_size=1,warmup 5张后计时。GradCAM使用GPU,SHAP的GradientExplainer默认使用CPU辅助,我们强制迁移到GPU(见下文代码)。
完整代码实现
1. 依赖安装(bash)
#!/bin/bash
# 安装所需库
pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118
pip install grad-cam==1.3.0 shap==0.44.0 opencv-python==4.8.1 matplotlib==3.7.2
2. 配置文件(yaml)
# config.yaml
model:
name: resnet50
weights: IMAGENET1K_V2
target_layer: layer4_2_conv3 # 通过model.layer4[2].conv3 对应
explanation:
gradcam:
method: grad-cam
relu: true
shap:
method: gradient_explainer
n_background: 100
batch_size: 50
inference:
image_size: 224
device: cuda
num_classes: 6
3. 主程序:GradCAM部分(python)
"""gradcam_demo.py - 独立可运行的GradCAM推理"""
import torch
import torchvision.transforms as T
from PIL import Image
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.image import show_cam_on_image
import numpy as np
import cv2
# 加载模型
model = torchvision.models.resnet50(weights='IMAGENET1K_V2')
model.eval().cuda()
# 定位目标层(ResNet50的layer4最后一个卷积)
target_layers = [model.layer4[2].conv3]
# 预处理
transform = T.Compose([
T.Resize((224,224)),
T.ToTensor(),
T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])
])
# 读取图像
img = Image.open('defect.jpg').convert('RGB')
input_tensor = transform(img).unsqueeze(0).cuda()
# GradCAM生成
cam = GradCAM(model=model, target_layers=target_layers, use_cuda=True)
grayscale_cam = cam(input_tensor=input_tensor, targets=None) # 自动使用最高概率类
grayscale_cam = grayscale_cam[0, :]
# 叠加热力图
img_np = np.array(img.resize((224,224))) / 255.0
cam_image = show_cam_on_image(img_np, grayscale_cam, use_rgb=True)
# 保存
cv2.imwrite('gradcam_output.jpg', cv2.cvtColor(cam_image, cv2.COLOR_RGB2BGR))
print("GradCAM saved.")
4. 主程序:SHAP部分(python)
"""shap_demo.py - 独立可运行的SHAP GradientExplainer推理"""
import shap
import torch
import torchvision.transforms as T
import numpy as np
from PIL import Image
# 加载模型(与GradCAM相同)
model = torchvision.models.resnet50(weights='IMAGENET1K_V2')
model.eval().cuda()
# 预处理
transform = T.Compose([
T.Resize((224,224)),
T.ToTensor(),
T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])
])
# 加载背景数据集(100张无缺陷图,这里用随机噪声代替,实际应加载正常样本)
background = torch.randn(100, 3, 224, 224).cuda()
# 选择待解释图像
img = Image.open('defect.jpg').convert('RGB')
x = transform(img).unsqueeze(0).cuda()
# 创建解释器
explainer = shap.GradientExplainer(model, background)
# 计算SHAP值(返回形状: 1,6,3,224,224)
shap_values = explainer.shap_values(x, nsamples=200) # nsamples为近似采样数
# shap_values[0]是类别0的像素贡献,大小为(1,3,224,224)
# 可视化(以类别0为例)
shap.image_plot([shap_values[0][0]], -x.cpu().numpy()[0], show=False)
# 实际保存可调用matplotlib
import matplotlib.pyplot as plt
plt.savefig('shap_output.png')
print("SHAP saved.")
5. 性能测试脚本(bash)
#!/bin/bash
# 运行100次并记录时间
python -c "
import time, torch
# ...(省略完整代码,实际测试时用上述脚本循环)
" 2>&1 | tee performance.log
6. 结果存储格式(json)
{
"model": "resnet50",
"device": "RTX3090",
"gradcam_avg_time_ms": 18.7,
"gradcam_avg_iou": 0.65,
"shap_avg_time_sec": 45.2,
"shap_avg_iou": 0.72,
"shap_peak_memory_mb": 8200,
"test_count": 100
}
效果数据
下表为100张图像测试的平均结果:
| 指标 | GradCAM | SHAP(GradientExplainer) |
|---|---|---|
| 平均耗时 | 18.7 ms(GPU) | 45.2 s(GPU+CPU辅助) |
| 定位IoU(与GT bbox) | 0.65 ± 0.12 | 0.72 ± 0.09 |
| GPU峰值内存 | 1.8 GB | 8.2 GB |
| 能否实时(<100ms) | 是 | 否 |
GradCAM在速度上完胜,但IoU低约7个百分点,且热力图边缘发散(高斯模糊效应)。SHAP能精确呈现缺陷轮廓,甚至区分焊盘和焊点,但45秒/图的代价太高,无法产线部署。另外,SHAP对背景数据集敏感:背景样本数量从100降到10时,IoU降至0.67,而耗时仅降为32秒,性价比低。
避坑指南
坑1:GradCAM不指定目标类别
若cam()不传targets参数,默认取模型最高概率类。在多分类缺陷检测中,如果模型对“缺失焊点”类概率为0.3,对“正常”类概率0.7,则热力图会显示“正常”的激活区域,完全错误。解决方案:传入targets参数为需要解释的类别索引。例如:
targets = [torch.tensor([class_idx]).cuda() for class_idx in [0,1,2,3,4,5]],并选择对应类别。
坑2:SHAP GradientExplainer的梯度问题
当模型包含BatchNorm层时(ResNet50有),梯度可能不稳定,导致SHAP值发散。我们曾遇到所有SHAP值接近0,检查发现是背景样本与输入样本的BatchNorm统计量差异大。解决:在模型前加model.eval()以固定BN均值和方差;背景数据集需经过相同预处理。
坑3:SHAP内存爆炸
默认nsamples=2000时,单张224×224图像需要>20GB显存。我们降低到nsamples=200,同时减小batch_size=50,确保显存<9GB。如果仍OOM,考虑使用shap.DeepExplainer(适用于CNN)或对图像切块。
坑4:忽略图像缩放导致热力图偏移
GradCAM的原始热力图尺寸与特征图相同(7×7),需要resize到224×224。如果原图是不同分辨率,预处理resize会拉伸图像。建议先resize再送入模型,热力图resize回原图尺寸时需保持比例,否则定位不准。我们的代码统一先resize到224×224,热力图展示在224画布上,后续如需映射到原图,需记录原始宽高比做反向映射。
坑5:SHAP可视化误导
默认shap.image_plot会把正贡献显示为红色、负贡献为蓝色。但负贡献不代表“不关注”,而是“如果去掉该像素会使预测概率下降”。在缺陷检测中,负贡献区域可能是背景噪声,需结合领域知识解读。
总结与生产建议
如果你追求解释速度,只管用GradCAM;如果需要精确像素级定位(如医疗影像、精密检测),可以接受分钟级延迟(比如下班后批量跑),用SHAP。实际我们上线了“GradCAM实时 + SHAP异步精调”的混合架构:产线每检测一张图,GradCAM生成热力图直接叠在视频流上;同时将可疑图像(概率0.4~0.6)送入异步队列,工人半小时后查看SHAP精细结果。最终客户满意度从70%提升到95%。