Python深度学习之计算机视觉(下)

858 阅读12分钟

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

本文承接上两篇文章:

Python深度学习之计算机视觉(上)

Python深度学习之计算机视觉(中)

卷积神经网络的可视化

5.4 Visualizing what convnets learn

我们常说深度学习是黑箱,我们很难从其学习的过程中提取出 human-readable 的表示。但是,做计算机视觉的卷积神经网络不是这样,卷积神经网络是能可视化的,我们看得懂的,因为卷积神经网络本来就是提取“视觉概念的表示”的嘛。

现在已经有很多种不同的方法可以从不同角度把卷积神经网络可视化,并合理解释其意义。下面介绍其中几种。

可视化中间激活

可视化中间激活(Visualizing intermediate activations),也就是可视化卷积神经网络的中间输出。这个可视化可以帮助我们理解一系列连续得多卷积层是如何变换处理输入数据的、以及每个过滤器的基本意义。

这个其实就是把卷积/池化层输出的 feature maps 显示出来看看(层的输出也可以叫做 activation,激活)。具体的做法就是把输出的各个 channel 都做成二维的图像显示出来看。

我们用一个之前生成的模型来作为例子:

from tensorflow.keras.models import load_model

model = load_model('/CDFMLR/dataset/dogs-vs-cats/cats_and_dogs_small_2.h5')
model.summary()
Model: "sequential_5"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_20 (Conv2D)           (None, 148, 148, 32)      896       
_________________________________________________________________
max_pooling2d_20 (MaxPooling (None, 74, 74, 32)        0         
_________________________________________________________________
conv2d_21 (Conv2D)           (None, 72, 72, 64)        18496     
_________________________________________________________________
max_pooling2d_21 (MaxPooling (None, 36, 36, 64)        0         
_________________________________________________________________
conv2d_22 (Conv2D)           (None, 34, 34, 128)       73856     
_________________________________________________________________
max_pooling2d_22 (MaxPooling (None, 17, 17, 128)       0         
_________________________________________________________________
conv2d_23 (Conv2D)           (None, 15, 15, 128)       147584    
_________________________________________________________________
max_pooling2d_23 (MaxPooling (None, 7, 7, 128)         0         
_________________________________________________________________
flatten_5 (Flatten)          (None, 6272)              0         
_________________________________________________________________
dropout (Dropout)            (None, 6272)              0         
_________________________________________________________________
dense_10 (Dense)             (None, 512)               3211776   
_________________________________________________________________
dense_11 (Dense)             (None, 1)                 513       
=================================================================
Total params: 3,453,121
Trainable params: 3,453,121
Non-trainable params: 0
_________________________________________________________________

然后我们找一张训练的时候网络没见过的图片:

img_path = '/Volumes/WD/Files/dataset/dogs-vs-cats/cats_and_dogs_small/test/cats/cat.1750.jpg'

from tensorflow.keras.preprocessing import image    # 把图片搞成 4D 张量
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)
img_tensor /= 255.

print(img_tensor.shape)
(1, 150, 150, 3)

显示图片:

import matplotlib.pyplot as plt

plt.imshow(img_tensor[0])
plt.show()

png

要提取特征图,就要用一个输入张量和一个输出张量列表来实例化一个模型:

from tensorflow.keras import models

layer_outputs = [layer.output for layer in model.layers[:8]]    # 提取前 8 层的输出
activation_model = models.Model(inputs=model.input, outputs=layer_outputs)

当输入一张图像时,这个模型就会返回原始模型前 8 层的激活值。我们之前的模型都是给一个输入,返一个输出的,但其实一个模型是可以给任意个输入,返任意个输出的。

接下来在把刚才找的图片输入进去:

activations = activation_model.predict(img_tensor)
# 返回 8 个 Numpy 数组组成的列表,每层一个,里面放着激活
first_layer_activation = activations[0]
print(first_layer_activation.shape)
(1, 148, 148, 32)

这东西有 32 个 channel,我们随便打一个出来看看:

# 将第 4 个 channel 可视化

import matplotlib.pyplot as plt
plt.matshow(first_layer_activation[0, :, :, 4], cmap='viridis')

png

这个每个 channel 是干嘛的基本是随机的,我这个就和书上不一样。

下面,来绘制整个网络中完整的、所有激活的可视化。我们将 8 个 feature maps,每个其中的所有 channels 画出来了,排到一张大图上:

# 将每个中间激活的所有通道可视化

layer_names = []
for layer in model.layers[:8]:
    layer_names.append(layer.name)
    
images_per_row = 16

for layer_name, layer_activation in zip(layer_names, activations):
    # 对于每个 layer_activation,其形状为 (1, size, size, n_features)
    n_features = layer_activation.shape[-1]    # 特征图里特征(channel)的个数
    
    size = layer_activation.shape[1]    # 图的大小
    
    n_cols = n_features // images_per_row
    display_grid = np.zeros((size * n_cols, images_per_row * size))    # 分格
    
    for col in range(n_cols):
        for row in range(images_per_row):
            channel_image = layer_activation[0, :, :, col * images_per_row + row]
            
            # 把图片搞好看一点
            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

    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')

png

png

png

png

png

png

png

png

可以看到,特征越来越抽象。同时,黑的也越来越多(稀疏度越来越大),那些都是输入图像中找不到这些过滤器所编码的模式,所以就空白了。

这里表现出来一个重要普遍特征:随着层数的加深,层所提取的特征变得越来越抽象。更高的层激活包含关于特定输入的信息越来越少,而关于目标的信息越来越多。

与人类和动物感知世界的方式类似:人类观察一个场景几秒钟后,可以记住其中有哪些抽象物体(比如自行车、树),但记不住这些物体的具体外观。你的大脑会自动将视觉输入完全抽象化,即将其转换为更高层次的视觉概念,同时过滤掉不相关的视觉细节。

深度神经网络利用信息蒸馏管道 (information distillation pipeline),将输入原始数据(本例中是 RGB 图像),反复进行变换,将无关信息过滤掉(比如图像的具体外观),并放大和细化有用的信息(比如图像的类别),最终完成对信息的利用(比如判断出图片是猫还是狗)。

可视化卷积神经网络的过滤器

可视化卷积神经网络的过滤器(Visualizing convnets filters),帮助理解各个过滤器善于接受什么样的视觉模式(pattern,我觉得“模式”这个词并不能很好的诠释 pattern 的意思)或概念。

要观察卷积神经网络学到的过滤器,我们可以显示每个过滤器所响应的视觉模式。这个可以在输入空间上用 gradient ascent(梯度上升)来实现:

为了从空白输入图像开始,让某个过滤器的响应最大化,可以将梯度下降应用于卷积神经网络输入图像的值。得到的输入图像即为对过滤器具有最大响应的图像。

这个做起来很简单,构建一个损失函数,其目的是让某个卷积层的某个过滤器的值最大化。然后,使用随机梯度下降来调节输入图像的值,使之激活值最大化。

例如,在 ImageNet 上预训练的 VGG16 网络的 block3_conv1 层第 0 个过滤器激活的损失就是:

from tensorflow.keras.applications import VGG16
from tensorflow.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])

print(loss)
Tensor("Mean_11:0", shape=(), dtype=float32)

要做梯度下降嘛,所以要得到 loss 相对于模型输入的梯度。用 Keras 的 backend 的 gradients 函数来完成这个:

import tensorflow as tf
tf.compat.v1.disable_eager_execution()  # See https://github.com/tensorflow/tensorflow/issues/33135

grads = K.gradients(loss, model.input)[0]
# 这东西返回的是一个装着一系列张量的 list,在此处,list 长度为1,所以取 [0] 就是一个张量
print(grads)
Tensor("gradients_1/block1_conv1_4/Conv2D_grad/Conv2DBackpropInput:0", shape=(None, None, None, 3), dtype=float32)

这里可以用一个小技巧来让梯度下降过程平顺地进行:将梯度张量除以其 L2 范数(张量的平方的平均值的平方根)来标准化。这种操作可以使输入图像更新地大小始终保持在同一个范围里:

grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)    # + 1e-5 防止除以0
print(grads)
Tensor("truediv_1:0", shape=(None, None, None, 3), dtype=float32)

