【图像分类】 可解释性神经网络(可视化):CAM/Grad-CAM pytorch相关代码

前言

神经网络往往被称为“黑盒”,Model学到的知识很难用人类可以理解的方式来提取和呈现。如何才能打开“黑盒”,一探究竟,因此有了神经网络的可解释性。目前,神经网络的可解释性主要有两大思路:

前处理:先数学理论证明,然后实验证明。
后处理:训练好的模型,通过可视化技术来理解模型的原理。
本文就一探可视化技术之CAM/Grad-CAM/Grad-CAM++。

类激活图(CAM,class activation map),可视化

Learning Deep Features for Discriminative Localization
论文地址

CAM是一张和原始图片等大小的图,指对输入图像生成类激活的热力图,可以理解为对预测输出的贡献分布,分数越高的地方表示原始图片对应区域对网络的响应越高、贡献越大,即**表示每个位置对该类别的重要程度。**如下图,颜色越深红的地方表示值越大,预测的类别分别“牙刷”和“伐树”。
在这里插入图片描述
CAM主要有以下作用:

有助于理解和分析神经网络的工作原理及决策过程,进而去更好地选择或设计网络。
利用可视化的信息引导网络更好的学习,例如可以利用CAM信息通过擦除裁剪的方式对数据进行增强。
利用CAM作为原始的种子,进行弱监督语义分割或弱监督定位。既然CAM能够cover到目标物体,所以可以仅利用分类标注来完成语义分割或目标检测任务,极大程度上降低了标注的工作量。

1.1 CAM的工作原理

卷积神经网络的卷积操作可以看做是滤波器对图片进行特征提取,通过滑动窗口的方式实现,因此特征图和输入图片存在空间上的对应关系。特征图的权重可以认为是被层层卷积核过滤后而保留的有效信息,**其值越大,表明特征越有效,对网络预测结果越重要。**一个深层的卷积神经网络,通过层层卷积操作,提取空间和语义信息。一般存在其他更难理解的层,例如分类的全连接层、softmax层等,很难以利用可视化的方式展示出来。所以,CAM的提取一般发生在卷积层,尤其是最后一层卷积。

CAM利用特征图权重叠加的原理获得热力图,公式如下:
在这里插入图片描述
其中,A 表示网络最后一层卷积层输出的大小,w 表示全连接层的权重大小,c 分类的类别。

1、假设输出为两个类别分别为猫和狗,最后一层卷积层有n个特征图 (最后一层卷积层特征图富含有最为丰富类别语义信息)。
在这里插入图片描述
2. 移除原始模型的全连接层,将最后一层卷积层的n个特征图做全局平均池化(GAP),得到n个神经元的全连接层,然后外接两个神经元的输出层重新训练(如上图所示)。

  1. 训练完后,输出层的两个神经元分别代表猫和狗的概率大小(实际上经过softmax才是概率)。假设我们要可视化某张图片中的猫是怎么进行识别的,取与猫对应神经元连接的n个权重,将这n个权重与最后一层卷积层的n个特征图进行相乘再相加,然后将得到的特征图进行上采样,得到与原始图像大小一致的图像,即为CAM。

**CAM的缺点:**需要修改网络结构并重新训练模型,导致在实际应用中非常不方便。

分别选用 resnet18、resnet50、densenet121 三种不同的模型,结合 hook 机制获取 CAM:

import numpy as np
from torchvision import models, transforms
import cv2
from PIL import Image
from torch.nn import functional as F

# 定义预训练模型: resnet18、resnet50、densenet121
resnet18 = models.resnet18(pretrained=True)
resnet50 = models.resnet50(pretrained=True)
densenet121 = models.densenet121(pretrained=True)
resnet18.eval()
resnet50.eval()
densenet121.eval()

# 图片数据转换
image_transform = transforms.Compose([
    # 将输入图片resize成统一尺寸
    transforms.Resize([224, 224]),
    # 将PIL Image或numpy.ndarray转换为tensor,并除255归一化到[0,1]之间
    transforms.ToTensor(),
    # 标准化处理-->转换为标准正太分布,使模型更容易收敛
    transforms.Normalize(
        mean=[0.485, 0.456, 0.406],
        std=[0.229, 0.224, 0.225]
    )
])

