Grad-CAM热力图:基本原理、代码实现及常见问题处理

写在前面:本博客仅作记录学习之用,部分图片来自网络,如需引用请注明出处,同时如有侵犯您的权益,请联系删除!


前言

在人工智能与机器学习迅猛发展的今天,深度神经网络虽在诸多领域大放异彩,但其“黑箱”特性常让人望而却步——模型如何做出决策?哪些特征起了关键作用? 特别是基于CNN的模型,在计算机视觉任务中取得了显著突破,但这些模型往往缺乏可解释性,导致在失败时难以理解其原因。

而热力图,作为一把揭开“黑箱”之谜的钥匙,正逐渐成为可解释性AI领域的研究热点。它通过直观的颜色编码,直观呈现模型对输入特征的关注分布,将模型对输入数据的关注程度可视化,帮助理解模型决策背后的逻辑。为神经网络的透明化部署与鲁棒性提升提供更强支撑。


预备知识


论文名: Learning Deep Features for Discriminative Localization

论文速递: 点我转跳哦

代码通道: GitHub


论文名: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization

论文速递: 点我转跳哦

代码通道: GitHub


论文《Learning Deep Features for Discriminative Localization》提出了类激活映射(Class Activation Mapping, CAM)技术,通过将输出层的权重反向投影到最后一个卷积层的特征图上,生成类激活图,突出显示对分类贡献最大的图像区域,提高模型的透明度和可解释性。

在这里插入图片描述

缺点:依赖于全局平均池化层作为CNN实现判别性定位的关键组件,必须在最终输出层之前(分类默认softmax)。


那中间层怎么办呢?
不是分类又该怎么办呢?


因此《Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization》主要提出了一种名为Grad-CAM(梯度加权类激活映射)的技术,用于为基于卷积神经网络(CNN)的模型生成视觉解释。通过计算目标类别分数相对于卷积层特征图激活的梯度,并对这些梯度进行全局平均,得到神经元重要性权重,进一步与特征图进行加权组合,并通过ReLU激活函数,生成最终的Grad-CAM定位图。

在这里插入图片描述

优点:Grad-CAM适用于多种CNN模型,包括带有全连接层的CNN、用于结构化输出的CNN,以及用于多模态输入或强化学习的CNN,无需架构更改或重新训练。


Grad-CAM

代码

网络以黑箱特点被炼丹师所熟知,其通常由众多的线性操作(如卷积)和非线性单元(如激活函数),再加上其参数多依赖于梯度下降而自动学习,进一步设计的损失函数很难保证其是凸优化问题,因此很难通过数学方法去精确描述,因此需要其他方式进行说明。

Grad-CAM:简言之在前向传播得到网络输出后,根据特定类别的分类特征进行损失计算,进一步通过反向传播获取对应特征层的梯度,进一步进行加权求和,在利用COLORMAP_JET做可视化,以不同颜色图像网络的关注点。以下代码参考自太阳花的小绿豆 | 霹雳吧啦Wz

直观的感受:对于分类任务而言,网络输出存在不同的类别,因此在反向传播时可针对某一类进行求偏导,即进行反向传播。通过链式求导法则可求导到任意层,通过求到的梯度的大小,或者说梯度系数来表示网络的关注点。通过对应梯度对通道特征进行加权,并缩放到输入图像尺寸,将网络的关注点直观展示出来。

非分类任务,又该如何实现Grad-CAM呢?


以下代码为分类任务:

import os
import cv2
import torch
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
from torchvision import models
from torchvision import transforms

class ActivationsAndGradients:
    """ Class for extracting activations and
    registering gradients from targeted intermediate layers """

    def __init__(self, model, target_layers, reshape_transform):
        self.model = model
        self.gradients = []
        self.activations = []
        self.reshape_transform = reshape_transform
        self.handles = []
        for target_layer in target_layers:
            self.handles.append(
                target_layer.register_forward_hook(
                    self.save_activation))
            # Backward compatibility with older pytorch versions:
            if hasattr(target_layer, 'register_full_backward_hook'):
                self.handles.append(
                    target_layer.register_full_backward_hook(
                        self.save_gradient))
            else:
                self.handles.append(
                    target_layer.register_backward_hook(
                        self.save_gradient))

    def save_activation(self, module, input, output):
        activation = output
        if self.reshape_transform is not None:
            activation = self.reshape_transform(activation)
        self.activations.append(activation.cpu().detach())

    def save_gradient(self, module, grad_input, grad_output):
        # Gradients are computed in reverse order
        grad = grad_output[0]
        if self.reshape_transform is not None:
            grad = self.reshape_transform(grad)
        self.gradients = [grad.cpu().detach()] + self.gradients

    def __call__(self, x):
        self.gradients = []
        self.activations = []
        return self.model(x)

    def release(self):
        for handle in self.handles:
            handle.remove()


