深層学習の解釈手法Grad-CAM/Grad-CAM++/Score-CAMのご紹介

この記事は約22分で読めます。

深層学習は特にCV分野で優れたパフォーマンスを実現する可能性があります。
しかしそれらは直感的でなく、理解可能なコンポーネントへの分解も難しいため、解釈可能性が低くなりがちです。
そのため近年では、深層学習モデルが、画像のどこにフォーカスして予測したのかといった、判断根拠を可視化する方法が研究されています。

今回はそのような画像系深層学習の判断根拠可視化手法について、近年人気のある手法Grad-CAMと、その改良版Grad-CAM++、さらに去年論文発表されたばかりのScore-CAMを、TensorFlow/Kerasで実装し、比較してみます。

深層学習の判断根拠解釈について

前述の通り、深層学習はCV分野において優れたパフォーマンスを発揮しますが、モデルの解釈を得づらいといった点があります。
したがって、モデルの判断根拠を可視化し解釈することは重要な領域の1つです。

以下は、Grad-CAMによるモデル判断根拠可視化の例です。

判断根拠の可視化のメリットは主に以下の2つです。

  • モデルの透明性をあげることができる
  • 学習データのバイアスに気づくことができる

1つ目はこれまで記している通り、深層学習はモデルの解釈を得づらいという特徴がありますが、判断根拠の可視化により、解釈可能性の低さを改善し、モデルの透明性を向上させることができます。
これにより、モデルの予測ロジックを言語化でき、妥当性を評価できるため、実社会の責任の伴う場面にも適用しやすくなる可能性があります。

2つ目は、学習データのバイアスに気づくことができることです。
上記でご紹介した可視化例はGrad-CAMの論文から抜粋したもので、DoctorとNurseを分類するモデルの可視化例です。
左列は答えで、上の女性がNurse、下の女性がDoctorのラベルが付与されています。
中央列は、どうやら学習データにDoctorには男性が多い/Nurseには女性多いのバイアスがかかっていたようで、モデルは人の顔や髪を見てどちらもNurseと予測してしまっているという様子を可視化で得ています。
右列では、性別のバイアスを学習データから取り除いて学習させた結果、人が持っている医療器具を注視するようなったということを表しています。
このように、判断根拠を可視化してみると、どうやら何か意図しない情報を使って予測しているかも?といったことに気づくことができます。

使用するデータセットと深層学習モデル

これから実装例を見ていくために、適当な深層学習モデルを用意してみます。

画像分類用のデータセットとして、以下のデータセットを利用します。

上記データセットは、以下の6クラスにラベル付されている画像データセットです。

  • 建物(buildings)
  • 森(forest)
  • 雪山(glacier)
  • 山(mountain)
  • 海(sea)
  • ストリート(street)

各クラスについて、枚数、比率、サンプル画像を何枚か確認してみると以下のような感じです。
いずれのクラスも同じくらいの比率で含まれており、各クラスごとに約2000個、合計12000個ほどの画像データが格納されているようです。

この時点で、どうやらglacierとmountainを正しく分類するのはやや難しそうな印象を受けます。

深層学習モデルは、学習済みのResNet50のファインチューニングでモデルを作ってみます。
train/testデータセットの作成などは省略しますが、以下のようにモデルを作成し、学習させてみたところ、90%ほどの精度となりました。
混合行列をプロットして確認してみると、やはりglacierとmountainは互いにやや間違えやすい傾向にありそうです。

def build_model(w, h, n_classes):
    """Build model function.
    
    Args:
        w (int): Width size of image.
        h (int): Height size of image.
        n_classes (int): The number of class.
        
    Returns:
        keras.engine.training.Model: Model.
    """ 
    # Resnet
    input_tensor = Input(shape=(w, h, 3)) # To change input shape
    resnet50 = ResNet50(
        include_top=False,                # To change output shape
        weights="imagenet",               # Use pre-trained model
        input_tensor=input_tensor,        # Change input shape for this task
    )
    
    # fc layer
    top_model = Sequential()
    top_model.add(GlobalAveragePooling2D())               # Add GAP for cam
    top_model.add(Dense(n_classes, activation="softmax")) # Change output shape for this task
    
    # model
    model = Model(input=resnet50.input, output=top_model(resnet50.output))
    
    # frozen weights
    for layer in model.layers[:-10]:
        layer.trainable = False or isinstance(layer, BatchNormalization) # If Batch Normalization layer, it should be trainable
        
    # compile
    model.compile(
        optimizer="adam", 
        loss="categorical_crossentropy", 
        metrics=["accuracy"],
    )
    
    return model

