Grad-CAM 全称 Gradient-weighted Class Activation Mapping,用于卷积神经网络的可视化,甚至可以用于语义分割
不过我是主要研究目标检测的,在看论文的时候就没有在意语义分割的部分
Grad-CAM 的前身是 CAM,CAM 的基本的思想是求分类网络某一类别得分对高维特征图 (卷积层的输出) 的偏导数,从而可以该高维特征图每个通道对该类别得分的权值;而高维特征图的激活信息 (正值) 又代表了卷积神经网络的所感兴趣的信息,加权后使用热力图呈现得到 CAM
在分类网络的可视化方面上,Grad-CAM 并没有太大的进步,两者都是基于全局平均池化的结构进行手动的推导,针对权值的计算进行改进
先讲一下 Grad-CAM 论文的基本思路,再讲讲我的 idea
以经典的分类网络 ResNet50 为例,卷积层 (下采样 4 次) + 全局平均池化 + 全连接层
当输入图像的 shape 为 [3, 224, 224] 时:
- 卷积层:[3, 224, 224] -> [2048, 14, 14],记输出为 A
- 全局平均池化:[2048, 14, 14] -> [2048, 1, 1],记输出为 F
- 全连接层:[2048, 1, 1] -> [2048, ] -> [1000, ],对应 1000 个类别的得分,记输出为 Y
对于第 class 个类别,其得分为 (重在思路,以下计算忽略线性层的偏置):
类别得分 对高维特征图每一个元素 的偏导数为:
因为使用了全局平均池化,所以最终求得偏导数与类别、高维特征图的通道有关,而与高维特征图的像素位置无关
Grad-CAM 表示为:
从等式中可以看出,Grad-CAM 其实就是求解了高维特征图对某类别得分的权值 (即贡献率),并在通道维度上对高维特征图进行加权,以表征每一个位置对该类别得分的贡献程度 (上采样之后拓展到全图)
对于更复杂的网络,其偏导数的推理肯定更为复杂,而“与高维特征图的像素位置无关”这个结论也将因为全局平均池化层的移除而不适用
在 Torch 框架中,我们可以借助反向传播梯度的机制,对复杂网络的 Grad-CAM 进行求解 (源代码在文末)
因为这个 Grad-CAM 具有非常好的定位性能,所以论文作者又对 Guided Backprop 下了手
Guided Backprop 是以图像 x 为自变量 (即 requires_grad),以某一类别的得分为 Loss 进行梯度的反向传播,最终以图像的梯度作为 Guided Backprop (梯度表征了该像素点对类别得分的贡献)
但是 Guided Backprop 的定位效果很差,从最上面的那幅图可以看到,当设置类别为“猫”时,Guided Backprop 中“狗”的轮廓特别的清晰
Grad-CAM 是一个掩膜矩阵,Guided Backprop 是图像的梯度矩阵,两者利用广播机制进行相乘,即得到 Guided Grad-CAM
Grad-CAM 复现
参考代码:https://github.com/jacobgil/pytorch-grad-cam
上面这份代码可使用 pip install grad-cam 下载;Grad-CAM++ 的论文我没有看,不知道是不是这篇论文的代码
我看了这份代码,总结出绘制分类网络的 Grad-CAM 的流程如下:
- 在目标层 (通常为最后一层卷积层) 设置 hook,在前向传播时保存该层输出张量 (高维特征图),在反向传播时保存该层输出张量的梯度 (高维特征图的梯度,用于与高维特征图加权,生成 Grad-CAM)
- 以类别得分作为 Loss 反向传播梯度,对高维特征图的梯度进行处理 (在该代码中则在对梯度进行全局平均池化,其实我觉得使用逐元素的梯度更好、更通用),然后使用梯度对高维特征图进行加权;而后将加权的高维特征图沿通道求和得到 Grad-CAM,使用 ReLU 函数剔除负值,再使用双线性插值 (上采样) 使 Grad-CAM 的尺寸与原图像相同
- 对于多个目标层,会产生多个 Grad-CAM,此时使用求平均的方法进行聚合 (但是对于 YOLO 这种路径聚合型的网络,更需要的是多个 Grad-CAM 进行叠加,也就是使用求最值的方法进行聚合)
而绘制 Guided Grad-CAM 的流程大有不同:
- 重新定义 ReLU 的反向传播机制 (我测试过了,如果没有这一步最后的效果会很差)
- 以类别得分作为 Loss 反向传播梯度,记录图像的梯度矩阵作为 Guided Backprop
- Grad-CAM 是一个掩膜矩阵,Guided Backprop 是图像的梯度矩阵,两者利用广播机制进行相乘,即得到 Guided Grad-CAM
我对这份代码有几个比较不满意的点:
- 对于高维特征图没有使用逐元素的梯度,通用性不强 (没法用到我计划研究的 YOLO)
- 多个 Grad-CAM 的聚合方式单一 (只有求平均),只可用于防抖动,不可用于叠加
- 封装程度太高,对于调包小伙比较方便,对于二创玩家来说很头疼
- 有些代码的写法明显太复杂了,有失优雅
我仿照这份代码的基本流程,进行了重建:
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
from torch import nn
def normalize(x, eps=1e-7, **kwargs):
''' 归一化'''
x -= x.min(**kwargs)
x /= x.max(**kwargs) + eps
return x
class BP_ReLU(nn.Module):
def forward(self, x):
return self.Func.apply(x)
class Func(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
y = F.relu(x)
ctx.save_for_backward(x, y)
return y
@staticmethod
def backward(ctx, gy):
x, y = ctx.saved_tensors
gx = F.relu(gy) * (x > 0)
return gx
class Grad_CAM:
''' Gradient-weighted Class Activation Mapping
model: 卷积神经网络模型
target_layers: 可视化的目标层列表
agg_fun: 多个目标层的 CAM 聚合方式 (max, avg)
act_replace: 更换激活函数'''
def __init__(self, model, target_layers, agg_fun='avg',
act_replace=[(nn.ReLU, BP_ReLU)]):
self.model = model.eval()
# 注册挂钩, 以保存目标层输出张量及其梯度
self.handle = sum([[
ty.register_forward_hook(
lambda module, x, y: self.activation.append(y.detach())
),
ty.register_full_backward_hook(
lambda module, gx, gy: self.grad.append(gy[0])
)] for ty in target_layers], [])
# 生成聚合函数
kwargs = dict(dim=1, keepdims=True)
self.agg_fun = {'max': lambda fm: fm.max(**kwargs)[0],
'avg': lambda fm: fm.mean(**kwargs)}[agg_fun]
# 记录需要被更换的网络层
self.act_replace = act_replace
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
for handle in self.handle: handle.remove()
def __call__(self, file_list, transform, file_mode='Project/%s.jpg'):
''' file_list: 图像的路径列表
transform: array(BGR) -> tensor 的转换函数'''
folder = Path(file_mode).parent
folder.mkdir(exist_ok=True)
# 保存 BGR 图像, 绘制热力图时使用
bgr_list = []
for file in file_list:
img = cv.imread(file)
assert isinstance(img, np.ndarray), f'OpenCV can\'t open file: {file}'
bgr_list.append(img)
# 转换成 tensor, 并在 batch 维度上拼接
x = torch.stack([transform(bgr) for bgr in bgr_list], dim=0)
grad_cam = self.get_cam(x)
guided_bp = self.get_bp(x)
# 可视化并存储结果
for file, bgr, bp, cam in zip(file_list, bgr_list, guided_bp, grad_cam):
stem = Path(file).stem
# 根据 grad-cam 生成热力图
cam_mask = np.uint8(np.round(cam * 255)).repeat(3, -1)
heat_map = cv.applyColorMap(cam_mask, cv.COLORMAP_JET)
# Grad-CAM
cam_image = cv.addWeighted(bgr, 0.5, heat_map, 0.5, 0)
cv.imwrite(file_mode % f'{stem}.Grad-CAM', cam_image)
# Guided-Backprop
gbp_image = self.write_grad(bp)
cv.imwrite(file_mode % f'{stem}.Guided-Backprop', gbp_image)
# Guided Grad-CAM
ggc_image = self.write_grad(bp * cam_mask)
cv.imwrite(file_mode % f'{stem}.Guided Grad-CAM', ggc_image)
def target(self, y, *args, **kwargs):
print(y.shape)
raise NotImplementedError('No maximization goal is defined')
def replace(self, old, new, model=None):
''' 更换模型中的 ReLU 模块'''
model = self.model if model is None else model
for key, module in model._modules.items():
if isinstance(module, old):
model._modules[key] = new()
self.replace(old, new, module)
def get_cam(self, x):
''' Grad-CAM'''
# 对激活函数进行还原, 撤销更换
for new, old in self.act_replace:
self.replace(old, new)
self.model.zero_grad()
size = x.shape[-2:]
# 清空挂钩读取的激活图和梯度
self.activation, self.grad = [], []
# 前向传播, 反向传播
tar = self.target(self.model(x))
tar.backward()
# 特征图根据反向传播的梯度进行组合, 沿通道求和得到该目标层的 CAM
# 将多个目标层的 CAM 上采样后, 量化后再沿通道维度拼接, 使用聚合函数合并
grad_cam = self.agg_fun(torch.cat([F.interpolate(
normalize(F.relu(act * grad).sum(dim=1, keepdims=True)),
size=size, mode='bilinear', align_corners=False
) for act, grad in zip(self.activation, reversed(self.grad))], dim=1))
return grad_cam.permute(0, 2, 3, 1).data.numpy()
def get_bp(self, x):
''' Guided Backprop'''
# 对激活函数进行更换, 以优化最终的可视化结果
for old, new in self.act_replace:
self.replace(old, new)
self.model.zero_grad()
x.requires_grad = True
# 前向传播, 反向传播
tar = self.target(self.model(x))
tar.backward()
guided_bp = x.grad
return guided_bp.permute(0, 2, 3, 1).data.numpy()[..., ::-1]
def write_grad(self, img, eps=1e-7):
img -= np.mean(img)
img /= (np.std(img) + eps)
img = img * 0.1
img = img + 0.5
img = np.clip(img, 0, 1)
return np.uint8(img * 255)
使用 Grad_CAM 对象的步骤如下:
- 重写 Grad_CAM 的 target 方法,在其中定义 Loss 的计算 (因为这个 Loss 是我们想要最大化的,与平常的最小化不同,所以我给这个函数起名 target)
- 加载卷积神经网络模型,与目标层一同实例化 Grad_CAM 管理器 (关注 __init__ 方法),再把想要可视化的图像文件名、BGR 图像变换为 tensor 的函数名传进管理器 (关注 __call__ 方法),在当前目录的 Project 文件夹中找到可视化结果
因为 YOLOv6、v7 还没有开始研究,所以只做了分类网络的实验 (ResNet50)
from torchvision import models
import torchvision.transforms as tf
from PIL import Image
import cv2 as cv
def image_tran(file_or_bgr, shape=None):
transform = [tf.Resize(shape) if shape else tf.Compose([]),
tf.ToTensor(),
tf.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])]
# 如果是文件, 则读取
if isinstance(file_or_bgr, str):
file_or_bgr = Image.open(file_or_bgr)
else:
file_or_bgr = file_or_bgr[..., ::-1].copy()
return tf.Compose(transform)(file_or_bgr)
class My_CAM(Grad_CAM):
def target(self, y, *args, **kwargs):
# 对于分类网络, 取每一张图片类别分数的最大值之和
max_score = y.max(dim=1)
return max_score[0].sum()
parent = Path(r'D:\Information\Python\Laboratory\data')
img_file = ['both.png', 'dog_cat.jfif', 'dogs.png']
model = models.resnet50(pretrained=True)
target_layers = [model.layer4]
with My_CAM(model, target_layers) as cam:
for img in img_file:
cam([str(parent / img)], image_tran)
以下四幅图分别为:原图、Guided Backprop、Grad-CAM、Guided Grad-CAM
在梯度图中,亮度高于背景的像素处为正梯度 (白色区),亮度低于背景的像素处为负梯度 (黑色区)
正梯度与负梯度的交界区 (或是正梯度的集中区) 表征了对“狗”这个类别有正增益的轮廓,而负梯度的集中区则表征了其它类别的轮廓