class GradCAM:
    def __init__(self,
                 model,
                 target_layers,
                 reshape_transform=None,
                 use_cuda=False):
        self.model = model.eval()
        self.target_layers = target_layers
        self.reshape_transform = reshape_transform
        self.cuda = use_cuda
        if self.cuda:
            self.model = model.cuda()
        self.activations_and_grads = ActivationsAndGradients(
            self.model, target_layers, reshape_transform)

    """ Get a vector of weights for every channel in the target layer.
        Methods that return weights channels,
        will typically need to only implement this function. """

    @staticmethod
    def get_cam_weights(grads):
        return np.mean(grads, axis=(2, 3), keepdims=True)

    @staticmethod
    def get_loss(output, target_category):
        loss = 0
        for i in range(len(target_category)):
            loss = loss + output[i, target_category[i]]
        return loss

    def get_cam_image(self, activations, grads):
        weights = self.get_cam_weights(grads)
        weighted_activations = weights * activations
        cam = weighted_activations.sum(axis=1)
        return cam

    @staticmethod
    def get_target_width_height(input_tensor):
        width, height = input_tensor.size(-1), input_tensor.size(-2)
        return width, height

    def compute_cam_per_layer(self, input_tensor):
        activations_list = [a.cpu().data.numpy()
                            for a in self.activations_and_grads.activations]
        grads_list = [g.cpu().data.numpy()
                      for g in self.activations_and_grads.gradients]
        target_size = self.get_target_width_height(input_tensor)

        cam_per_target_layer = []
        # Loop over the saliency image from every layer

        for layer_activations, layer_grads in zip(activations_list, grads_list):
            cam = self.get_cam_image(layer_activations, layer_grads)
            cam[cam < 0] = 0  # works like mute the min-max scale in the function of scale_cam_image
            scaled = self.scale_cam_image(cam, target_size)
            cam_per_target_layer.append(scaled[:, None, :])

        return cam_per_target_layer

    def aggregate_multi_layers(self, cam_per_target_layer):
        cam_per_target_layer = np.concatenate(cam_per_target_layer, axis=1)
        cam_per_target_layer = np.maximum(cam_per_target_layer, 0)
        result = np.mean(cam_per_target_layer, axis=1)
        return self.scale_cam_image(result)

    @staticmethod
    def scale_cam_image(cam, target_size=None):
        result = []
        for img in cam:
            img = img - np.min(img)
            img = img / (1e-7 + np.max(img))
            if target_size is not None:
                img = cv2.resize(img, target_size)
            result.append(img)
        result = np.float32(result)
        return result

    def __call__(self, input_tensor, target_category=None):
        if self.cuda:
            input_tensor = input_tensor.cuda()

        # 正向传播得到网络输出logits(未经过softmax)
        output = self.activations_and_grads(input_tensor)
        if isinstance(target_category, int):
            target_category = [target_category] * input_tensor.size(0)

        if target_category is None:
            target_category = np.argmax(output.cpu().data.numpy(), axis=-1)
            print(f"category id: {target_category}")
        else:
            assert (len(target_category) == input_tensor.size(0))

        self.model.zero_grad()
        loss = self.get_loss(output, target_category)
        loss.backward(retain_graph=True)

        cam_per_layer = self.compute_cam_per_layer(input_tensor)
        return self.aggregate_multi_layers(cam_per_layer)

    def __del__(self):
        self.activations_and_grads.release()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, exc_tb):
        self.activations_and_grads.release()
        if isinstance(exc_value, IndexError):
            # Handle IndexError here...
            print(
                f"An exception occurred in CAM with block: {exc_type}. Message: {exc_value}")
            return True


def show_cam_on_image(img: np.ndarray,
                      mask: np.ndarray,
                      use_rgb: bool = False,
                      colormap: int = cv2.COLORMAP_JET) -> np.ndarray:
    """ This function overlays the cam mask on the image as an heatmap.
    By default the heatmap is in BGR format.

    :param img: The base image in RGB or BGR format.
    :param mask: The cam mask.
    :param use_rgb: Whether to use an RGB or BGR heatmap, this should be set to True if 'img' is in RGB format.
    :param colormap: The OpenCV colormap to be used.
    :returns: The default image with the cam overlay.
    """

    heatmap = cv2.applyColorMap(np.uint8(255 * mask), colormap)
    if use_rgb:
        heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
    heatmap = np.float32(heatmap) / 255

    if np.max(img) > 1:
        raise Exception(
            "The input image should np.float32 in the range [0, 1]")

    cam = heatmap + img
    cam = cam / np.max(cam)
    return np.uint8(255 * cam)


def center_crop_img(img: np.ndarray, size: int):
    h, w, c = img.shape

    if w == h == size:
        return img

    if w < h:
        ratio = size / w
        new_w = size
        new_h = int(h * ratio)
    else:
        ratio = size / h
        new_h = size
        new_w = int(w * ratio)

    img = cv2.resize(img, dsize=(new_w, new_h))

    if new_w == size:
        h = (new_h - size) // 2
        img = img[h: h+size]
    else:
        w = (new_w - size) // 2
        img = img[:, w: w+size]
    return img

