Visualizing what convnets learn
卷积神经网络的可视化
人们常说,深度学习模型是“黑盒”,即模型学到的表示很难用人类可以理解的方式来提取和呈现。虽然对于某些类型的深度学习模型来说,这种说法部分正确,但对卷积神经网络来说绝对不是这样。卷积神经网络学到的表示非常适合可视化,很大程度上是因为它们是视觉概念的表示。自 2013 年以来,人们开发了多种技术来对这些表示进行可视化和解释。我们不会在书中全部介绍,但会介绍三种最容易理解也最有用的方法。
- 可视化卷积神经网络的中间输出(中间激活):有助于理解卷积神经网络连续的层如何对输入进行变换,也有助于初步了解卷积神经网络每个过滤器的含义。
- 可视化卷积神经网络的过滤器:有助于精确理解卷积神经网络中每个过滤器容易接受的视觉模式或视觉概念。
- 可视化图像中类激活的热力图:有助于理解图像的哪个部分被识别为属于某个类别,从而可以定位图像中的物体。
对于第一种方法(即激活的可视化),我们将使用 5.2 节在猫狗分类问题上从头开始训练的小型卷积神经网络。对于另外两种可视化方法,我们将使用 5.3 节介绍的 VGG16 模型。
1. 可视化中间激活
可视化中间激活,是指对于给定输入,展示网络中各个卷积层和池化层输出的特征图(层的输出通常被称为该层的激活,即激活函数的输出)。这让我们可以看到输入如何被分解为网络 学到的不同过滤器。我们希望在三个维度对特征图进行可视化:宽度、高度和深度(通道)。每个通道都对应相对独立的特征,所以将这些特征图可视化的正确方法是将每个通道的内容分别绘制成二维图像。我们首先来加载 5.2 节保存的模型。
from keras.models import load_model
model = load_model('cats_and_dogs_small_2.h5')
model.summary() # As a reminder.( 作为提醒)
①预处理单张图像
img_path = 'D:\\Kaggle\\dogs-vs-cats-small\\test\\cats\\cat.1700.jpg'
# We preprocess the image into a 4D tensor(将图像预处理为一个 4D 张量)
from tensorflow.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)(# 其形状为 (1, 150, 150, 3))
print(img_tensor.shape)
②显示测试图像
import matplotlib.pyplot as plt
plt.imshow(img_tensor[0])
plt.show()
③用一个输入张量和一个输出张量列表将模型实例化
from keras import models
# Extracts the outputs of the top 8 layers:(提取前 8 层的输出)
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)
④以预测模式运行模型
# This will return a list of 5 Numpy arrays:
# one array per layer activation
# 返回 8 个 Numpy 数组组成的列表, 每个层激活对应一个 Numpy 数组
activations = activation_model.predict(img_tensor)
first_layer_activation = activations[0]
print(first_layer_activation.shape)
⑤将第4个通道可视化
import matplotlib.pyplot as plt
plt.matshow(first_layer_activation[0, :, :, 3], cmap='viridis')
plt.show()
⑥将第7个通道可视化
plt.matshow(first_layer_activation[0, :, :, 7],cmap='viridis')
⑦ 将每个中间激活的所有通道可视化
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)
# 特征图的形状为 (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()
这里需要注意以下几点。
- 第一层是各种边缘探测器的集合。在这一阶段,激活几乎保留了原始图像中的所有信息。
- 随着层数的加深,激活变得越来越抽象,并且越来越难以直观地理解。它们开始表示更高层次的概念,比如“猫耳朵”和“猫眼睛”。层数越深,其表示中关于图像视觉内容的信息就越少,而关于类别的信息就越多。
- 激活的稀疏度(sparsity)随着层数的加深而增大。在第一层里,所有过滤器都被输入图像激活,但在后面的层里,越来越多的过滤器是空白的。也就是说,输入图像中找不到这些过滤器所编码的模式。
2. 可视化卷积神经网络的过滤器
想要观察卷积神经网络学到的过滤器,另一种简单的方法是显示每个过滤器所响应的视觉模式。这可以通过在输入空间中进行梯度上升来实现:从空白输入图像开始,将梯度下降应用于卷积神经网络输入图像的值,其目的是让某个过滤器的响应最大化。得到的输入图像是选定 过滤器具有最大响应的图像。
这个过程很简单:我们需要构建一个损失函数,其目的是让某个卷积层的某个过滤器的值最大化;然后,我们要使用随机梯度下降来调节输入图像的值,以便让这个激活值最大化。例如, 对于在ImageNet 上预训练的 VGG16 网络,其 block3_conv1层第 0 个过滤器激活的损失如下所示:
①为过滤器的可视化定义损失张量
from tensorflow.keras.applications import VGG16
from tensorflow.keras import backend as K
tf.compat.v1.disable_eager_execution() #报错添加语句:使用tf1的compat模式禁用eager-execution约束表单tf2
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])
②获取损失相对于输入的梯度
# The call to `gradients` returns a list of tensors (of size 1 in this case)
# hence we only keep the first element -- which is a tensor.
# 调用 gradients 返回的是一个张量列表(本例中列表长度为 1)。因此,只保留第一个元素,它是一个张量
grads = K.gradients(loss, model.input)[0]
③梯度标准化技巧
# We add 1e-5 before dividing so as to avoid accidentally dividing by 0.
grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)
④给定Numpy输入值,得到Numpy输出值
iterate = K.function([model.input], [loss, grads])
# Let's test it:(测试下)
import numpy as np
loss_value, grads_value = iterate([np.zeros((1, 150, 150, 3))])
⑤通过随机梯度下降让损失最大化
# 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(运行 40 次梯度上升)
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 = iterate([input_img_data])
# Here we adjust the input image in the direction that maximizes the loss
# 沿着让损失最大化的 方向调节输入图像
input_img_data += grads_value * step
得到的图像张量是形状为 (1, 150, 150, 3) 的浮点数张量,其取值可能不是 [0, 255] 区间内的整数。因此,你需要对这个张量进行后处理,将其转换为可显示的图像。下面这个简单的实用函数可以做到这一点。
def deprocess_image(x):
# normalize tensor: center on 0., ensure std is 0.1
# 对张量做标准化,使其均值为 0, 标准差为 0.1
x -= x.mean()
x /= (x.std() + 1e-5)
x *= 0.1
# clip to [0, 1](将 x 裁切(clip)到 [0, 1] 区间)
x += 0.5
x = np.clip(x, 0, 1)
# convert to RGB array(将 x 转换为 RGB 数组)
x *= 255
x = np.clip(x, 0, 255).astype('uint8')
return x
⑥生成过滤器可视化的函数
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.
# 构建一个损失函数,将该层第 n 个过滤器的激活最大化
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(运行 40 次梯度上升)
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)
看起来,block3_conv1 层第 0 个过滤器响应的是波尔卡点(polka-dot)图案。
下面来看有趣的部分:我们可以将每一层的每个过滤器都可视化。为了简单起见,我们只查看每一层的前 64 个过滤器,并只查看每个卷积块的第一层(即 block1_conv1、block2_conv1、block3_conv1、block4_ conv1、block5_conv1)。我们将输出放在一个 8×8 的网格中, 每个网格是一个 64 像 素×64 像素的过滤器模式,两个过滤器模式之间留有一些黑边(见上图)。
⑦生成某一层中所有过滤器想要模式组成的网络
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(遍历 results 网格的行)
for j in range(8): # iterate over the columns of our results grid(遍历 results 网格的列)
# Generate the pattern for filter `i + (j * 8)` in `layer_name`
# 生成 layer_name 层第 i +(j * 8)个过滤器的模式
filter_img = generate_pattern(layer_name, i + (j * 8), size=size)
# Put the result in the square `(i, j)` of the results grid
# 将结果放到 results 网格第 (i, j) 个方块中
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(显示 results 网格)
plt.figure(figsize=(20, 20))
plt.imshow(results)
plt.show()
这些过滤器可视化包含卷积神经网络的层如何观察世界的很多信息:卷积神经网络中每一层都学习一组过滤器,以便将其输入表示为过滤器的组合。这类似于傅里叶变换将信号分解为一 组余弦函数的过程。随着层数的加深,卷积神经网络中的过滤器变得越来越复杂,越来越精细。
- 模型第一层(block1_conv1)的过滤器对应简单的方向边缘和颜色(还有一些是彩色边缘)。
- block2_conv1 层的过滤器对应边缘和颜色组合而成的简单纹理。
- 更高层的过滤器类似于自然图像中的纹理:羽毛、眼睛、树叶等。
3. 可视化类激活的热力图(无语,一整个大报错,未解决!)
我还要介绍另一种可视化方法,它有助于了解一张图像的哪一部分让卷积神经网络做出了最终的分类决策。这有助于对卷积神经网络的决策过程进行调试,特别是出现分类错误的情况下。 这种方法还可以定位图像中的特定目标。
这种通用的技术叫作类激活图(CAM,class activation map)可视化,它是指对输入图像生成类激活的热力图。类激活热力图是与特定输出类别相关的二维分数网格,对任何输入图像的每个位置都要进行计算,它表示每个位置对该类别的重要程度。举例来说,对于输入到猫狗分 类卷积神经网络的一张图像,CAM可视化可以生成类别“猫”的热力图,表示图像的各个部分与“猫”的相似程度,CAM 可视化也会生成类别“狗”的热力图,表示图像的各个部分与“狗”的相似程度。
我们将使用的具体实现方式是“Grad-CAM: visual explanations from deep networks via gradient- based localization”a 这篇论文中描述的方法。这种方法非常简单:给定一张输入图像,对于一个卷积层的输出特征图,用类别相对于通道的梯度对这个特征图中的每个通道进行加权。直观上来看,理解这个技巧的一种方法是,你是用“每个通道对类别的重要程度”对“输入图像对不同通道的激活强度”的空间图进行加权,从而得到了“输入图像对类别的激活强度”的空间图。
我们再次使用预训练的 VGG16 网络来演示此方法。
①加载带有预训练权重的VGG16网络
from tensorflow.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')
②为VGG16模型预处理一张输入图像
from tensorflow.keras.preprocessing import image
from tensorflow.keras.applications.vgg16 import preprocess_input, decode_predictions
import numpy as np
# The local path to our target image(目标图像的 本地路径)
img_path = 'D:\\Kaggle\\creative_commons_elephant.jpg'
# `img` is a PIL image of size 224x224
# (大小为 224×224 的Python图像库(PIL,Python imaging library)图像)
img = image.load_img(img_path, target_size=(899, 600))
# `x` is a float32 Numpy array of shape (224, 224, 3)
# x形 状 为 (224, 224, 3) 的float32 格式的 Numpy 数组
x = image.img_to_array(img)
# We add a dimension to transform our array into a "batch"
# of size (1, 224, 224, 3)
# 添加一个维 度,将数组转换为 (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)
preds = model.predict(x)
print('Predicted:', decode_predictions(preds, top=3)[0])
③应用Grad-CAM算法
# 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
# block5_conv3 层的输出特征图,它是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`
# “非洲象”类别相对于 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
# 形状为 (512,) 的向量,每个元素是特定特征图通道的梯度平均大小
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
# 访问刚刚定义的量:对于给定的样本图像,pooled_grads 和 block5_conv3 层的输出特征图
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
# 对于两个大象的样本图像, 这两个量都是 Numpy 数组
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)
④热力图后处理
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
plt.show()
⑤将热力图与原始图像叠加
import cv2 #可能会报错!
# We use cv2 to load the original image(用 cv2 加载原始图像)
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(将热力图转换为 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(这里的 0.4 是热力图强度因子)
superimposed_img = heatmap * 0.4 + img
# Save the image to disk(将图像保存到硬盘)
cv2.imwrite('D:\\Kaggle\\elephant_cam.jpg', superimposed_img)
这种可视化方法回答了两个重要问题:
- 网络为什么会认为这张图像中包含一头非洲象?
- 非洲象在图像中的什么位置? 尤其值得注意的是,小象耳朵的激活强度很大,这可能是网络找到的非洲象和印度象的不同之处。