jihoon's profile image

jihoon

January 17, 2020 22:50

Grad-CAM

localization , CNN , Grad-CAM , CAM

Introduction

Image Classification은 Convolutional Deep Neural Network를 활용하는 대표적인 문제들 중 하나입니다. 이와 관련된 유명한 대회로 ILSVRC (ImageNet Large Scale Visual Recognition Challenge) 가 있습니다. ImageNet 데이터베이스의 데이터를 이용하여 대회가 이루어지는데, 약 백만 장의, 천 개의 카테고리로 분류된 training image들을 이용하여 각 test image에 대해서 어떤 카테고리에 속할 지 잘 예측하는 것을 목표로 합니다. 이 대회에서 널리 알려진 네트워크 구조인 AlexNet, GoogLeNet, VGGNet, ResNet 모델 등이 제안되기도 하였습니다.

고양이가 있는 사진이 ‘고양이’ 카테고리로 분류되어 있다고 해 봅시다. 그렇다면 사람은 고양이를 있는 위치를 보고 사진이 ‘고양이’ 카테고리로 분류되어 있다고 생각할 것입니다. 그렇다면 과연 CNN은 어떻게 카테고리를 예측할까요?

이러한 궁금증을 해결하기 위해 여러 솔루션들이 제안되었습니다. 이 글에서는 가장 널리 알려진 솔루션 중 하나인 CAM (Class Activation Map)과 이후에 나온 Grad-CAM (Gradient-weighted CAM) 에 대해서 다루도록 하겠습니다.

CAM (Class Activation Map)

Convolutional layer를 사용한 모델은 (특히) 이미지 처리를 할 때 Fully-connected layer를 사용할 때에 비해 많은 task에서 좋은 성능을 보여주었습니다. 또한 Convolutional layer는 layer를 거친 후에도 spatial information을 보존하는데 비해, Fully-Connected layer는 flatten 과정을 거치게 되므로 spatial information의 손실이 발생합니다.

많은 Image Classification 모델들은 여러 층의 Convolutional layer를 거친 후, Fully-Connected layer를 통해서 classification을 진행합니다. 반면에 CAM은 Convolutional layer를 거친 후 Fully-Connected Layer를 바로 사용하는 대신 GAP (Global Average Pooling) 를 사용한 후 마지막으로 하나의 Fully-Connected Layer를 사용합니다. GAP는 Average Pooling Layer에서 kernel size가 layer의 input size와 동일한 경우로, Network In Network 논문에서 parameter 개수를 줄여 overfitting을 방지하기 위한 아이디어로 제안되었습니다. 이 논문에서는 GAP가 overfitting 뿐만 아니라 특정 카테고리에 대한 부분을 효과적으로 localization하는 데에 사용할 수 있다는 것을 보였습니다.

CAM 계산

CAM을 구하기 위해서는 마지막 convolutional layer의 output (= GAP의 input) 과 바로 뒤의 Fully-Connected Layer의 weight, 이렇게 두 가지 정보가 필요합니다. $f_k(x, y)$를 GAP의 input의 $k$번째 unit의 $(x, y)$ 좌표에 해당하는 값이라고 하고, $w_{k}^{c}$를 Fully-Connected layer에서 input의 $k$번째 unit과 output의 $c$번째 (class)에 대응되는 weight이라고 정의합시다.

그렇다면 softmax를 거치기 전 Fully-Connected layer를 거친 후 $c$번째에 대응되는 output $S_{c}$는 아래와 같이 나타낼 수 있습니다:

$S_c = \sum_{k} w_{k}^{c} \sum_{x, y} f_k(x, y) = \sum_{k} \sum_{x, y} w_{k}^{c} f_k(x, y) = \sum_{x, y} \sum_{k} w_{k}^{c} f_k(x, y)$

편의 상 $M_c(x, y) = \sum_{k} w_{k}^{c} f_k(x, y)$로 정의합시다. 그러면 $M_c(x, y)$를 GAP의 input에서 좌표 $(x, y)$가 class $c$에 얼마나 영향을 주는지와 관련된 수치라고 생각할 수 있습니다. 그리고 CAM의 결과는 $M_c(x, y)$로 이루어진 행렬을 원래 이미지 크기에 맞게 upscaling한 결과로 나타내어집니다.

출처: Learning Deep Features for Discriminative Localization

위의 사진은 위의 식을 이용해 계산한 CAM의 결과를 보여줍니다. (빨간색에 가까울수록 수치가 크고, 파란색에 가까울수록 수치가 작습니다.) 왼쪽 그림은 흰 사다새 (White Pelican), 오른쪽 그림은 Orchard oriole이라는 새의 실제 위치와 CAM 결과를 나타내고 있습니다. Object의 위치를 따로 학습하지 않고 classification task에 대해서만 학습해도, CAM이 새의 실제 위치를 매우 잘 예측하는 것을 확인할 수 있습니다.

CAM 구현

편의 상 VGG 모델 에서 CAM을 사용한다고 가정하고 설명을 진행하겠습니다. 먼저 끝 부분의 max pooling layer와 fully connected layer를 제거하고, CAM에 사용할 마지막 Convolutional layer와 Global Average Pooling 그리고 Fully-Connected Layer를 붙여주면 됩니다.

