Grad-CAMでヒートマップを表示

## はじめに

『PythonとKerasによるディープラーニング』の5章で、VGG16による推定の根拠を表すヒートマップを表示する例が載っています。
同じ5章で犬猫画像を判別するモデルを作り、学習させていますので、この学習済みモデルでヒートマップを表示してみたいと思います。

VGG16は多クラス分類なのに対し、犬猫判別モデルは2クラス分類で、出力クラスが1つしかありません。なので「犬らしさ」を表すヒートマップと「猫らしさ」を表すヒートマップを別々に作るということができません。
2クラス分類の場合に出来上がるヒートマップは、与えた入力画像のどの部分が変化すると判定に大きな影響が出るか、という意味になると思うのですが、結果的にはこの1種類のヒートマップで、猫画像を入力すれば「猫らしさ」を表す、犬画像を入力すれば「犬らしさ」を表すヒートマップができているような気がします。

* K.mean(grads, axis=(0, 1, 2))のところの理解が間違っていたので修正しました。（2018/10/10）

## 学習済みモデルの読み込み

まず5章の前の方で作った犬猫判定の学習済みモデルを読み込み、モデル構成を確認します。

```python
from tensorflow.python.keras.models import load_model

model = load_model('c:/temp/saved_model.h5')
model.summary()
```

次のように出力されます。

```
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 148, 148, 32)      896       
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 74, 74, 32)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 72, 72, 64)        18496     
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 36, 36, 64)        0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 34, 34, 128)       73856     
_________________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 17, 17, 128)       0         
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 15, 15, 128)       147584    
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 7, 7, 128)         0         
_________________________________________________________________
flatten_1 (Flatten)          (None, 6272)              0         
_________________________________________________________________
dropout_1 (Dropout)          (None, 6272)              0         
_________________________________________________________________
dense_1 (Dense)              (None, 512)               3211776   
_________________________________________________________________
dense_2 (Dense)              (None, 1)                 513       
=================================================================
Total params: 3,453,121
Trainable params: 3,453,121
Non-trainable params: 0
_________________________________________________________________
```

モデルの最終出力の形状は(None, 1)となっており、1つの入力画像に対して値が1つだけ出力されます。
このモデルでは、値が1に近いと犬と判断し、0に近いと猫と判断します。

また、最後の畳込み層はconv2d_4という名前で、出力のサイズは(None, 15, 15, 128)となっています。
これは入力画像の平面上の位置に対応する15*15の行列が128チャネル分あるということです。

Grad-CAMでは畳込み層出力の最終出力に関する勾配を求めます。
つまり、最終畳込み層の出力のぞれぞれ（このケースでは15*15*128個の値がある）を少しだけ変化させたときにモデルの最終出力値が大きく変動するほど、その位置のグレイ値が判定に影響しているはず、という前提に立っているのだと思います。
そして、最終畳込み層の1チャネル分の15*15平面上の位置は入力画像の位置に対応しているため、勾配の大きいセルに対応する入力画像の部分が判定に影響している、ということになります。~~それが128チャネル分あるので、15*15平面上の各位置毎にチャネル方向に平均をとってヒートマップを作っているようです。~~

それでは勾配を求めるための関数のインスタンスを作っていきます。
（ここからの内容は[ここ](https://github.com/fchollet/deep-learning-with-python-notebooks/blob/master/5.4-visualizing-what-convnets-learn.ipynb)の下から3セル分の内容に対応します。）

## Grad-CAMの準備

まず、モデルの最終出力を取り出します。
モデルの最終出力の形状は(None, 1)ですが、出力のカテゴリ数は1つしかないので0番目のカテゴリ[:, 0]を指定します。

```python
# モデルの最終出力を取り出す
model_output = model.output[:, 0]
```

次に最後の畳込み層であるconv2d_4を取り出します。

```python
# 最後の畳込み層を取り出す
last_conv = model.get_layer('conv2d_4')
```

最終畳込み層の出力の、モデル最終出力に関しての勾配を求めるオペレーションを定義します。

```python
from tensorflow.python.keras import backend as K 

grads = K.gradients(model_output, last_conv.output)[0]
```

ここでgradsは最終畳込み層の勾配なので、最終畳込み層の出力値の形状(None, 15, 15, 128)と同じ形状です。
この15*15の平面は入力画像上の位置（座標）に対応しており、チャネル方向には各特徴量別にそれぞれ勾配が求まっている状態です。

ここで15*15平面上での平均をとります。
pooled_gradsの形状はチャネル数分の(128, )になります。
~~ヒートマップを作るうえで、チャネル方向には平均をとっています。（本の中ではそうなっている。平均値ではなく、たとえば最大値を使わない理由は書いてないので不明。論文を読めば書いてあるかも。また、チャネル毎にヒートマップを作ることができれば、どのチャネルにどんな特徴量が割り当たっているのかがわかり、CNN内部の理解が深まるのではないかな？）~~

```python
pooled_grads = K.mean(grads, axis=(0, 1, 2))
```

以上を関数にします。

```python
iterate = K.function([model.input],
                     [pooled_grads, last_conv.output[0]])
```

## ヒートマップを求める

元画像ファイルを読み込んで、学習済みの犬猫判別モデルが受け取れるテンソルの形に変換します。

```python
from tensorflow.python.keras.preprocessing import image
import numpy as np

img_path = 'c:/temp/cat.1700.jpg'
img_keras = image.load_img(img_path, target_size=(150, 150))
img_tensor = image.img_to_array(img_keras)
img_tensor = np.expand_dims(img_tensor, axis=0)

# モデルの訓練時と同じ方法で前処理
img_tensor /= 255.
```

入力画像をさっき作った関数に入れて、入力画像に対する最終畳込み層出力の値と勾配を求めます。

```python
pooled_grads_val, conv_output_val = iterate([img_tensor])
```

ここで、最終畳込み層出力の値に平均勾配をかけて、~~もう一度~~チャネル方向に平均をしたものが求めるヒートマップとなります。
この最終畳込み層出力の値に平均勾配をかける処理がどういう意味なのかは今のところ理解できていません。

```python
print(conv_output_val.shape)     # (15, 15, 128)
print(pooled_grads_val.shape)    # (128, )

for i in range(128):
    conv_output_val[:, :, i] *= pooled_grads_val[i]

heatmap = np.mean(conv_output_val, axis=-1)
```

後はheatmapを後処理して元画像にスーパーインポーズしてファイルに書き出します。

```python
import cv2

# ヒートマップの後処理
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)

# cv2を使って元画像を読み込む
img = cv2.imread(img_path)

# 元の画像と同じサイズになるようにヒートマップのサイズを変更
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))

# ヒートマップをRGBに変換
heatmap = np.uint8(255 * heatmap)

# ヒートマップを元画像に適用
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)

# 0.4はヒートマップの強度係数
superimposed_img = heatmap * 0.4 + img

# 画像を保存
cv2.imwrite('c:/temp/superimposed.jpg', superimposed_img)
```

実際に試した結果が下図です。

![cat1700.jpg](https://qiita-image-store.s3.amazonaws.com/0/280874/3b1df616-fa83-7e17-230c-ec0482eaad1e.jpeg)![dog1500.jpg](https://qiita-image-store.s3.amazonaws.com/0/280874/749114d1-9d69-4b97-4433-ea219697a3e9.jpeg)