# =====注册hook start=====
feature_data = []


def feature_hook(model, input, output):
    feature_data.append(output.data.numpy())


resnet18._modules.get('layer4').register_forward_hook(feature_hook)
resnet50._modules.get('layer4').register_forward_hook(feature_hook)
densenet121._modules.get('features').register_forward_hook(feature_hook)
# =====注册hook end=====

# 获取fc层的权重
fc_weights_resnet18 = resnet18._modules.get('fc').weight.data.numpy()
fc_weights_resnet50 = resnet50._modules.get('fc').weight.data.numpy()
fc_weights_densenet121 = densenet121._modules.get('classifier').weight.data.numpy()

# 获取预测类别id
image = image_transform(Image.open("cat.jpg")).unsqueeze(0)
out_resnet18 = resnet18(image)
out_resnet50 = resnet50(image)
out_densenet121 = densenet121(image)
predict_classes_id_resnet18 = np.argmax(F.softmax(out_resnet18, dim=1).data.numpy())
predict_classes_id_resnet50 = np.argmax(F.softmax(out_resnet50, dim=1).data.numpy())
predict_classes_id_densenet121 = np.argmax(F.softmax(out_densenet121, dim=1).data.numpy())


# =====获取CAM start=====
def makeCAM(feature, weights, classes_id):
    print(feature.shape, weights.shape, classes_id)
    # batchsize, C, h, w
    bz, nc, h, w = feature.shape
    # (512,) @ (512, 7*7) = (49,)
    cam = weights[classes_id].dot(feature.reshape(nc, h * w))
    cam = cam.reshape(h, w)  # (7, 7)
    # 归一化到[0, 1]之间
    cam = (cam - cam.min()) / (cam.max() - cam.min())
    # 转换为0~255的灰度图
    cam_gray = np.uint8(255 * cam)
    # 最后,上采样操作,与网络输入的尺寸一致,并返回
    return cv2.resize(cam_gray, (224, 224))


cam_gray_resnet18 = makeCAM(feature_data[0], fc_weights_resnet18, predict_classes_id_resnet18)
cam_gray_resnet50 = makeCAM(feature_data[1], fc_weights_resnet50, predict_classes_id_resnet50)
cam_gray_densenet121 = makeCAM(feature_data[2], fc_weights_densenet121, predict_classes_id_densenet121)
# =====获取CAM start=====

# =====叠加CAM和原图,并保存图片=====
# 1)读取原图
src_image = cv2.imread("cat.jpg")
h, w, _ = src_image.shape
# 2)cam转换成与原图大小一致的彩色度(cv2.COLORMAP_HSV为彩色图的其中一种类型)
cam_color_resnet18 = cv2.applyColorMap(cv2.resize(cam_gray_resnet18, (w, h)),
                                       cv2.COLORMAP_HSV)
cam_color_resnet50 = cv2.applyColorMap(cv2.resize(cam_gray_resnet50, (w, h)),
                                       cv2.COLORMAP_HSV)
cam_color_densenet121 = cv2.applyColorMap(cv2.resize(cam_gray_densenet121, (w, h)),
                                          cv2.COLORMAP_HSV)
# 3)合并cam和原图,并保存
cam_resnet18 = src_image * 0.5 + cam_color_resnet18 * 0.5
cam_resnet50 = src_image * 0.5 + cam_color_resnet50 * 0.5
cam_densenet121 = src_image * 0.5 + cam_color_densenet121 * 0.5
cam_hstack = np.hstack((src_image, cam_resnet18, cam_resnet50, cam_densenet121))
cv2.imwrite("cam_hstack.jpg", cam_hstack)
# 可视化
Image.open("cam_hstack.jpg").show()

最终的可视化效果如下图所示:
在这里插入图片描述

2. 基于梯度的CAM(Grad-CAM)