# Build the model
model = build_model(w=W, h=H, n_classes=N_CLASSES)

# Finetuning the model
history = model.fit_generator(
    datagen_train.flow(
        x_train, 
        y_train, 
        batch_size=BATCH_SIZE,
    ),
    epochs=N_EPOCHS,
    validation_data=datagen_test.flow(
        x_test, 
        y_test, 
        batch_size=BATCH_SIZE,
    ),
)
Epoch 1/5
351/351 [==============================] - 70s 201ms/step - loss: 0.5062 - accuracy: 0.8201 - val_loss: 0.2515 - val_accuracy: 0.8917
Epoch 2/5
351/351 [==============================] - 48s 137ms/step - loss: 0.3234 - accuracy: 0.8817 - val_loss: 0.0313 - val_accuracy: 0.9024
Epoch 3/5
351/351 [==============================] - 49s 139ms/step - loss: 0.2801 - accuracy: 0.9011 - val_loss: 0.2366 - val_accuracy: 0.9020
Epoch 4/5
351/351 [==============================] - 48s 138ms/step - loss: 0.2494 - accuracy: 0.9080 - val_loss: 0.3192 - val_accuracy: 0.9102
Epoch 5/5
351/351 [==============================] - 49s 139ms/step - loss: 0.2238 - accuracy: 0.9169 - val_loss: 0.1859 - val_accuracy: 0.9081

Grad-CAMの実装について

それでは、それぞれの判断根拠の可視化手法を実装し、試してみます。

まずはGrad-CAMです。
論文は以下になります。

Grad-CAMは2016年に発表されましたが、それより少し以前に発表されたCAM(Class Activation Mapping)の拡張として発表されました。
ロジックの数式は以下です。
(他のCAM手法と比較がわかりやすくなるように、こちらで表現を少し変えました)

CAMは、一般的な畳み込み層にGlobal Average Poolingをかけて学習させた時の特徴マップの出力は、重みがクラス分類の重要度を特徴マップ上で考えられるという考えから提案されています。

つまり、このままだとモデルとしてGlobal Average Poolingが必要になるのですが、それを勾配で代用できることを示し、どんなモデルアーキテクチャにもCAMのような可視化が可能だとしたのがGrad-CAMになります。
実は先ほどの深層学習モデルを準備している段階でGlobal Average Pooling層を追加しましたが、多分これはなくても大丈夫だと思います。

ランプ関数はクラスに対するマイナスの勾配は無視するためです。
意図は微妙にクリアではないですが、個人的には、勾配がマイナスに寄与している=そのクラスではない、を表すとしそのクラスと判断した根拠の可視化からは除外して考えているだけなのかなと理解しています。

Grad-CAMの実装は以下のようになります。

def grad_cam(model, x, layer_name):
    """Grad-CAM function.
    
    Args:
        model (keras.engine.training.Model): Model.
        x (np.ndarray): Input.
        layer_name (str): Get layer name
        
    Returns:
        tuple[int, np.ndarray]: Predicted class, heatmap of CAM.
    """
    cls = np.argmax(model.predict(x))
    
    y_c = model.output[0, cls]
    conv_output = model.get_layer(layer_name).output
    grads = K.gradients(y_c, conv_output)[0]

    # Get outputs and grads
    gradient_function = K.function([model.input], [conv_output, grads])
    output, grads_val = gradient_function([x])
    output, grads_val = output[0, :], grads_val[0, :, :, :]
    
    weights = np.mean(grads_val, axis=(0, 1)) # Passing through GlobalAveragePooling

    cam = np.dot(output, weights) # multiply
    cam = np.maximum(cam, 0)      # Passing through ReLU
    cam /= np.max(cam)            # scale 0 to 1.0
    
    return cls, cam

Grad-CAM++の実装について

Grad-CAM++は、2017年に、Grad-CAMの改良版として発表されました。

こちらは、特徴マップにかかる重みみたいなものがあったとしたら、それはどう表現されるかを、これまでの論文で出てきた数式からガリガリと紐解いていて、以下のように表現しています。

