前言
神经网络往往被称为“黑盒”,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个神经元的全连接层,然后外接两个神经元的输出层重新训练(如上图所示)。
- 训练完后,输出层的两个神经元分别代表猫和狗的概率大小(实际上经过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(1−ac) ,而对于已经训练好的网络,该项为定值。后面特征层进行加权求和,再归一化后,该项
a
c
(
1
−
a
c
)
a_c(1-a_c)
ac(1−ac) 会被消掉。所以通过softmax和不通过softmax在理论上完全一致。但是在实际应用中我发现,加上softmax后,权重
s
o
f
t
m
a
x
−
a
k
c
softmax-a^c_k
softmax−akc 会变得非常小,就是因为训练的充分好的网络,预测输出
a
c
a^c
ac的值是非常接近1的,所以
a
c
(
1
−
a
c
)
a^c(1-a^c)
ac(1−ac) 的值非常非常小,存在丢失精度的风险。所以目标类别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')