人们常说,神经网络模型就像一个“黑盒”,这对一些神经网络模型确实如此,不过卷积神经网络,在可视化方面取得长足进步,我们可以看到卷积的中间结果、可视化不同的卷积核、可视化图像中类激活的热力图(决定类分类的关键区域)。
这三种方法的具体内容为:
1、卷积核输出的可视化(Visualizing intermediate convnet outputs (intermediate activations),即可视化卷积核经过激活之后的结果。能够看到图像经过卷积之后结果,帮助理解卷积核的作用
2、卷积核的可视化(Visualizing convnets filters),帮助我们理解卷积核是如何感受图像的。
3、热度图可视化(Visualizing heatmaps of class activation in an image),通过热度图,了解图像分类问题中图像哪些部分起到了关键作用,同时可以定位图像中物体的位置。
22.1 可视化中间结果
可视化中间结果,是指对于给定输入,展示网络中各个卷积层和池化层输出的特征图(层的输出通常是激活函数的输出,故又称为该层的激活)。
每个通道上特征图是相对独立的,我们可以将这些特征图可视化的正确方法是将每个通道的内容分别绘制成二维图像。
1)可视化下例模型的中间输出
本章模型cats_and_dogs_small_2.h5、图像cat.1700.jpg、creative_commons_elephant.jpg
下载地址
1 2 3 4 |
from keras.models import load_model model = load_model('cats_and_dogs_small_2.h5') model.summary() # As a reminder. |
运行结果
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
conv2d_13 (Conv2D) (None, 148, 148, 32) 896
_________________________________________________________________
max_pooling2d_13 (MaxPooling (None, 74, 74, 32) 0
_________________________________________________________________
conv2d_14 (Conv2D) (None, 72, 72, 64) 18496
_________________________________________________________________
max_pooling2d_14 (MaxPooling (None, 36, 36, 64) 0
_________________________________________________________________
conv2d_15 (Conv2D) (None, 34, 34, 128) 73856
_________________________________________________________________
max_pooling2d_15 (MaxPooling (None, 17, 17, 128) 0
_________________________________________________________________
conv2d_16 (Conv2D) (None, 15, 15, 128) 147584
_________________________________________________________________
max_pooling2d_16 (MaxPooling (None, 7, 7, 128) 0
_________________________________________________________________
flatten_5 (Flatten) (None, 6272) 0
_________________________________________________________________
dropout_5 (Dropout) (None, 6272) 0
_________________________________________________________________
dense_13 (Dense) (None, 512) 3211776
_________________________________________________________________
dense_14 (Dense) (None, 1) 513
=================================================================
Total params: 3,453,121
Trainable params: 3,453,121
Non-trainable params: 0
2)获取测试数据中的一张图像
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
img_path = './cats_and_dogs_small/test/cats/cat.1700.jpg' # We preprocess the image into a 4D tensor from keras.preprocessing import image import numpy as np img = image.load_img(img_path, target_size=(150, 150)) img_tensor = image.img_to_array(img) img_tensor = np.expand_dims(img_tensor, axis=0) # Remember that the model was trained on inputs # that were preprocessed in the following way: img_tensor /= 255. # Its shape is (1, 150, 150, 3) print(img_tensor.shape) |
运行结果
(1, 150, 150, 3)
转换为3D
1 |
img_tensor[0].shape |
(150, 150, 3)
3)可视化这个图像
1 2 3 4 |
import matplotlib.pyplot as plt plt.imshow(img_tensor[0]) plt.show() |
4)抽取前8层的特征图或激活输出
1 2 3 4 5 6 |
from keras import models # Extracts the outputs of the top 8 layers: layer_outputs = [layer.output for layer in model.layers[:8]] # Creates a model that will return these outputs, given the model input: activation_model = models.Model(inputs=model.input, outputs=layer_outputs) |
5) 返回8个Numpy数组组成的列表,每个激活输出对应一个Numpy数组
1 2 3 4 5 6 |
# This will return a list of 8 Numpy arrays: # one array per layer activation activations = activation_model.predict(img_tensor) first_layer_activation = activations[0] print(first_layer_activation.shape) |
(1, 148, 148, 32)
6) 查看第一层,第4个通道的激活输出图像
1 2 3 4 |
mport matplotlib.pyplot as plt plt.matshow(first_layer_activation[0, :, :, 4], cmap='viridis') plt.show() |
第4通道似乎是对角边缘检测器。
查看第7个通道的输出图像
1 2 |
plt.matshow(first_layer_activation[0, :, :, 7], cmap='viridis') plt.show() |
第7通道似乎是圆点检测器,这对寻找猫眼睛非常有帮助。
7)把各通道组合成一个完整图形
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
import keras # These are the names of the layers, so can have them as part of our plot layer_names = [] for layer in model.layers[:8]: layer_names.append(layer.name) images_per_row = 16 # Now let's display our feature maps for layer_name, layer_activation in zip(layer_names, activations): # This is the number of features in the feature map n_features = layer_activation.shape[-1] # The feature map has shape (1, size, size, n_features) size = layer_activation.shape[1] # We will tile the activation channels in this matrix n_cols = n_features // images_per_row display_grid = np.zeros((size * n_cols, images_per_row * size)) # We'll tile each filter into this big horizontal grid for col in range(n_cols): for row in range(images_per_row): channel_image = layer_activation[0, :, :, col * images_per_row + row] # Post-process the feature to make it visually palatable channel_image -= channel_image.mean() channel_image /= channel_image.std() channel_image *= 64 channel_image += 128 channel_image = np.clip(channel_image, 0, 255).astype('uint8') display_grid[col * size : (col + 1) * size, row * size : (row + 1) * size] = channel_image # Display the grid scale = 1. / size plt.figure(figsize=(scale * display_grid.shape[1], scale * display_grid.shape[0])) plt.title(layer_name) plt.grid(False) plt.imshow(display_grid, aspect='auto', cmap='viridis') plt.show() |
上图从第1层到第8层,各通道的拼接图,从这些拼接图可以看出:
①第一层是各种边缘探测器的集合。在这一阶段,激活几乎保留了原始图像中的所有信息。
②随着层数的加深,激活变得越来越抽象,并且越来越难以直观理解。层数越深,关于图像视觉内容的信息越少,而关于类别的信息就越多。
③激活的稀疏性随着层数的加深而增大。
22.2 可视化卷积网络的过滤器
参考:https://blog.csdn.net/weiwei9363/article/details/79112872
https://www.jianshu.com/p/fb3add126da1
卷积核到底是如何识别物体的呢?想要解决这个问题,有一个方法就是去了解卷积核最感兴趣的图像是怎样的。我们知道,卷积的过程就是特征提取的过程,每一个卷积核代表着一种特征。如果图像中某块区域与某个卷积核的结果越大,那么该区域就越“像”该卷积核。
基于以上的推论,如果我们找到一张图像,能够使得这张图像对某个卷积核的输出最大,那么我们就说找到了该卷积核最感兴趣的图像。
具体思路:输入一张随机内容的图像I, 求某个卷积核F对图像的梯度 G=∂F/∂I,用梯度上升的方法迭代更新图像 I=I+η∗G,η为学习率。
我们可以从空白输入图像开始,将梯度下降应用于卷积神经网络输入图像的值,让某个过滤器的响应最大化。这样得到的输入图像就是选定过滤器具有最大响应的图形。
具体过程,
1)先构建一个损失函数,让某个卷积层的某个过滤器作用输入图像的激活值最大化;
2)使用随机梯度下降来调节输入图像的值,以便让这个激活值最大化。
以下我们以VGG16网络的block3_conv1层的第0个过滤器为例,
(1)首先构建有关过滤器激活值的损失函数。
1 2 3 4 5 6 7 8 9 10 11 |
from keras.applications import VGG16 from keras import backend as K model = VGG16(weights='imagenet', include_top=False) layer_name = 'block3_conv1' filter_index = 0 layer_output = model.get_layer(layer_name).output loss = K.mean(layer_output[:, :, :, filter_index]) |
(2)为了求相对于模型输入loss的梯度,可以使用keras的backend模块内置的gradients函数。
1 |
grads = K.gradients(loss, model.input)[0] |
为便于计算梯度,使梯度用L2进行标准化处理
1 2 |
# We add 1e-5 before dividing so as to avoid accidentally dividing by 0. grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5) |
(3)利用keras后端函数计算loss及梯度
利用keras后端函数,可以根据一个输入图像,计算损失张量和梯度张量的值。
1 2 3 4 5 |
output = K.function([model.input], [loss, grads]) # Let's test it: import numpy as np loss_value, grads_value = output([np.zeros((1, 150, 150, 3))]) |
利用一个循环进行随机梯度下降,从而更新梯度
1 2 3 4 5 6 7 8 9 10 |
# We start from a gray image with some noise input_img_data = np.random.random((1, 150, 150, 3)) * 20 + 128. # Run gradient ascent for 40 steps step = 1. # this is the magnitude of each gradient update for i in range(40): # Compute the loss value and gradient value loss_value, grads_value = output([input_img_data]) # Here we adjust the input image in the direction that maximizes the loss input_img_data += grads_value * step |
为便于可视化,对输入图像进行预处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def deprocess_image(x): # normalize tensor: center on 0., ensure std is 0.1 x -= x.mean() x /= (x.std() + 1e-5) x *= 0.1 # clip to [0, 1] x += 0.5 x = np.clip(x, 0, 1) # convert to RGB array x *= 255 x = np.clip(x, 0, 255).astype('uint8') return x |
(4)将以上代码整合到一个函数中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
def generate_pattern(layer_name, filter_index, size=150): # Build a loss function that maximizes the activation # of the nth filter of the layer considered. layer_output = model.get_layer(layer_name).output loss = K.mean(layer_output[:, :, :, filter_index]) # Compute the gradient of the input picture wrt this loss grads = K.gradients(loss, model.input)[0] # Normalization trick: we normalize the gradient grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5) # This function returns the loss and grads given the input picture iterate = K.function([model.input], [loss, grads]) # We start from a gray image with some noise input_img_data = np.random.random((1, size, size, 3)) * 20 + 128. # Run gradient ascent for 40 steps step = 1. for i in range(40): loss_value, grads_value = iterate([input_img_data]) input_img_data += grads_value * step img = input_img_data[0] return deprocess_image(img) |
(5)可视化每一层前64个卷积核
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
for layer_name in ['block1_conv1', 'block2_conv1', 'block3_conv1', 'block4_conv1']: size = 64 margin = 5 # This a empty (black) image where we will store our results. results = np.zeros((8 * size + 7 * margin, 8 * size + 7 * margin, 3)) for i in range(8): # iterate over the rows of our results grid for j in range(8): # iterate over the columns of our results grid # Generate the pattern for filter `i + (j * 8)` in `layer_name` filter_img = generate_pattern(layer_name, i + (j * 8), size=size) # Put the result in the square `(i, j)` of the results grid horizontal_start = i * size + i * margin horizontal_end = horizontal_start + size vertical_start = j * size + j * margin vertical_end = vertical_start + size results[horizontal_start: horizontal_end, vertical_start: vertical_end, :] = filter_img # Display the results grid plt.figure(figsize=(20, 20)) plt.imshow(results) plt.show() |
block1_conv1 层前64个过滤器模式
block2_conv1 前64个过滤器模式
block3-conv1 前64过滤器模式
block4-conv1 前64个过滤器模式
结论:
低层的卷积核似乎对颜色,边缘信息感兴趣。
越高层的卷积核,感兴趣的内容越抽象(非常魔幻啊),也越复杂。
高层的卷积核感兴趣的图像越来越难通过梯度上升获得(block5_conv1有很多还是随机噪声的图像)
22.3 可视化类激活的热力图
1)CAM
在介绍Grad-CAM,Grad- Class Activation Mapping)之前,我们先介绍一下CAM。
我们日常生活中讲的热力图,是根据动物散发热量而形成的图形,图1中动物或人因为散发出热量,所以能够清楚的被看到。
图1
这次我们讲的深度学习中的类激活的热力图与此类似。
对一个深层的卷积神经网络而言,通过多次卷积和池化以后,它的最后一层卷积层包含了最丰富的空间和语义信息,再往下就是全连接层和softmax层了,如图2,其中所包含的信息都是难以理解的,难以可视化的方式展示出来。如果要让卷积神经网络的对其分类结果给出一个合理解释,充分利用好最后一个卷积层是关键。
图2
CAM借鉴了很著名的论文Network in Network中的思路,利用GAP(Global Average Pooling)替换掉了全连接层。可以把GAP视为一个特殊的average pool层,只不过其pool size和整个特征图一样大,其实就是求每张特征图所有像素的均值。具体可参考图3
图3
图4
GAP(参考图4)的优点在NIN的论文中说的很明确了:由于没有了全连接层,输入就不用固定大小了,因此可支持任意大小的输入;此外,引入GAP更充分的利用了空间信息,且没有了全连接层的各种参数,鲁棒性强,也不容易产生过拟合;还有很重要的一点是,在最后的 mlpconv层(也就是最后一层卷积层)强制生成了和目标类别数量一致的特征图,经过GAP以后再通过softmax层得到结果,这样做就给每个特征图赋予了很明确的意义。
我们重点看下经过GAP之后与输出层的连接关系(暂不考虑softmax层),实质上也是就是个全连接层,只不过没有了偏置项,如图4所示:
图5
从图5中可以看到,经过GAP之后,我们得到了最后一个卷积层每个特征图的均值,通过加权和得到输出(实际中是softmax层的输入)。需要注意的是,对每一个类别C,每个特征图k的均值都有一个对应的w,记为ω_k^c。CAM的基本结构就是这样了,下面就是和普通的CNN模型一样训练就可以了。训练完成后才是重头戏:我们如何得到一个用于解释分类结果的热力图呢?其实非常简单,比如说我们要解释为什么分类的结果是羊驼,我们把羊驼这个类别对应的所有ω_k^c取出来,求出它们与自己对应的特征图的加权和即可。由于这个结果的大小和特征图是一致的,我们需要对它进行上采样,叠加到原图上去,如图6所示。
图6
这样,CAM以热力图的形式告诉了我们,模型通过哪些像素确定这个图片是羊驼了
2)Grad-CAM
前面我们简单介绍了CAM,CAM的解释效果已经很不错了,但是它有一个不足,就是它要求修改原模型的结构,导致需要重新训练该模型,这大大限制了它的使用场景。如果模型已经上线了,或着训练的成本非常高,我们几乎是不可能为了它重新训练的。为了解决这个问题,人们就提出了Grad-CAM。
Grad-CAM的基本思路和CAM是一致的,也是通过得到每对特征图对应的权重,最后求一个加权和。但是它与CAM的主要区别在于求权重ω_k^c的过程。CAM通过替换全连接层为GAP层,重新训练得到权重,而Grad-CAM另辟蹊径,用梯度的全局平均来计算权重。事实上,经过严格的数学推导,Grad-CAM与CAM计算出来的权重是等价的。为了和CAM的权重做区分,定义Grad-CAM中第k个特征图对类别c的权重为ω_k^c,可通过下面的公式计算:
其中,Z为特征图的像素个数,y^c是对应类别c的分数(在代码中一般用logits表示,是输入softmax层之前的值),A_ij^k表示第k个特征图中,(i,j)位置处的像素值。求得类别对所有特征图的权重后,求其加权和就可以得到热力图。
Grad-CAM的整体结构如下图所示:
图7
注意这里和CAM的另一个区别是,Grad-CAM对最终的加权和加了一个ReLU,加这么一层ReLU的原因在于我们只关心对类别c有正影响的那些像素点,如果不加ReLU层,最终可能会带入一些属于其它类别的像素,从而影响解释的效果。使用Grad-CAM对分类结果进行解释的效果如下图所示:
图8
3)用Keras如何实现Grad-CAM?
①加载带有预训练权重的VGG16网络
1 2 3 4 5 6 7 |
from keras.applications.vgg16 import VGG16 K.clear_session() # Note that we are including the densely-connected classifier on top; # all previous times, we were discarding it. model = VGG16(weights='imagenet') |
Downloading data from https://github.com/fchollet/deep-learning-models/releases/download/v0.1/vgg16_weights_tf_dim_ordering_tf_kernels.h5
553467904/553467096 [==============================] - 2601s 5us/step
②预处理一张图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from keras.preprocessing import image from keras.applications.vgg16 import preprocess_input, decode_predictions import numpy as np # The local path to our target image img_path = '/home/wumg/data/elephants/creative_commons_elephant.jpg' # `img` is a PIL image of size 224x224 img = image.load_img(img_path, target_size=(224, 224)) # `x` is a float32 Numpy array of shape (224, 224, 3) x = image.img_to_array(img) # We add a dimension to transform our array into a "batch" # of size (1, 224, 224, 3) x = np.expand_dims(x, axis=0) # Finally we preprocess the batch # (this does channel-wise color normalization) x = preprocess_input(x) |
查看预训练的VGG16网络,并将其预测向量解码为人们可读的格式。
1 2 |
preds = model.predict(x) print('Predicted:', decode_predictions(preds, top=3)[0]) |
redicted: [('n02504458', 'African_elephant', 0.90942115), ('n01871265', 'tusker', 0.08618273), ('n02504013', 'Indian_elephant', 0.004354583)]
从上面运行结果可以看出:
非洲象(African_elephant),占90%
长牙动物(tusker),占8%
印度象(Indian_elephant),占0.4%
网络识别出图像中包含数据量不确定的非洲象。预测向量中被最大激活的元素是对应“非洲象”类别元素(即类别概率最大项),索引编号为386
1 |
np.argmax(preds[0]) |
386
④ 实现Grad-CAM算法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# This is the "african elephant" entry in the prediction vector african_elephant_output = model.output[:, 386] # The is the output feature map of the `block5_conv3` layer, # the last convolutional layer in VGG16 last_conv_layer = model.get_layer('block5_conv3') # This is the gradient of the "african elephant" class with regard to # the output feature map of `block5_conv3` grads = K.gradients(african_elephant_output, last_conv_layer.output)[0] # This is a vector of shape (512,), where each entry # is the mean intensity of the gradient over a specific feature map channel pooled_grads = K.mean(grads, axis=(0, 1, 2)) # This function allows us to access the values of the quantities we just defined: # `pooled_grads` and the output feature map of `block5_conv3`, # given a sample image iterate = K.function([model.input], [pooled_grads, last_conv_layer.output[0]]) # These are the values of these two quantities, as Numpy arrays, # given our sample image of two elephants pooled_grads_value, conv_layer_output_value = iterate([x]) # We multiply each channel in the feature map array # by "how important this channel is" with regard to the elephant class for i in range(512): conv_layer_output_value[:, :, i] *= pooled_grads_value[i] # The channel-wise mean of the resulting feature map # is our heatmap of class activation heatmap = np.mean(conv_layer_output_value, axis=-1) |
⑥可视化类激活图
1 2 3 4 |
heatmap = np.maximum(heatmap, 0) heatmap /= np.max(heatmap) plt.matshow(heatmap) plt.show() |
图9
⑦把原始图叠加在刚生成的热力图上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import cv2 # We use cv2 to load the original image img = cv2.imread(img_path) # We resize the heatmap to have the same size as the original image heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0])) # We convert the heatmap to RGB heatmap = np.uint8(255 * heatmap) # We apply the heatmap to the original image heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET) # 0.4 here is a heatmap intensity factor superimposed_img = heatmap * 0.4 + img # Save the image to disk cv2.imwrite('/home/wumg/data/elephants/elephant_cam.jpg', superimposed_img) |
图10
参考资料:
《Python深度学习》弗朗索瓦•肖莱著
https://bindog.github.io/blog/2018/02/10/model-explanation/
http://spytensor.com/index.php/archives/20/(包括keras、pytorch实现Grad-CAM算法,class activation map)
http://spytensor.com/index.php/archives/19/(介绍GAP)