Grad-CAM: Why did you say that? Visual Explanations from Deep Networks via Gradient-based Localization
论文地址
上文的局限性就是网络架构里必须有GAP层,但并不是所有模型都配GAP层的。而本文就是为克服该缺陷提出的,其基本思路是目标特征图的融合权重 w k c w^c_k wkc可以表达为梯度。另外,因为热图关心的是对分类有正面影响的特征,所以加上了relu以移除负值。其实并不一定要是分类问题,只要是可求导的激活函数,在其他问题也一样使用Grad-CAM。

在这里插入图片描述
如上图,为了获得类别判别图Grad-CAM,记为高度为 h h h、宽度为 w w w、类别为 c c c。首先利用(softmax之前的,logits)计算c这个类的梯度,定义特征图的激活值为 A k A^k Ak。这些回流的梯度在宽度和高度维度(分别由i和j索引)上全局平均池化,以获得神经元重要性权重 α k c \alpha^c_k αkc
在这里插入图片描述
在计算 α k c \alpha^c_k αkc 的同时,相对于激活反向传播梯度,精确的计算等于权重矩阵和梯度相对于激活函数的连续矩阵乘积,直到将梯度传播到的最终卷积层为止。因此,该权重 α k c \alpha^c_k αkc 代表A下游的深层网络的部分线性化,并捕获了特征图k对于目标类别c的“重要性”。

我们前向传播激活图的加权组合,然后通过ReLU来获得:
在这里插入图片描述
请注意,这会导致生成与卷积特征图大小相同的粗略热图(在VGG和AlexNet网络的最后一个卷积层的情况下为14×14)。本文将ReLU应用于特征图的线性组合,因为仅对对关注类别有积极影响的特征感兴趣,即,应该增加强度以增加 y c y^c yc的像素。负像素可能属于图像中的其他类别。不出所料,如果没有此ReLU,定位上有时会突出显示更多内容,而不仅仅是所需的类,并且在定位方面表现较差。

举一个例子:

1.假设我们还是两个类别:猫和狗,我们要可视化猫这个类别的GradCAM,首先通过softmax得到猫的概率,然后对最后一层卷积层的所有特征图求偏导,得到大小与最后一层卷积层大小一致的偏导矩阵。
2.将这偏导特征图做GAP(全局平均池化),得到一个权重向量,向量长度就是特征图数量。
3.将权重向量与特征图对应相乘再相加,此时得到一个二维的矩阵,宽高与特征图一致。
4.将这个二维矩阵送入Relu过一遍,把负数变成0。
5.最后,进行上采样,得到GradCAM。

grad-CAM 和 CAM的区别?
1.CAM 只能用于最后一层特征图和输出之间是GAP的操作,grad-CAM可适用非GAP连接的网络结构;
2.CAM只能提取最后一层特征图的热力图,而gard-CAM可以提取任意一层;

目标类别score y c y^c yc是用通过softmax之后的还是之前的?

论文原文中目标类别score是指网络未经过softmax的得分,但是某些代码实现当中也使用了通过softmax后的。二者有无区别?下面我们通过公式推导一下。因为这两种做法仅仅相差一个softmax,所以对softmax的求导。假设softmax层有C个输出,记为: [ a 1 , a 2 , . . . a c , . . . a C ] [a^1,a^2,...a^c,...a^C] [a1,a2,...ac,...aC] 。则softmax输出为:

在这里插入图片描述
则输出 a c a_c ac y c y_c yc 的偏导为,由于这里计算的是目标类别,所以仅需计算对应输出的偏导:
在这里插入图片描述
所以特征融合的权重则变成:
在这里插入图片描述
可以看出,二者的梯度差异就是softmax输出的多一项 a c ( 1 − a c ) a_c(1-a_c) ac(1ac) ,而对于已经训练好的网络,该项为定值。后面特征层进行加权求和,再归一化后,该项 a c ( 1 − a c ) a_c(1-a_c) ac(1ac) 会被消掉。所以通过softmax和不通过softmax在理论上完全一致。但是在实际应用中我发现,加上softmax后,权重 s o f t m a x − a k c softmax-a^c_k softmaxakc 会变得非常小,就是因为训练的充分好的网络,预测输出 a c a^c ac的值是非常接近1的,所以 a c ( 1 − a c ) a^c(1-a^c) ac(1ac) 的值非常非常小,存在丢失精度的风险。所以目标类别score建议使用不经过softmax的值。