from torch import nn

class VGG16_CAM(nn.Module):
	def __init__(self):
		super(VGG16_CAM, self).__init__()
		# [16개의 Convolutional Layer]
		# 마지막 Convolutional Layer
		self.CAM_conv = nn.Conv2d(512, 1024, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), groups=2)
        self.CAM_relu = nn.ReLU(inplace=True)
        # Global Average Pooling과 CAM 계산 및 classification을 위한 Fully-Connected Layer
        self.CAM_gap = nn.AvgPool2d(kernel_size=14, stride=14)
        self.CAM_fc = nn.Linear(in_features=1024, out_features=1000, bias=True)

그 후 CAM_relu layer에 forward_hook을 추가해서, CAM을 구할 때 마지막 convolutional layer의 output을 사용할 수 있도록 합니다.

self.model.CAM_relu.register_forward_hook(self.forward_hook)

def forward_hook(self, _, input, output):
	self.forward_result = torch.squeeze(output)

아래처럼 tensordot을 이용하여 $M_{c}(x, y)$를 쉽게 구할 수 있습니다. 그 후 normalize와 원래 이미지의 크기에 맞게 bilinear upsampling을 해주면 CAM을 구할 수 있습니다.

cam = torch.tensordot(self.model.CAM_fc.weight[target_label, :], self.forward_result.squeeze(), dims=1).squeeze()

# normalize
cam = (cam + torch.abs(cam)) / 2
cam /= torch.max(cam)

# Bilinear-upsample (14*14 -> 224*224)
cam = torch.nn.Upsample(scale_factor=16, mode='bilinear')(cam.unsqueeze(0).unsqueeze(0))
return cam.squeeze().cpu().detach().numpy()

단점

CAM의 가장 큰 단점은 바로 Global Average Pooling layer가 반드시 필요하다는 점입니다. GAP이 이미 포함되어 있는 GoogLeNet의 경우에는 문제가 없겠지만, 그렇지 않은 경우에는 마지막 convolutional layer 뒤에 GAP를 붙여서 다시 fine-tuning 해야 한다는 문제가 생기고, 약간의 성능 감소를 동반하는 문제가 있습니다. 또한, 같은 이유로 마지막 layer에 대해서만 CAM 결과를 얻을 수 있습니다.

Grad-CAM

Grad-CAM은 gradient를 이용하여 Global Average Pooling에 의존하지 않는 아이디어를 제안했습니다. 이에 따라 자연스럽게 CAM의 문제점을 해결하게 되었습니다. 즉, Grad-CAM을 사용하면 어떤 Convolutional Layer를 가진 모델이어도 모델 구조의 수정 없이 CAM 결과를 얻을 수 있습니다. 또한, Convolutional Layer를 가진 모델이면 모두 Grad-CAM을 적용할 수 있기 때문에 Image Captioning이나 Visual Question Answering 등의 다른 문제들을 해결하는 모델들에서도 사용할 수 있게 되었습니다.

Grad-CAM을 얻기 위해서는 관찰하려는 convolutional layer의 gradient와 그 layer를 통과한 output의 정보가 필요합니다. $y^{c}$를 softmax를 거치기 전 output의 $c$번째 값, $A^{k}{ij}$를 관찰하려는 layer를 통과한 output의 좌표 $(i, j)$에 대응되는 값으로 정의합시다. 이 두 정보를 이용하여 neuron importance weight이라 불리는 $\alpha{k}^{c}$는 다음과 같이 정의됩니다.

$\alpha_{k}^{c} = \frac{1}{Z} \sum_{i,j} \frac{\partial y^{c}}{\partial A^{k}_{ij}}$

최종적으로 $\alpha_{k}^{c}$와 $A^k_{ij}$를 이용하여, Grad-CAM은 아래 식과 같이 계산됩니다.

$M_c(i, j) = ReLU (\sum_{k} \alpha_{k}^{c} A^{k}_{ij}) $

마지막에 ReLU를 적용하는 이유는 어떠한 class에 대한 긍정적인 영향에 대해서만 관심이 있기 때문이라고 설명하고 있습니다. 또한 실험적으로 ReLU를 제거했을 때 localization 성능이 크게 떨어졌다고 합니다.

그리고 놀랍게도 Grad-CAM의 계산결과와 CAM의 계산결과는 같습니다. 왜 두 결과가 같은지에 대해서는 Grad-CAM 논문 에 소개되어 있습니다. 수학적 지식이 필요하므로 여기서는 언급하지 않도록 하겠습니다.

Grad-CAM 구현

앞에서 설명한대로, CAM과는 다르게 신경망 구조의 변경은 필요하지 않습니다. 먼저 forward_hook과 backward_hook을 추가해서 관찰하려는 convolutional layer (아래 사진에서는 target_layer)를 통과한 output과 gradient를 구합니다.

target_layer.register_forward_hook(self.forward_hook)
target_layer.register_backward_hook(self.backward_hook)

def forward_hook(self, _, input, output):
	self.forward_result = torch.squeeze(output)