现在需要接近的问题是,给定输入图像,计算出 loss 和 grads 的值。这个可以用 iterate 函来做:它将一个 Numpy 张量转换为两个 Numpy 张量组成的列表,这两个张量分别是损失值和梯度值:

iterate = K.function([model.input], [loss, grads])

import numpy as np
loss_value, grads_value = iterate([np.zeros((1, 150, 150, 3))])
print(loss_value, grads_value)
0.0 [[[[0. 0. 0.]
   [0. 0. 0.]
   [0. 0. 0.]
   ...
   ...
   [0. 0. 0.]
   [0. 0. 0.]
   [0. 0. 0.]]]]

然后,就来写梯度下降了:

# 通过随机梯度下降让损失最大化

input_img_data = np.random.random((1, 150, 150, 3)) * 20 + 128.   # 随便一个有燥点的灰度图

plt.imshow(input_img_data[0, :, :, :] / 225.)
plt.figure()

step = 1.    # 梯度更新的步长
for i in range(40):
    loss_value, grads_value = iterate([input_img_data])
    input_img_data += grads_value * step    # 沿着让损失最大化的方向调节输入图像
    
plt.imshow(input_img_data[0, :, :, :] / 225.)
plt.show()
Clipping input data to the valid range for imshow with RGB data ([0..1] for floats or [0..255] for integers).