pytorch 实现 Grad-CAM

这里只给出了 VGG 的实现方式,若想要进行修改,详细阅读模型复现源码进行修改,或者移步pytorch-cnn-visualizations,这里给出了比较多的可视化方法。

import torch
from torch.autograd import Variable
from torch.autograd import Function
from torchvision import models
from torchvision import utils
import cv2
import sys
import numpy as np
import argparse

class FeatureExtractor():
    """ Class for extracting activations and 
    registering gradients from targetted intermediate layers """
    def __init__(self, model, target_layers):
        self.model = model
        self.target_layers = target_layers
        self.gradients = []

    def save_gradient(self, grad):
        self.gradients.append(grad)

    def __call__(self, x):
        outputs = []
        self.gradients = []
        for name, module in self.model._modules.items():
            x = module(x)
            if name in self.target_layers:
                x.register_hook(self.save_gradient)
                outputs += [x]
        return outputs, x

class ModelOutputs():
    """ Class for making a forward pass, and getting:
    1. The network output.
    2. Activations from intermeddiate targetted layers.
    3. Gradients from intermeddiate targetted layers. """
    def __init__(self, model, target_layers):
        self.model = model
        self.feature_extractor = FeatureExtractor(self.model.features, target_layers)

    def get_gradients(self):
        return self.feature_extractor.gradients

    def __call__(self, x):
        target_activations, output  = self.feature_extractor(x)
        output = output.view(output.size(0), -1)
        output = self.model.classifier(output)
        return target_activations, output

def preprocess_image(img):
    means=[0.485, 0.456, 0.406]
    stds=[0.229, 0.224, 0.225]

    preprocessed_img = img.copy()[: , :, ::-1]
    for i in range(3):
        preprocessed_img[:, :, i] = preprocessed_img[:, :, i] - means[i]
        preprocessed_img[:, :, i] = preprocessed_img[:, :, i] / stds[i]
    preprocessed_img = \
        np.ascontiguousarray(np.transpose(preprocessed_img, (2, 0, 1)))
    preprocessed_img = torch.from_numpy(preprocessed_img)
    preprocessed_img.unsqueeze_(0)
    input = Variable(preprocessed_img, requires_grad = True)
    return input

def show_cam_on_image(img, mask):
    heatmap = cv2.applyColorMap(np.uint8(255*mask), cv2.COLORMAP_JET)
    heatmap = np.float32(heatmap) / 255
    cam = heatmap + np.float32(img)
    cam = cam / np.max(cam)
    cv2.imwrite("../../images/cam01.jpg", np.uint8(255 * cam))

class GradCam:
    def __init__(self, model, target_layer_names, use_cuda):
        self.model = model
        self.model.eval()
        self.cuda = use_cuda
        if self.cuda:
            self.model = model.cuda()

        self.extractor = ModelOutputs(self.model, target_layer_names)

    def forward(self, input):
        return self.model(input) 

    def __call__(self, input, index = None):
        if self.cuda:
            features, output = self.extractor(input.cuda())
        else:
            features, output = self.extractor(input)

        if index == None:
            index = np.argmax(output.cpu().data.numpy())

        one_hot = np.zeros((1, output.size()[-1]), dtype = np.float32)
        one_hot[0][index] = 1
        one_hot = Variable(torch.from_numpy(one_hot), requires_grad = True)
        if self.cuda:
            one_hot = torch.sum(one_hot.cuda() * output)
        else:
            one_hot = torch.sum(one_hot * output)

        self.model.features.zero_grad()
        self.model.classifier.zero_grad()
        #one_hot.backward(retain_variables=True)
        one_hot.backward()
        grads_val = self.extractor.get_gradients()[-1].cpu().data.numpy()

        target = features[-1]
        target = target.cpu().data.numpy()[0, :]

        weights = np.mean(grads_val, axis = (2, 3))[0, :]
        cam = np.zeros(target.shape[1 : ], dtype = np.float32)

        for i, w in enumerate(weights):
            cam += w * target[i, :, :]

        cam = np.maximum(cam, 0)
        cam = cv2.resize(cam, (224, 224))
        cam = cam - np.min(cam)
        cam = cam / np.max(cam)
        return cam