特に特徴量マップの中でクラス予測に影響を与えるが、その大きさが大きくなかったものは、これまで取れていなかったが、これにより捉えるようになった特徴などあります。
こちらを実装すると、以下のようになります。

def grad_cam_plus_plus(model, x, layer_name):
    """Grad-CAM++ function.
    
    Args:
        model (keras.engine.training.Model): Model.
        x (np.ndarray): Input.
        layer_name (str): Get layer name.
        
    Returns:
        tuple[int, np.ndarray]: Predicted class, heatmap of CAM.
    """
    cls = np.argmax(model.predict(x))
    y_c = model.output[0, cls]
    conv_output = model.get_layer(layer_name).output
    grads = K.gradients(y_c, conv_output)[0]

    # first / second / third derivative
    first = K.exp(y_c) * grads
    second = K.exp(y_c) * grads * grads
    third = K.exp(y_c) * grads * grads * grads

    # Get outputs, grads and higher order derivatives
    gradient_function = K.function([model.input], [y_c, first, second, third, conv_output, grads])
    y_c, conv_first_grad, conv_second_grad, conv_third_grad, conv_output, grads_val = gradient_function([x])
    
    # Calculate weight alpha
    global_sum = np.sum(conv_output[0].reshape((-1,conv_first_grad[0].shape[2])), axis=0)
    alpha_num = conv_second_grad[0]
    alpha_denom = conv_second_grad[0] * 2.0 + conv_third_grad[0] * global_sum.reshape((1, 1, conv_first_grad[0].shape[2]))
    alpha_denom = np.where(alpha_denom != 0.0, alpha_denom, np.ones(alpha_denom.shape))
    alphas = alpha_num / alpha_denom

    weights = np.maximum(conv_first_grad[0], 0.0)
    alpha_normalization_constant = np.sum(np.sum(alphas, axis=0), axis=0)
    alphas /= alpha_normalization_constant.reshape((1, 1, conv_first_grad[0].shape[2]))
    deep_linearization_weights = np.sum((weights * alphas).reshape((-1, conv_first_grad[0].shape[2])), axis=0)

    cam = np.sum(deep_linearization_weights * conv_output[0], axis=2) # multiply
    cam = np.maximum(cam, 0)                                          # Passing through ReLU
    cam /= np.max(cam)                                                # scale 0 to 1.0  

    return cls, cam

Score-CAMの実装について

Score-CAMは、2019年10月に発表された新しい手法です。

勾配での表現は、時々入力層のわずかな小さな変化に対しても、過剰に大きな値を返してしまう問題があります。
これはGrad-CAMやGrad-CAM++においても指摘されていたことでした。

そこで、この論文では、特徴量ヒートマップを勾配を使わないで作成する方法を提案しています。

Iはインプット画像で、特徴量マップ x 画像のスコアを表現するような形をしています。
これを実装すると、以下のようになります

def softmax(x):
    """Softmax function.
    
    Args:
        x (np.ndarray): Input.
        
    Returns:
        np.ndarray: Softmax(x)
    """
    return np.exp(x) / np.sum(np.exp(x), axis=1, keepdims=True)