png

png

Emmm,刚才这个画图是我随便写的啦,所以爆了个 Warning,为了正规地画出图来,下面好好处理一下:

# 将张量转换为有效图像的 utility 函数

def deprocess_image(x):
    # 标准化,均值为 0, 标准差为 0.1
    x -= x.mean()
    x /= (x.std() + 1e-5)
    x *= 0.1
    
    # 裁剪到 [0, 1]
    x += 0.5
    x = np.clip(x, 0, 1)
    
    # 将 x 转换为 RGB 数组
    x *= 255
    x = np.clip(x, 0, 255).astype('uint8')
    return x

把上面那些东西全部拼起来就可以得到完整的生成过滤器可视化的函数了:

# 生成过滤器可视化的函数

import tensorflow as tf
tf.compat.v1.disable_eager_execution()  # See https://github.com/tensorflow/tensorflow/issues/33135

def generate_pattern(layer_name, filter_index, size=150):
    # 构建一个损失函数,将 layer_name 层第 filter_index 个过滤器的激活最大化
    layer_output = model.get_layer(layer_name).output
    loss = K.mean(layer_output[:, :, :, filter_index])
    
    # 计算损失相对于输入图像的梯度,并将梯度标准化
    grads = K.gradients(loss, model.input)[0]
    grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)
    
    # 返回给定输入图像的损失和梯度
    iterate = K.function([model.input], [loss, grads])
    
    # 从带有噪声的灰度图开始,梯度上升 40 次
    input_img_data = np.random.random((1, size, size, 3)) * 20 + 128.
    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)

然后就可以用了,还是那刚才那个例子是一下|:

plt.imshow(generate_pattern('block3_conv1', 1))

png

这个图就是 block3_conv1 层第 0 个过滤器的响应了。这种 pattern 叫做 polka-dot(波尔卡点图案)。

接下来我们把每一层的每个过滤器都可视化。为了快一点,我们只可视化每个卷积块的第一层的前64个过滤器(防止也没什么意义,就是看看,随便搞几个就行了):

# 生成一层中所有过滤器响应模式组成的网格
def generate_patterns_in_layer(layer_name, size=64, margin=5):
    results = np.zeros((8 * size + 7 * margin, 8 * size + 7 * margin, 3), dtype=np.int)
    for i in range(8):
        for j in range(8):
            filter_img = generate_pattern(layer_name, i + (j * 8), size=size)
            
            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
            
    return results


layer_names = ['block1_conv1', 'block2_conv1', 'block3_conv1', 'block4_conv1']
for layer in layer_names:
    img = generate_patterns_in_layer(layer)
    plt.figure(figsize=(20, 20))
    plt.title(layer)
    plt.imshow(img)

png

png

png

png

这些图里就表现了卷积神经网络的层如何观察图片中的信息的:卷积神经网络中每一层都学习一组过滤器,然后输入会被表示成过滤器的组合,这个其实类似于傅里叶变换的过程。随着层数的加深,卷积神经网络中的过滤器变得越来越复杂,越来越精细,所以就可以提取、“理解”根据抽象的信息了。

可视化图像中类激活的热力图

可视化图像中类激活的热力图(Visualizing heatmaps of class activation in an image),用来了解网络是靠哪部分图像来识别一个类的,也有助于知道物体在图片的哪个位置。

用来对输入图像生成类激活的热力图的一种技术叫 CAM (class activation map 类激活图)可视化。类激活热力图对任意输入的图像的每个位置进行计算,表示出每个位置对该类别的重要程度。比如我们的猫狗分类网络里,对一个猫的图片生成类激活热力图,可以得到这个图片的各个不同的部位有多像猫(对模型认为这是猫的图片起了过大的作用)。

