1. 为什么要把CNN的“注意力”画出来CAM类方法的由来做图像分类、目标检测这类视觉任务时模型吐出一组置信度分数很简单但“它凭什么这么判断”始终是个让人头疼的问题。尤其是把模型部署到自动驾驶、医疗辅助诊断这类场景里只给一个类别标签根本没法让业务方放心。我之前做过一个模拟项目X需要向客户解释模型为什么把某块区域判定为“障碍物”当时就是靠CAM热力图把模型决策依据可视化出来才算把问题讲清楚了。CAM的全称是Class Activation Mapping类别激活映射。这类方法的思路非常直白卷积网络最后几层特征图里每个通道其实在关注一种“视觉模式”——有的通道对轮胎敏感有的通道对车灯敏感有的通道对车身边缘敏感。它们都有响应但只有那些真正推动模型预测为“车”的通道才应该被强调。CAM做的事情就是把最后一层卷积输出的特征图按每个通道对目标类别的贡献权重做加权求和得到一张和输入图像空间位置对齐的热力图。热力图亮的地方就是模型认为的“证据所在区域”。这篇内容适合两类读者。一类是刚接触深度学习可解释性的学生想找一个能跑通、能改、能复用的可视化Demo另一类是做工程实践的开发者遇到“模型预测结论没法向别人交代”的困境需要给模型决策配一个可视化解释工具。下面我会用PyTorch把原始CAM和Grad-CAM两条技术路线都写一遍从原理推导到核心代码再到热力图渲染最后把我实际踩过的坑一并列出来方便你直接照着复现和二次开发。这里先给一个结论性的对比方便你心里有数方法需要GAP层定位精度实现难度适用场景原始CAM必须一般低网络结构带GAP比如ResNet系列Grad-CAM不需要较好中任意CNN结构包括VGG、MobileNet等为什么Grad-CAM成了我日常工作里的主力方案而不是原始CAM主要原因是VGG这类经典网络根本没有全局平均池化层原始CAM理论推导的前提条件就不成立。而Grad-CAM只需要“目标类别分数对特征图的梯度”这个信息在任何可微网络里都存在。接下来的内容会围绕这两条线展开先讲清楚梯度在这个过程里到底起什么作用再给完整代码。2. 图像梯度这条线从传统边缘检测到Grad-CAM的权重推导2.1 传统图像梯度和深度特征梯度是两回事“图像梯度”这个词在不同语境下意思差别很大。传统图像处理里说梯度指的是像素灰度值在x方向和y方向的差分Sobel算子、Scharr算子都是在算这种东西用来找边缘。比如一张照片里车身的边界灰度值变化剧烈梯度幅值就大。但Grad-CAM里说的梯度是模型预测分数对特征图的偏导数也就是反向传播过程中流到某一层特征图上的梯度张量。它表达的意思是如果特征图某个位置的激活值稍微变大一点模型的类别分数会跟着变多少。激活值变化对输出分数影响越大说明这个位置越“重要”。这跟Sobel那种像素层面的边缘检测完全不是一个层次。可以这么类比传统梯度告诉你“图像哪里颜色跳变剧烈”那是像素级别的局部信息深度梯度告诉你“特征图哪里对最终判决贡献大”这是语义级别的归因信息。两个都叫梯度差的却是好几层抽象。2.2 Grad-CAM权重计算的核心公式Grad-CAM的推导过程可以拆成三步。第一步把目标类别分数对第k个通道特征图求梯度得到梯度张量∂y^c / ∂A^k_ij其中y^c是模型对类别c的原始分数softmax之前的值一般用logitsA^k_ij是第k个通道在空间位置(i,j)上的激活值。第二步对梯度做全局平均池化得到这个通道的权重α_k^c (1 / Z) Σ_i Σ_j (∂y^c / ∂A^k_ij)Z是特征图的空间尺寸H×W。为什么要做平均因为每个通道内部会有很多位置有的位置梯度为正、有的为负、有的很小直接累加会有正负抵消的问题而平均能代表这个通道整体上对类别c的“平均支持程度”。第三步用权重对特征图做加权求和然后过ReLU取正L^c_Grad-CAM ReLU(Σ_k α_k^c · A^k)ReLU这一步很关键。它把负贡献的通道直接置零因为我们的目标是找“对类别c有正向推动的区域”负贡献区域反而可能是抑制这个类别的证据不应该在热力图上高亮。2.3 为什么梯度比原始激活值更适合做权重有人会问直接用特征图激活值做加权不行吗不行。因为激活值大只代表这个通道对某种模式响应强烈但响应强烈不一定真的推动了特定类别的判定。举个例子一个通道可能对“草地纹理”非常敏感激活值很大但它跟预测“汽车”这件事关系不大甚至可能拉低汽车类别的分数。如果直接拿激活值做加权草地反而会变成高亮区这显然是错误归因。梯度天然地解决了这个问题它衡量的是“这个特征变化对目标类别分数的影响方向”。影响为正的通道留下影响为负的干掉影响微弱的基本忽略。这也是为什么Grad-CAM的定位精度普遍优于直接看激活值在消融实验里用梯度加权得到的注意力区域和人类标注的目标区域重合度明显更高。在实际工程中很多团队还会用Grad-CAM的变体比如Grad-CAM针对多目标重叠场景做了改进Score-CAM干脆不用梯度、用通道对目标类别的分数贡献来定权重。这些我后面会提到但理解基础版Grad-CAM是关键变体都是在权重计算这一步做替换。3. 环境准备与模型选型ResNet、VGG各自适配哪种CAM3.1 依赖版本与安装我平时在Python 3.9到3.11的环境下跑这套代码都没有问题。核心依赖就四个PyTorch、torchvision、OpenCV、NumPy。安装命令如下pip install torch torchvision pip install opencv-python pip install numpy matplotlib如果机器有GPU需要按官方文档安装对应CUDA版本的PyTorch。没有GPU也能跑只是推理速度慢一些。我这里演示用的模型都不大CPU跑一张224×224的图做一次Grad-CAM时间大概在0.1秒到0.3秒之间完全能接受。OpenCV主要负责两部分工作一个是热力图的色彩映射也就是把灰度值变成彩色图另一个是把热力图缩放到原图尺寸后做叠加。这两步都是纯图像操作用PIL也能做但OpenCV的applyColorMap和resize接口更顺手所以我一直用它。3.2 模型结构差异决定了CAM方案选型原始CAM的推导依赖一个重要前提特征图先经过全局平均池化GAP再接全连接层做分类。ResNet正好满足这个结构。ResNet18、ResNet34、ResNet50这些经典的Backbone最后一个卷积块输出特征图之后会接一个AdaptiveAvgPool2d(1)然后是全连接层fc。fcl的权重矩阵每一行对应的就是某个类别对所有特征通道的加权系数可以直接拿来作为CAM的通道权重。VGG就不满足这个前提。VGG的features网络输出特征图之后直接展平Flatten接全连接层没有GAP。原始CAM的办法没法直接用因为全连接层的权重对应的是展平后的特征向量等价于对特征图空间位置也做了线性组合每个空间位置都有不同的权重这让“每个通道只对应一个标量权重”的假设失效了。所以对VGG这类结构老老实实用Grad-CAM。不同模型选哪一层作为目标层也比较讲究。我的经验是模型系列推荐目标层说明ResNet18/34layer4 最后一个Bottleneck输出7×7或14×14特征图语义足够抽象ResNet50/101layer4 最后一个Block同上但通道数多显存占用更大VGG16/19features 的最后一个Conv层去掉最后几个池化和全连接层的位置MobileNetV3features 最后一个Block轻量模型特征图分辨率可到7×7左右EfficientNetfeatures 最后一个Block结构配置文件里有明确的层序号选目标层的基本原则是选择网络分类前最后一个能保留空间信息的卷积层。太浅的层特征还不够抽象热力图往往是零碎边缘太深了比如直接选全连接层没有空间位置概念图都画不出来。另一个要注意的是模型模式。做解释性分析时模型必须切到eval模式这一点我后面踩坑部分会详细说。先记住一个原则torchvision加载的预训练模型权重本来就是针对ImageNet归一化方案训练出来的输入图像必须用mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]做标准化否则热力图会非常离谱。4. 原始CAM的PyTorch实现GAP与FC权重的配合4.1 图像读取与预处理先写一个标准的图像加载函数。这里有个细节我们最终要把热力图叠加到原图上所以需要保留原始PIL图像用于显示而输入模型的张量必须经过标准化。import torch import torchvision from torchvision import transforms, models from PIL import Image import numpy as np import cv2 transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def load_image(path): img Image.open(path).convert(RGB) input_tensor transform(img).unsqueeze(0) return img, input_tensorunsqueeze(0)是把维度从(C,H,W)变成(1,C,H,W)补出batch维。模型输入必须是四维张量。4.2 原始CAM核心代码原始CAM只需要一次前向计算不需要反向传播所以用torch.no_grad()包起来速度最快显存占用也小。def original_cam(model, input_tensor, class_idxNone): forward_feats {} def hook_fn(module, input, output): forward_feats[x] output # ResNet系列在GAP之前取layer4输出 hook_handle model.layer4.register_forward_hook(hook_fn) with torch.no_grad(): logits model(input_tensor) hook_handle.remove() if class_idx is None: class_idx logits.argmax(dim1).item() feat forward_feats[x][0] # [C, H, W] fc_w model.fc.weight.detach() # [num_classes, C] w fc_w[class_idx] # [C] # 权重与特征图逐通道加权求和 cam torch.tensordot(w, feat, dims([0], [0])) # [H, W] cam torch.relu(cam) cam cam.detach().cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam这里我用了forward hook来抓取layer4的输出。为什么不直接把模型拆开因为在很多工程场景里模型是被整体加载的拆开会破坏后续推理逻辑而hook可以做到不改模型结构就拿到中间层特征。这段代码里需要注意的是logits.argmax(dim1).item()取的是模型自己判定概率最高的类别如果你想看某个特定类别的CAM就在调用时传入class_idx参数。4.3 原始CAM的局限性原始CAM在实际使用中有几个明显的局限性。第一模型结构受限必须带GAP层前面已经说过VGG那种结构就用不了。第二它的权重来自全连接层而全连接层是在全局池化之后接的这意味着它无法捕捉到空间位置信息之间的交互定位结果比较“粗”。第三它只能针对分类网络使用检测、分割这类任务网络没有直接对应的全连接分类结构扩展困难。所以在我的项目里原始CAM更多是用来做概念验证真正稳定跑线上任务的是Grad-CAM。下面这一节才是整套代码的重头戏。5. Grad-CAM核心代码forward hook与autograd.grad的两种姿势5.1 PyTorch钩子机制的原理详解在动手写Grad-CAM之前先要把钩子机制讲明白。PyTorch的模块钩子分两类。前向钩子register_forward_hook在模块前向传播结束后触发你可以拿到模块的输入和输出反向钩子register_backward_hook在模块的梯度计算阶段触发你可以拿到梯度信息。Grad-CAM恰好需要这两样东西前向钩子拿特征图A反向钩子拿梯度∂y/∂A。这里有个版本问题需要注意。PyTorch 1.9之前register_backward_hook对非叶子模块经常不好使因为早期实现里模块会在反向时释放输入输出缓存hook拿到的是空张量。PyTorch 1.10以后引入了register_full_backward_hook它保证能拿到完整的梯度信息官方也推荐新代码使用这个接口。为了兼容老环境我用try/except做了一层兜底try: handle target_layer.register_full_backward_hook(backward_hook) except (AttributeError, RuntimeError): handle target_layer.register_backward_hook(backward_hook)反向钩子的签名是hook(module, grad_input, grad_output)。grad_output是一个元组里面是模块输出张量对应的梯度Grad-CAM需要的就是grad_output[0]。5.2 GradCAM类的完整实现下面这套代码是我在项目中实际使用的版本基于hook实现结构清晰适合直接复用class GradCAM: def __init__(self, model, target_layer): self.model model self.activations None self.gradients None handle_fwd target_layer.register_forward_hook(self.forward_hook) try: handle_bwd target_layer.register_full_backward_hook(self.backward_hook) except (AttributeError, RuntimeError): handle_bwd target_layer.register_backward_hook(self.backward_hook) self.handles [handle_fwd, handle_bwd] def forward_hook(self, module, input, output): self.activations output.detach() def backward_hook(self, module, grad_input, grad_output): self.gradients grad_output[0].detach() def generate(self, input_tensor, class_idxNone): self.model.eval() self.model.zero_grad() logits self.model(input_tensor) if class_idx is None: class_idx logits.argmax(dim1).item() # 构造one-hot向量只让目标类别这一路梯度回传 one_hot torch.zeros_like(logits) one_hot[0, class_idx] 1.0 logits.backward(gradientone_hot) weights torch.mean(self.gradients, dim(2, 3), keepdimTrue) cam torch.relu(torch.sum(weights * self.activations, dim1, keepdimTrue)) cam cam.squeeze(0).squeeze(0).cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam def remove_hooks(self): for handle in self.handles: handle.remove()代码里有两个细节值得说明。第一backward(gradientone_hot)这种方式是标准做法。因为模型输出有1000个类别直接调用backward()会把所有类别的梯度都回传我们需要的是“只关心类别c的梯度”所以构造一个只在[0, class_idx]位置为1的one-hot向量作为回传的梯度种子其他位置梯度直接置零这样反向传播到目标层时流过来的梯度就全部是类别c贡献的。第二weights * self.activations这里用了广播机制。weights形状是(1, C, 1, 1)activations形状是(1, C, H, W)乘完之后每个通道的特征图都乘上了自己的权重然后在通道维求和得到(1, 1, H, W)的热力图张量。5.3 不依赖反向钩子的另一套写法有时候项目的PyTorch版本太老或者模型里某些层对钩子兼容性不好我还会用另一套基于torch.autograd.grad的写法。它的思路是前向钩子保存特征图且不调用detach保持特征图和计算图的连接然后直接用autograd.grad求梯度绕开反向钩子。class GradCAMv2: def __init__(self, model, target_layer): self.model model self.features None self.target_layer target_layer self.target_layer.register_forward_hook(self.forward_hook) def forward_hook(self, module, input, output): # 注意这里不要detach后面要基于它求梯度 self.features output def generate(self, input_tensor, class_idxNone): self.model.eval() logits self.model(input_tensor) if class_idx is None: class_idx logits.argmax(dim1).item() score logits[0, class_idx] grads torch.autograd.grad(score, self.features, retain_graphFalse)[0] weights torch.mean(grads, dim(2, 3), keepdimTrue) # 这里可以安全地detach了因为我们需要的是数值而非梯度 cam torch.relu(torch.sum(weights * self.features.detach(), dim1, keepdimTrue)) cam cam.squeeze(0).squeeze(0).cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam这种写法的好处是完全没有注册反向钩子不会遇到老版本PyTorch的钩子兼容问题。代价是self.features一直持有整个计算图的引用在生成热力图之前这个图不会被释放如果模型很深、输入batch很大内存峰值会比hook方案高一些。所以我的建议是新项目优先用第一种写法老环境或者遇到钩子异常时切到第二种。5.4 单张图片的完整调用示例加载一个预训练ResNet18生成热力图model models.resnet18(pretrainedTrue) model.eval() img, input_tensor load_image(demo.jpg) target_layer model.layer4[-1] cam_generator GradCAM(model, target_layer) cam cam_generator.generate(input_tensor, class_idx281) # 猫是282这里举例 cam_generator.remove_hooks()这里target_layer选model.layer4[-1]和直接选model.layer4效果差别不大因为layer4本身是一个Sequential容器其输出和最后一个子模块的输出是一样的。但注册到具体子模块上钩子的触发回调会更干净所以我习惯选layer4[-1]。6. 热力图渲染链路归一化、上采样、色彩映射与叠加6.1 从张量到二维热力图的标准化流程generate函数返回的cam是二维numpy数组数值范围已经被归一化到了[0,1]。这个归一化不是可选步骤而是必须的底层特征图的激活值和梯度值数量级差异很大不同模型、不同层之间可能差好几个数量级不归一化的话后面的色彩映射完全没法用。归一化公式我习惯加一个极小值保护项cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8)1e-8是为了防止特征图所有位置数值完全相同时除零。实际中这个情况很少见但防御性编程成本很低写了没有坏处。6.2 用OpenCV做上采样和叠加模型输出的特征图一般是7×7或者14×14而原图可能是几百甚至上千像素直接把热力图缩放到原图大小会非常模糊。但Grad-CAM本身的定位粒度就停留在这个分辨率上所以上采样到原图尺寸是正常的操作不用追求更细腻的边界。渲染叠加函数如下def overlay_cam_on_image(pil_img, cam, alpha0.5): img np.asarray(pil_img).copy() h, w img.shape[:2] cam_resized cv2.resize(cam, (w, h), interpolationcv2.INTER_LINEAR) cam_resized np.uint8(255 * cam_resized) heatmap cv2.applyColorMap(cam_resized, cv2.COLORMAP_JET) heatmap cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) result cv2.addWeighted(img, 1 - alpha, heatmap, alpha, 0) return resultapplyColorMap会把灰度值映射成Jet风格的彩色蓝色表示低注意力红色表示高注意力。映射完是BGR格式显示前要转成RGB。addWeighted做的是带权重的图像融合alpha越大热力图的颜色越醒目原图细节越弱。我平时取0.5既能看高热区又不至于把原图完全盖住。这里有一个容易翻车的小细节如果你是从PIL读的图颜色通道顺序是RGB但numpy数组在OpenCV里默认按BGR处理所以颜色映射之后必须先转RGB再叠加否则热力图会偏蓝偏黄。我一开始在这个问题上卡了半天最后发现是通道顺序的锅。6.3 保存与展示叠加结果可以直接保存from PIL import Image overlay overlay_cam_on_image(img, cam, alpha0.5) Image.fromarray(overlay).save(cam_result.jpg) # 也可以把原图和热力图并排展示 plt.figure(figsize(10, 5)) plt.subplot(1, 2, 1) plt.imshow(img) # 原图 plt.axis(off) plt.subplot(1, 2, 2) plt.imshow(img) plt.imshow(cam, cmapjet, alpha0.5) # 直接matplotlib叠一层 plt.axis(off) plt.show()matplotlib也支持直接用imshow叠加两层alpha参数控制透明度效果和OpenCV叠加差不多。区别是matplotlib的插值算法不同在高分辨率下边缘会更平滑一些但数据本质一样按需选择即可。7. 多类别与batch场景一次跑出一组热力图7.1 指定类别与最高置信度类别的选择逻辑前面的代码默认取logits.argmax(dim1)作为目标类别也就是模型自己认为最有把握的类别。但实际使用中信息量往往不在于“模型分对了什么”而在于“模型为什么把某样东西判成了某个错类”。举个例子我用一个预训练模型分析道路场景照片模型把一辆卡车误判成了轿车。这时候如果只画最高置信度类别的热力图看到的是它为什么判成轿车但业务方更想知道它为什么忽略卡车证据。解决办法是把卡车类别的索引传进generate方法让热力图回答“模型有没有看到卡车的区域只是权重不够”。两个热力图并排一对比问题立竿见影。指定类别时的代码就这么简单cam_truck cam_generator.generate(input_tensor, class_idx656) # 假设656是卡车类别 cam_car cam_generator.generate(input_tensor, class_idx817) # 假设817是轿车类别注意同一张图生成两张热力图时如果复用的是同一个GradCAM对象要确认前后两次generate之间没有残留梯度。我在generate开头写了self.model.zero_grad()就是为了防止上一次反向传播的梯度累加干扰本次结果。如果不放心可以连续调用两次之后对比一下两张图是否稳定。7.2 Batch输入时的批量热力图生成在实际项目中一次可能要对几十上百张图生成热力图循环逐张调用会慢。一个自然的优化是一次喂给模型一个batch前向只跑一次然后为每个样本反向传播一次梯度信息在同一个batch里共享前向计算结果。为此我在GradCAM类里加一个批量生成方法def generate_batch(self, input_tensors, class_idxs): input_tensors: (B, C, H, W)class_idxs: 长度B的列表 self.model.eval() self.model.zero_grad() logits self.model(input_tensors) # 一次前向 B logits.size(0) one_hot torch.zeros_like(logits) for i in range(B): one_hot[i, class_idxs[i]] 1.0 logits.backward(gradientone_hot) weights torch.mean(self.gradients, dim(2, 3), keepdimTrue) cam torch.relu(torch.sum(weights * self.activations, dim1, keepdimTrue)) cams [] for i in range(B): c cam[i, 0].cpu().numpy() c (c - c.min()) / (c.max() - c.min() 1e-8) cams.append(c) return cams这里的关键是backward(gradientone_hot)one_hot是一个(B, num_classes)的张量第i行只在class_idxs[i]处为1这样第i个样本的梯度就只针对它自己的目标类别回传。整个batch一次反向传播全部搞定效率比循环高很多。实测过的经验对一个64张图的小batch做批量热力图生成时间开销几乎等于一次普通batch推理这个成本对线上调试来说完全可以接受。8. 实操踩坑与调优心得8.1 DataParallel带来的钩子失效问题这是我在实际项目里踩过的最深的一个坑。模型用nn.DataParallel包起来之后原来的model.layer4其实只是一个包装壳真正执行前向的是model.module.layer4。如果你把hook注册在model.layer4上hook可能永远不会触发或者触发时机和预期完全不同最后拿到空的特征图代码直接报错。解决办法有两个。最省事的是注册到model.module.layer4上base_model models.resnet18(pretrainedTrue) model nn.DataParallel(base_model) target_layer model.module.layer4[-1] cam_generator GradCAM(model, target_layer)另一个办法是拆掉DataParallel再注册hook处理完热力图再包回去但工程上很别扭。总而言之遇到“hook不触发”“activations是None”这类问题时优先检查模型是不是被并行包装过。8.2 钩子不清理导致的内存泄漏每次register_forward_hook都会在模块内部维护一个hook列表如果不调用handle.remove()就把GradCAM对象丢掉这些hook会一直挂在模型上。短时间内没什么问题但在长循环或者服务端常驻进程里每生成一次热力图就新增一对hook内存和显存会缓慢上涨跑一晚上之后进程直接OOM。我的习惯是GradCAM类里保存所有handle在生成完热力图后立即调用remove_hooks()或者用with语法包一层。如果需要在循环里对同一模型反复生成热力图就只创建一个GradCAM实例循环里复用循环结束后再清理。8.3 BN层在eval和train模式下的天壤之别BatchNorm层有个特性训练时它使用当前batch的均值和方差做归一化推理时使用全局统计的running_mean和running_var。如果你忘了调用model.eval()模型里所有BN层都会用当前batch的统计量而解释性场景下输入图像往往只有一张一张图的统计量和全局统计量差距巨大最后生成的热力图会莫名其妙地出现大片噪声高亮区域甚至同一样本不同批次时热力图都不稳定。所以GradCAM的generate方法里我第一行就写self.model.eval()把这个坑从根上堵死。如果你自己改造这段代码千万别把这个调用省掉。8.4 热力图不聚焦时的调优方向如果你发现热力图太散、不够聚焦或者高亮区域和直觉不符我建议按下面的顺序排查和调优现象可能原因处理建议热力图大面积高亮目标层偏浅特征不够抽象换成更靠近分类头的卷积层高亮区域破碎不连续模型对目标本身就不够确信换更强backbone或者微调模型叠加后颜色发灰失真反归一化没做原图不是RGB用原始PIL图叠加不要用归一化后的张量同一张图结果不稳定没切eval模式或梯度残留确认model.eval()并zero_grad()高亮区集中在边缘可能是图像本身纹理干扰可尝试Grad-CAM或Score-CAM目标太小导致热力图偏移7×7特征图分辨率不够改用高分辨率输入或者使用拥有更大特征图的目标层另外如果你在分割、检测任务上也想用CAM做可视化思路是一样的不要用分类概率做目标分数改用它输出的类别相关的响应图或者置信度然后做梯度回传。比如检测模型里可以把某个候选框的类别得分作为y再把感兴趣层的特征图作为A流程完全复用Grad-CAM的代码。8.5 我的一个实用建议最后分享一个小技巧。做项目报告或者写论文需要展示多张对比图时我习惯把“原始图像、叠加热力图、纯热力图、高亮掩膜”四张图做成一行一行的网格图。热力图本身信息量有限但配上阈值化后的二值掩膜能够量化说明“模型关注的区域占整张图的百分之多少”这种量化指标在跨模型对比时特别有用。threshold 0.5 mask (cam threshold).astype(np.uint8) focus_ratio mask.mean() * 100 print(f高亮区域占比: {focus_ratio:.1f}%)这个百分比加上几组不同模型的对比图基本就能把“模型注意力是否合理”讲成一个有数据支撑的结论而不只是停留在“看起来挺准”的层面。整套方案从原理到代码再到调优到这里就完整闭环了你可以直接拿去做自己的可视化任务。
阅读完成 · 觉得有帮助?