def score_cam(
        model, 
        x, 
        layer_name, 
        max_N=-1,
    ):
    """Score-CAM function.
    
    Args:
        model (keras.engine.training.Model): Model.
        x (np.ndarray): Input.
        layer_name (str): Get layer name.
        max_N (int): max N.
        
    Returns:
        tuple[int, np.ndarray]: Predicted class, heatmap of CAM.
    """
    cls = np.argmax(model.predict(x))
    act_map_array = Model(inputs=model.input, outputs=model.get_layer(layer_name).output).predict(x)
    
    # extract effective maps
    if max_N != -1:
        act_map_std_list = [np.std(act_map_array[0, :, :, k]) for k in range(act_map_array.shape[3])]
        unsorted_max_indices = np.argpartition(-np.array(act_map_std_list), max_N)[:max_N]
        max_N_indices = unsorted_max_indices[np.argsort(-np.array(act_map_std_list)[unsorted_max_indices])]
        act_map_array = act_map_array[:, :, :, max_N_indices]

    input_shape = model.layers[0].output_shape[1:]  # get input shape
    
    # 1. upsampled to original input size
    act_map_resized_list = [cv2.resize(act_map_array[0,:,:,k], input_shape[:2], interpolation=cv2.INTER_LINEAR) for k in range(act_map_array.shape[3])]
    
    # 2. normalize the raw activation value in each activation map into [0, 1]
    act_map_normalized_list = []
    for act_map_resized in act_map_resized_list:
        if np.max(act_map_resized) - np.min(act_map_resized) != 0:
            act_map_normalized = act_map_resized / (np.max(act_map_resized) - np.min(act_map_resized))
        else:
            act_map_normalized = act_map_resized
        act_map_normalized_list.append(act_map_normalized)
        
    # 3. project highlighted area in the activation map to original input space by multiplying the normalized activation map
    masked_input_list = []
    for act_map_normalized in act_map_normalized_list:
        masked_input = np.copy(x)
        for k in range(3):
            masked_input[0, :, :, k] *= act_map_normalized
        masked_input_list.append(masked_input)
    masked_input_array = np.concatenate(masked_input_list, axis=0)
    
    # 4. feed masked inputs into CNN model and softmax
    pred_from_masked_input_array = softmax(model.predict(masked_input_array))
    
    # 5. define weight as the score of target class
    weights = pred_from_masked_input_array[:, cls]
    
    # 6. get final class discriminative localization map as linear weighted combination of all activation maps
    cam = np.dot(act_map_array[0, :, :, :], weights) # multiply
    cam = np.maximum(0, cam)                         # Passing through ReLU
    cam /= np.max(cam)                               # scale 0 to 1.0
    
    return cls, cam

各解釈手法の結果比較

各手法の実装ができましたので、各クラスの判断根拠の可視化を比較してみます。

建物(buildings):

モデルは建物の全体部分に注目しているような可視化結果が得られました。
Grad-CAM++とScore-CAMの方が、Grad-CAMよりも、より建物の全体を見ているような結果となりました。
Grad-CAM++とScore-CAMはそれほど大きな違いはないように見えます。

森(forest):

木の幹の部分を見つけて、森と予測しているように見えます。
特に、Grad-CAM++とScore-CAMの方が、Grad-CAMよりも顕著にその特徴を捉えているように見えます。

雪山(glacier):

山の形を見ている?ような結果となりました。
山の色も見ているのかもしれません。
これに関しては、いずれの可視化手法も大きな違いはなさそうに見えます。

山(mountain):

こちらも山の形を見ているような、雪山と同じような結果となりました。
山の画像にも、上記の1列目画像や最後列画像は、色的に見ても雪山の方が正しいような気もするし、判断に迷います。
そのくらい、差別化できるような画像要素を見つけられていないように思います。
またこちらも、いずれの可視化手法も大きな違いはなさそうです。

海(sea):

こちらは、海の表面や水平線を見ているような可視化結果となりました。
Grad-CAM++とScore-CAMの方が、Grad-CAMよりもより海全体を捉えているように見えます。

ストリート(street):

モデルは、路面およびその両側に建つ建物について見ているような結果となりました。
こちらも、Grad-CAM++とScore-CAMの方が、より全体を捉えているように見えます。

まとめ

今回は、KerasでGrad-CAM、Grad-CAM++、Score-CAMの実装および結果の比較をしてみました。

Grad-CAMは、クラス予測に関連のある部分のごく一部にしか反応できていなかったように見えますが、Grad-CAM++とScore-CAMの方が、より関連のある部分全体を可視化してくれていたように見えました。
Grad-CAM++とScore-CAMには、可視化にはそれほど大きな違いはないようです。

また、それぞれの手法の実行にかかる時間は、新しくなるにつれて時間がかかります。(Score-CAM > Grad-CAM++ > Grad-CAM)
Grad-CAMよりもGrad-CAM++は微分計算などが加えられていて当然実行時間は長くなりますし、また、Score-CAMはマスク画像を複数枚推論する必要があるために実行時間がかかっているように思います。

実行時間を気にしないならば、個人的には、Grad-CAM++かScore-CAMがおすすめかと思いました。

このような手法で、上記の判断根拠可視化のメリットで示した通り、学習データのバイアスなどないかを感覚的に調べたり、モデルの透明性や妥当性を示すのに使用すると良いでしょう。

可視化もある意味定性的な判断にはなってしまうのですが、やっぱり画像って人間もなんとなく認識している場合が多く、人間が見れば確かにこの画像はネコなんだけど、モデルはなんでそう思ったのか、をうまく言語化できなくてビジネス報告しづらい時に、これらのような表現が活用できます。