具体来说,我们用 [Grad-CAM](arxiv.org/abs/ 1610.02391.) 这个方法:给定一张输入图像,对于一个卷积层的输出特征图,用类别相对于通道的梯度对这个特征图中的每个通道进行加权。说人话就是用「每个通道对分类的重要程度」对「输入图像对不同通道的激活的强弱程度」的空间图进行加权,从而得到「输入图像对类别的激活强度」的空间图。(emmm,这种超长的句子看原文比较好😭:Intuitively, one way to understand this trick is that you’re weighting a spatial map of “how intensely the input image activates different channels” by “how important each channel is with regard to the class,” resulting in a spatial map of “how intensely the input image activates the class.”)

我们在 VGG16 模型来演示这个方法:

# 加载带有预训练权重的 VGG16 网络

from tensorflow.keras.applications.vgg16 import VGG16

model = VGG16(weights='imagenet')    # 注意这个是带有分类器的,比较大,下载稍慢(有500+MB)
model.summary()
Model: "vgg16"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         [(None, 224, 224, 3)]     0         
_________________________________________________________________
... (篇幅限制,中间这些就省略了)
_________________________________________________________________
fc2 (Dense)                  (None, 4096)              16781312  
_________________________________________________________________
predictions (Dense)          (None, 1000)              4097000   
=================================================================
Total params: 138,357,544
Trainable params: 138,357,544
Non-trainable params: 0
_________________________________________________________________

然后我们照一张用来测试的图片:

creative_commons_elephant

(图片来自《Deep Learning with Python》(第二版,François Chollet),抱歉使用没有经过授权,侵删)

这是两只亚洲象🐘哦,把这个图片处理成 VGG16 模型需要的样子:

from tensorflow.keras.preprocessing import image
from tensorflow.keras.applications.vgg16 import preprocess_input, decode_predictions
import numpy as np

img_path = './creative_commons_elephant.jpg'
img = image.load_img(img_path, target_size=(224, 224))

img = image.load_img(img_path, target_size=(224, 224))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)

预测一下图片里的是啥:

preds = model.predict(x)
print('Predicted:', decode_predictions(preds, top=3)[0])

输出结果:

Predicted: [('n02504458', 'African_elephant', 0.909421),
            ('n01871265', 'tusker', 0.086182885), 
            ('n02504013', 'Indian_elephant', 0.0043545826)]

有 90% 的把握是亚洲象,不错。接下来就要用 Grad-CAM 算法来显示图像中哪些部分最像非洲象了。

from tensorflow.keras import backend as K

import tensorflow as tf
tf.compat.v1.disable_eager_execution()  # See https://github.com/tensorflow/tensorflow/issues/33135

african_elephant_output = model.output[:, 386]    # 这个是输出向量中代表“非洲象”的元素

last_conv_layer = model.get_layer('block5_conv3')   # VGG16 最后一个卷积层的输出特征图

grads = K.gradients(african_elephant_output, last_conv_layer.output)[0]

pooled_grads = K.mean(grads, axis=(0, 1, 2))    # 特定特征图通道的梯度平均大小,形状为 (512,) 

iterate = K.function([model.input], [pooled_grads, last_conv_layer.output[0]])

pooled_grads_value, conv_layer_output_value = iterate([x])    # 对刚才的测试🐘图片计算出梯度和特征图

for i in range(512):
    # 将特征图数组的每个 channel 乘以「这个 channel 对‘大象’类别的重要程度」
    conv_layer_output_value[:, :, i] *= pooled_grads_value[i]
    
heatmap = np.mean(conv_layer_output_value, axis=-1)  # 处理后的特征图的逐通道平均值即为类激活的热力图

把它画出来看看:

import matplotlib.pyplot as plt

heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)

png

emmmm,看不懂啊,所以,我们用 OpenCV 把这个叠加到原图上去看:

import cv2

img = cv2.imread(img_path)    # 加载原图
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))    # 调整热力图大小,符合原图
heatmap = np.uint8(255 * heatmap)    # 转换为 RGB 格式
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
superimposed_img = heatmap * 0.4 + img    # 叠加
cv2.imwrite('./elephant_cam.jpg', superimposed_img)    # 保存

得到的图像如下:

叠加到原图的热力图

可以看出,VGG16 网络其实只识别了那只小的象,注意,小象头部的激活强度很大,这可能就是网络找到的非洲象的独特之处。