CAM系手法のちょっとしたまとめとPyTorch実装
深層学習ベースの画像認識手法について判断根拠を与える手法のうちCAM系のものについて勉強し、モデルの実装自身を変更せずに実装でき、有名な手法を選んでPyTorchの実装練習もかねて実装してみました。
- 実装について
- Pytorch 実装上のtips
- class activation mapping (CAM) [Zhou+, CVPR2016]
- Grad-CAM [Selvaraju+, ICCV2017]
- Grad-CAM++ [Chattopadhay+, WACV2018]
- Score-CAM [Wang+, CVPRW2020]
- 参考サイトなど
実装について
colab.research.google.com
誰でも試せるように、Google Colabで実装を行いました。
使用するデータセットは、STL-10と呼ばれる画像のデータセットです。元の画像が96x96と比較的大きく、10クラスしかないためちゃんと実装できてるかの検証が比較的しやすく、各クラス500枚しかないためColabでも短時間で学習が終わりそうというのが選んだ理由です。
モデル自体の識別性能は意識せず、全体的に自分で実装したかったのでモデルは自分で適当に作ったものです。data augmentationや正規化もしていません。そのため検証データでの性能がそれほど良くないですが、目的はheatmapをうまく出力するということなのでご容赦ください。
今後いろんなCAMでのheatmap作成結果を載せていきますが、ラベルが「車」でちゃんと「車」と識別した図1のvalidデータの画像についてheatmapを作ることにします。
図1の画像を識別モデルに入力した際の各クラスの出力をsoftmaxで確率分布化した結果を簡単に載せておきます。carが約0.85, truckが約0.15ですね。
class probability 0 airplane 1.360998e-05 1 bird 4.917460e-08 2 car 8.490953e-01 3 cat 7.586426e-07 4 deer 1.025798e-07 5 dog 3.446858e-07 6 horse 2.043831e-05 7 monkey 1.075173e-07 8 ship 1.048932e-05 9 truck 1.508588e-01
Pytorch 実装上のtips
hook機能
各層の勾配を求めるためにはtorch.autograd.grad()も使えますが、各層の特徴マップと勾配を求めるにはhook機能が便利です。
CAMでは、こんな感じの実装をしています。
class CAM(): def __init__(self, model): self.model = model self.image_reconstruction = None self.layer_names = [] self.feature_maps_each_layer = [] self.feature_gradients_each_layer = [] self.forward_hook_handles = [] self.backward_hook_handles = [] self.model.eval() self.register_hooks() def forward_hook(self, module, inp, outp): self.feature_maps_each_layer.append(outp) def backward_hook(self, module, inp, outp): self.feature_gradients_each_layer.append(outp) def register_hooks(self): for name, layer in self.model._modules.items(): if isinstance(layer, nn.Conv2d): self.layer_names.append(name) self.forward_hook_handles.append(layer.register_forward_hook(self.forward_hook)) self.backward_hook_handles.append(layer.register_backward_hook(self.backward_hook)) def remove_hooks(self): for i in range(len(self.forward_hook_handles)): self.forward_hook_handles[i].remove() for i in range(len(self.backward_hook_handles)): self.backward_hook_handles[i].remove() def analyze(self, target_image): target_image_numpy = target_image.squeeze(dim=0).to('cpu').detach().numpy().transpose((1,2,0)) probs = self.model.forward(target_image, analyze_cam=True) self.remove_hooks() df = pd.DataFrame({'class': LABELS, 'probability': F.softmax(probs, dim=1).squeeze(dim=0).to('cpu').detach().numpy()}) print(df) # 各クラスについてheatmapを求める heatmaps = [] for i in range(len(LABELS)): heatmap = sum([self.feature_maps_each_layer[-1][:,j] * self.model.fc.weight[i][j] for j in range(512)]).to('cpu').detach().numpy().transpose((1,2,0)) heatmaps.append(heatmap) return heatmaps
CAM
クラスのインスタンスが作成されると、__init__()
によりself.register_hooks()
が実行されます。
self.register_hooks()
内では、各層がConv2d層であるかどうかを調べ、Conv2d層であった場合にはその層についてregister_forward_hook()
およびregister_backward_hook()
を実行する (フックをかける) というような処理をしています。そうすると、各Conv2d層について、前者はforward-passを行った場合、後者はbackward-passを行った場合に自動的に実行されるようになります。register_backward_hook()
は、3つの引数module
, inp
, out
を持ち、forward-passを行うと、inpにはその層への入力、outにはその層の処理を行った後の出力が自動的に入り、関数内の処理で使うことができます。register_backward_hook()
も3つの引数module
, inp
, out
を持ち、backward-passを行うと、inpにはモデルの出力に対するその層の勾配、outにはその層への入力に対するその層の出力の勾配が入り、関数内の処理で使うことができます。連鎖律によって、前の層のoutと次の層のinpは等しくなります。
ただ、hookをかけっぱなしだと、上の実装では層の入力などを保持するリストにforward-passのたびに記録されていきColabでメモリを圧迫してくるなどの不都合が起きます。そこで、remove_hook()
によってフックを外す処理も入れておいた方がいいです。
このhook機能を使えば、各層の入力、出力、勾配を容易に獲得できます。参考サイトにものせてありますが、Understanding Pytorch hooks | Kaggleでの解説が非常にわかりやすいのでおすすめします。
class activation mapping (CAM) [Zhou+, CVPR2016]
アイデア
CAM系の手法は、heatmapを作成することで、モデルが画像のどこの部分に注目して識別を行ったのか判断根拠を提示する手法です。
CAMでは、最後のConv2d層で得られた各特徴マップについて画素の平均値をとって平坦化する global average pooling (GAP) を適用し、その出力である特長マップの個数と同じ要素数のベクトルを全結合層に入力することによって識別クラス数の要素数のベクトルを出力して予測するようなモデルに限って使用することができます。
いま、クラスのheatmapを作りたいとして、GAP前の最後の特徴マップの枚数を、番目の特徴マップを、の座標における値を、全結合層の、GAP後のベクトルの特徴マップに対応する要素から出力層のクラスのノードへの重みをと表します。
CNNでは特徴マップの各画素の輝度値を注目画素の周辺画素とフィルターの畳み込みから求めていくため、特徴マップの各画素の位置と入力画像の各画素の位置は対応しているといえます。これがCAMのポイントその1です。次に、番目の特徴マップの平均から出力層のクラスのノードへの重みが大きいほどクラスに対する出力が変化し識別に影響を与えることから、重みは番目の特徴マップの、予測に対する寄与の大きさを表しているといえます。これがCAMのポイントその2です。
これらのポイントを踏まえ、CAMのクラスに対するheatmapの座標の値は次の式で表せます。
すなわち、heatmapは特徴マップのによる重み付き和といえます。特徴マップの各画素の位置と入力画像の各画素の位置は対応しているため、heatmapの座標の値が大きいとき、入力画像におけるその座標付近の画素がモデルの予測に大きな影響を与えているといえます。言い換えれば、モデルはその座標付近の値を根拠に予測を行ったといえます。
CAMは特徴マップと重みを用いるだけで計算できるので、他のCAM系手法と比べてシンプルで実装が簡単です。一方で、CAMはGAPを用いたモデルに対してしか使用できず、汎用性に欠けます。
heatmapの例
各クラスで、heatmapを作ってみました。carとtruckクラスについては画像下部の部分が赤くなっており、その部分を根拠に予測結果を出したことがわかります。
Grad-CAM [Selvaraju+, ICCV2017]
アイデア
CAMは、GAPの結果から出力層に至る重みを特徴マップの予測に対する寄与の大きさとして使用していましたが、これではGAPを利用したモデルに応用が限定されてしまいます。Grad-CAMでは、クラスのsoftmax関数適用前の出力からbackpropにより求められる特徴マップ上の各画素の勾配の平均を特徴マップの予測に対する寄与の大きさとして使用します。クラスの出力に対する勾配の平均が大きければ、その特徴マップが微小に変化しただけで予測結果が大きく変わる、すなわち予測結果に大きく寄与するといえます。使用する情報は特徴マップと特徴マップの各画素の勾配情報であるため、GAPを利用したモデルに限定されない任意のCNNモデルに応用でき、任意の層での可視化も可能になります (ただし最終のConv2d層が一番判断根拠を示すのでわざわざ各層でheatmapを出す必要はありません)。
これらを踏まえて、に対する勾配の平均を求めます。特徴マップの画素数をとします。
このを用いて、Grad-CAMのクラスに対するheatmapの座標の値は次の(3)式で表せます。CAMと同じで、特徴マップの重み付き和です。ReLUを用いることで、正の勾配の画素のみを扱います。勾配が正であれば、その画素の輝度値が高いほどクラスに対する出力が大きくなり、予測結果に寄与するためです。
ところで、Grad-CAMはその名の通りCAMの拡張版といえます。いま、モデルが図2のような構成だったとすると、クラスのsoftmax関数適用前の出力は(4)式で表せます。
(4)式において、は特徴マップのGAPであり、それの重み付き和がクラスのsoftmax関数適用前の出力となっています。
このGAPの部分をとします。
次に、のによる微分を考えます。
(6)式の両辺をで微分すると、(5)式の両辺をで微分するとであり、これらを(7)に代入して(8)式を得ます。
(8)式について、すべての(i, j)の総和をとります。
であるから、
すなわち
したがって、CAMにおける重みを、特徴マップの各画素の勾配の総和で代替した形となっています。平均ではなく総和となっていますが、可視化の過程で正規化を行うことになるので平均をとっても総和をとってもheatmapは変わりません。
heatmapの例
Grad-CAMでは任意の層でheatmapを作成できるため、carクラスに関して各Conv2d層でheatmapを作成してみました。最終層から前の層になるにつれて赤い部分が分散していき、最初の層付近では画像のエッジ付近のみ赤くなっていることがわかります。
Grad-CAM++ [Chattopadhay+, WACV2018]
アイデア
特徴マップの各画素の勾配の平均値を特徴マップの予測結果への寄与の程度としているGrad-CAMでは、局所的な特徴をとらえた特徴マップが軽視されてしまうという問題があります。たとえば、識別クラスに含まれる物体が画像上のサイズが異なっている状態で2つ入力画像に映りこんでいたとして、大きく映っているほうをA1、小さく映っている方をA2とします。また、CNNの最後の特徴マップが2枚あり、対象クラスの出力に関するbackpropを行ったところ1枚目が物体A1、2枚目が物体A2に反応していた (その部分の勾配が大きかった) とします。このとき、Grad-CAMでは、特徴マップの各画素の勾配の平均値を特徴マップの予測結果への寄与の程度としていたことから、多くの画素で勾配の大きかった1枚目は重視され、少ない画素で勾配が大きかった2枚目は重視されないということになり、作成されるheatmapは「物体Aのみを判断根拠として予測結果を出力した」ことが読み取れるようなものとなってしまいます。
これを解決しようとしたのがGrad-CAM++です。Grad-CAM++は、特徴マップの各画素の勾配の平均値を特徴マップの予測結果への寄与の程度とせず、各画素の勾配の重み付き和を特徴マップの予測結果への寄与の程度とします。クラスに関する、この特徴マップの画素ごとの重みをとして、特徴マップに対する重みを次の式で求めます。
重みは、(11)式を(4)式に代入し、二次微分をとってについて解くことによって獲得できます。(4)式から重みを求めるということは、Grad-CAM++はGrad-CAMと同様にCAMの拡張であり、かつGrad-CAM++は最後のConv2d層にしか適用できません。
(12)式から、を求めるにはクラスのsoftmax関数適用前の出力のに関する二次微分と三次微分を求める必要がありますが、PyTorchの自動微分を利用してもこれらを求めるのは容易ではありません。しかし、最後の特徴マップから出力層に至るまでにReLUなどの線形変換しか存在しない場合、高次微分は一次微分のみを用いて簡単に求めることができることが論文内で述べられています。
ややこしい話になりますが、今のをと表し、新たにをsoftmax適用前の出力とします。すなわち、自然対数の底を底とする指数関数による出力を新たな出力とします。これにより各クラスの出力の値が変化してしまいますが、exp(x)は単調増加関数であるため各クラスの出力の大小関係は変化せず、識別結果に影響しないため良しとします。
それではのに関する一次微分、二次微分、三次微分を求めていきます。
まず、一次微分は合成関数の微分法により(13)式で求まります。
の一次微分は、PyTorchの自動微分で求めます。
次に、二次微分です。
二次微分が登場しますが、もし最後の特徴マップから出力層の計算に至るまでにReLUなどの線形変換しか存在していない場合、一次微分は定数になり、二次微分以上は0になります。したがって、(15)式のようにの一次微分のみで求められる簡単な式にできます。
でも、ここで「このの二次微分が求められるなら変換前のの二次微分も0として求められるんじゃないか?」っと思ったんですがどうなんでしょうか…。まあ0だと(12)式の分母が0になってよくないのはわかりますが…。
三次微分も同様に求められます。
(15)式、(16)式により、(12)式のが求まります。そのを用いて、(11)式のを求めます。
最後に、Grad-CAM++のクラスに対するheatmapの座標の値は次の式で表せます。
heatmapの例
各クラスで、heatmapを作ってみました。どのクラスでもほぼ同じ部分で赤くなっていることがわかります。(実装合っているのでしょうか…)
CAMのheatmapと比べて赤い部分が多くなっています。局所的な特徴をとらえる特徴マップも重視されるようになった結果でしょうか。
Score-CAM [Wang+, CVPRW2020]
アイデア
Score-CAMでは勾配情報を用いず、特徴マップでマスクした入力画像をモデルに入力した際の対象クラスのスコアを用いることでどの特徴マップが重要であるか判断します。使用する情報は入力画像と各層の特徴マップのみであるため、任意の層の特徴マップでの可視化も可能です。
まずは、特徴マップを入力画像と同サイズまで拡大し、入力画像のマスクのために特徴マップをに正規化します。max, minはそれぞれ最大輝度値、最小輝度値をとる処理です。
次に、入力画像と各特徴マップとのアダマール積を求めてマスク済み画像を求めます。
次に、マスク済み画像をCNNモデルに入力して、その結果をさらにsoftmax関数により確率分布へ変換したを得ます。
このから対象クラスにあたる出力を抽出し、特徴マップの重要度を獲得します。
これを利用し、クラスに関するScore-CAMのheatmapであるを求め、正規化を行います。
heatmapの例
各クラスで、heatmapを作ってみました。Grad-CAM++のheatmapと似ています。(これまた実装合っているのでしょうか…)
参考サイトなど
PyTorchのhook機能のわかりやすい解説。
CAMの論文。
Grad-CAMの論文。
Grad-CAM++の論文。
Score-CAMの論文。
Grad-CAM, Grad-CAM++, Score-CAMの紹介とKerasを使った実装。
Grad-CAMの著者らによる実装。
Grad-CAM++の著者らによる実装。
Grad-CAM++の解説記事。
Score-CAMの著者らによる実装。
[Zhou+, CVPR2016] B. Zhou, A. Khosla, A. Lapedriza, A. Oliva and A. Torralba, "Learning Deep Features for Discriminative Localization," in Proc. IEEE Conference on Computer Vision and Pattern Recognition, 2016, pp. 2921-2929.
[Selvaraju+, ICCV2017] R. R. Selvaraju, M. Cogswell, A. Das, R. Vedantam, D. Parikh, and D. Batra, "Grad-cam: Visual explanations from deep networks via gradient-based localization," in Proc. IEEE International Conference on Computer Vision, 2017, pp. 618-626.
[Chattopadhay+, WACV2018] A. Chattopadhay, A. Sarkar, P. Howlader, and V. N. Balasubramanian, "Grad-cam++: Generalized gradient-based visual explanations for deep convolutional networks," in Proc. IEEE Winter Conference on Applications of Computer Vision, 2018, pp. 839-847.
[Wang+, CVPRW2020] H. Wang, Z. Wang, M. Du, F. Yang, Z. Zhang, S. Ding, P. Mardziel, and X. Hu, "Score-cam: Score-weighted visual explanations for convolutional neural networks," in Proc. IEEE/CVF Conference on Computer Vision and Pattern Recognition Workshops, 2020, pp. 24-25.