def main():
    model = models.mobilenet_v3_large(pretrained=True)
    target_layers = [model.features[-1]]
    data_transform = transforms.Compose([transforms.ToTensor(),
                                         transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
    # load image
    img_path = "test.png"
    assert os.path.exists(img_path), "file: '{}' dose not exist.".format(img_path)
    img = Image.open(img_path).convert('RGB')
    img = np.array(img, dtype=np.uint8)
    # img = center_crop_img(img, 224)

    # [C, H, W]
    img_tensor = data_transform(img)
    # expand batch dimension
    # [C, H, W] -> [N, C, H, W]
    input_tensor = torch.unsqueeze(img_tensor, dim=0)

    cam = GradCAM(model=model, target_layers=target_layers, use_cuda=False)
    target_category = 281  # tabby, tabby cat
    grayscale_cam = cam(input_tensor=input_tensor, target_category=target_category)

    grayscale_cam = grayscale_cam[0, :]
    visualization = show_cam_on_image(img.astype(dtype=np.float32) / 255.,
                                      grayscale_cam,
                                      use_rgb=True)
    plt.imshow(visualization)
    plt.show()

if __name__ == '__main__':
    main()

  • 上述内容调用时的预训练好的模型,如果换成自己代码,则需要实例化模型,可参考训练和测试时的模型实例化,并加载对应的权重。
  • target_layers 的选取,可先打印网络结构后进行选择,并非只能最后卷积层的输出。
  • target_category的选取,和使用的数据集有关,如Imagenet数据上预训练好的模型,id则是Imagenet数据中1000个类别的id。

注意事项

色彩相反

由于要将热力图叠加到原图上,有时候会出现色彩相反,可考虑图像的RGB顺序不对,由于OpenCV读取默认是BGR,而PIL、plt,np所使用的RGB,因此需要检查是否通道顺序不对。

在下列代码中,可见heatmap 默认BGR,如需要将其转化为RGB,在调用函数show_cam_on_image时将use_rgb设置未True。

def show_cam_on_image(img: np.ndarray,
                      mask: np.ndarray,
                      use_rgb: bool = False,
                      colormap: int = cv2.COLORMAP_JET) -> np.ndarray:
    heatmap = cv2.applyColorMap(np.uint8(255 * mask), colormap)
    if use_rgb:
        heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
    heatmap = np.float32(heatmap) / 255

    if np.max(img) > 1:
        raise Exception(
            "The input image should np.float32 in the range [0, 1]")

    cam = heatmap + img
    cam = cam / np.max(cam)
    return np.uint8(255 * cam)

颜色溢出

COLORMAP_JET的颜色分布如下,值越大越红,反之越蓝,意味着暖色调的部分是关注的地方。
在这里插入图片描述

在RGB的图像叠加中,如果对于原图某通道在一定范围内等于255,在后续处理中叠加热力图的时候可能存在其他颜色,如粉红色或者其他区别于COLORMAP_JET的色彩,如果有这种情况,则需要降低两者叠加的比例,即cam = A*heatmap + B*img,其中A、B的值可酌情调整,或者满足A+B=1

非分类任务

以下内容仅供参考(若有不对请指出):

对于非分类任务而言,在反向传播时不可能针对某一类进行求偏导,此时需要根据任务需要进行损失计算并反向传播,并通过对应梯度对通道特征进行加权,此时修改下列函数修改损失替换方式。

def get_loss(output, target_category):
    loss = your_loss
    return loss

并且随着网络深度增加,特征层抽象程度越高,既然是非分类任务,也无需特定层的特征进行特定加权,或者说一视同仁,所有的通道权重相等,在通道进行平均,进而去描述网络总体关注的地方,而非针对莫一类的关注点,此时Grad-CAM退化成类CAM的算法,只不过此时不依赖于于全局平均池化和最终输出层之前。此时修改下列函数的权重为1即可。

def get_cam_image(self, activations, grads):
    weights = 1
    weighted_activations = weights * activations
    cam = weighted_activations.sum(axis=1)
    return cam

总结

总结: 本文探讨了深度神经网络可解释性中的热力图可视化技术,详细解析了Grad-CAM的实现原理,即通过反向传播获取目标类别的梯度信息,对特征图进行加权融合,最终生成可视化热力图。代码部分展示了如何提取中间层激活与梯度,通过梯度加权实现分类任务的热力图生成,最后提供了常见显示异常和非分类任务的可视化问题的解决方案。


致谢

欲尽善本文,因所视短浅,怎奈所书皆是瞽言蒭议。行文至此,诚向予助与余者致以谢意。


参考

[1] 太阳花的小绿豆
[2] 霹雳吧啦Wz
[3] Learning Deep Features for Discriminative Localization
[4] Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization

发表评论

滚动至顶部