class GuidedBackpropReLU(Function):

    def forward(self, input):
        positive_mask = (input > 0).type_as(input)
        output = torch.addcmul(torch.zeros(input.size()).type_as(input), input, positive_mask)
        self.save_for_backward(input, output)
        return output

    def backward(self, grad_output):
        input, output = self.saved_tensors
        grad_input = None

        positive_mask_1 = (input > 0).type_as(grad_output)
        positive_mask_2 = (grad_output > 0).type_as(grad_output)
        grad_input = torch.addcmul(torch.zeros(input.size()).type_as(input), torch.addcmul(torch.zeros(input.size()).type_as(input), grad_output, positive_mask_1), positive_mask_2)

        return grad_input

class GuidedBackpropReLUModel:
    def __init__(self, model, use_cuda):
        self.model = model
        self.model.eval()
        self.cuda = use_cuda
        if self.cuda:
            self.model = model.cuda()

        # replace ReLU with GuidedBackpropReLU
        for idx, module in self.model.features._modules.items():
            if module.__class__.__name__ == 'ReLU':
                self.model.features._modules[idx] = GuidedBackpropReLU()

    def forward(self, input):
        return self.model(input)

    def __call__(self, input, index = None):
        if self.cuda:
            output = self.forward(input.cuda())
        else:
            output = self.forward(input)

        if index == None:
            index = np.argmax(output.cpu().data.numpy())

        one_hot = np.zeros((1, output.size()[-1]), dtype = np.float32)
        one_hot[0][index] = 1
        one_hot = Variable(torch.from_numpy(one_hot), requires_grad = True)
        if self.cuda:
            one_hot = torch.sum(one_hot.cuda() * output)
        else:
            one_hot = torch.sum(one_hot * output)

        # self.model.features.zero_grad()
        # self.model.classifier.zero_grad()
        one_hot.backward()

        output = input.grad.cpu().data.numpy()
        output = output[0,:,:,:]

        return output

if __name__ == '__main__':
    """ python grad_cam.py <path_to_image>
    1. Loads an image with opencv.
    2. Preprocesses it for VGG19 and converts to a pytorch variable.
    3. Makes a forward pass to find the category index with the highest score,
    and computes intermediate activations.
    Makes the visualization. """

    image_path = "../../images/dog-cat.jpg"

    # Can work with any model, but it assumes that the model has a 
    # feature method, and a classifier method,
    # as in the VGG models in torchvision.
    grad_cam = GradCam(model = models.vgg19(pretrained=True), \
                    target_layer_names = ["35"], use_cuda=True)

    img = cv2.imread(image_path, 1)
    img = np.float32(cv2.resize(img, (224, 224))) / 255
    input = preprocess_image(img)

    # If None, returns the map for the highest scoring category.
    # Otherwise, targets the requested index.
    target_index = None

    mask = grad_cam(input, target_index)

    show_cam_on_image(img, mask)

    gb_model = GuidedBackpropReLUModel(model = models.vgg19(pretrained=True), use_cuda=True)
    gb = gb_model(input, index=target_index)
    utils.save_image(torch.from_numpy(gb), '../../images/gb.jpg')

    cam_mask = np.zeros(gb.shape)
    for i in range(0, gb.shape[0]):
        cam_mask[i, :, :] = mask

    cam_gb = np.multiply(cam_mask, gb)
    utils.save_image(torch.from_numpy(cam_gb), '../../images/cam_gb.jpg')

在这里插入图片描述

  • 26
    点赞
  • 104
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TechMasterPlus

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值