def backward_hook(self, _, grad_input, grad_output):
    self.backward_result = torch.squeeze(grad_output[0])

먼저 $A^{k}{ij}$를 구하기 위해서, 이미지를 model에 input으로 넣습니다. 그 후 $\alpha{k}^{c}$ 를 구하기 위해 backward propagation을 하여 gradient를 얻습니다. 그 후 hook을 통해 구한 두 값을 이용하여 Grad-CAM을 계산합니다. 자세한 과정은 아래의 코드를 참고하세요.

# 자동으로 foward_hook 함수가 호출되고, self.forward_result에 관찰하려는 layer를 거친 output이 저장됩니다.
outs = self.model(img).squeeze()

# backward를 통해서 자동으로 backward_hook 함수가 호출되고, self.backward_result에 gradient가 저장됩니다.
outs[target_class].backward(retain_graph=True)
        
# gradient의 평균을 구합니다. (alpha_k^c)
a_k = torch.mean(self.backward_result, dim=(1, 2), keepdim=True)

# self.foward_result를 이용하여 Grad-CAM을 계산합니다.
out = torch.sum(a_k * self.forward_result, dim=0).cpu()

# normalize
out = (out + torch.abs(out)) / 2
out = out / torch.max(out)
        
# Bilinear upsampling (14*14 -> 224*224)
m = torch.nn.Upsample(scale_factor=16, mode='bilinear')
return m(out.unsqueeze(0).unsqueeze(0)).detach().squeeze().numpy()

Guided Grad-CAM

Grad-CAM은 image 상에서 class와 관련된 부분을 대략적으로는 잘 찾아내지만, Bilinear upsampling 등의 영향으로 그 부분의 detail은 잘 잡아내지 못합니다. 그래서 저자는 좀 더 효과적인 관찰을 위해 Guided backpropagation과 Grad-CAM을 함께 이용하려고 하였습니다. Guided backpropagation은 detail은 잘 나타내지만 특정 class와 관련된 부분을 잘 찾아내지는 못한다는 특징을 가지고 있습니다. 두 가지 아이디어의 장점을 모두 살리기 위해 저자는 Grad-CAM과 Guided Backpropagation의 결과를 element-wise multiplication을 해서 얻을 수 있는 Guided Grad-CAM을 제안했습니다.

출처: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization

위의 figure는 원본 이미지와 Guided Backpropagation의 결과, Grad-CAM의 결과, 그리고 Guided Grad-CAM의 결과를 모두 보여줍니다. 위에서 설명한 대로, Grad-CAM과 Guided backpropagation의 장점을 모두 갖추어서 좀 더 효과적으로 이미지를 관찰할 수 있게 되었습니다.

Guided Backpropagation 구현

Guided Backpropagation은 ReLU를 변형하여 아래와 같이 간단하게 구현할 수 있습니다. forward propagation은 ReLU와 완전히 동일하고, backward propagation 부분에 차이가 있습니다. ReLU에서는 gradient에서 activation이 음수인 부분만 0으로 바뀌는데, Guided Backpropagation에서는 activation이 음수인 부분과 gradient가 음수인 부분이 모두 0으로 치환되는 특징을 가집니다. guided backpropagation의 결과를 얻기 위해서는 Grad-CAM을 구할 모델에서 ReLU를 모두 치환한 후, backpropagation을 통해 gradient를 구하면 됩니다.

class GReLUInner(torch.autograd.Function):
    # forward and backward functions for Guided Backpropagation
    @staticmethod
    def forward(ctx, input):
        ctx.save_for_backward(input)
        return input * (input > 0).float()
    
    @staticmethod
    def backward(ctx, grad_output):
        input = ctx.saved_tensors
        return grad_output * (grad_output > 0).float() * (input[0] > 0).float()

Grad-CAM의 활용

Weakly-supervised Localization

앞에서도 간략히 언급한 것처럼 Object의 위치를 따로 학습하지 않고 classification task에 대해서만 학습해도, CAM이 class와 관련된 부분을 대략적으로 찾아낼 수 있습니다.

Counterfactual Explanations

고양이와 관련된 그림이 주어졌다고 해봅시다. 그러면 사람은 고양이의 얼굴을 보고 그림이 고양이와 관련된 것이라는 판단을 할 것이고, Grad-CAM을 이용해도 비슷한 판단을 하게 될 것입니다.

출처: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization

그렇다면 반대로 개와 관련된 것이라고 판단하지 않은 이유는 무엇일까요? 그것은 바로 고양이 얼굴 부분이 ‘개’라는 키워드에 대해서 negative influence를 주었기 때문일 것입니다. Grad-CAM을 아주 조금 수정해서, 이러한 부정적인 영향에 대해서도 분석할 수 있습니다. 아래 식처럼 부호를 붙여주어서 계산을 하면 됩니다.

$\alpha_{k}^{c} = \frac{1}{Z} \sum_{i,j} - \frac{\partial y^{c}}{\partial A^{k}_{ij}}$

Reference

(CAM paper) Learning Deep Features for Discriminative Localization

Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization

Striving for Simplicity: The All Convolutional Net