안녕하세요.

 

오늘은 Grad-CAM 논문의 code review를 진행해보려고 합니다.

 

해당 논문의 paper review는 다음 주소에서 확인하실 수 있습니다.

 

https://cumulu-s.tistory.com/40

 

9. Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization - paper review

안녕하세요. 오늘은 CAM을 발전시킨 방법론인 Grad-CAM의 논문을 review 해보겠습니다. 논문 주소: arxiv.org/abs/1610.02391 Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization..

cumulu-s.tistory.com

 

 

 

이번 code review에서는 ImageNet Pre-trained resnet50을 이용해서 Grad-CAM을 만들어보면서 code review를 진행해보겠습니다.

 

물론, resnet50을 받아올 때 pretrained = False로 parameter를 setting 하게 되면 별도의 학습된 weight를 가지고 오지 않으므로, 이를 활용해서 원하시는 custom dataset에 동일한 방법을 사용해 Grad-CAM을 생성하실 수 있습니다.

 

custom dataset에 활용하는 방법은 제 Github에 구현되어 있으니, 확인해주시면 됩니다.

 

다만 제가 이번 code review에서 Pre-trained resnet50을 사용하는 이유는, 본 논문에서 제시되었던 Fig. 1을 직접 구현하기 위함입니다.

 

Fig. 1은 다음과 같습니다.

 

 

그럼 본격적으로 시작해보겠습니다.

 

import torchvision.models as models
import torch.nn as nn
import torch
import os
import cv2
import PIL
import torchvision
import torchvision.transforms as transforms
import datetime
import numpy as np
import torch.nn.functional as F
import matplotlib.pyplot as plt
import matplotlib
from torchvision.utils import make_grid, save_image

device = 'cuda' if torch.cuda.is_available() else 'cpu'

resnet50 = models.resnet50(pretrained = True).to(device)
resnet50.eval()

 

구현하는데 필요한 library를 불러오고, device를 cuda로 설정하였으며 Pre-trained resnet50를 가져옵니다.

 

그리고 Grad-CAM에서는 별도로 학습을 진행하지 않으므로, resnet50.eval()을 선언하여 inference mode로 전환합니다.

 

 

img_dir = 'images'
img_name = 'both.png'
img_path = os.path.join(img_dir, img_name)

pil_img = PIL.Image.open(img_path)
pil_img

 

images라는 directory는 제 Github에 올라와있으며, 이를 통해서 논문에 나왔던 개와 고양이가 같이 있는 사진을 가져옵니다.

 

해당 사진은 다음과 같습니다.

 

 

def normalize(tensor, mean, std):
    if not tensor.ndimension() == 4:
        raise TypeError('tensor should be 4D')

    mean = torch.FloatTensor(mean).view(1, 3, 1, 1).expand_as(tensor).to(tensor.device)
    std = torch.FloatTensor(std).view(1, 3, 1, 1).expand_as(tensor).to(tensor.device)

    return tensor.sub(mean).div(std)


class Normalize(object):
    def __init__(self, mean, std):
        self.mean = mean
        self.std = std

    def __call__(self, tensor):
        return self.do(tensor)
    
    def do(self, tensor):
        return normalize(tensor, self.mean, self.std)
    
    def undo(self, tensor):
        return denormalize(tensor, self.mean, self.std)

    def __repr__(self):
        return self.__class__.__name__ + '(mean={0}, std={1})'.format(self.mean, self.std)

위 사진을 normalization 해야 하므로, 이를 위한 코드를 만들어줍니다.

 

 

 

normalizer = Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
torch_img = torch.from_numpy(np.asarray(pil_img)).permute(2, 0, 1).unsqueeze(0).float().div(255).cuda()
torch_img = F.interpolate(torch_img, size=(224, 224), mode='bilinear', align_corners=False) # (1, 3, 224, 224)
normed_torch_img = normalizer(torch_img) # (1, 3, 224, 224)

 

mean값과 std 값은 ImageNet의 값들이며, 알려져 있는 값들이라 그대로 사용하면 됩니다.

 

pil_img를 numpy로 만들고, permute를 활용해 차원의 위치를 바꿔주고, unsqueeze(0)으로 맨 첫 번째 차원을 하나 늘려주면서 255로 나눠줍니다. 이를 torch.from_numpy를 통해 torch.Tensor로 전환합니다.

 

그리고 우리가 사용할 ImageNet pre-trained resnet50은 input shape가 224x224가 되어야 합니다. 따라서 이를 F.interpolate를 이용해서 224,224가 되도록 width / height를 변경해줍니다. 

 

이를 적용하면 torch_img의 shape는 (1, 3, 224, 224)가 됩니다.

 

앞에서 함수로 만들어뒀던 normalizer를 활용해서 이 이미지를 normalization 해줍니다.

 

 

current_time = datetime.datetime.now() + datetime.timedelta(hours= 9)
current_time = current_time.strftime('%Y-%m-%d-%H:%M')

saved_loc = os.path.join('./', current_time)
if os.path.exists(saved_loc):
    shutil.rmtree(saved_loc)
os.mkdir(saved_loc)

print("결과 저장 위치: ", saved_loc)

제 코드 리뷰를 보신 분들은 아시겠지만, 항상 사용하는 코드들입니다.

 

해당 코드를 돌렸을 때 결과를 저장하기 위해서 돌린 시간을 폴더 이름으로 만들어줍니다.

 

from torch.autograd import Function


class GuidedBackpropReLU(Function):
    @staticmethod
    def forward(self, input_img):
        # input image 기준으로 양수인 부분만 1로 만드는 positive_mask 생성
        positive_mask = (input_img > 0).type_as(input_img)
        
        # torch.addcmul(input, tensor1, tensor2) => output = input + tensor1 x tensor 2
        # input image와 동일한 사이즈의 torch.zeros를 만든 뒤, input image와 positive_mask를 곱해서 output 생성
        output = torch.addcmul(torch.zeros(input_img.size()).type_as(input_img), input_img, positive_mask)
        
        # backward에서 사용될 forward의 input이나 output을 저장
        self.save_for_backward(input_img, output)
        return output

    @staticmethod
    def backward(self, grad_output):
        
        # forward에서 저장된 saved tensor를 불러오기
        input_img, output = self.saved_tensors
        grad_input = None

        # input image 기준으로 양수인 부분만 1로 만드는 positive_mask 생성
        positive_mask_1 = (input_img > 0).type_as(grad_output)
        
        # 모델의 결과가 양수인 부분만 1로 만드는 positive_mask 생성
        positive_mask_2 = (grad_output > 0).type_as(grad_output)
        
        # 먼저 모델의 결과와 positive_mask_1과 곱해주고,
        # 다음으로는 positive_mask_2와 곱해줘서 
        # 모델의 결과가 양수이면서 input image가 양수인 부분만 남도록 만들어줌
        grad_input = torch.addcmul(torch.zeros(input_img.size()).type_as(input_img),
                                   torch.addcmul(torch.zeros(input_img.size()).type_as(input_img), grad_output,
                                                 positive_mask_1), positive_mask_2)
        return grad_input

 

이번에 소개할 내용은 GuidedBackpropagation 입니다. 

 

Grad-CAM 논문에서는 별도로 Guided Backpropagation을 설명하고 있지는 않지만, 논문의 Fig. 1을 만들기 위해서는 알아야 하므로 설명을 해보겠습니다.

 

Guided backpropagation을 설명해주는 많은 글에서 사용하는 그림 자료입니다.

 

Forward pass에서는, input image에서 양수인 부분들은 그대로 출력하고, 음수인 부분은 0으로 처리합니다.

 

ReLU와 동일하다고 할 수 있죠.

 

Backward pass에서, 우리가 살펴볼 내용은 가장 아래쪽에 있는 Guided backpropagation입니다.

 

Guided backpropagation은 gradient가 0보다 크면서, relu의 output이 양수인 부분만 backward로 넘겨줍니다.

 

Guided backpropagation을 보게 되면 gradient가 현재 -2, 3, -1, 6, -3, 1, 2, -1, 3입니다.

 

여기서 먼저 0보다 큰 부분을 남기면 0, 3, 0, 6, 0, 1, 2, 0, 3이 될 것이고,

 

relu의 output이 양수인 부분만 다시 한번 걸러서 남겨주게 되면 0, 0, 0, 6, 0, 0, 0, 0, 3이 됩니다.

 

이와 같은 방식을 코드로 구현한 내용이라고 보시면 되겠습니다.

 

 

 

앞에서 설명드린 대로, Guided Backpropagation은 크게 forward와 backward 두 가지로 나눠서 생각해봐야 합니다.

 

Forward는 ReLU와 동일합니다.

 

input_img가 들어왔을 때, 값이 0보다 큰 경우만 남기기 위해 positive_mask를 생성합니다.

 

예를 들어서, input 값이 [1, -2, 3, -5, 7]이라고 한다면, positive_mask는 [1, 0, 1, 0, 1]이 됩니다.

 

만들어진 positive_mask를 이용해서, torch.addcmul 함수를 이용해 input_img 중에 양수인 부분만 남기도록 만들어줍니다.

 

torch.addcmul은 input와 tensor1, tensor2를 입력으로 받고, input + tensor1 x tensor 2를 결과로 내게 되는데 해당 코드에서는 input은 그냥 torch.zeros로 0으로 채워진 tensor를 만들어주고, input_img와 positive_mask를 곱해서 나온 결과를 output으로 만들어줍니다.

 

그러고 나서 self.save_for_backward 함수를 통해 input과 output을 저장해줍니다.

 

이렇게 저장을 해두면, backward 단에서 이를 가지고 와서 사용할 수 있게 됩니다.

 

 

다음으로는 backward입니다. 

 

먼저 forward에서 저장된 input_img와 output을 self.saved_tensors를 통해서 가져옵니다.

 

backward에서는 앞에서 그림과 함께 설명한 대로, 두 가지 기준을 만족해야 하는데요.

 

1) forward에서 relu output이 0보다 큰 부분 / 2) gradient가 0보다 큰 부분이라는 조건을 만족해야 합니다.

 

따라서 이번에는 positive_mask를 두 개 생성합니다.

 

positive_mask_1은 input_img가 0보다 큰 부분을, positive_mask_2는 gradient가 0보다 큰 부분을 잡습니다.

 

그리고 이전과 동일하게 torch.addcmul 함수를 이용해 이 두 개의 mask를 적용해서 최종 결과를 내게 됩니다.

 

 

 

class GuidedBackpropReLUModel:
    def __init__(self, model, use_cuda):
        self.model = model
        self.model.eval()
        self.cuda = use_cuda
        if self.cuda:
            self.model = model.cuda()

        def recursive_relu_apply(module_top):
            for idx, module in module_top._modules.items():
                recursive_relu_apply(module)
                if module.__class__.__name__ == 'ReLU':
                    module_top._modules[idx] = GuidedBackpropReLU.apply

        # replace ReLU with GuidedBackpropReLU
        recursive_relu_apply(self.model)

    def forward(self, input_img):
        return self.model(input_img)

    def __call__(self, input_img, target_category=None):
        if self.cuda:
            input_img = input_img.cuda()

        input_img = input_img.requires_grad_(True)

        output = self.forward(input_img)

        if target_category is None:
            target_category = np.argmax(output.cpu().data.numpy())

        one_hot = np.zeros((1, output.size()[-1]), dtype=np.float32)
        one_hot[0][target_category] = 1
        one_hot = torch.from_numpy(one_hot).requires_grad_(True)
        if self.cuda:
            one_hot = one_hot.cuda()

        one_hot = torch.sum(one_hot * output)
        # 모델이 예측한 결과값을 기준으로 backward 진행
        one_hot.backward(retain_graph=True)

        # input image의 gradient를 저장
        output = input_img.grad.cpu().data.numpy()
        output = output[0, :, :, :]
        output = output.transpose((1, 2, 0))
        return output

 

다음으로 소개할 내용은 모델을 Guided Backpropagation이 가능한 모델로 바꿔주는 코드입니다.

 

__init__에서 받는 인자들을 보시면 model과 use_cuda를 받게 되고, 우리의 코드에서는 model = resnet50이고 use_cuda는 True입니다.

 

recursive_relu_apply라는 함수를 self.model에 적용하는 것을 확인할 수 있는데, 이는 ReLU를 GuidedBackpropReLU로 바꾸게 됩니다. GuidedBackpropReLU는 방금 위에서 설명드린 코드입니다.

 

 

실제로 이 모델이 불렸을 때 (call 되었을 때) 작동하는 부분도 보겠습니다.

 

인자로는 input_img와 target_category를 받게 됩니다. target_category는 어떤 class를 기준으로 Guided Backprop을 시행할지를 결정하는 것이라고 보시면 되겠습니다.

 

고양이에 대한 그림을 그리고 싶으면 target_category에 cat class에 해당하는 값을 집어넣으면 되고, 개에 대한 그림을 그리고 싶으면 dog class에 해당하는 값을 집어넣으면 됩니다.

 

먼저 input_img의 requires_grad를 True로 지정하고, 이를 가지고 self.forward로 모델을 통과시킵니다.

 

즉 이 작업은 이미지가 주어졌을 때, 이를 기반으로 어떤 class인지 예측을 하는 image classification을 작동시킵니다.

 

그럼 이걸 통해서 예측된 결과가 나오게 되겠죠? 그게 output이라는 변수에 저장됩니다.

 

one_hot은 output 사이즈만큼 np.zeros로 만들어주는 것이고, one_hot[0][target_category] = 1은 말 그대로 target_category에 해당하는 곳만 1로 만들어줍니다.

 

예를 들어서 고양이는 281번인데, target_category를 281로 지정하면 one_hot에서 281번째 위치만 1이 되고 나머지는 0이 됩니다.

 

그리고 이를 torch.from_numpy로 해서 torch Tensor로 바꿔줍니다. 

 

다음으로는 one_hot = torch.sum(one_hot * output)이 되는데, 이는 예측한 값과 one_hot을 곱해 우리가 원하는 class의 예측값만을 빼냅니다. 

 

즉, 고양이를 예로 들자면 고양이가 될 확률을 얼마로 예측했는지를 저장한 것이죠.

 

그리고 이를 가지고 .backward()를 통해서 backward를 진행합니다.

 

output이라는 변수로 input image의 gradient를 저장하고, numpy로 바꾼 다음 transpose를 통해 차원의 위치를 바꿔줍니다.

 

이 작업은 torch에서의 차원 순서 별 의미와 numpy에서의 차원 순서 별 의미가 다르기 때문에 작업합니다.

 

pytorch에서는 224x224 짜리 RGB 이미지를 (3, 224, 224)로 저장하게 되는데, numpy에서는 (224, 224, 3)으로 저장하기 때문이죠.

 

def deprocess_image(img):
    """ see https://github.com/jacobgil/keras-grad-cam/blob/master/grad-cam.py#L65 """
    img = img - np.mean(img)
    img = img / (np.std(img) + 1e-5)
    img = img * 0.1
    img = img + 0.5
    img = np.clip(img, 0, 1)
    return np.uint8(img * 255)

 

해당 코드는 Guided Backprop을 통해서 얻어진 이미지를 처리하는 함수입니다.

 

논문에 나온 사진처럼 만들기 위해 관례적으로 사용하는 처리 방식인 것 같습니다.

 

 

 

# final conv layer name 
finalconv_name = 'layer4'

# activations
feature_blobs = []

# gradients
backward_feature = []

# output으로 나오는 feature를 feature_blobs에 append하도록
def hook_feature(module, input, output):
    feature_blobs.append(output.cpu().data)
    

# Grad-CAM
def backward_hook(module, input, output):
    backward_feature.append(output[0])
    

resnet50._modules.get(finalconv_name).register_forward_hook(hook_feature)
resnet50._modules.get(finalconv_name).register_backward_hook(backward_hook)

finalconv_name을 통해서 어떤 conv layer에서 Grad-CAM을 뽑아낼지 결정합니다.

 

만약 다른 conv의 결과를 가지고 Grad-CAM을 구현하고 싶다면, 이 부분을 변경하시면 됩니다.

 

feature_blobs는 forward 단계에서 얻어지는 feature를 저장하는 곳이고, backward_feature는 backward 단계에서 얻어지는 feature를 저장하는 곳입니다.

 

hook_feature라는 함수를 통해 forward 진행 시 특정한 layer에서 얻어지는 output을 feature_blobs에 저장합니다.

 

backward_hook이라는 함수를 통해 backward 진행 시 특정한 layer에서 얻어지는 output을 backward_feature에 저장합니다.

 

resnet50._modules.get(finalconv_name).register_forward_hook(hook_feature)은 forward 단계에서 hook_feature를 적용하도록 만드는 함수이고, 그 밑은 backward 단계에서 backward_hook을 적용하도록 만드는 함수입니다.

 

 

 

forward라는 것은, input image가 주어졌을 때 conv layer들을 거쳐서 최종 output을 내는 과정을 말합니다.

 

예를 들자면, 어떤 사진이 주어졌을 때, layer들을 거쳐 최종 output으로 해당 이미지가 어떤 종류의 이미지인지를 분류하는 것이죠.

 

 

ResNet 논문에 있는 전체 구조 table을 살펴보자면, 마지막 conv을 거치고 나온 결과는 output size가 7x7이고 channel 수가 512입니다. 

 

따라서, forward를 진행하고 나서 이 feature가 feature_blobs에 저장될 것입니다.

 

 

Backward는 계산한 output을 가지고 backpropagation을 통해 gradient를 계산하는 과정이라고 보시면 됩니다.

 

 

 

논문에서 "gradients via backprop"이라고 표현된 부분이 바로 위의 코드에서 구현된 부분이라고 보시면 되겠습니다.

 

의미적으로 본다면, 해당 Activation map($A^k$)이 어떤 예측값에 영향을 준 정도가 되겠죠?

 

이 또한 마지막 conv을 거치고 나온 결과와 사이즈가 동일하므로, output size가 7x7이고 channel 수가 512가 나옵니다.

 

 

 

# get the softmax weight
params = list(resnet50.parameters())
weight_softmax = np.squeeze(params[-2].cpu().detach().numpy()) # [1000, 512]

# Prediction
logit = resnet50(normed_torch_img)

 

resnet50에 있는 모든 파라미터를 list로 만들어두고, 여기서 마지막에서 두 번째 파라미터만 빼냅니다

 

params[-1]은 1000개로 예측할 때 사용된 bias이고, params[-2]는 weight가 됩니다.

 

이 부분은 모델을 설계할 때 마지막 fc layer에서 bias = False로 지정될 경우 이를 -1로 바꿔줘야 하기 때문에 반드시 모델을 어떻게 설계했는지 확인하셔야 합니다.

 

 

아까 만들어뒀던 normalization을 거친 img를 resnet50에 투입하여 결괏값을 냅니다.

 

이를 logit이라는 변수에 저장합니다.

 

 

 

# ============================= #
# ==== Grad-CAM main lines ==== #
# ============================= #


# Tabby Cat: 281, pug-dog: 254
score = logit[:, 254].squeeze() # 예측값 y^c
score.backward(retain_graph = True) # 예측값 y^c에 대해서 backprop 진행

 

지금부터가 본 논문의 메인 파트가 됩니다.

 

어쩌다 보니 앞의 내용이 너무 길었어서 오히려 본론이 늦게 나온 느낌인데요.

 

코드를 따라가시면서 이해가 최대한 잘 되도록 주석으로 shape를 표기하였으니, 같이 확인하시면서 따라가 주시면 좋을 것 같습니다.

 

logit에서 254번째로 예측한 값을 score라는 변수로 빼냅니다.

 

ImageNet class name을 확인해보니, index 기준 254가 pug-dog이었습니다.

 

따라서 logit[:, 254]로 예측값을 빼낼 수 있습니다.

 

고양이의 경우 281로 바꿔주시면 됩니다.

 

그리고 해당 score를 가지고 backward를 진행하도록 해줍니다.

 

 

activations = feature_blobs[0].to(device) # (1, 512, 7, 7), forward activations
gradients = backward_feature[0] # (1, 512, 7, 7), backward gradients
b, k, u, v = gradients.size()

alpha = gradients.view(b, k, -1).mean(2) # (1, 512, 7*7) => (1, 512), feature map k의 'importance'
weights = alpha.view(b, k, 1, 1) # (1, 512, 1, 1)

 

feature_blobs[0]는 위에서 설명드렸지만, forward pass를 진행하면서 얻게 되는 마지막 conv layer의 feature map이 됩니다. 

 

이는 크기가 (1, 512, 7, 7)이 되고요. (1은 batch size인데 지금은 이미지를 한 장 투입했으므로 1입니다.)

 

이를 activations라는 변수로 저장합니다.

 

backward_feature[0]도 앞에서 설명했듯이 backward pass를 진행하면서 얻게 되는 마지막 conv layer의 feature map의 gradient가 됩니다. 

 

이 또한 activations와 크기는 동일합니다.

 

다음으로는 gradient를 (1, 512, 7*7) 사이즈로 만든 뒤, 이를 3번째 차원 기준으로 평균을 냅니다.

 

이는 논문에서 다음 수식을 코드로 구현한 것입니다.

 

 

따라서 이 변수는 크기가 (1, 512)가 되며, 각 feature map k에 대한 importance weight를 나타냅니다.

 

논문에서 보셨겠지만, 이 importance weight는 forward activation map과의 weighted combination을 진행해야 하므로 사이즈가 동일해야 합니다.

 

이를 위해 .view를 이용하여 (1, 512, 1, 1)로 만들어줍니다.

 

grad_cam_map = (weights*activations).sum(1, keepdim = True) # alpha * A^k = (1, 512, 7, 7) => (1, 1, 7, 7)
grad_cam_map = F.relu(grad_cam_map) # Apply R e L U
grad_cam_map = F.interpolate(grad_cam_map, size=(224, 224), mode='bilinear', align_corners=False) # (1, 1, 224, 224)
map_min, map_max = grad_cam_map.min(), grad_cam_map.max()
grad_cam_map = (grad_cam_map - map_min).div(map_max - map_min).data # (1, 1, 224, 224), min-max scaling

 

앞에서 구해놓은 weights와 forward activation map인 activations를 곱해주고, 이를 2 번째 차원 기준으로 더해줍니다.

 

이는 논문의 해당 수식을 코드로 구현한 것입니다.

 

 

그리고 여기에 F.relu로 ReLU를 적용하고요.

 

마지막 conv layer의 feature map 사이즈가 7x7였기 때문에, 이를 그대로 두면 원본 이미지와 더해줄 수가 없습니다. 

 

따라서 F.interpolate 함수를 이용하여 이를 224x224로 upsampling 해줍니다. 

 

mode는 bilinear로 설정해주는데, 이는 논문에 나온 내용을 그대로 구현한 것입니다.

 

논문의 3.2 Guided Grad-CAM에는 다음과 같은 내용이 적혀있습니다.

 

 

다음으로는 grad_cam_map을 min과 max를 구해 이를 min-max scaling 해줍니다. 

 

이를 통해 (1, 1, 224, 224)의 shape를 가지고 값은 0 ~ 1을 가지는 Grad-CAM을 가지게 되었습니다.

 

# grad_cam_map.squeeze() : (224, 224)
grad_heatmap = cv2.applyColorMap(np.uint8(255 * grad_cam_map.squeeze().cpu()), cv2.COLORMAP_JET) # (224, 224, 3), numpy

# Grad-CAM heatmap save
cv2.imwrite(os.path.join(saved_loc, "Grad_CAM_heatmap.jpg"), grad_heatmap)

grad_heatmap = np.float32(grad_heatmap) / 255

grad_result = grad_heatmap + img
grad_result = grad_result / np.max(grad_result)
grad_result = np.uint8(255 * grad_result)

# Grad-CAM Result save
cv2.imwrite(os.path.join(saved_loc, "Grad_Result.jpg"), grad_result)

 

논문의 그림을 보시면, Grad-CAM이라고 하는 것이 빨간색, 초록색, 파란색 등 색깔이 있는 무언가로 만들어지는 것을 확인하실 수 있는데요. 이는 Grad-CAM heatmap입니다.

 

이를 코드로 구현하려면 opencv에서 지원하는 cv2.applyColorMap 함수를 통해 구현이 됩니다.

 

cv2는 input이 반드시 uint8 타입일 때만 작동하므로 주의해야 하고요.

 

아까 구해둔 grad_cam_map은 (1, 1, 224, 224)였으므로, 이를 .squeeze()를 통해서 (224, 224)의 2차원 형식으로 바꿔줍니다.

 

이에 255를 곱해주고, np.uint8을 적용하여 0 ~ 255의 값을 가지는 numpy array로 변환합니다. 

 

그리고 cv2.applyColorMap을 적용하면 여전히 numpy array이고, 크기는 (224, 224, 3) 짜리의 RGB image가 됩니다.

 

opencv의 cv2.imwrite 함수를 이용해서 이를 이미지로 저장해줍니다.

 

이를 통해 저장되는 이미지는 다음과 같습니다.

 

 

Grad-CAM heatmap

 

 

다음으로는 Grad-CAM heatmap과 original image가 같이 나오는 사진을 만들어보겠습니다.

 

먼저 기존에 만들어뒀던 heatmap을 float로 바꾼 뒤, 255로 나눠줍니다. 이러면 값이 0 ~ 1 사이로 맞춰집니다.

 

그리고 이 heatmap과 기존에 저장해뒀던 img와 더해줍니다. img도 마찬가지로 0 ~ 1 사이의 값을 가집니다.

 

이렇게 둘을 더하면, 값이 1을 벗어나기 때문에 0 ~ 1 사이로 값을 맞춰주고자 np.max 값으로 나눠줍니다.

 

마지막으로 이를 다시 np.uint8(255 * grad_result)로 0 ~ 255의 값을 가지는 데이터로 바꿔줍니다.

 

또다시 cv2.imwrite를 이용해 이미지로 저장해줍니다.

 

이를 통해 저장되는 이미지는 다음과 같습니다.

 

Grad-CAM heatmap + original image

 

개로 예측한 케이스이므로, 개의 주변에 빨간색으로 만들어지는 것을 알 수 있습니다.

 

즉, 개의 얼굴에 해당하는 이미지의 region을 보고 개로 예측했다는 것을 의미합니다.

 

 

# ============================= #
# ==Guided-Backprop main lines= #
# ============================= #

# gb_model => ReLU function in resnet50 change to GuidedBackpropReLU.
gb_model = GuidedBackpropReLUModel(model=resnet50, use_cuda=True)
gb_num = gb_model(torch_img, target_category = 254)
gb = deprocess_image(gb_num) # (224, 224, 3), numpy

# Guided Backprop save
cv2.imwrite(os.path.join(saved_loc, "Guided_Backprop.jpg"), gb)

 

다음으로는 앞에서 소개해드렸던 Guided Backpropagation 관련 코드입니다.

 

먼저 GuidedBackpropReLUModel이라는 클래스를 이용해서, 기존 resnet50이라는 모델의 ReLU를 GuidedBackpropReLU로 변경해줍니다.

 

이를 통해 얻게 되는 것이 gb_model입니다.

 

다음으로는 이 모델에 image를 투입하여 얻게 되는 Guided Backpropagation 이미지는 gb_num으로 저장됩니다.

 

저 위에서 소개해드렸지만, gb_num은 input 이미지의 gradient가 됩니다.

 

그리고 deprocess_image라는 함수를 이용해 이 gb_num을 처리해줍니다.

 

이를 통해 얻게 되는 Guided Backpropagation의 결과는 다음과 같습니다.

 

Guided Backpropagation

 

 

# Guided-Backpropagation * Grad-CAM => Guided Grad-CAM 
# See Fig. 2 in paper.
# grad_cam_map : (1, 1, 224, 224) , torch.Tensor
grayscale_cam = grad_cam_map.squeeze(0).cpu().numpy() # (1, 224, 224), numpy
grayscale_cam = grayscale_cam[0, :] # (224, 224)
cam_mask = cv2.merge([grayscale_cam, grayscale_cam, grayscale_cam]) # (224, 224, 3)

cam_gb = deprocess_image(cam_mask * gb_num)

# Guided Grad-CAM save
cv2.imwrite(os.path.join(saved_loc, "Guided_Grad_CAM.jpg"), cam_gb)

 

이제 소개해드릴 코드 중 거의 마지막에 다 왔습니다.

 

해당 부분은 Guided-Backpropagation과 Grad-CAM을 곱해서 얻어지는 Guided Grad-CAM을 만드는 코드입니다.

 

이전에 만들어둔 grad_cam_map을 가져옵니다. 이는 (1, 1, 224, 224) shape를 가지는 torch.Tensor였는데, 이를 0차원을 기준으로 squeeze 시키고 numpy로 변환합니다.

 

그러고 나서 첫 번째 데이터를 갖도록 만들어 결국 (224, 224) shape가 되도록 만들어줍니다.

 

만들어진 grayscale_cam을 cv2.merge를 통해서 합쳐줍니다. 

 

이를 앞에서 만들어둔 Guided Backpropagation의 결과인 gb_num과 곱해줍니다. 

 

이를 저장한 결과는 다음과 같습니다.

 

Guided Grad-CAM

 

 

Original_image = cv2.cvtColor(cv2.imread(os.path.join(img_dir, img_name)), cv2.COLOR_BGR2RGB)
G_heatmap = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Grad_CAM_heatmap.jpg")), cv2.COLOR_BGR2RGB)
G_result = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Grad_Result.jpg")), cv2.COLOR_BGR2RGB)
G_Back = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Guided_Backprop.jpg")), cv2.COLOR_BGR2RGB)
G_CAM = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Guided_Grad_CAM.jpg")), cv2.COLOR_BGR2RGB)


Total = cv2.hconcat([Original_image, G_heatmap, G_result, G_Back, G_CAM])

plt.rcParams["figure.figsize"] = (20, 4)
plt.imshow(Total)
ax = plt.gca()
ax.axes.xaxis.set_visible(False)
ax.axes.yaxis.set_visible(False)
plt.savefig(os.path.join(saved_loc, "Final_result.jpg"))
plt.show()

 

마지막은 지금까지 만들어준 이미지들을 모두 합쳐 하나의 이미지로 만드는 과정입니다.

 

코드를 보시면, cv2.cvtColor라는 함수가 보이실 텐데, 이는 color space를 바꿔주는 함수입니다.

 

기본적으로 opencv는 BGR (Blue, Green, Red) 순으로 데이터를 저장합니다.

 

따라서, 그냥 cv2.imread를 이용해서 읽게 되면 RGB 데이터를 BGR로 읽은 것이므로 색이 이상하게 나오게 됩니다.

 

이를 방지하고자 cv2.cvtColor( ~~, cv2.COLOR_BGR2RGB)라는 기능을 사용하게 되는 것입니다.

 

그리고 cv2.hconcat은 가로로 이미지를 합치는 함수입니다. 

 

이를 통해서 최종적으로 얻어지는 이미지는 다음과 같습니다.

 

Final Result

 

맨 위에서 얘기했던 논문의 Fig. 1과 동일한 그림을 얻을 수 있었습니다.

 

 

이번 글은 꽤나 내용이 많아 글이 많이 길어졌습니다.

 

사실 모델 자체가 엄청 복잡하거나 하지는 않지만, forward pass를 통해서 특정한 conv layer를 통과하고 난 이후의 결과를 얻는 방법, backward pass를 거쳐서 얻게 되는 gradient를 저장하는 방법, 이미지의 차원이나 값들을 적절히 처리하는 방법 등 다양한 기술들이 요구되는 코드라고 생각합니다.

 

저 또한 이렇게 min-max scaling을 했다가, 255로 곱했다가 이런저런 과정들을 거치면서 이미지를 처리해본 경험은 처음이라 많이 헤매면서 만들게 되었습니다.

 

특히 opencv의 함수를 쓰는 과정에서 np.uint8을 사용하지 않아 오류가 나거나, 이미지가 0 ~ 1 사이의 값이라서 cv2.imwrite를 했을 때 이미지가 검은색으로 저장되거나, color space가 RGB가 아닌 것을 모르고 저장했다가 이미지가 이상한 색으로 저장되는 등 다양한 상황에 부딪힐 수 있었습니다.

 

어쨌든 우여곡절 끝에 논문에서 나온 그림을 온전히 만들 수 있어서 재미있는 코드 작업이었습니다.

 

이번 포스팅에서 다룬 코드는 제 Github에서 확인하실 수 있으며, 코드 자체는 .ipynb로 되어 있어 바로바로 출력 결과를 확인하실 수 있습니다.

 

 

https://github.com/PeterKim1/paper_code_review/tree/master/9.%20Visual%20Explanations%20from%20Deep%20Networks%20via%20Gradient-based%20Localization(Grad-CAM) 

 

PeterKim1/paper_code_review

paper review with codes. Contribute to PeterKim1/paper_code_review development by creating an account on GitHub.

github.com

 

다음 포스팅에서는 또 다른 논문을 가지고 찾아뵙겠습니다.

 

 

감사합니다.

 

 

 

안녕하세요.

 

오늘은 CAM을 발전시킨 방법론인 Grad-CAM의 논문을 review 해보겠습니다.

 

논문 주소: arxiv.org/abs/1610.02391

 

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

We propose a technique for producing "visual explanations" for decisions from a large class of CNN-based models, making them more transparent. Our approach - Gradient-weighted Class Activation Mapping (Grad-CAM), uses the gradients of any target concept, f

arxiv.org

 

그럼 시작해보겠습니다.

 

 

Abstract

 

 

본 논문의 저자들은 Convolutional Neural Network (CNN) 기반의 모델이 만든 의사결정에 대한 'visual explanations'를 만드는 기술을 제안하며, 이는 CNN 기반의 모델을 더욱 투명하고 설명 가능하게 만듭니다.

 

저자들이 제안하는 접근법인 Gradient-weighted Class Activation Mapping (Grad-CAM)은 final convolutional layer로 흐르는 target concept (classification network에서는 'dog'가 될 수 있으며, captioning network에서는 word의 sequence가 될 수 있습니다.)의 gradient를 사용해서 concept을 예측할 때 이미지에서 중요한 부분들을 강조하는 coarse localization map을 만들어냅니다.

 

 

이전의 접근법과는 달리, Grad-CAM은 더 다양한 CNN 기반 모델에 사용할 수 있습니다. 

 

(1) VGG와 같이 fully-connected layer를 가지는 CNN 기반 모델

 

(2) captioning처럼 structured output에 사용되는 CNN 기반 모델

 

(3) reinforcement learning이나 visual question answering과 같은 multi-modal input을 이용하는 task에 사용되는 CNN 기반 모델

 

위 3가지 모델들에 모두 적용 가능하며, 별도의 architectural 변화나 재학습을 필요로 하지 않는다는 것이 특징입니다.

 

 

본 논문의 저자들은 고화질의 class-discriminative visualization을 만들어 내기 위해서 Grad-CAM을 이미 존재하는 fine-grained visualization과 결합한 Guided Grad-CAM을 만들었고, 이를 ResNet-based architectures를 포함한 image classification, image captioning, visual question answering (VQA) model에 적용하였습니다.

 

 

이미지 분류 모델의 맥락에서, Grad-CAM은 

 

(a) failure mode에 대한 insight를 제공합니다. (비합리적으로 보이는 예측이 합리적인 설명을 가지고 있다는 것을 입증합니다.)

 

(b) ILSVRC-15 weakly-supervised localization task에 대해서 이전 method에 비해 더 좋은 성능을 냅니다.

 

(c) adversarial perturbation에 강건합니다.

 

(d) underlying model에 대해 더욱 믿음을 가질 수 있게 해 줍니다.

 

(e) dataset bias를 확인함으로써 model generalization를 성취하는데 도움을 줍니다.

 

 

Image captioning과 VQA에 대해서, Grad-CAM은 심지어 non-attention based model도 input image의 discriminative region의 위치를 학습할 수 있음을 보여줍니다.

 

저자들은 Grad-CAM을 통해 중요한 neuron을 확인할 수 있는 방법을 고안하며 이를 neuron names와 결합해 model decision에 대한 textual explanation을 제공합니다.

 

마지막으로, 저자들은 Grad-CAM explanation이 user가 deep network로부터의 prediction에 적절한 신뢰를 만드는 데 있어서 도움이 되는지 아닌지를 측정하고자 human study를 설계하고 수행하였고, Grad-CAM은 심지어 두 모델이 동일한 예측을 만들었을 때도 학습되지 않은 user가 성공적으로 더 강한 deep network를 포착하는데 도움을 줄 수 있다는 사실을 보입니다.

 

 

1. Introduction

 

 

Convolutional Neural Networks (CNNs)을 기반으로 하는 deep neural model은 image classification, object detection 등 다양한 computer vision task에서 훌륭한 성능을 보여줬습니다.

 

이러한 모델들이 좋은 성능을 가능하게 만들었지만, 이들의 각각의 직관적인 요소로의 decomposability의 부족은 이들을 해석하기 어렵게 만들었습니다.

 

따라서 오늘날의 지능형 시스템은 어떠한 경고나 설명 없이 실패하는 경우가 많으며, 이는 사용자가 지능형 시스템의 일관성 없는 output을 보면서 시스템이 왜 그런 의사결정을 했는지에 대해서 궁금하게 됩니다.

 

 

Interpretability matters. (해석 가능성은 중요합니다.) 지능형 시스템에 있어서 신뢰를 구축하기 위해서는 왜 그렇게 예측했는지를 설명할 능력을 가진 'transparent' model을 구축해야 합니다.

 

모델의 투명성과 설명할 수 있는 능력은 인공지능 진화의 3가지 다른 단계에서 유용합니다.

 

첫 번째, AI가 인간보다 상당히 약하고 아직 신뢰하면서 사용하기 어려운 경우 (예: visual quenstion answering), 설명과 투명성의 목표는 failure mode을 확인하는 것이며, 이에 의해 연구자들이 더욱 생산적인 연구 방향에 집중할 수 있도록 도울 수 있습니다.

 

두 번째, AI가 인간과 동등하고 안정적으로 사용할 수 있는 경우 (예: 충분한 데이터에 학습된 image classification), 목표는 사용자에게 신뢰와 확신을 만들어내는 것입니다.

 

세 번째, AI가 인간보다 상당히 강력할 때 (예: 바둑, chess), 설명의 목표는 machine teaching에 있습니다. 즉, 기계가 어떻게 더 나은 의사결정을 만드는지에 대해서 사람을 가르치는 것입니다. 

(실제로 요즘에 바둑 분야에서는 알파고와 같은 강화 학습 인공지능을 가지고서 연습을 하는 것으로 알려져 있습니다.)

 

 

전형적으로 accuracy와 simplicity or interpretability 사이에는 trade-off가 있습니다. 

 

고전적인 rule-based나 expert system은 매우 해석 가능하지만, 매우 정확하거나 강건하지는 않습니다.

 

각 단계가 손수 설계된 분해 가능한 piepline은 각각의 individual component가 자연스러운 직관적 설명을 가정하므로 더욱 해석 가능하지만, deep model을 사용하는 경우 더 좋은 성능을 달성하기 위해 해석 가능한 모듈을 희생하게 됩니다.

 

최근 도입된 deep residual networks (ResNets)는 200-layer 이상으로 깊은 네트워크를 구성하며, 이는 여러 가지 어려운 task에서 SOTA 성능을 보여주었습니다. 

 

이러한 복잡성은 모델을 해석하기 어렵게 만듭니다.

 

 

최근에 Zhou et al. 은 discriminative regions을 식별하고자 fully-connected layer가 없는 image classification CNN에 사용되는 기법인 Class Activation Map을 제안하였습니다.

 

본질적으로, CAM은 모델의 작동에 대한 투명성을 높이고자 모델의 복잡성과 성능을 절충하게 됩니다.

(Fully-connected layer를 제거하므로 parameter의 수가 줄어들게 되고 이에 따라 성능도 줄어들기 때문이죠.)

 

그에 반해서, 본 논문의 저자들은 모델의 architecture를 바꾸지 않고 존재하는 SOTA deep model을 interpretable 하게 만들며 이를 통해 interpretability vs. accuracy trade-off를 회피합니다.

 

본 논문에서 제시하는 접근법은 CAM의 generalization이며, 다양한 CNN model에 적용할 수 있습니다.

 

 

 

무엇이 좋은 visual explanation을 만들까요?

 

 

Image classification을 생각해보겠습니다. 어떤 target category의 타당함을 보여주기 위한 모델로부터의 '좋은' visual explanation은 (a) class-discriminative 해야 하고, (즉, 이미지 내에서 category의 위치를 알아낼 수 있어야 함) (b) high-resolution이어야 합니다. (즉, fine-grained detail을 포착해야 함)

 

Fig. 1은 'tiger cat' class (위)와 'boxer' (개) class (아래)에 대한 수많은 visualization output을 보여주고 있습니다.

 

Guided Back-propagation과 Deconvolution와 같은 pixel-space gradient visualization은 high-resolution이고 이미지 내에서 fine-grained detail을 강조하지만, class-discriminative 하지는 않습니다. (Fig. 1b와 Fig. 1h가 매우 유사한 것이 그 예시가 됩니다.)

 

그에 반해서, CAM이나 저자들이 제안하는 방법인 Gradient-weighted Class Activation Mapping (Grad-CAM)와 같은 localization approaches는 매우 class-discriminative 합니다. ('cat'에 대한 설명은 오로지 'cat' region만 강조하며 'dog' region을 강조하지 않습니다. Fig. 1c, Fig. 1i을 통해 확인할 수 있습니다.)

 

두 방법론의 장점을 결합하기 위해, 저자들은 high-resolution이고 class-discriminative 한 Guided Grad-CAM visualization을 만들어 내기 위해 pixel-space gradient visualization을 Grad-CAM과 결합하는 것이 가능하다는 것을 보입니다.

 

그 결과로, Fig 1d와 1j에서 보이는 것처럼, 심지어 이미지가 여러 가지 가능한 concept을 포함하고 있을 때에도 관심이 있는 어떤 의사결정에 대응되는 이미지의 중요한 region을 high-resolution으로 시각화합니다. 

 

'tiger cat'을 시각화할 때, Guided Grad-CAM은 cat region을 강조할 뿐만 아니라 고양이에 있는 줄무늬도 강조하며, 이는 고양이의 특정한 종을 예측하는 데 있어서 중요합니다.

 

 

요약하자면, 본 논문의 contribution은 다음과 같습니다.

 

  • 재학습이나 architectural change를 요구하지 않고 어떠한 CNN 기반의 network에 대해서 visual explanation을 만들어내는 class-discriminative localization technique인 Grad-CAM을 소개합니다. 저자들은 localization (Sec. 4.1)와 모델에 대한 믿음 (Sec. 5.3)에 대해서 Grad-CAM을 평가하였으며, 이는 baseline보다 더 좋은 성능을 나타냅니다.

 

  • 저자들은 Grad-CAM을 존재하는 최고 성능의 classification model, captioning model, VQA model에 적용하였습니다. Image classification에 대해서는, 현재 CNN의 실패에 대한 통찰력을 제공하며, 보기에는 불합리해 보이는 예측이 합리적인 설명을 가지고 있음을 보여줍니다. Captioning과 VQA에 대해서는, CNN + LSTM이 grounded image-text pair에 대해서 학습되지 않았음에도 불구하고 discriminative image region의 위치를 찾는 데 있어서 잘한다는 사실을 보여줍니다.

 

  • Dataset에서 bias를 발견함으로써 해석 가능한 Grad-CAM visualization이 failure mode를 진단하는 데 있어서 어떻게 도움이 되는지를 보여줍니다. 이는 generalization을 위해서 중요할 뿐만 아니라, 사회에서 알고리즘에 의해 점점 더 많은 의사결정이 내려지기 때문에 공정하고 편견이 없는 결과를 위해서도 중요합니다.

 

  • 저자들은 Image classification과 VQA에 적용된 ResNets에 대한 Grad-CAM visualization을 제시합니다.

 

  • Grad-CAM으로부터 neuron importance를 사용하며 model decision에 대한 textual explanation을 얻습니다.

 

  • Guided Grad-CAM explanation이 class-discriminative 하고 사람이 신뢰를 만드는 데 있어서 도움을 줄 뿐만 아니라, 심지어 두 모델이 동일한 예측을 만들었을 때도 untrained users가 '약한' 모델로부터 '강한' 모델을 성공적으로 알아보는데 도움이 된다는 것을 보여주는 human study를 수행합니다.

 

2. Related Works

 

선행연구는 생략합니다.

 

 

3. Grad-CAM

 

 

수많은 이전 연구들은 CNN에서의 deeper representation이 higher-level visual construct를 포착한다고 주장해왔습니다.

 

더욱이, convolutional layers는 선천적으로 fully-connected layers에서 잃은 spatial information을 보유하고 있으며, 따라서 저자들은 last convolutional layer가 high-level semantics와 detailed spatial information 사이의 절충안을 가지고 있을 것이라고 기대하였습니다.

 

이러한 layers에 있는 neuron들은 semantic 한 class-specific information을 image내에서 찾습니다.

 

Grad-CAM은 CNN의 마지막 convolutional layer로 흐르는 gradient information을 사용하여 관심이 있는 특정한 의사결정을 위해 각 뉴런에 importance value를 할당하는 방법입니다.

 

 

 

Fig. 2에서 보이는 것처럼, width가 $u$이고 height가 $v$인 어떤 class $c$에 대한 class-discriminative localization map Grad-CAM $L^c_{Grad-CAM} \in R^{u \times v}$을 얻기 위해서, 저자들은 먼저 class $c$에 대한 점수 $y^c$ (before the softmax)에 대한 gradient를 convolutional layer의 feature map activation $A^k$에 대해서 계산합니다.

 

즉 이는 $\frac{\partial y^c}{\partial A^k}$로 표현할 수 있습니다.

 

이 gradients는 neuron importance weights $\alpha^c_k$를 얻기 위해서 width와 height dimension에 대해서 global average pooled 됩니다.

 

 

weight $\alpha^c_k$는 target class $c$에 대한 feature map $k$의 'importance'를 포착합니다.

 

저자들은 forward activation maps의 weighted combination을 수행하고, 다음으로 ReLU를 수행하여 다음을 얻게 됩니다.

 

 

이는 convolutional feature maps와 동일한 사이즈의 coarse heatmap의 결과를 낳게 됩니다. 

 

저자들은 maps의 linear combination에 ReLU를 적용하였는데, 이는 저자들이 오직 관심 있는 class에 positive 영향을 주는 feature에만 관심이 있기 때문입니다.

 

즉, $y^c$를 증가시키기 위해서 증가되어야 할 intensity를 가지는 pixel을 말합니다.

 

ReLU를 적용하지 않으면, localization에서 더 나쁜 성능을 보여준다고 합니다.

 

$y^c$는 image classification CNN에서 만들어진 class score일 필요는 없으며, caption이나 question에 대한 answer로부터 나오는 word를 포함한 어떠한 미분 가능한 activation도 모두 적용 가능합니다.

 

 

 

3.1 Grad-CAM generalizes CAM

 

 

이번 section에서는 Grad-CAM과 Class Activation Mapping (CAM) 사이의 connection에 대해서 논의하고, Grad-CAM이 다양한 CNN-based architecture에 대해 CAM을 일반화한다는 것을 형식적으로 검증합니다.

 

끝에서 두 번째 layer가 $K$ feature maps을 만든다고 가정한다면, $A^k \in R^{u \times v}$이며, 각 element는 i, j에 의해서 indexing 됩니다.

 

따라서, $A^k_{i, j}$는 feature map $A^k$의 location (i, j)에 있는 activation을 의미합니다.

 

이러한 feature map은 Global Average Pooling (GAP)을 사용하여 공간적으로 pooling 되며 각 class $c$에 대한 score $Y^c$을 만들어내고자 선형적으로 변형됩니다.

 

 

$F^k$를 global average pooled output이라고 가정합니다.

 

CAM은 final score를 다음과 같이 계산합니다.

 

 

$w^c_k$는 $c^{th}$ class와 연결된 $k^{th}$ feature map의 weight를 나타냅니다. Class $c$에 대한 score ($Y^c$)의 feature map $F^k$에 대한 gradient를 다음과 같이 계산할 수 있습니다.

 

 

(4)의 $A^k_{ij}$에 대한 partial derivative를 얻으면, 이는 $\frac{\partial F^k}{\partial A^k_{ij}}$ = $1/Z$입니다. 이를 (6)에서 대체하면, 이를 얻을 수 있습니다.

 

(5)으로부터 $\frac{\partial Y^c}{\partial F^k}$ = $w^c_k$를 얻습니다. 따라서,

 

 

(8)의 양변을 모든 픽셀 (i, j)에 대해서 모두 더하면,

 

 

$Z$와 $w^c_k$는 (i, j)에 의존하지 않으므로, 다음과 같이 쓸 수 있습니다.

 

(좌변은 i와 j를 모두 더한 것이므로, 그것의 개수인 Z가 남게 됩니다.)

 

$Z$는 feature map에서의 pixel 수를 나타내기 때문에, 다음과 같이 양변을 $Z$로 나눠줄 수 있습니다.

 

 

normalize out 해주는 proportionality constant (1/Z)를 빼면, $w^c_k$라는 expression은 Grad-CAM에 의해서 사용되는 $\alpha^c_k$와 동일합니다.

 

따라서, Grad-CAM은 CAM의 엄격한 generalization입니다.

 

 

3.2 Guided Grad-CAM

 

 

Grad-CAM은 class-discriminative 하고 관련이 있는 image region의 위치를 찾아주지만, 이는 Guided Backpropagation이나 Deconvolution과 같은 pixel-space gradient visualization methods와 같이 fine-grained details를 강조하는 능력은 부족하다고 합니다.

 

Figure 1c를 보게 되면, Grad-CAM은 쉽게 고양이의 위치를 찾아내지만, coarse heatmap으로부터 왜 network가 이 특정한 instance를 'tiger cat'으로 예측했는지는 불명확합니다.

 

양쪽의 장점을 결합하고자, 저자들은 Guided Backpropagation과 Grad-CAM visualizations을 element-wise multiplication을 통해서 융합합니다.

 

이를 통해 얻게 되는 visualization은 high-resolution이고 class-discriminative 합니다.

 

 

3.3 Counterfactual Explanations

 

 

Grad-CAM을 약간 수정하면 네트워크가 이것의 예측을 바꾸게 만드는 지역을 강조하는 설명을 얻을 수 있게 됩니다.

 

그 결과로, 이러한 regions에서 나타나는 concept을 제거하는 것이 이것의 예측에 대해서 모델이 더욱 확신할 수 있게 만듭니다. 

 

저자들은 이러한 explanation modality를 counterfactual explanations이라고 부릅니다.

 

 

구체적으로, 저자들은 class $c$에 대한 score인 $y^c$의 convolutional layer의 feature maps $A$에 대한 gradient 값을 마이너스로 만듭니다. 

 

따라서 importance weight $\alpha^c_k$는 다음과 같은 식으로 바뀌게 됩니다.

 

 

이전의 (2)에서 보여준 것처럼, 저자들은 weights $\alpha^c_k$를 forward activation maps A의 weighted sum을 취하고, 이를 ReLU에 통과시켜 Fig. 3에서 나타나는 counterfactual explanations을 얻게 됩니다.

 

 

 

여기까지가 모델과 관련된 내용이며, 다음 내용부터는 실험과 관련된 내용입니다.

 

 

실험과 관련된 내용들은 간단하게만 다루겠습니다.

 

 

4. Evaluating Localization Ability of Grad-CAM

 

4.1 Weakly-supervised Localization

 

 

이번 section에서는 image classification 맥락에서 Grad-CAM의 localization 능력을 평가하게 됩니다.

 

CAM 논문에서와 동일하게, 이미지가 주어졌을 때 network는 class prediction을 하게 되며 만들어진 Grad-CAM map의 max 값의 15%를 threshold로 지정하여 이보다 큰 값들을 가지게 되는 map의 위치들을 포함할 수 있는 하나의 bounding box를 만들어냅니다.

 

ILSVRC-15 데이터셋에 대해서, localization error는 다음과 같습니다.

 

 

VGG16에 대한 Grad-CAM이 top-1 loalization error에서 최고 성능을 나타냈으며, CAM은 모델 구조의 변경으로 인해 re-training이 필요하고 classification error가 높아지지만, Grad-CAM은 classification performance에 있어서 악화되는 현상이 없다는 것이 장점입니다.

 

 

5. Evaluating Visualizations

 

 

이번 section에서는 human study를 수행한 결과를 나타냅니다. 

 

 

5.1 Evaluating Class Discrimination

 

 

실험은 90개의 image-category pair에 대해서 4가지의 visualization (Deconvolution, Guided Backpropagation, Deconvolution Grad-CAM, Guided Grad-CAM)을 제시하고, 각 이미지에 대해서 정답이 무엇인지에 대한 평가를 받습니다.

 

Guided Grad-CAM을 보여줬을 때, 실험에 참가한 사람들은 케이스의 61.23%에 대해서 category를 맞췄으며, 이는 Guided Backpropagation의 44.44%와 비교했을 때 human performance를 16.79%만큼 향상한 결과입니다.

 

유사하게, Grad-CAM은 Deconvolution을 더욱 class-discriminative 하게 만들었으며, 53.33%에서 60.37%로 향상되었습니다.

 

 

6. Diagnosing image classification CNNs with Grad-CAM

 

 

이번 section에서는 image classification CNN의 failure mode를 분석할 때, adversarial noise의 효과를 이해할 때, dataset에서의 bias를 확인하고, 제거할 때 Grad-CAM의 사용을 검증합니다.

 

 

6.1 Analyzing failure modes for VGG-16

 

 

네트워크가 어떤 실수를 만들었는지 보기 위해서, 저자들은 먼저 network가 분류를 정확히 하지 못한 예시들을 확인하였습니다.

 

이러한 오분류된 케이스에 대해서, Guided Grad-CAM을 사용하여 정답 class와 예측된 class를 시각화하였습니다.

 

Fig. 6에서 확인할 수 있듯이, 몇몇 failure는 ImageNet classification에서 내재된 애매모호함 때문에 발생하였습니다.

 

즉, network가 아예 잘못된 예측을 한다기보다는, 사진이 다른 class로 오분류될 수 있을 법한 애매모호함을 가지고 있다는 것이죠.

 

겉보기에는 비합리적인 예시가 합리적인 설명을 가지고 있다는 것을 이를 통해 증명하였습니다.

 

 

6.2 Effect of adversarial noise on VGG-16

 

 

저자들은 ImageNet-pretrained VGG-16 model에 대해 adversarial image를 생성하여 모델이 이미지 내에서 나타나지 않은 category로 높은 확률 (>0.9999)을 assign 하고 이미지 내에 나타난 category로 낮은 확률을 assign 하도록 만듭니다.

 

그러고 나서, 이미지에 나타난 category에 대해 Grad-CAM visualization을 만들었습니다.

 

Fig. 7에서 나타난 것처럼, network는 이미지에 존재하는 category에 대해서 매우 낮은 확률로 예측하고 있으나, 그럼에도 불구하고 이것들의 위치는 정확하게 잡아내는 것을 확인할 수 있습니다.

 

이를 통해 Grad-CAM은 adversarial noise에 꽤 강건하다는 사실을 알 수 있습니다.

 

 

6.3 Identifying bias in dataset

 

 

이번 section에서, 저자들은 training dataset에서의 bias를 확인하고 줄이는데 Grad-CAM을 사용할 수 있다는 것을 검증합니다.

 

편향된 데이터셋에 학습된 모델은 현실 세계의 문제에 일반화되지 않거나, 더 나쁜 경우 편견과 고정관념을 영구화할 수 있습니다. (예를 들어 성별, 인종, 연령 등)

 

저자들은 ImageNet-pretrained VGG-16 model을 의사와 간호사 binary classification task에 finetune 하였습니다.

 

training dataset과 validation dataset을 image search engine을 이용해 각 class 당 가장 관련 있는 250개의 이미지를 이용해 구성하였습니다.

 

그러고 나서 test set은 두 클래스 간 gender의 분포를 균형 있도록 통제하였습니다.

 

비록 trained model은 좋은 validation accuracy를 달성하였으나, 일반화에서는 성능이 떨어졌습니다. (82% test accuracy)

 

 

 모델 예측에 대한 Grad-CAM visualization (Fig. 8의 middle column의 red box region)은 모델이 간호사를 의사로부터 구별하는 데 있어서 사람의 얼굴과 머리 스타일을 보도록 학습되었음을 나타내며, 따라서 gender stereotype을 학습하였다는 것을 나타냅니다.

 

실제로, 모델은 여러 여성 의사를 간호사로, 남성 간호사를 의사로 오분류하였습니다.

 

명확하게, 이는 문제가 됩니다.

 

image search result는 gender-biased 되었다는 사실이 판명되었습니다. (의사 이미지의 78%가 남성이었으며, 간호사 이미지의 93%가 여성이었습니다.)

 

 

Grad-CAM visualization으로부터 얻은 이러한 직관을 이용해서, 클래스 당 이미지의 수는 유지하면서 남성 간호사와 여성 의사의 이미지를 추가함으로써 training set에서의 bias를 감소시킬 수 있습니다.

 

re-trained model은 더 잘 일반화할 뿐만 아니라 (90% test accuracy), 올바른 구역을 본다는 것을 알 수 있었습니다. (Fig. 8의 마지막 column)

 

이는 Grad-CAM이 dataset에서의 bias를 확인하고 제거하는데 도움을 줄 수 있으며, 이는 더 나은 일반화와 공정하고 윤리적인 결과를 위해서 중요합니다.

 

 

 

여기까지 Grad-CAM 논문을 살펴보았습니다.

 

더 많은 실험 내용이 궁금하신 분들은 논문을 직접 봐주시면 되겠습니다.

 

다양한 CNN 기반의 모델에 모두 적용할 수 있고, 또 원하는 layer에 대해서 적용할 수 있으며, failure mode을 판단하는데도 사용될 수 있고 training set의 bias를 확인하는 데 사용할 수 있는 등 기존 CAM에 비해서 굉장한 확장성을 보여주는 모델이라고 생각합니다.

 

paper review는 여기까지 마무리하고, 다음 글에서는 Grad-CAM을 코드로 구현한 내용들에 대해서 살펴보겠습니다.

 

감사합니다.

 

 

안녕하세요.

 

 

오늘은 저번 글에서 review 했던 Learning Deep Features for Discriminative Localization 논문의 code review를 진행해보겠습니다.

 

 

해당 논문 실험은, 기존에 제가 실험했던 CIFAR10이나 MNIST와 같이 torchvision에서 제공하는 데이터셋이 아닌 Kaggle에서 제공하는 CatVsDog dataset을 사용하여서 dataset을 setting 하는 작업이 별도로 필요합니다.

 

 

따라서 실제로 해당 코드를 직접 구동해보시면서 따라가실 분들은 제 Github에 있는 코드를 보시고 .ipynb 파일과 README 내용들을 따라가시면서 직접 dataset을 setting 하신 후 실험을 진행해주시면 되겠습니다.

 

그리고 Class Activation Map은 모델과, 이미 학습이 완료된 weight가 있어야 만들 수 있습니다.

 

따라서 실제 생성을 하시기 위해서는 모델을 별도로 학습해주셔야 합니다.

 

이 과정 또한 제 Github에 나와있으니, 실제 구현을 해보실 것이라면 이 과정도 거치셔야 합니다.

 

제 Github 주소는 다음과 같습니다.

 

github.com/PeterKim1/paper_code_review/tree/master/8.%20Learning%20Deep%20Features%20for%20Discriminative%20Localization(CAM)

 

PeterKim1/paper_code_review

paper review with codes. Contribute to PeterKim1/paper_code_review development by creating an account on GitHub.

github.com

 

dataset이 setting 되어있고, 이미 학습이 완료된 weight가 있다는 가정 하에 모델과 관련한 내용만 review를 진행해보겠습니다.

 

 

그럼 시작하겠습니다.

 

 

 

1. 저장된 모델 불러오기

 

 

 

먼저, Class Activation Map은 이미 학습이 완료된 model을 기반으로 만들어지기 때문에 이미 학습이 완료된 모델이 있어야 하며 이를 불러와야 만들 수 있습니다.

 

이를 불러오는 과정을 진행합니다.

 

import torch
import os

sample = torch.load(os.path.join(os.getcwd(), '2021-05-04-13:55/checkpoint/ckpt.pth'))

import torchvision.models as models
import torch.nn as nn

resnet18 = models.resnet18(pretrained = False)

resnet18.fc = nn.Linear(512, 2)
print(resnet18.fc)

resnet18.load_state_dict(sample['net'])

 

먼저 저장된. pth 파일을 sample이라는 변수로 저장합니다.

 

그리고 torchvision에서 지원하는 resnet18 모델을 가져옵니다. 

 

이때, torchvision의 resnet18의 경우 1000 class classification 모델이므로 마지막 layer를 binary classification layer로 변경합니다.

 

그러고 나서 load_state_dict()를 사용해 저장된 weight를 가져옵니다.

 

load_state_dict()를 사용하고 나서 다음과 같은 메시지가 떠야 정상적으로 불러오기가 된 것입니다.

 

 

 

 

2. DataLoader setting 및 모델을 GPU에 올리기

 

 

Class Activation Map은 내가 어떤 이미지를 분류하는 과정에서, 이미지의 어떤 discriminative region을 보고 판단했는지를 보여주는 방법론입니다.

 

따라서, 기본적으로는 처음 보는 이미지를 classification 합니다. 

 

그래서 train dataloader는 필요가 없으며, test dataloader만 가져오면 됩니다.

 

import cv2
import torchvision
import torchvision.transforms as transforms
import datetime
import numpy as np

device = 'cuda' if torch.cuda.is_available() else 'cpu'

transform_test = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize((0.4876, 0.4544, 0.4165), (0.2257, 0.2209, 0.2212)),
])

test_dataset = torchvision.datasets.ImageFolder(root = os.path.join(os.getcwd(), 'CatvsDog_test'), transform = transform_test)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size = 1, shuffle = True, num_workers = 0)


current_time = datetime.datetime.now() + datetime.timedelta(hours= 9)
current_time = current_time.strftime('%Y-%m-%d-%H:%M')


saved_loc = os.path.join('./', current_time)
if os.path.exists(saved_loc):
    shutil.rmtree(saved_loc)
os.mkdir(saved_loc)

print("결과 저장 위치: ", saved_loc)

device = 'cuda' if torch.cuda.is_available() else 'cpu'

resnet18.to(device)

 

test dataset을 가지고 torchvision.datasets.ImageFolder로 dataset 객체를 만들어준 뒤에, 이를 DataLoader에 투입시킵니다.

 

이때, batch_size는 1로 주어 한 개의 이미지씩 나오도록 만들어줍니다.

 

물론 여러 장씩 나오게 할 수도 있지만, 1개씩 나오게끔 하는 것이 전반적으로 간단해질 것 같아 이렇게 설정하였습니다.

 

그리고 결과를 저장해주기 위해서, saved_loc이라는 이름으로 현재 실행 시간을 폴더명으로 해서 폴더를 만들어줍니다.

 

마지막으로, 불러온 resnet18 모델을 GPU로 이동시킵니다.

 

 

3. CAM 만들기

 

 

본격적으로 CAM을 만들어보겠습니다.

 

import torch.nn.functional as F
import matplotlib.pyplot as plt
from scipy.ndimage import label
from skimage.measure import regionprops
import matplotlib

# final conv layer name 
finalconv_name = 'layer4'

# inference mode
resnet18.eval()

# number of result
num_result = 3

먼저 CAM을 만드는데 필요한 library를 가지고 오고, 마지막 conv layer의 이름을 가져옵니다.

 

그리고 resnet18을 inference mode로 변경한 뒤, result를 몇 개 나오게 만들 것인지를 결정합니다.

 

feature_blobs = []

# output으로 나오는 feature를 feature_blobs에 append하도록
def hook_feature(module, input, output):
    feature_blobs.append(output.cpu().data.numpy())
    
resnet18._modules.get(finalconv_name).register_forward_hook(hook_feature)

# get the softmax weight
params = list(resnet18.parameters())
weight_softmax = np.squeeze(params[-2].cpu().detach().numpy()) # [2, 512]

 

feature_blobs는 빈 형태의 list인데, 추후 여기에 feature가 들어가게 됩니다.

 

hook_feature라는 함수는 output으로 나오는 feature를 feature_blobs에 append 하도록 짜인 함수입니다.

 

그리고 resnet18._modules.get(finalconv_name).register_forward_hook(hook_feature)라는 코드를 통해서 마지막 conv layer인 'layer4'를 거쳐서 나오게 되는 feature를 feature_blobs에 저장하도록 만들어줍니다.

 

resnet18의 parameter들을 list로 만들어 params에 저장해주고, 여기서 마지막에서 두 번째 weight를 weight_softmax라는 변수로 저장해줍니다.

 

resnet18의 parameter는 layer 순서대로 저장이 되어 있는데, 가장 마지막은 fully-connected layer의 bias로 구성되어 있고 마지막에서 두 번째는 fully-connected layer의 가중치가 저장되어 있습니다.

 

따라서 params [-2]를 weight_softmax에 저장해주게 됩니다.

 

만약 제가 사용한 모델이 아니라 별도의 모델을 사용하시는 경우, 본인의 모델의 parameter들 중에서 어떤 부분이 output을 내는데 이용되는 parameter인지를 확인하신 후 사용하셔야 합니다.

 

 

# generate the class activation maps
def returnCAM(feature_conv, weight_softmax, class_idx):
    size_upsample = (224, 224)
    _, nc, h, w = feature_conv.shape # nc : number of channel, h: height, w: width (1, 512, 7, 7)
    output_cam = []
    # weight 중에서 class index에 해당하는 것만 뽑은 다음, 이를 conv feature와 곱연산
    cam = weight_softmax[class_idx].dot(feature_conv.reshape((nc, h*w))) 
    cam = cam.reshape(h, w)
    cam = cam - np.min(cam)
    cam_img = cam / np.max(cam)
    cam_img = np.uint8(255 * cam_img)
    output_cam.append(cv2.resize(cam_img, size_upsample))
    return output_cam

 

먼저 CAM을 만드는 데 사용되는 함수부터 소개해드리겠습니다.

 

returnCAM이라는 함수는 마지막 conv layer를 통과해서 나오게 되는 feature와 마지막 output을 만드는 데 사용되는 weight, 그리고 해당 예측의 class index를 input으로 받아 CAM을 만들어주는 함수입니다.

 

먼저 어떤 사이즈로 CAM을 만들 것인지를 결정합니다. 

 

저희 이미지는 (224, 224)로 구성되어 있어 이를 이미지의 사이즈와 동일하게 맞춰주면 됩니다.

 

다음으로는 feature_conv.shape를 이용해 channel의 수와 height, width를 저장합니다.

 

output_cam은 빈 list로 추후 결과를 저장하는 list이며

 

cam은 weight_softmax [class_idx]와 feature_conv를 (nc, h*w)로 reshape 한 것을 곱 연산해줍니다.

 

weight_softmax의 경우 (2, 512)로 구성되는데요, 이 중에서 class index에 해당하는 값만 선택합니다.

 

그렇다면 shape가 (1, 512)가 되겠죠? 

 

그러고 feature_conv는 (1, 512, 7, 7)의 shape를 가지고 있는데 이를 (512, 7*7) 형태로 바꿔주고 나서 dot product를 수행합니다.

 

matrix 연산은 첫 번째 matrix의 가로와 두 번째 matrix의 세로가 같아야만 연산이 되니까요. 그래서 결과는 (1, 7*7)이 됩니다.

 

이 결과를 (7, 7) 모양으로 다시 reshape 해주면 마지막 conv layer를 통과해서 나온 (1, 512, 7, 7)와 가로, 세로 관점에서 동일한 크기를 가지는 CAM을 만들게 되는 것이죠.

 

그러고 나서 이를 최솟값으로 빼주고, 최댓값으로 나눠줍니다. 이 작업은 보통 min-max scaling이라고 부르던 것 같은데 이 작업을 수행하게 되면 가장 작은 값은 0이 되고 가장 큰 값은 1이 될 겁니다.

 

그러고 나서 여기에 255를 곱해줘서 사실상 0부터 255의 값을 가지는 image를 만들어낸 것이라고 보시면 됩니다.

 

마지막으로는 이를 cv2.resize()를 활용해 이미지의 원래 사이즈인 (224, 224) 형태로 resize 해줍니다.

 

def generate_bbox(img, cam, threshold):
    labeled, nr_objects = label(cam > threshold)
    props = regionprops(labeled)
    return props

 

해당 함수는 CAM을 기반으로 bounding box를 그리는 함수인데, 자세한 내용은 해당 글을 참고해주시면 됩니다.

 

이미 제가 글을 올렸던 내용이라서 자세한 내용은 글로 대체합니다.

 

cumulu-s.tistory.com/38

 

scipy.ndimage.label과 skimage.measure.regionprops를 이용한 이미지 내에서 특정값 이상의 값을 가지는 위치

안녕하세요. 오늘은 scipy.ndimage.label과 skimage.measure.regionprops를 이용해서 이미지 내에서 threshold 이상의 값을 가지는 위치를 찾는 작업을 해보려고 합니다. 구글링 해보니 한국어로 된 자료가 검색

cumulu-s.tistory.com

 

for i, (image, target) in enumerate(test_loader):
    
    # 모델의 input으로 주기 위한 image는 따로 설정
    image_for_model = image.clone().detach()

    # Image denormalization, using mean and std that i was used.
    image[0][0] *= 0.2257
    image[0][1] *= 0.2209
    image[0][2] *= 0.2212
    
    image[0][0] += 0.4876
    image[0][1] += 0.4544
    image[0][2] += 0.4165

 

이제부터 본격적으로 CAM 만들기에 들어갑니다.

 

먼저 test_loader로부터 image와 target을 받고, 모델의 input으로 주기 위한 image를 따로 image_for_model로 지정합니다.

 

그리고 이전에 학습을 진행할 때와 test loader에도 동일하게 normalization을 해줬어서 normalized 되지 않은 이미지를 저장해주기 위해 이를 반대로 역정규화 해줍니다. 

 

이렇게 하지 않으면 원본 이미지를 저장할 때 굉장히 이상한 이미지의 모습으로 보이기 때문에 정상적인 이미지 형태를 저장하고자 이 작업을 추가하였습니다.

 

    fig = plt.figure(tight_layout = True)
    rows = 1
    cols = 3
    
    ax1 = fig.add_subplot(rows, cols, 1)
    ax1.imshow(np.transpose(image[0].numpy(), (1, 2, 0)))
    ax1.set_title("Original Image")
    ax1.axis("off")

 

원래 이미지와 작업을 수행한 결과를 저장해야 하므로, plt.figure로 fig를 만들어주고 row는 1, col은 3으로 잡습니다.

 

그리고 여기에 add_subplot을 통해서 원본 이미지를 저장해줍니다. np.transpose의 경우 차원의 위치를 바꿔주는데, pytorch에서 지원하는 차원의 순서와 matplotlib에서 지원하는 차원의 순서가 다르기 때문에 이 변환 과정을 거쳐야 합니다.

 

    image_PIL = transforms.ToPILImage()(image[0])
    image_PIL.save(os.path.join(saved_loc, 'img%d.png' % (i + 1)))

 

해당 코드는 image를 PIL Image로 읽은 다음, 저장하는 코드입니다.

 

    # 모델의 input으로 사용하도록.
    image_tensor = image_for_model.to(device)
    logit = resnet18(image_tensor)
    h_x = F.softmax(logit, dim=1).data.squeeze()
    
    probs, idx = h_x.sort(0, True)
    print("True label : %d, Predicted label : %d, Probability : %.2f" % (target.item(), idx[0].item(), probs[0].item()))

 

아까 저장해둔 image_for_model을 GPU로 이동하고, 이를 resnet18에 통과시켜서 결과를 내놓습니다. 

 

해당 이미지가 개와 고양이 중 무엇인지를 예측한 결과가 logit에 저장됩니다.

 

이에 대해서 softmax를 거쳐주고, 이것을 sorting 한 결과를 probs, idx에 저장합니다.

 

probs는 예측 확률이 될 것이고, idx는 예측이라고 판단된 결과의 index가 되겠죠. 

 

이를 이용해서 실제 label과 예측된 label, 그리고 probability를 출력할 수 있게 됩니다.

 

    CAMs = returnCAM(feature_blobs[0], weight_softmax, [idx[0].item()])
    img = cv2.imread(os.path.join(saved_loc, 'img%d.png' % (i + 1)))
    height, width, _ = img.shape
    heatmap = cv2.applyColorMap(cv2.resize(CAMs[0], (width, height)), cv2.COLORMAP_JET)
    result = heatmap * 0.7 + img * 0.5
    cv2.imwrite(os.path.join(saved_loc, 'cam%d.png' % (i + 1)), result)

 

아까 feature_blobs는 마지막 conv layer를 통과하고 나서 나오게 되는 feature라는 말씀을 드렸었습니다.

 

이는 (1, 512, 7, 7)의 크기를 가지고 있는데, 이를 returnCAM이라는 함수에 투입시켜 Class Activation Map을 만들어줍니다.

 

그리고 img라는 변수명으로 원본 이미지를 읽어오고, heatmap이라는 이름으로 Class Activation Map을 저장합니다. 

 

result는 최종 결과이며, CAM와 image의 가중치의 합으로 계산합니다.

 

heatmap의 가중치가 클수록 사진에서 CAM이 더 뚜렷하게 나타나고, img의 가중치가 클수록 사진에서 원본 사진이 더 뚜렷하게 나타납니다.

 

그리고 이 최종 결과인 result를 저장합니다.

 

    props = generate_bbox(img, CAMs[0], CAMs[0].max()*0.6)
    
    ax2 = fig.add_subplot(rows, cols, 2)
    im2 = ax2.imshow(CAMs[0])
    ax2.set_title("Class Activation Map")
    ax2.axis("off")
    
    ax3 = fig.add_subplot(rows, cols, 3)
    ax3.imshow(np.transpose(image[0].numpy(), (1, 2, 0)))
    ax3.set_title("Original Image + patch")
    ax3.axis("off")

 

generate_bbox 함수를 이용해서 props를 만들어줍니다.

 

CAM은 ax2라는 이름으로 subplot을 만들어주고, 원래 이미지에 patch를 추가한 이미지를 ax3라는 이름으로 만들어줍니다.

 

ax3에는 아직 patch를 추가하진 않았고, 현재는 original image만 올라가 있는 상태입니다.

 

    for b in props:
        bbox = b.bbox
        xs = bbox[1]
        ys = bbox[0]
        w = bbox[3] - bbox[1]
        h = bbox[2] - bbox[0]
        
        rect = matplotlib.patches.Rectangle((xs, ys), w, h, linewidth = 2, edgecolor = 'r', facecolor = 'none')
        ax3.add_patch(rect)
    
    
    plt.savefig(os.path.join(saved_loc, 'Result%d.png' % (i+1)))
    
    plt.show()
    
    if i + 1 == num_result:
        break
        
    feature_blobs.clear()

 

for b in props:를 통해서 ax3에 bounding box를 만들어줍니다.

 

해당 코드의 자세한 내용은 위에 올렸던 제 블로그 링크를 확인해주시면 될 것 같습니다.

 

만들어진 plt를 save 하고, show() 합니다.

 

만약 i + 1가 num_result랑 같아지면, 해당 for문을 break 하고요.

 

feature_blobs는 for문을 돌릴 때마다 비워줍니다.

 

이를 통해서 나오게 되는 결과는 다음과 같습니다.

 

 

 

Class Activation Map을 보면 고양이가 위치한 부분과 개가 위치한 부분에서 더 많이 activation 되는 것을 확인할 수 있으며, 이를 통해서 만들어진 bounding box도 각각 개와 고양이에 해당하는 부분만 잡힌다는 사실을 확인할 수 있습니다.

 

 

 

여기까지 Class Activation Map에 대한 code review를 진행해보았습니다.

 

실제로 구현해보니 정말 각 동물에 해당하는 이미지의 위치에 활성화된다는 것을 확인할 수 있었습니다.

 

이번 review는 여기까지 해서 마무리짓고, 다음 글에서 뵙도록 하겠습니다.

 

감사합니다.

 

 

 

안녕하세요.

 

 

오늘은 scipy.ndimage.label과 skimage.measure.regionprops를 이용해서 이미지 내에서 threshold 이상의 값을 가지는 위치를 찾는 작업을 해보려고 합니다.

 

 

구글링 해보니 한국어로 된 자료가 검색이 잘 안되어서, 작성해두면 많은 분들께 도움이 되지 않을까 싶어 작성하게 되었습니다.

 

예시 코드들을 첨부해보면서, 하나하나 같이 익혀보겠습니다.

 

그럼 시작해보겠습니다.

 

 

 

이미지 내에서 특정 값 이상을 가지는 위치를 어떻게 하면 찾을 수 있을까?

 

 

 

먼저, 필요한 library를 import 해줍니다.

 

import numpy as np
import scipy
from scipy.ndimage import label
from skimage.measure import regionprops

 

저는 이해를 돕기 위해, 임의로 numpy array를 생성하여 설명을 드리겠습니다.

 

원리만 이해한다면, 일반적으로는 제가 설명드리는 예시보다 훨씬 더 큰 이미지 내에서도 동일한 작업을 수행하실 수 있을 겁니다.

 

(혹시나 pytorch나 tensor flow 등 deep learning framework를 사용하신다면, 대부분 .numpy()를 통해 tensor 형태의 데이터를 numpy array로 전환하실 수 있습니다.)

 

이해를 돕기 위해, 간단하게 (6, 6) 짜리 numpy array를 생성해줍니다.

 

image = np.array([[1, 2, 3, 4, 4, 4],
               [1, 2, 6, 10, 9, 7],
                [2, 3, 11, 15, 16, 7],
               [1, 2, 7, 8, 9, 7],
               [6, 7, 8, 9, 10, 7],
               [4, 4, 4, 4, 4, 4]])

 

아까 import 했던 scipy.ndimage.label을 사용할 시간입니다.

 

제가 하고 싶은 작업은, 해당 image에서 6보다 큰 부위를 찾아내고 싶습니다.

 

따라서, 다음과 같은 코드를 작성해줍니다.

 

labeled, nr_objects = label(image > 6)
print(labeled)

 

여기서 labeled는 입력으로 준 image 중에서 6보다 큰 지점들에 label을 주게 됩니다.

 

print를 해보면 다음과 같이 나오게 됩니다.

 

 

이렇게 하게 되면, labeled 된 지점들만 추출해 위치를 잡을 수 있게 됩니다.

 

그리고 nr_objects의 경우는 1이 됩니다.

 

 

 

이걸 보시고 이런 생각을 하실 수 있을 것 같습니다.

 

만약 특정값보다 큰 지점들이 저렇게 다 연결되어 있는 것이 아니라, 조금씩 조금씩 모여있는 경우는 어떻게 될까?

 

그런 경우도 보여드리겠습니다.

 

먼저, 기존에 사용했던 image의 값을 다음과 같이 변경해줍니다.

 

image = np.array([[1, 2, 3, 7, 7, 7],
               [1, 2, 6, 10, 9, 7],
                [2, 3, 4, 4, 4, 4],
               [1, 2, 7, 8, 9, 7],
               [6, 7, 8, 9, 10, 7],
               [4, 4, 4, 4, 4, 4]])

 

이렇게 설정하고 나서, 다시 label을 찍어보면 다음과 같이 되는 것을 보실 수 있습니다.

 

 

이번에는 오른쪽 상단에 한 구역과 오른쪽 하단에 한 구역으로, 총 두 구역이 잡히게 됩니다.

 

이처럼, 특정 값보다 높은 지역이 모두 연결되는 것이 아니라 분리되어 있을 경우, label이 1, 2,... 등으로 매겨지면서 지점을 찾아주게 됩니다.

 

그리고 이렇게 두 구역이 잡히는 경우, nr_objects는 2가 됩니다. 

 

즉, nr_objects는 구역의 개수를 뜻한다는 것이죠.

 

 

 

자 그럼, 여기까지 진행하면 내가 가지고 있는 이미지에 대해서 특정 값보다 높은 값을 가지는 구역에 대해 labeling 작업이 된 것이죠?

 

다음으로는 그 구간의 좌표와 면적을 구해보겠습니다.

 

아까 구해둔 labeled라는 변수를 이용해서 skimage.measure.regionprops를 이용합니다.

 

# regionprops : Measure properties of labeled image regions.
props = regionprops(labeled)

 

props라는 변수로 regionprops 객체를 저장하였는데요. 

 

이는 다음과 같은 것을 포함합니다.

 

 

RegionProperties의 list이죠. 따라서 이는 type을 찍어보면 list가 됩니다.

 

다음으로는 이 RegionProperties의 property를 사용해서 여러 가지 정보를 얻어보겠습니다.

 

 

 

먼저, 구역이 1개인 기존 케이스에 대해서 적용해보겠습니다.

 

 

props가 list이므로, list 내부에 있는 값들에 대해서 적용하기 위해 for문을 적용해줍니다.

 

b.area는 labeled 된 구역의 pixel 개수를 구해줍니다. 

 

이전 사진에서 확인하실 수 있겠지만, 1로 labeled 된 pixel은 총 16개입니다.

 

 

 

b.bbox_area는 bounding box의 pixel 개수를 구해줍니다.

 

bounding box는 labeled 된 구역을 감싸는 box를 생성하게 되는 것으로, b.area보다는 크거나 같은 값이 나오게 됩니다.

 

 

 

b.image는 bounding box와 같은 사이즈를 가지고 있는 binary region image를 보여줍니다.

 

따라서, False와 True로 표시된 저 값이 실제 bounding box의 크기와 동일하고, bounding box 내에서 labeled 된 데이터와 unlabeled 데이터가 어떻게 구성되어 있는지 확인할 수 있습니다.

 

 

 

b.bbox의 경우는 Bounding box의 정보를 출력해주게 되며, (min_row, min_col, max_row, max_col)의 형태로 출력을 내줍니다. 

 

주의해야 할 사항은, bounding box에 속하는 pixel은 half-open interval [min_row; max_row)와 [min_col; max_col)에 속하게 됩니다.

 

그렇다면, 이 b.bbox를 어떻게 해석해볼 수 있을지 알아봐야겠죠?

 

기본적으로 col은 가로, row는 세로를 뜻하는 것을 기억해주셔야 하고,

 

index는 0부터 시작합니다.

 

 

이전에 labeled 된 데이터를 보여드렸었는데, 이걸 가지고 설명을 드리겠습니다.

 

빨간색row, 파란색col입니다.

 

방금 bounding box는 half-open interval에 속한다는 말씀을 드렸었는데, 먼저 row부터 보겠습니다.

 

row[min_row; max_row) 범위에 속하게 되는데, 해당 image 데이터 기준으로 보자면 bounding box를 그린다고 했을 때 세로 기준으로 1부터 4까지 그려지는 게 맞겠죠? 

 

근데, max_row는 open입니다. 아마 고등학교 수학에서 open(열린 구간)과 close(닫힌 구간)의 개념을 배우셨겠지만, 쉽게 얘기하면 max_row에는 등호가 없습니다.

 

따라서, bounding box 기준에서 봤을 때는 min_row는 1이고, max_row는 5입니다.

 

쉽게 부등호로 표현하자면 1 <=row <5라는 것입니다.

 

 

col도 보겠습니다.

 

마찬가지로 col도 [min_col; max_col) 범위에 속하게 됩니다.

 

해당 image 데이터 기준으로 bounding box는 1부터 5까지 그려지는 게 맞겠죠?

 

그렇다면 bounding box 기준에서 봤을 때 min_col은 1이고, max_col은 6이게 됩니다. 

 

 

 

그래서, 아까 보여드렸던 이 그림에서 b.bbox의 값들이 이렇게 나온 것입니다.

 

 

(1, 1, 5, 6)인 것을 확인하실 수 있죠.

 

 

다음으로는 이 값들을 이용해서 bounding box의 xy starting point와 width, height를 계산해보겠습니다.

 

bounding box를 그리려면 starting point와 width, height가 필요한데, 먼저 b.bbox [1]이 x 좌표의 시작점이 됩니다.

 

그리고 b.bbox [0]이 y 좌표의 시작점이 되지요.

 

 

width는 가로를 의미하므로, max_col과 min_col을 뺀 b.bbox [3] - b.bbox [1]이 되고, 

 

height는 세로를 의미하므로, max_row와 min_row를 뺀 b.bbox [2] - b.bbox [0]이 됩니다.

 

 

이 정보를 이용해서 원하시는 곳에 bounding box를 그리시면 됩니다...!

 

 

아까 label 쪽을 설명하면서 특정값 이상인 구역이 여러 개인 경우에 대해서도 말씀을 드렸는데, for b in props: 코드를 진행하시게 되면 이렇게 여러 개인 경우 bounding box가 여러 개 나오게 됩니다.

 

이를 이용해서 여러 개의 bounding box를 그리시면 되겠습니다.

 

원리는 당연히 동일하므로, 이 부분에 대해서는 별도로 제가 예시를 들어 설명을 해드리지 않아도 될 것 같습니다.

 

 

 

여기까지 해서 scipy.ndimage.label과 skimage.measure.regionprops를 활용해 특정값보다 큰 값을 가지는 이미지의 위치를 잡아내는 방법을 소개해보았습니다.

 

분명 다양한 이미지 task에 사용할 수 있는 방법이라고 생각이 되고, 유용한 자료가 되었으면 좋겠습니다.

 

감사합니다.

 

 

 

 

 

 

 

 

 

안녕하세요.

 

 

오늘은 Learning Deep Features for Discriminative Localization이라는 논문에 대해서 정리해보려고 합니다.

 

 

해당 논문에서 제안하는 접근법을 통해 만들어지는 Class Activation Map(CAM)으로도 유명한 논문입니다.

 

 

논문 주소: arxiv.org/abs/1512.04150

 

Learning Deep Features for Discriminative Localization

In this work, we revisit the global average pooling layer proposed in [13], and shed light on how it explicitly enables the convolutional neural network to have remarkable localization ability despite being trained on image-level labels. While this techniq

arxiv.org

 

그럼 시작해보겠습니다.

 

 

Abstract

 

 

본 논문에서 저자들은 global average pooling layer에 대해서 다시 논의하고, 어떻게 이것에 의해서 convolutional neural network (CNN)가 image-level label에 학습되었음에도 불구하고 놀라운 localization ability를 가지도록 만들어주는지에 대해서 논의합니다.

 

Global average pooling은 학습을 규제하기 위한 수단으로써 이전에 제안되었지만, 본 논문의 저자들은 이것이 이미지에 대해서 CNN의 암시된 attention을 드러내는 포괄적인 localizable deep representation을 만들어낸다고 생각하였습니다.

 

Global average pooling의 단순함에도 불구하고, 본 논문의 저자들은 어떠한 bounding box annotation을 이용한 학습을 하지 않고도 ILSVRC 2014 object localization에 대해서 37.1% top-5 error를 달성할 수 있었습니다.

 

저자들은 본 논문에서 network가 classification task를 해결하기 위해서 학습되었음에도 불구하고, discriminative image region의 위치를 알아낼 수 있다는 것을 다양한 실험을 통해서 검증하였습니다.

 

 

 

1. Introduction

 

 

Zhou et al에 의한 최근 연구는 object의 위치에 대한 어떠한 supervision이 제공되지 않았음에도 불구하고 convolutional neural network (CNNs)의 여러 가지 layers의 convolutional units이 실제로 object detector로써 행동한다는 것을 보여주었습니다.

 

Convolutional layers가 object의 위치를 알아낼 수 있는 놀라운 능력을 가지고 있음에도 불구하고, 이러한 능력은 classification을 위해 사용되는 fully-connected layer가 존재할 때 잃게 됩니다.

 

최근에, Network in Network (NIN)와 GoogLeNet과 같은 여러 인기 있는 fully-convolutional neural networks는 좋은 성능을 유지하면서도 parameter의 수를 최소화하기 위해 fully-connected layers의 사용을 피하기 위해 제안되었습니다. (convolution layer에 비해서 fully-connected layer의 weight 수가 훨씬 많기 때문이죠.)

 

 

이를 달성하고자, Zhou et al는 structural regularizer의 역할을 수행하는 global average pooling을 사용하였으며, 이는 학습이 진행되는 도중에 overfitting을 방지합니다.

 

본 논문의 저자들은 실험을 통해 global average pooling의 이점이 단순히 regularizer의 역할을 수행하는 것 이상으로 확장될 수 있다는 사실을 발견하였습니다. 

 

즉, network의 구조를 약간 수정하면, network가 놀라운 localization ability를 마지막 layer까지 보유할 수 있다는 것입니다.

 

이러한 수정은 네트워크가 매우 다양한 task를 위한 discriminative image region을 single forward-pass에서 쉽게 식별하도록 만들어줍니다. 

 

Figure 1(a)에서 보이는 것처럼, object categorization에 대해 학습된 CNN은 action classification을 위한 discriminative regions을 사람보다는 사람이 상호작용하고 있는 개체의 위치로 잘 찾고 있는 것을 볼 수 있습니다.

 

 

본 논문의 저자들의 접근법이 분명하게 간단함에도 불구하고, ILSVRC benchmark에 대한 weakly supervised object localization에 대해서 저자들의 최고 성능은 top-5 test error 기준 37.1%를 달성하였고, 이는 fully supervised AlexNet이 달성한 top-5 error 34.2%에 근접합니다.

 

추가적으로, 본 논문의 저자들은 제안하는 접근법을 통해 얻어지는 deep feature의 localizability가 generic classification, localization, 그리고 concept discovery를 위한 다른 recognition dataset에도 쉽게 사용될 수 있다는 것을 여러 가지 실험을 통해 검증합니다.

 

 

1.1 Related Work

 

선행 연구 부분은 생략합니다.

 

 

2. Class Activation Mapping

 

 

이번 section에서, 저자들은 CNNs에 있는 global average pooling을 사용해 class activation maps (CAM)를 만들어내는 절차에 대해서 설명합니다.

 

특정한 category에 대한 class activation map은 해당 이미지를 특정한 category로 식별하기 위해 CNN에 의해서 사용된 discriminative image region을 나타냅니다. 

 

Figure 3는 이에 대한 예시를 보여줍니다.

 

 

이러한 map을 만들어내는 절차는 Figure 2에 설명되어 있습니다.

 

 

 

저자들은 Network in Network와 GoogLeNet과 유사한 network architecture를 사용하였습니다.

 

즉, network는 주로 convolutional layer로 구성되고, 마지막 output layer의 바로 직전 convolutional feature map에 대해서 global average pooling을 수행하여 이를 desired output을 만들어내는 fully-connected layer를 위한 feature로써 사용합니다.

 

이 간단한 connectivity structure가 주어졌을 때, 저자들은 output layer의 weight를 convolutional feature maps에 대해서 다시 project 함으로써 image regions에 대한 importance를 확인할 수 있으며 저자들은 이 기법을 class activation mapping이라고 부릅니다.

 

 

Figure 2에서 볼 수 있듯이, global average pooling은 마지막 convolutional layer의 각 unit의 feature map의 spatial average를 만들어냅니다. 

 

이러한 값들의 weighted sum은 final output을 만들어내는 데 사용됩니다.

 

유사하게, 저자들은 class activation map을 얻기 위해서 last convolutional layer의 feature map의 weighted sum을 계산합니다.

 

저자들은 이를 softmax의 케이스에 대해 formally 하게 설명합니다. 이 기법은 다른 loss나 regression에 대해서도 적용할 수 있습니다.

 

 

 

(이 파트부터는 제가 직접 만든 그림과 함께 설명을 진행하겠습니다.)

 

 

주어진 이미지에 대해서, $f_k(x, y)$는 spatial location $(x, y)$에서의 last convolutional layer의 unit $k$의 activation을 나타냅니다. 

 

 

 

last convolutional layer의 크기를 (Batch, 3, 4, 4)라고 가정하겠습니다. (channel이 3, 가로 세로가 각각 4라고 가정)

 

여기서 channel이 3 이므로, $k$는 3까지 존재하게 됩니다.

 

그러고 3번째 channel의 (1, 1)의 위치는 $f_3(1,1)$로 표현될 수 있습니다.

 

 

 

그러고 나서, unit $k$에 대해, global average pooling을 수행한 결과인 $F^k$는 $\sum_{x, y}f_k(x, y)$입니다.

 

 

 

 

global average pooling을 하게 되면, 이전 $f_k$에서 4x4 짜리였던 데이터를 평균 내서 하나의 값으로 만들게 됩니다.

 

따라서 그림에서 $F^k$가 하나의 데이터로 표현이 되는 것이죠.

 

각 데이터들은 $F^1$, $F^2$, $F^3$가 됩니다.

 

 

따라서, 주어진 class $c$에 대해서, softmax의 input $S_c$는 $\sum_k w^c_kF_k$이며, $w^c_k$는 unit $k$가 class $c$에 대응되는 weight를 나타냅니다.

 

 

 

 

문제를 단순하게 하기 위해서, 하나의 이미지를 개와 고양이를 분류하는 이진 분류 문제로 가정하였습니다.

 

Global Average Pooling을 통해서 얻은 $F^k$를 weight와 곱해서 최종적으로 softmax의 input인 $S_c$를 얻게 됩니다.

 

이때, $S_1$ = $F^1 \times 0.3(w^1_1) + F^2 \times 0.5(w^1_2) + F^3 \times 0.1(w^1_3)$로 구할 수 있습니다.

 

 

 

동일한 방법을 적용하면, $S_2$ = $F^1 \times 0.4(w^2_1) + F^2 \times 0.6(w^2_2) + F^3 \times 0.8(w^2_3)$로 구할 수 있게 됩니다.

 

 

 

본질적으로, $w^c_k$는 class $c$에 대한 $F_k$의 importance를 나타냅니다.

 

 

최종적으로 class $c$에 대한 softmax output $P_c$는 ${exp(S_c)}/{\sum_c exp(S_c)}$로 나타내질 수 있습니다.

 

 

여기서 저자들은 bias term를 무시하고자 명시적으로 softmax의 input bias를 0으로 지정하는데, 이는 classification performance에 영향을 거의 주지 않기 때문이라고 합니다.

 

 

 

$F_k = \sum_{x, y}f_k(x, y)$를 class score $S_c$를 나타내는 식에 적용하면, 이렇게 표현할 수 있습니다.

 

 

 

저자들은 $M_c$를 class $c$에 대한 class activation map으로 정의하고, 각 spatial element는 다음과 같이 나타낼 수 있습니다.

 

 

 

위의 예시 그림을 통해서 표현해보면 다음과 같습니다.

 

 

따라서, $S_c = \sum_{x, y}M_c(x, y)$이며, $M_c(x, y)$는 직접적으로 image의 분류가 class $c$가 되도록 이끄는 spatial grid $(x, y)$에서의 activation의 importance를 나타냅니다.

 

 

 

직관적으로는, 이전 연구에 기반해서 본 논문의 저자들은 각 unit이 receptive field 내에서 어떤 visual pattern에 의해 활성화되는 것을 기대합니다.

 

따라서, $f_k$는 이러한 visual pattern의 존재에 대한 map이 됩니다.

 

Class activation map은 간단하게 다른 spatial locations에서 이러한 visual pattern의 존재에 대한 weighted linear sum이 됩니다.

 

Class activation map을 input image의 사이즈로 upsampling 해줌으로써, 특정한 category와 가장 관련이 있는 image regions을 식별할 수 있게 됩니다.

 

 

Figure 3에서는 위의 접근법을 사용해서 나온 CAM output의 여러 예시들을 보여줍니다.

 

다양한 클래스의 이미지들의 discriminative region이 강조된 것을 볼 수 있습니다.

 

Figure 4에서는, 하나의 이미지에 대해서 map을 만들 때 다른 classes $c$를 사용했을 때 CAM에서의 차이를 보여줍니다.

 

이를 통해서 다른 category에 대한 discriminative region이 주어진 이미지에 대해서 다르다는 것을 확인할 수 있습니다.

 

이는 저자들의 접근법이 기대한 대로 작동함을 나타냅니다. 

 

 

Global average pooling (GAP) vs Global max pooling (GMP)

 

 

Weakly supervised object localization에서 GMP을 사용한 이전 연구들을 고려해 볼 때, 저자들은 GAP와 GMP 사이의 직관적인 차이를 강조하는 것이 중요하다고 생각했다고 합니다.

 

저자들이 생각하기에는 하나의 discriminative part만을 식별하도록 만드는 GMP와 비교했을 때 GAP loss는 network가 object의 면적을 식별할 수 있도록 만든다고 합니다. 

 

이는 맵의 평균을 낼 때, 낮은 activation이 맵의 output을 감소시키므로 object의 모든 discriminative part를 찾아 최대화될 수 있기 때문이라고 합니다.

 

반면에 GMP의 경우는 가장 discriminative 한 image region을 제외하고는 모든 image region의 낮은 score가 max를 수행할 때 score에 영향을 주지 않습니다.

 

저자들은 이를 Section 3에서 ILSVRC dataset에 대해서 실험적으로 검증합니다.

 

GMP는 classification performance에 있어서는 GAP와 유사한 성능을 내지만, GAP는 localization에 있어서는 GMP보다 더 좋은 성능을 냅니다.

 

 

3. Weakly-supervised Object Localization

 

 

이번 section에서는 ILSVRC 2014 benchmark dataset에 대해서 CAM의 localization ability를 평가합니다.

 

모든 내용을 자세히는 다루지 않을 것이고, 간략하게 다루려고 합니다.

 

 

3.1 Setup

 

 

해당 실험에서는, AlexNet, VGGnet, GoogLeNet을 사용하여 실험을 진행합니다.

 

이 3개의 network는 원래 fully-connected layer가 있지만, 이를 제거하고 GAP로 대체하여 실험을 진행합니다. 

 

fully-connected layer가 network parameter의 수를 상당히 많이 감소시키므로, classification performance의 drop을 가져오게 됩니다.

 

 

그리고 저자들은 network의 localization ability가 GAP 이전의 last convolutional layer가 더 큰 spatial resolution을 가질 때 향상된다는 사실을 발견하였으며, 이를 mapping resolution이라고 부른다고 합니다.

 

 

Classification에 대해서는 original AlexNet, VGGnet, GoogLeNet, Network in Network에 대한 결과를 제공합니다.

 

Localization에 대해서는 original GoogLeNet, NIN, 그리고 backpropagation을 이용한 결과를 비교합니다.

 

추가적으로, max pooling과 average pooling을 비교하기 위해, global max pooling으로 학습된 GoogLeNet의 결과도 제공합니다. (GoogLeNet-GMP)

 

 

3.2 Results

 

 

Classification

 

 

Table 1에서 classification performance의 비교를 보여줍니다.

 

 

다양한 network에서 layer를 제거하는 것에 의해 약간의 성능 하락이 보이고 있습니다.

 

AlexNet이 fully-connected layer의 제거에 의해 가장 크게 영향을 받았는데, 이를 보상해주기 위해 두 개의 convolutional layer를 추가하여 AlexNet*-GAP network로 실험했다고 합니다.

 

그리고 GoogLeNet-GMP와 GoogLeNet-GAP는 유사한 성능을 보이고 있는데, 이는 저자들이 예측한 대로 나오고 있음을 알 수 있는 부분입니다.

 

 

Localization

 

 

Localization을 수행하려면, bounding box를 만들어야 하는데요.

 

저자들은 CAM에서 bounding box를 만들기 위해서, thresholding 하는 방법을 사용했습니다.

 

CAM의 max value의 20%보다 높은 값들을 가지는 구역만 잘라낸 뒤에, 이를 포함할 수 있는 가장 큰 bounding box를 만드는 방법입니다.

 

Figure 6(a)에는 이 방법을 사용하여 만들어낸 bounding box의 예시들을 볼 수 있습니다.

 

 

그리고 ILSVRC validation set에서의 localization performance는 Table 2에 나와있고, 예시 output은 Figure 5에 나와있습니다.

 

 

추가적으로 저자들이 제안한 CAM 방법과 이미 존재하는 weakly-supervised, fully-supervised CNN method와 비교하기 위해서, GoogLeNet-GAP을 ILSVRC test set에 대해 성능 실험을 수행했습니다. 

 

성능 비교와 관련된 자료는 Table 3에 나와있습니다.

 

fully-supervised AlexNet과 weakly-supervised GoogLeNet의 성능이 거의 비슷하네요.

 

 

4. Deep Features for Generic Localization

 

 

CNN의 higher-level layer들로부터 나오는 response는 다양한 이미지 데이터셋에서 SOTA 성능을 내게 해주는 매우 효과적인 generic feature라는 것이 알려져 왔습니다.

 

해당 section에서는, 저자들이 제안한 GAP CNNs으로부터 학습된 feature가 generic feature로써 잘 작동하는지를 검증합니다.

 

 

4.1 Fine-grained Recognition

 

 

 

해당 section에서는, 200개의 새 품종을 식별하는 CUB-200-2011 dataset을 이용해 실험을 진행합니다.

 

section 3.2에서 사용한 것과 동일하게, thresholding을 사용하여서 bounding box를 만들어낸 모습입니다.

 

 

4.2 Pattern Discovery

 

Discovering informative objects in the scenes

 

SUN dataset를 이용해 실험을 진행하였으며, 다음 Figure 9는 예측된 scene category에 대한 CAM과 높은 CAM activation이 가장 빈번하게 겹치는 top 6 objects를 나타낸 것입니다. 

 

 

 

Concept localization in weakly labeled images

 

 

concept detector를 학습하고, 이미지에서 concept의 위치를 확인하기 위해 CAM technique을 적용하였습니다.

 

Figure 10은 top ranked image와 CAM을 보여주는 그림입니다.

 

 

나머지 실험들도 더 있었지만, 결국 CAM의 localization ability를 검증하는 실험이어서 생략하도록 하겠습니다.

 

 

6. Conclusion

 

 

본 논문에서, 저자들은 global average pooling을 가지고 있는 CNN 모델에 대해 Class Activation Mapping이라고 불리는 일반적인 기술을 제안합니다.

 

이는 classification에 학습된 CNN이 어떠한 bounding box annotation 없이 object localization을 수행할 수 있도록 만들어줍니다.

 

Class activation map은 주어진 이미지에 대해서 예측된 class score를 시각화하게 해 주고, CNN에 의해서 detected 된 discriminative object part를 강조합니다.

 

저자들은 CAM을 ILSVRC benchmark에 대해 실험을 진행했으며, global average pooling CNN이 정확한 object localization을 수행할 수 있음을 검증하였습니다.

 

추가적으로 저자들은 CAM localization technique이 다른 visual recognition task에 대해 일반화할 수 있다는 사실을 검증하였습니다.

 

즉, CAM은 다른 연구자들이 CNN에 의해 사용되는 discrimination의 기초를 이해하는 데 있어서 도움을 줄 수 있는 generic localizable deep feature를 만들어냅니다.

 

 

 

여기까지 CAM 논문에 대한 review를 해보았습니다.

 

Deep Learning의 경우, black-box 방법이기 때문에 왜 이러한 의사결정을 하는지 판단하기가 어렵습니다.

 

하지만 CAM을 이용하게 되면, 과연 어느 부분을 보고 이러한 의사결정을 했는지 파악할 수 있다는 점에서 딥러닝의 해석 가능성과 연관된 논문이라고 생각했고, 그 점에서 흥미롭다고 판단되어 review를 진행하게 되었습니다.

 

다음 포스팅에서는 해당 논문에 대한 code review를 진행하겠습니다.

 

감사합니다.

 

 

안녕하세요.

 

이번 글에서는 저번 글에서 리뷰했던 Wide Residual Networks(WRN)의 code review를 진행해보겠습니다.

 

ResNet과 유사한 형태이기 때문에, ResNet를 이해하셨다면 조금 더 쉽게 보실 수 있을 것 같습니다.

 

해당 논문에 대한 paper review는 다음 주소에서 확인하실 수 있습니다.

 

cumulu-s.tistory.com/35

 

7. Wide Residual Networks(WRN) - paper review

안녕하세요. 오늘은 ResNet을 발전시켜서 만들어진 Wide Residual Networks(WRN)에 대한 내용을 정리해보려고 합니다. 논문 주소: arxiv.org/abs/1605.07146 Wide Residual Networks Deep residual networks were..

cumulu-s.tistory.com

 

시작해보겠습니다.

 

 

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from tqdm.auto import tqdm
import torchvision
import torchvision.transforms as transforms
import datetime
import os
from torch.utils.tensorboard import SummaryWriter
import shutil

먼저, 필요한 library를 가져옵니다.

 

 

device = 'cuda' if torch.cuda.is_available() else 'cpu'

# Data
print('==> Preparing data..')
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

trainset = torchvision.datasets.CIFAR10(
    root='/content/drive/MyDrive/CIFAR10', train=True, download=True, transform=transform_train)
trainloader = torch.utils.data.DataLoader(
    trainset, batch_size=128, shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(
    root='/content/drive/MyDrive/CIFAR10', train=False, download=True, transform=transform_test)
testloader = torch.utils.data.DataLoader(
    testset, batch_size=100, shuffle=False, num_workers=2)

current_time = datetime.datetime.now() + datetime.timedelta(hours= 9)
current_time = current_time.strftime('%Y-%m-%d-%H:%M')

saved_loc = os.path.join('/content/drive/MyDrive/WRN_Result', current_time)
if os.path.exists(saved_loc):
    shutil.rmtree(saved_loc)
os.mkdir(saved_loc)

print("결과 저장 위치: ", saved_loc)

writer = SummaryWriter(saved_loc)

데이터를 들고 올 준비를 해줍니다.

 

 

class BasicBlock(nn.Module):
    def __init__(self, in_planes, out_planes, stride, dropRate=0.0):
        super(BasicBlock, self).__init__()
        self.bn1 = nn.BatchNorm2d(in_planes)
        self.relu1 = nn.ReLU(inplace=True)
        self.conv1 = nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,
                               padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_planes)
        self.relu2 = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_planes, out_planes, kernel_size=3, stride=1,
                               padding=1, bias=False)
        self.droprate = dropRate
        self.equalInOut = (in_planes == out_planes)
        self.convShortcut = (not self.equalInOut) and nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride,
                               padding=0, bias=False) or None
    def forward(self, x):
        if not self.equalInOut:
            x = self.relu1(self.bn1(x))
        else:
            out = self.relu1(self.bn1(x))
        out = self.relu2(self.bn2(self.conv1(out if self.equalInOut else x)))
        if self.droprate > 0:
            out = F.dropout(out, p=self.droprate, training=self.training)
        out = self.conv2(out)
        return torch.add(x if self.equalInOut else self.convShortcut(x), out)

class NetworkBlock(nn.Module):
    def __init__(self, nb_layers, in_planes, out_planes, block, stride, dropRate=0.0):
        super(NetworkBlock, self).__init__()
        self.layer = self._make_layer(block, in_planes, out_planes, nb_layers, stride, dropRate)
    def _make_layer(self, block, in_planes, out_planes, nb_layers, stride, dropRate):
        layers = []
        for i in range(int(nb_layers)):
            layers.append(block(i == 0 and in_planes or out_planes, out_planes, i == 0 and stride or 1, dropRate))
        return nn.Sequential(*layers)
    def forward(self, x):
        return self.layer(x)

class WideResNet(nn.Module):
    def __init__(self, depth, num_classes, widen_factor=1, dropRate=0.0):
        super(WideResNet, self).__init__()
        nChannels = [16, 16*widen_factor, 32*widen_factor, 64*widen_factor]
        assert((depth - 4) % 6 == 0) # depth must be 6n + 4
        n = (depth - 4) / 6

        # 1st conv before any network block
        self.conv1 = nn.Conv2d(3, nChannels[0], kernel_size=3, stride=1,
                               padding=1, bias=False)
        # 1st block
        self.block1 = NetworkBlock(n, nChannels[0], nChannels[1], BasicBlock, 1, dropRate)
        # 2nd block
        self.block2 = NetworkBlock(n, nChannels[1], nChannels[2], BasicBlock, 2, dropRate)
        # 3rd block
        self.block3 = NetworkBlock(n, nChannels[2], nChannels[3], BasicBlock, 2, dropRate)
        # global average pooling and classifier
        self.bn1 = nn.BatchNorm2d(nChannels[3])
        self.relu = nn.ReLU(inplace=True)
        self.fc = nn.Linear(nChannels[3], num_classes)
        self.nChannels = nChannels[3]


        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
            elif isinstance(m, nn.BatchNorm2d):
                m.weight.data.fill_(1)
                m.bias.data.zero_()
            elif isinstance(m, nn.Linear):
                m.bias.data.zero_()


    def forward(self, x):
        out = self.conv1(x)
        out = self.block1(out)
        out = self.block2(out)
        out = self.block3(out)
        out = self.relu(self.bn1(out))
        out = F.avg_pool2d(out, 8, 1, 0)
        out = out.view(-1, self.nChannels)
        return self.fc(out)

 

WideResNet이라는 이름으로 WRN를 나타내는 class를 만들어줍니다. 

 

input으로는 depth, class 개수, widen_factor, dropout rate를 받게 됩니다.

 

Channel의 개수는 16, 16*k, 32*k, 64*k로 구성되며, k는 이전 논문 review에서도 언급되었듯이 widening factor를 나타냅니다.

 

이를 통해 기존 original residual network보다 더 넓은 형태의 residual network를 구성할 수 있습니다.

 

depth의 경우는 반드시 6n + 4의 값이 되어야 하는데, 이는 다음 표를 보고 이해해볼 수 있습니다.

 

먼저, conv1에서 1개, 그리고 conv2, conv3, conv4에서 shortcut connection 형태로 layer가 들어가게 되어 기본적으로 4개의 layer가 있어야 합니다.

 

그리고 6n이 되는 이유는, conv2에서 3x3 짜리 2개, conv3에서 3x3짜리 2개, conv4에서 3x3짜리 2개로 총 6개가 기본적인 setting이므로, 이 setting보다 몇 배 더 많은 layer를 구성할 것인지를 결정하기 위해 6n가 되게 됩니다. 

 

만약 layer의 총개수가 16이라면, 6*2 + 4가 되며, 이는 conv2 block 내에서의 layer가 4개, conv3 block 내에서의 layer가 4개, conv4 block 내에서의 layer가 4개가 됩니다. 

 

즉, n이라는 것은 conv block 내에서 3x3 conv layer의 개수에 곱해지는 계수라고 생각할 수 있습니다.

 

 

 

위의 Table 1의 structure가 코드로 구현되어 있음을 확인할 수 있는데요.

 

WRN의 전체적인 흐름부터 살펴보겠습니다.

 

    def forward(self, x):
        out = self.conv1(x)
        out = self.block1(out)
        out = self.block2(out)
        out = self.block3(out)
        out = self.relu(self.bn1(out))
        out = F.avg_pool2d(out, 8, 1, 0)
        out = out.view(-1, self.nChannels)
        return self.fc(out)

(WideResNet에서 이쪽 부분을 확인해주시면 됩니다!)

 

 

먼저 self.conv1은 3x3 conv이며, 16 channel이 output이 되도록 연산이 되게 됩니다.

 

self.block1, self.block2, self.block3는 위 Table 1에서 conv2, conv3, conv4에 대응됩니다.

 

self.block에 대해서는 조금 있다가 자세히 살펴보도록 할 예정입니다.

 

self.block3까지 지난 결과를 BN과 ReLU를 통과시키고, average pooling을 적용해줍니다. 

 

그리고 이를 [batch_size, 64*widen_factor]의 shape로 만들어주고, 이를 마지막 fully connected layer에 연결하여

 

dataset의 class 각각에 대한 probability를 정답으로 내게 됩니다.

 

 

 

이전에 paper review를 진행할 때, WRN의 contribution 중 하나가 바로 batch normalization, activation, convolutional layer의 순서에 대한 연구를 진행한 것이었고, 따라서 어떤 순서로 연산할지 변경되었다는 얘기를 했었습니다.

 

그렇다면, 여기서 ResNet과 Wide Residual Network는 어떻게 다른지 한번 살펴보겠습니다.

 

class ResNet(nn.Module):
    def __init__(self, block, num_blocks, num_classes=10):
        super(ResNet, self).__init__()
        self.in_planes = 64

        self.conv1 = nn.Conv2d(3, 64, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        self.linear = nn.Linear(512*block.expansion, num_classes)

    def _make_layer(self, block, planes, num_blocks, stride):
        strides = [stride] + [1]*(num_blocks-1)
        layers = []
        for stride in strides:
            layers.append(block(self.in_planes, planes, stride))
            self.in_planes = planes * block.expansion
        return nn.Sequential(*layers)

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = F.avg_pool2d(out, 4)
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out

 

이전 ResNet code review를 할 때 사용했던 코드입니다.

 

ResNet에서는 먼저 input 데이터를 받아 conv1 -> BN -> relu를 거치고 conv block을 거친 후, average pooling을 하고, 이를 fully connected layer에 연결하는 순서로 이루어집니다.

 

하지만, Wide residual network는 input 데이터를 받아 conv1을 거치고, conv block을 거친 후, BN -> relu를 거치고 average pooling을 한 후 이를 fully connected layer에 연결하는 순서로 이루어집니다.

 

전체적인 흐름상 batch normalization과 ReLU의 적용 위치가 바뀌었네요.

 

그리고 conv block이 ResNet에서는 4개였으나, Wide residual network에서는 3개라는 점도 하나의 차이가 될 수 있겠네요.

 

conv block 1개당 stride = 2가 적용되므로, 따라서 ResNet은 conv block이 모두 진행되었을 때 resolution이 4x4지만, wide residual network는 resolution이 8x8이 됩니다.

 

 

 

다음으로는 conv block에 대해서 세부적으로 살펴보겠습니다.

 

self.block1 = NetworkBlock(n, nChannels[0], nChannels[1], BasicBlock, 1, dropRate)

 

먼저 첫 번째 conv block을 살펴봅니다.

 

간단한 예시를 들기 위해서, n = 2 / nChannels[0] = 16 / nChannels[1] = 64 / dropRate = 0.3으로 지정한다고 가정하겠습니다.

 

class NetworkBlock(nn.Module):
    def __init__(self, nb_layers, in_planes, out_planes, block, stride, dropRate=0.0):
        super(NetworkBlock, self).__init__()
        self.layer = self._make_layer(block, in_planes, out_planes, nb_layers, stride, dropRate)
    def _make_layer(self, block, in_planes, out_planes, nb_layers, stride, dropRate):
        layers = []
        for i in range(int(nb_layers)):
            layers.append(block(i == 0 and in_planes or out_planes, out_planes, i == 0 and stride or 1, dropRate))
        return nn.Sequential(*layers)
    def forward(self, x):
        return self.layer(x)

 

따라서, 우리가 만들어낸 것은 NetworkBlock(nb_layers = 2, in_planes = 16, out_planes = 64, block = BasicBlock, stride = 1, dropRate= 0.3)이 됩니다.

 

이를 통해서 만들어지는 것은 다음과 같습니다.

 

def _make_layer(self, block = BasicBlock, in_planes=16, out_planes=64, nb_layers=2, stride=1, dropRate=0.3):
        layers = []
        for i in range(int(nb_layers)):
            layers.append(block(i == 0 and in_planes or out_planes, out_planes, i == 0 and stride or 1, dropRate))
        return nn.Sequential(*layers)

 

layer의 개수만큼 for문을 돌게 되고, 이를 통해서 layers = []에 block이 append 되는 구조입니다.

 

nb_layers = 2이므로, block이 2개 쌓이게 되겠죠?

 

여기서 조금 생소한 코드가 등장하는데요, A and B or C 형태의 문법이 사용되었습니다.

 

이는 B if A else C와 똑같은 의미입니다.

 

즉, BasicBlock(in_planes if i == 0 else out_planes, out_planes, stride if i == 0 else 1, dropRate)가 만들어집니다.

 

layers = [BasicBlock(16, 64, 1, 0.3), BasicBlock(64, 64, 1, 0.3)]가 될 것입니다.

 

다음으로는 BasicBlock에 대해서 살펴봐야겠죠?

 

class BasicBlock(nn.Module):
    def __init__(self, in_planes, out_planes, stride, dropRate=0.0):
        super(BasicBlock, self).__init__()
        self.bn1 = nn.BatchNorm2d(in_planes)
        self.relu1 = nn.ReLU(inplace=True)
        self.conv1 = nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,
                               padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_planes)
        self.relu2 = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_planes, out_planes, kernel_size=3, stride=1,
                               padding=1, bias=False)
        self.droprate = dropRate
        self.equalInOut = (in_planes == out_planes)
        self.convShortcut = (not self.equalInOut) and nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride,
                               padding=0, bias=False) or None
    def forward(self, x):
        if not self.equalInOut:
            x = self.relu1(self.bn1(x))
        else:
            out = self.relu1(self.bn1(x))
        out = self.relu2(self.bn2(self.conv1(out if self.equalInOut else x)))
        if self.droprate > 0:
            out = F.dropout(out, p=self.droprate, training=self.training)
        out = self.conv2(out)
        return torch.add(x if self.equalInOut else self.convShortcut(x), out)

 

BasicBlock(16, 64, 1, 0.3)이라고 가정하고 생각해보겠습니다.

 

여기서 먼저 따져봐야 할 점은 input의 shape와 output의 shape가 같은지입니다. 이를 확인한 후, self.equalInOut이라는 변수로 boolean 값으로 저장합니다.

 

BasicBlock(16, 64, 1, 0.3)이라면 input의 shape와 output의 shape가 다른 경우이므로, self.equalInOut은 False가 됩니다.

 

self.convShortcut의 경우는 self.equalInOut이 False이면 nn.conv2d를 적용하고, True이면 None이 됩니다.

 

다음으로는 forward 부분을 보면 됩니다.

 

if not self.equalInOut이므로, 이 if문에 들어가게 됩니다.

 

따라서 x = self.relu1(self.bn1(x))이 됩니다.

 

그러고 나서 out = self.relu2(self.bn2(self.conv1(x)))이 되고

 

droprate = 0.3이므로, 여기에 dropout을 적용하여 out = F.dropout(out, p = self.droprate, training = self.training)을 적용합니다.

 

training = self.training을 적용하는 이유는, 현재 모델이 train모드인지 eval 모드인지에 따라서 dropout을 사용할지 아닐지를 결정하게 됩니다.

 

왜냐하면 학습이 된 상태에서 추론을 하는 경우에는 dropout을 사용하지 않아야 하기 때문이죠.

 

그리고 나서 out = self.conv2(out)을 해주고

 

torch.add(self.convShortcut(x))을 해준 것을 return 해줍니다.

 

이를 그림으로 한번 그려보자면, 다음과 같습니다.

 

 

k는 kernel size이며 s는 stride, p는 padding을 나타냅니다.

 

본 그림을 통해서 BasicBlock의 구동 방식을 이해해볼 수 있을 것이라고 생각이 들고요.

 

만약 stride = 2인 경우는 self.conv1에서 s = 2로 변하고, 그 이후로 가로와 세로의 길이가 /2로 줄어듭니다.

 

그리고 input과 output의 channel 수가 동일한 경우는 위의 그림에서 self.conv1 = nn.Conv2d(16, 64, ~)을 통과하면서 channel수가 변하는 것이 아닌, 그대로 channel 수가 유지되게 됩니다.

 

self.convShortcut 또한 필요가 없어지기 때문에, 이는 None이 되고 위의 def forward에서 return 쪽을 확인해보시게 되면, input인 x를 그대로 torch.add에 넣어주는 것을 확인할 수 있습니다. 

 

여기까지 WRN의 모델에 대한 설명은 마무리가 되었습니다.

 

 

net = WideResNet(40, 10, 2, 0.3) # WRN-40-2

net = net.to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.1,
                      momentum=0.9, weight_decay=0.0005)
scheduler = optim.lr_scheduler.MultiStepLR(optimizer, milestones = [60, 120, 160], gamma = 0.2)

best_acc = 0

 

WideResNet의 첫 번째 인자는 layer의 수, 두 번째 인자는 class의 수, 세 번째 인자는 widening factor, 네 번째 인자는 dropout rate입니다.

 

optimizer는 논문에 나온 그대로 lr = 0.1, momentum = 0.9, weight_decay = 0.0005를 적용하였으며

 

scheduler는 MultiStepLR을 이용하여 구현하였습니다. milestones을 통해서 어떤 지점에서 scheduler를 적용할지 입력하고, gamma는 얼마큼 감소시킬지 값을 알려줍니다.

 

처음 learning rate는 0.1로, 그리고 60, 120, 160 에폭 일 때 각각 0.2씩 감소하라는 내용을 그대로 반영하였습니다.

 

 

def train(epoch):
    print('\nEpoch: %d' % epoch)
    net.train()
    train_loss = 0
    correct = 0
    total = 0
    batch_count = 0
    for batch_idx, (inputs, targets) in enumerate(trainloader):
        inputs, targets = inputs.to(device), targets.to(device)
        optimizer.zero_grad()
        outputs = net(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

        train_loss += loss.item()
        _, predicted = outputs.max(1)
        total += targets.size(0)
        correct += predicted.eq(targets).sum().item()
        batch_count += 1

    print("Train Loss : {:.3f} | Train Acc: {:.3f}".format(train_loss / batch_count, 100.*correct/total))
    final_loss = train_loss / batch_count
    final_acc = 100.*correct / total
    return final_loss, final_acc

 

inputs로 이미지 데이터를, targets로 label을 받아오고

 

이미지를 신경망에 투입하여 outputs을 만들고

 

outputs과 targets 과의 cross entropy를 계산해서 loss로 만듭니다.

 

이 값은 backpropagation 시키고, train_loss에 더해줍니다.

 

output.max(1)을 적용하면, row 기준으로 output에서 가장 높은 값을 가지는 index와 예측값을 뽑아줍니다.

 

예측값은 필요 없으므로 _로 처리하고, 예측된 index를 predicted로 저장합니다.

 

그리고 predicted.eq(targets)를 통해서 label과 예측된 label이 몇 개나 같은지를 boolean 값으로 계산합니다.

 

그리고 sum()을 해주면, 배치 사이즈 기준으로 몇 개나 label와 일치하는지를 값으로 계산할 수 있습니다. 

 

 

def test(epoch):
    global best_acc
    net.eval()
    test_loss = 0
    correct = 0
    total = 0
    batch_count = 0
    with torch.no_grad():
        for batch_idx, (inputs, targets) in enumerate(testloader):
            inputs, targets = inputs.to(device), targets.to(device)
            outputs = net(inputs)
            loss = criterion(outputs, targets)

            test_loss += loss.item()
            _, predicted = outputs.max(1)
            total += targets.size(0)
            correct += predicted.eq(targets).sum().item()
            batch_count += 1


    print("Test Loss : {:.3f} | Test Acc: {:.3f}".format(test_loss / batch_count, 100.*correct/total))

    # Save checkpoint.
    acc = 100.*correct/total
    if acc > best_acc:
        print('Saving..')
        state = {
            'net': net.state_dict(),
            'acc': acc,
            'epoch': epoch,
        }
        if not os.path.isdir(os.path.join(saved_loc, 'checkpoint')):
            os.mkdir(os.path.join(saved_loc, 'checkpoint'))
        torch.save(state, os.path.join(saved_loc, 'checkpoint/ckpt.pth'))
        best_acc = acc
    
    final_loss = test_loss / batch_count
    return final_loss, acc

 

위의 내용은 동일하니 설명을 생략하고,

 

accuracy의 경우 best_acc를 계속 갱신하면서, best_acc보다 더 높은 accuracy가 나오면 이를 best_acc로 저장하면서 동시에 모델과 accuracy 값, 그 당시 epoch을 저장합니다.

 

이를 통해서 test 기준으로 가장 높게 나온 네트워크의 가중치와 accuracy 값을 저장할 수 있게 됩니다.

 

 

for epoch in tqdm(range(200)):
    train_loss, train_acc = train(epoch)
    test_loss, test_acc = test(epoch)

    writer.add_scalars('Loss', {"Train Loss" : train_loss, "Test Loss" : test_loss}, epoch)
    writer.add_scalars('Accuracy', {"Train acc" : train_acc, "test acc" : test_acc}, epoch)
    
    scheduler.step()

writer.close()

학습은 200 epochs 동안 진행하고, writer.add_scalars는 tensorboard에 값을 저장하는 코드입니다.

 

이를 통해서 train_loss, test_loss, train_acc, test_acc 그래프를 그릴 수 있게 됩니다.

 

그리고 앞에서 scheduler를 지정했기 때문에, 1 epoch이 지날 때마다 scheduler.step()을 적용하여 원하는 지점에서 learning rate가 변하도록 지정해줍니다. 

 

 

 

이를 통해 얻게 되는 Accuracy 그래프는 다음과 같습니다.

 

 

 

중간에 learning rate가 변했다 보니, 약간 각진 그래프가 나오게 되네요.

 

 

 

loss graph도 마찬가지로 잘 수렴하는 모습을 보여주고 있습니다.

 

 

 

여기까지 WRN의 code review를 해보았습니다.

 

해당 글에서 다룬 모든 코드는 제 Github에서 확인하실 수 있습니다.

 

github.com/PeterKim1/paper_code_review

 

PeterKim1/paper_code_review

paper review with codes. Contribute to PeterKim1/paper_code_review development by creating an account on GitHub.

github.com

 

다음에는 또 새로운 논문으로 찾아오도록 하겠습니다.

 

감사합니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

안녕하세요.

 

 

오늘은 ResNet을 발전시켜서 만들어진 Wide Residual Networks(WRN)에 대한 내용을 정리해보려고 합니다.

 

 

논문 주소: arxiv.org/abs/1605.07146

 

Wide Residual Networks

Deep residual networks were shown to be able to scale up to thousands of layers and still have improving performance. However, each fraction of a percent of improved accuracy costs nearly doubling the number of layers, and so training very deep residual ne

arxiv.org

 

그럼 시작해보겠습니다.

 

 

Abstract

 

 

Deep Residual networks는 수천 개의 layer까지 scale up 할 수 있음을 보여주었고, 향상된 성능을 보여준 모델입니다.

 

하지만, 정확도를 향상하기 위해서는 layer의 수를 많이 늘려야 했고, 매우 깊은 residual network를 학습시키는 것은 감소된 feature를 재사용하는 문제를 가지고 있으며, 이는 학습을 매우 느리게 만든다고 합니다.

 

이 문제를 해결하기 위해, 본 논문에서 저자들은 residual network의 width는 증가시키고 depth는 감소시키는 새로운 architecture를 기반으로 ResNet block의 architecture에 대한 상세한 실험 연구를 수행합니다.

 

저자들은 그 결과로 나온 network structure를 wide residual network (WRNs)라고 부르고, 흔하게 사용되는 얇고 매우 깊은 네트워크에 비해서 훨씬 더 우월한 성능을 내는 것을 보여줍니다.

 

 

1. Introduction

 

 

Convolutional neural network는 지난 몇 년간 층의 수를 점차적으로 늘려왔습니다.

 

AlexNet에서부터 시작해서, VGG, Inception, Residual network까지 층의 수를 늘리는 것을 통해 image recognition task에서 많은 성능 향상을 가져왔습니다.

 

하지만, deep neural network를 학습시키는 것은 exploding/vanishing gradient와 degradation을 포함해 여러 가지 어려움을 가지고 있습니다. 

 

Well-designed initialization strategies, better optimizers, skip connections, knowledge transfer and layer-wise training과 같은 다양한 기법들이 deeper neural network를 학습시킬 수 있도록 하기 위해서 제안되어 왔습니다.

 

가장 최근의 residual network는 다양한 benchmark에서 SOTA를 달성하였으며, 이와 관련된 후속 연구에서는 residual network에서의 activation의 순서에 대해서 연구가 진행되었습니다.

 

 

지금까지, residual network의 연구는 주로 ResNet block 내에서의 activation의 순서와 residual network의 depth에 대해서만 이루어졌습니다.

 

본 논문에서, 저자들은 이 point를 넘어서는 실험적인 연구를 수행하려고 시도했습니다.

 

본 논문에서의 저자들의 목표는 ResNet blocks의 훨씬 더 풍부한 network architecture를 탐색하고, activations의 순서 외에 다른 몇 가지 측면들이 성능에 어떻게 영향을 미치는지 철저히 검토하는 것입니다.

 

 

Width vs depth in residual networks

 

 

Residual network의 저자들은 residual network의 depth를 증가시키고 더 적은 parameter를 가지게 하기 위해서 network를 가능한 얇게 만드려고 시도했으며, 심지어 ResNet block을 더욱 얇게 만들기 위해서 bottleneck block을 도입하였습니다.

 

그러나, 본 논문의 저자들은 매우 깊은 네트워크를 훈련시킬 수 있는 identity mapping을 가진 residual block이 동시에 residual network의 약점임을 주목했다고 합니다.

 

Gradient가 네트워크를 통해서 흐르기 때문에 residual block weight를 거치도록 강제할 수 있는 것이 없으며, 훈련 중에 어떤 것도 학습하지 않을 수 있으므로 오직 몇 개의 블록만 유용한 표현을 학습하거나, 혹은 많은 블록들은 최종 목표에 적은 기여를 하는 매우 작은 정보만 공유할 수 있다고 합니다.

 

이 문제는 diminishing feature reuse로 이전 선행연구에서 표현되었다고 하네요. 

 

(제가 생각하기에는, layer가 많다 보면 backpropagation을 통해서 gradient가 layer 각각에 전달되는 과정에서 모든 layer가 모두 유용한 representation을 얻지 못할 수도 있다 라는 얘기를 하는 것 같습니다. 우리 몸으로 따지자면 심장에 가까운 부위들은 산소를 많이 가지고 있는 혈액을 공급받고, 심장에서 먼 부위들은 산소가 적은 혈액을 공급받는 느낌..?) 

 

 

본 논문의 저자들은 network의 depth를 증가시키는 것과 비교했을 때 ResNet block의 widening이 적절하게 이루어진다면 성능을 향상하는 더욱 효과적인 방법을 제공하는 것임을 증명합니다.

 

특히, 저자들은 이전 연구에 비해서 상당히 향상되고, 50배 더 적은 layer를 가지며 2배 더 빠른 wider deep residual network를 제시하였는데, 이를 wide residual networks라고 부릅니다.

 

예를 들어, 본 논문의 저자들이 제시한 wide 16-layer deep network는 1000-layer thin deep network와 유사한 accuracy를 가지며, 몇 배 더 빠르게 학습되면서 비슷한 수의 parameter를 가집니다. 

 

따라서, 이러한 유형의 실험들은 deep residual network의 주요한 힘이 residual block에 있으며, depth의 효과는 추가적임을 나타냅니다. 

 

 

Use of dropout in ResNet blocks

 

 

Dropout은 'Dropout: A simple way to prevent neural networks from overfitting'이라는 논문에서 처음으로 도입되었고, 많은 성공적인 architecture에 채택되었습니다.

 

이는 대부분 많은 수의 parameter를 가지고 있는 top layer에 적용되었고, 이는 feature coadaptation과 overfitting을 방지하기 위함입니다.

 

Dropout은 주로 batch normalization에 의해서 대체되었는데, 이는 batch normalization도 regularizer의 역할을 수행하기 때문이며, 실험적으로 batch normalization을 가지는 network가 dropout을 가진 network에 비해서 더 나은 accuracy를 가진다는 것이 보였기 때문입니다. 

 

 

본 논문에서는, residual block의 widening이 parameter의 수를 증가시키는 결과를 가져오기 때문에, 학습을 규제하고 overfitting을 막는 dropout의 효과를 연구하였다고 합니다.

 

이전에는, residual network에서의 dropout은 block의 identity part에 투입되었으며 부정적인 효과를 보였습니다.

 

그 대신에, 본 논문에서는 dropout을 convolutional layers들 사이에 투입시킵니다. 

 

Wide residual network에 대한 실험적인 결과는 dropout이 일관성 있게 성능을 향상한다는 것을 보여주며, 심지어 새로운 SOTA result를 만들어내는 것을 보였습니다.

 

 

지금까지의 내용을 요약하자면, 본 논문의 contribution은 다음과 같습니다.

 

  • ResNet block structure에 대한 여러가지 중요한 측면을 철저히 조사하는 residual network architecture의 상세한 실험적인 연구를 제시합니다.
  • Residual network가 상당히 향상된 성능을 낼 수 있도록 만들어주는 ResNet block에 대한 새로운 widened architecture를 제안합니다.
  • 학습이 이루어지는 동안에 network를 적절하게 규제하고 overfitting을 회피하기 위해 deep residual network 내에서 dropout을 활용하는 새로운 방법을 제안합니다.
  • 마지막으로, 새롭게 제안된 ResNet architecture가 여러 가지 dataset에서 향상된 accuracy와 속도를 보여주며 SOTA result를 달성하는 것을 보입니다.

 

2. Wide residual networks

 

 

Identity mapping을 가지는 residual block은 다음과 같은 식으로 표현될 수 있습니다.

 

 

$x_l$과 $x_{l+1}$은 network에서 $l$번째 unit의 input과 output을 의미하며, $\mathcal{F}$은 residual function이고 $\mathcal{W}_l$은 block의 parameter를 나타냅니다. 

 

Residual network는 연속적으로 쌓여진 residual block으로 구성됩니다.

 

 

 

Residual network는 두 가지 형태의 block으로 구성되는데요.

 

 

  • Basic - 연속된 3x3 convolution으로 구성되며, batch normalization과 ReLU가 앞선 convolution에 적용되는 구조
  • Bottleneck - 하나의 3x3 convolution은 차원을 감소하고 증가시키는 1x1 convolution layer에 둘러싸여 있는 구조

 

Original architecture와 비교해서, residual block에서의 batch normalization, activation and convolution의 순서는 conv-BN-ReLU에서 BN-ReLU-conv로 변경합니다.

 

후자가 더 빠르게 학습되고 더 나은 결과를 가져오기 때문에, 본 논문에서는 original version을 고려하지 않습니다.

 

Bottleneck block은 layer의 수를 늘리기 위해서 block의 연산량을 감소시키고자 사용되었는데, 본 논문에서 저자들은 widening의 효과를 연구하길 원하는 것이므로 networks를 얇게 만드는 데 사용되는 bottleneck 구조는 고려하지 않습니다.

 

 

Residual block의 representational power를 증가시키는 3가지 근본적인 간단한 방법이 있습니다.

 

  • Block 당 더 많은 convolutional layer를 추가하는 방법
  • 더 많은 feature planes를 추가하여 convolutional layer를 넓히는 방법
  • Convolutional layer의 filter size를 증가시키는 방법

여러 연구에서 작은 filter가 매우 효과적이라는 것이 검증되었기 때문에, 3x3보다 더 큰 filter는 고려하지 않습니다.

 

또한 저자들은 추가적으로 deepening factor $l$와 widening factor $k$라는 2개의 요소를 도입하는데, $l$는 block에서의 convolution의 개수이고 $k$는 convolutional layer에서의 feature의 수에 곱해지는 값입니다.

 

따라서, baseline basic block은 $l = 2, k = 1$에 대응됩니다.

 

Figure 1(a)와 1(c)는 각각 basic block과 basic-wide block을 나타냅니다.

 

 

본 논문에서 사용하는 residual network의 일반적인 구조는 table 1에 나와있습니다.

 

 

처음에 convolutional layer conv1 다음에 3개의 group (각 사이즈는 $N$)의 residual blocks conv2, conv3, conv4 다음에 average pooling, final classification layer가 뒤따르는 구조입니다.

 

conv1의 사이즈는 모든 실험에서 고정이며, 반면에 새롭게 도입된 widening factor $k$는 3개의 그룹 conv2-conv4에서의 residual block의 width를 변경합니다. 

 

본 논문의 저자들은 residual block의 representational power의 효과를 연구하길 원하며, 이를 위해 다음 subsection에서 자세히 설명되어 있는 기본 architecture에 대한 몇 가지 수정을 수행하고 테스트를 진행합니다.

 

 

2.1 Type of convolutions in residual block

 

 

$B(M)$은 residual block structure를 나타내며, $M$은 block 내부에 있는 convolutional layers의 kernel size list를 나타냅니다.

 

예를 들어, $B(3, 1)$은 3x3 convolutional layer와 1x1 convolutional layer를 가지고 있는 residual block을 나타냅니다.

 

이전에 언급했듯이, 본 논문에서는 bottleneck block을 고려하지 않으므로 block마다의 feature planes의 수는 항상 똑같이 유지됩니다.

 

본 논문의 저자들은 basic residual architecture에서 3x3 convolutional layer 각각이 얼마나 중요한지에 대한 질문에 답을 하고 싶었고, 이것이 1x1 layer나 1x1과 3x3 convolutional layer의 조합으로 대체될 수 있는지가 궁금했습니다.

 

따라서, 다음과 같은 조합들을 실험하였습니다.

 

 

2.2 Number of convolutional layers per residual block

 

 

본 논문의 저자들은 또한 block deepening factor $l$이 성능에 어떻게 영향을 미치는지 보기 위해 실험을 진행하였습니다.

 

비교는 같은 수의 파라미터를 가지는 네트워크들 사이에 이루어졌으며, 따라서 이 경우에 저자들은 network complexity를 거의 동일하게 유지하면서 동시에 다른 수의 $l$과 $d$를 가지는 네트워크를 만들었습니다. ($d$는 block의 전체 수)

 

예를 들어서, $l$이 증가할 때, $d$는 감소해야 함을 의미합니다.

 

 

2.3 Width of residual blocks

 

 

저자들은 block의 widening factor $k$에 대해서도 실험을 진행했습니다. 

 

파라미터의 수는 $l$ (deepening factor)와 $d$(ResNet block의 수)에 선형적으로 증가하지만, 파라미터의 수와 computational complexity는 $k$에 대해서는 2차로 증가합니다.

 

하지만 GPU가 large tensor의 병렬 계산에서 훨씬 더 효율적이기 때문에 수천 개의 small kernel을 가지는 것보다 layer를 넓히는 것이 계산적으로 더 효과적이므로, 저자들은 최적의 $d$ to $k$ ratio에 관심을 가졌습니다.

 

 

더 넓은 residual network에 대한 하나의 주장은, 가장 성공적이었던 Inception, VGG를 포함한 residual network 이전의 모든 architecture들은 residual network에 비해서 훨씬 더 넓었다는 것입니다.

 

예를 들어, residual network WRN-22-8과 WRN-16-10(해당 notation에 대한 설명은 다음 paragraph에서 나옵니다.)은 VGG architecture와 parameter의 수, width, depth에서 매우 유사합니다.

 

 

저자들은 더 나아가 $k=1$을 가지는 original residual network를 <<thin>>으로 지칭하고, $k>1$인 네트워크는 <<wide>>로 지칭합니다. 

 

본 논문의 나머지 부분에서는, 다음과 같은 notation을 사용합니다.

 

WRN-$n$-$k$는 convolutional layers의 총개수를 $n$개 가지고 있으며 widening factor $k$를 가지고 있는 residual network를 나타냅니다. (예를 들어, 40개의 layer를 가지고 있고 original보다 2배 더 넓은 경우는 WRN-40-2로 표기)

 

또한, 이 표기에 block type을 덧붙여서 표현할 수 있습니다. 예를 들어, WRN-40-2-B(3, 3)과 같이 표현이 가능합니다.

 

 

2.4 Dropout in residual blocks

 

 

layer를 넓히는 것이 parameter의 수를 증가시키기 때문에, 본 논문의 저자들은 regularization의 방법을 연구하고자 하였습니다.

 

Residual network는 이미 regularization effect를 제공하는 batch normalization을 가지고 있으나, 이는 heavy data augmentation을 요구하고 저자들은 이를 피하고자 하였습니다.

 

따라서 저자들은 fig. 1(d)에 보여지는 것처럼 dropout layer를 residual block 안에 있는 convolution 사이에 추가하였습니다.

 

 

3. Experimental results

 

 

Type of convolutions in a block

 

 

다른 block types $B$를 가지고 학습된 네트워크에 대한 실험 결과를 보고하는 것부터 시작합니다.

 

WRN-40-2를 사용해서 $B(1, 3, 1), B(3, 1), B(1, 3), B(3, 1, 1)$을 block을 사용하였습니다.

 

또한 파라미터의 수를 유지하기 위해서, $B(3, 3), B(3, 1, 3)$의 경우는 더 적은 수의 layer를 가지는 네트워크를 사용하였습니다.

 

Table 2에서는 epoch 당 걸린 시간과 5번 돌렸을 때의 중앙값으로 test accuracy를 포함한 결과를 제공합니다.

 

 

Block $B(3, 3)$이 간소한 차이로 가장 좋다는 사실을 알 수 있었고, $B(3, 1), B(3, 1, 3)$은 $B(3, 3)$과 accuracy에서 유사하지만 더 적은 parameter와 더 적은 layer를 가집니다. 

 

위를 기반으로, 비슷한 수의 parameter를 가진 block들은 거의 유사한 결과를 가져옴이 확인되었습니다.

 

이 사실을 토대로, 저자들은 이후로 3x3 convolution을 가지는 WRN만 사용합니다.

 

 

Number of convolutions per block

 

 

저자들은 다음으로 deepening factor $l$(block 당 convolutional layer의 수)을 다양하게 하는 것과 관련된 실험을 진행했습니다.

 

Table 3에서 결과를 제시하고 있으며, WRN-40-2 with 3x3 convolution을 사용하여 실험을 진행하였다.

 

 

이를 통해 알 수 있듯이, $B(3, 3)$이 최고인 것으로 밝혀졌고 반면에 $B(3, 3, 3), B(3, 3, 3, 3)$은 더 안 좋은 성능을 냈습니다.

 

결론적으로, $B(3, 3)$이 block 당 convolution의 수 관점에서 최적이라는 것입니다.

 

따라서 남은 실험에서 저자들은 오직 $B(3, 3)$ block을 가진 wide residual network만 사용하였습니다.

 

 

Width of residual blocks

 

 

저자들이 widening parameter $k$를 증가시키려고 시도할 때, layer의 총숫자를 감소시켜야만 했습니다.

 

최적의 비율을 찾기 위해서, 저자들은 $k$를 2부터 12까지, depth는 16부터 40까지 실험하였으며, 결과는 Table 4에서 확인할 수 있습니다.

 

 

 

Table 4에서 볼 수 있는 것처럼, 40, 22, 16 layer를 가지는 모든 네트워크는 width가 1부터 12배까지 증가함에 따라서 일관적인 증가를 보여주고 있습니다. (논문에는 이렇게 쓰여있었는데, 보니까 22 layer는 CIFAR-10에서는 감소했고, CIFAR-100에서는 증가했습니다.)

 

반면에, 동일한 고정된 widening factor $k = 8$ or $k = 10$을 유지할 때, 그리고 depth를 16에서 28까지 다양하게 했을 때 일관적으로 성능 향상이 있었습니다. 

 

하지만, depth가 40까지 증가했을 때 accuracy는 오히려 감소했습니다. (즉, WRN-40-8은 WRN-22-8보다 더 낮은 accuracy를 가집니다.)

 

 

본 논문의 저자들은 얇은 residual network와 넓은 residual network를 비교한 추가적인 결과를 Table 5에서 제공합니다.

 

 

 

wide WRN-40-4와 thin ResNet-1001을 비교했을 때, wide WRN이 CIFAR-10과 CIFAR-100에서 모두 더 나은 accuracy를 보여줌을 확인할 수 있습니다. 

 

또한, wide WRN-28-10은 thin ResNet-1001보다 36배 더 적은 layer를 가지고 CIFAR-10에서 0.92%, CIFAR-100에서 3.46% 더 좋은 성능을 냈습니다.

 

 

Figure 2에서는 CIFAR-10과 CIFAR-100에서의 training curve를 나타내고 있습니다.

 

 

Depth가 regularization effect를 주고, width는 network가 overfitting 되도록 야기한다는 이전의 주장에도 불구하고, 본 논문의 저자들은 ResNet-1001보다 몇 배 더 많은 수의 parameter를 가진 network를 성공적으로 학습했습니다.

 

일반적으로, 저자들은 CIFAR mean/std preprocessing이 wider and deeper network를 더 좋은 정확도를 가지고 학습하도록 만들어준다는 사실을 발견하였다고 합니다.

 

지금까지의 실험 내용을 요약하자면,

 

  • Widening은 다른 depth를 가지는 residual network 사이에서 일관적으로 성능을 향상한다.
  • Depth와 width를 모두 증가시키는 것은 parameter의 수가 너무 높아지거나 혹은 더 강력한 regularization이 요구될 때까지는 도움이 된다.
  • 매우 깊은 깊이를 가지는 residual network에서 어떠한 regularization effect가 보이지 않는데, 이는 같은 수의 parameter를 가지는 thin network와 비교했을 때 wide network가 동일하거나 혹은 더 나은 representation을 학습할 수 있기 때문이다. 또한, wide network는 thin network보다 2배 또는 그 이상의 parameter를 성공적으로 학습할 수 있다. 

 

Dropout in residual blocks

 

 

본 논문의 저자들은 모든 데이터셋에 대해서 residual block 안에 convolution 사이에 dropout을 삽입한 network를 학습했다. 

 

저자들은 dropout probability value를 결정하기 위해 cross-validation을 사용하였으며, CIFAR에서는 0.3이었다. 

 

 

Dropout은 WRN-28-10을 가지고 CIFAR-10과 CIFAR-100에 대해서 test error를 감소시켰으며, 다른 ResNet에서도 동일한 성능 향상을 가져왔다.

 

 

저자들은 thin network와 wide network 모두에게서 dropout을 사용하였을 때 상당한 성능 향상을 관측할 수 있었다.

 

전체적으로, batch normalization과 결합한다는 주장에도 불구하고, dropout은 그 자체로 thin network와 wide network에서의 효과적인 regularization 기술이다. 

 

 

Computational efficiency

 

 

Width를 증가시키는 것은 효과적으로 computation을 균형 맞추는 데 더욱 최적의 방식으로 도움을 주며, 따라서 thin network보다 wide netowrk가 몇 배는 더 효율적이다.

 

저자들은 cudnn v5와 Titan X를 사용해 여러 가지 네트워크에 대해 minibatch size 32를 가지고 forward + backward update time을 측정했으며, 결과는 figure 4와 같다.

 

 

CIFAR에서 최고의 성능을 낸 WRN-28-10가 thin ResNet-1001보다 1.6배 더 빠르다.

 

게다가, wide WRN-40-4는 ResNet-1001와 거의 유사한 accuracy를 가지지만, 8배 더 빠르다.

 

 

Implementation details

 

 

본 논문의 모든 실험에서 Nesterov momentum을 활용하는 SGD와 cross-entropy loss를 사용하였다.

 

Initial learning rate는 0.1이고, weight decay는 0.0005고, momentum은 0.9며 minibatch size는 128이다.

 

CIFAR에서 learning rate는 60, 120, 160 epochs에서 0.2씩 떨어지고, 총 200 epochs동안 학습했다.

 

 

 

4. Conclusions

 

 

본 논문에서는 residual network의 width에 대한 연구와 residual architecture에서 dropout의 사용에 대한 연구를 제시하였습니다.

 

이 연구를 기반으로, 저자들은 여러가지 benchmark dataset에 대해서 SOTA results를 가져오는 wide residual network architecture를 제안합니다.

 

저자들은 16 layer를 가지는 wide network가 1000-layer deep network에 비해서 상당히 더 좋은 성능을 낸다는 것을 검증하였으며, 따라서 residual network의 주요한 힘은 극단적인 depth에 있는 것이 아니라 residual blocks에 있음을 검증하였습니다.

 

또한, wide residual network는 학습하는데 몇 배 더 빠르게 학습됩니다.

 

 

 

여기까지 Wide Residual network 논문에 대해서 살펴보았습니다.

 

기존 ResNet이 depth에 초점을 두었다면, 본 논문은 width에 초점을 두고 연구를 진행했으며 dropout을 추가했고 BN와 ReLU, conv의 architecture 순서에 대한 연구를 통해 순서를 바꾸었다는 점이 가장 핵심이 되는 연구라고 보입니다.

 

다음 글에서는 해당 모델에 대한 code review를 진행해보도록 하겠습니다.

 

감사합니다.

 

 

 

 

 

 

 

안녕하세요.

 

이번 글에서는 ResNet의 code를 리뷰해보겠습니다.

 

ResNet 논문에 대한 내용은 이전 글에서 확인하실 수 있습니다.

 

cumulu-s.tistory.com/33

 

6. Deep Residual Learning for Image Recognition(ResNet) - paper review

안녕하세요. 오늘은 Deep Residual Learning for Image Recognition(ResNet) 논문에 대해서 정리해보겠습니다. 논문 주소: arxiv.org/abs/1512.03385 Deep Residual Learning for Image Recognition Deeper neural..

cumulu-s.tistory.com

 

code review 시작해보겠습니다.

 

 

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torch.backends.cudnn as cudnn
from tqdm.auto import tqdm
import torchvision
import torchvision.transforms as transforms
import datetime
import os
from torch.utils.tensorboard import SummaryWriter
import shutil

먼저, 필요한 package들을 가져옵니다.

 

device = 'cuda' if torch.cuda.is_available() else 'cpu'

# Data
print('==> Preparing data..')
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

trainset = torchvision.datasets.CIFAR10(
    root='/content/drive/MyDrive/CIFAR10', train=True, download=True, transform=transform_train)
trainloader = torch.utils.data.DataLoader(
    trainset, batch_size=128, shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(
    root='/content/drive/MyDrive/CIFAR10', train=False, download=True, transform=transform_test)
testloader = torch.utils.data.DataLoader(
    testset, batch_size=100, shuffle=False, num_workers=2)

current_time = datetime.datetime.now() + datetime.timedelta(hours= 9)
current_time = current_time.strftime('%Y-%m-%d-%H:%M')

saved_loc = os.path.join('/content/drive/MyDrive/ResNet_Result', current_time)
if os.path.exists(saved_loc):
    shutil.rmtree(saved_loc)
os.mkdir(saved_loc)

print("결과 저장 위치: ", saved_loc)

writer = SummaryWriter(saved_loc)

사용할 데이터셋을 가져오는 코드들입니다.

 

 

 

class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_planes, planes, stride=1):
        super(BasicBlock, self).__init__()
        self.conv1 = nn.Conv2d(
            in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)

        self.shortcut = nn.Sequential()
        if stride != 1 or in_planes != self.expansion*planes:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_planes, self.expansion*planes,
                          kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(self.expansion*planes)
            )

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        out = F.relu(out)
        return out

첫 번째로 볼 부분은, ResNet에서 사용하는 BasicBlock입니다.

 

논문에서 ResNet18과 ResNet34에 사용되는 Building Block이죠.

 

in_planes와 planes, stride라는 변수를 받아서 생성되게 되며

 

3x3 conv - BN - 3x3 conv - BN - shortcut으로 구성되는 코드라고 보시면 됩니다.

 

논문에서는 다음 그림으로 표현되었죠.

 

 

self.shortcut = nn.Sequential()은 연결만 해주는 코드로, input을 그대로 연결해주는 역할을 합니다.

 

if문은 stride가 1이 아니거나 in_planes != self.expansion*planes인 경우(즉, input으로 들어온 차원과 output에 해당하는 차원이 다른 경우)에 해당하게 되는데, 이 때는 1x1 conv를 진행하도록 해서 차원이 일치하도록 만들어줍니다. 

 

 

만약 input이 32x32x64이고 stride가 2로 설정되어 있는 경우의 shape를 나타낸 그림입니다.

 

k는 kernel size를 의미하고, s는 stride, p는 padding을 의미합니다.

 

input인 32x32x64이었으나, layer가 거치면서 16x16x128가 되었으므로 input을 그대로 더해줄 수 없게 됩니다.

 

따라서, 1x1 conv를 이용해서 channel 수를 맞춰주면서 동시에 stride = 2를 적용하여 가로, 세로 길이도 /2 해서 16x16x128를 맞춰주게 되면 shortcut을 더해줄 수 있게 되는 것입니다.

 

 

input의 shape와 layer를 통과하고 나서의 shape가 동일한 경우는, 다음과 같이 살펴볼 수 있습니다.

 

 

앞에서도 언급했지만, nn.Sequential()은 말 그대로 input 값을 그대로 가지고 있다고 생각하시면 됩니다.

 

BasicBlock에 대한 설명은 이쯤이면 충분한 것 같아 다음 block으로 넘어가겠습니다.

 

class Bottleneck(nn.Module):
    expansion = 4

    def __init__(self, in_planes, planes, stride=1):
        super(Bottleneck, self).__init__()
        self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3,
                               stride=stride, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)
        self.conv3 = nn.Conv2d(planes, self.expansion *
                               planes, kernel_size=1, bias=False)
        self.bn3 = nn.BatchNorm2d(self.expansion*planes)

        self.shortcut = nn.Sequential()
        if stride != 1 or in_planes != self.expansion*planes:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_planes, self.expansion*planes,
                          kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(self.expansion*planes)
            )

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = F.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
        out += self.shortcut(x)
        out = F.relu(out)
        return out

 

다음에 설명할 코드는 Bottleneck 코드입니다.

 

ResNet의 layer가 50층 이상 넘어갈 경우, 연산량을 줄여주기 위해 기존의 3x3 conv를 두 개 쌓아서 만든 layer를 사용하는 것이 아닌, 1x1 conv - 3x3 conv - 1x1 conv를 이용하는 방식을 사용합니다.

 

이번에도 if문에 해당하는 경우 어떻게 흘러가는지를 설명드리도록 하겠습니다.

 

input이 32x32x256인 경우의 상황을 보여주고 있는 그림입니다.

 

먼저, 1x1 conv을 이용해서 차원을 128차원으로 줄여줍니다. 

 

그러고 나서, 3x3 conv 연산을 진행하고, 1x1 conv 연산을 통해서 다시 512차원으로 늘려주게 됩니다.

 

input이 32x32x256이었으므로, shortcut은 input을 그대로 전달해주면 안 됩니다.

 

따라서 Basicblock과 마찬가지로 1x1 conv을 통해서 차원을 맞춰주면서 동시에 stride = 2를 적용해 가로, 세로 사이즈를 /2 해줍니다.

 

다음으로는 stride가 1인 경우에 대해서 보여드리겠습니다.

 

 

stride가 2로 되어서 가로, 세로가 /2이 되는 경우가 아니라면, channel의 수만 중간에 한번 변경하게 됩니다.

 

이는 3x3 conv를 적용할 때 더 적은 차원의 데이터만 적용하도록 만들어서 연산량을 줄이기 위함입니다.

 

이 경우에는 input과 layer를 통과한 output의 shape가 같으므로, nn.Sequential()을 이용해서 shortcut을 진행해주면 됩니다.

 

 

class ResNet(nn.Module):
    def __init__(self, block, num_blocks, num_classes=10):
        super(ResNet, self).__init__()
        self.in_planes = 64

        self.conv1 = nn.Conv2d(3, 64, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        self.linear = nn.Linear(512*block.expansion, num_classes)

    def _make_layer(self, block, planes, num_blocks, stride):
        strides = [stride] + [1]*(num_blocks-1)
        layers = []
        for stride in strides:
            layers.append(block(self.in_planes, planes, stride))
            self.in_planes = planes * block.expansion
        return nn.Sequential(*layers)

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = F.avg_pool2d(out, 4)
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out

 

다음으로는 ResNet class입니다. 

 

논문 리뷰할 때 ResNet 전체 architecture 표에서는 맨 처음 conv가 7x7로 되어 있었는데, 논문의 CIFAR-10 실험한 부분을 보면 CIFAR-10의 데이터가 32x32x3 임을 감안해서 architecture가 살짝 변경되었음을 확인할 수 있습니다.

 

따라서, 해당 코드는 CIFAR-10에 대해 실험을 진행했기 때문에 구조가 살짝 다릅니다. (핵심적인 ResNet 구조는 동일).

 

ResNet을 만들 때는 block과 num_blocks를 입력으로 받습니다. (def __init__의 parameter를 보시면 됩니다.)

 

맨 처음에 3x3 conv - BN을 적용해주고, 그 이후에는 _make_layer 함수를 이용해서 block이 포함된 layer들을 만들어줍니다.

 

여기서 block이란, 앞에서 제가 설명드린 BasicBlock이나 Bottleneck block을 의미합니다. (앞에서 input으로 block을 받는다고 얘기했었죠?)

 

함수가 생각보다는 복잡하기 때문에, 간단하게 하나 예시를 들어서 하나하나 따라가 보겠습니다.

 

 

가장 간단한 형태였던 ResNet18를 예로 들어보도록 하겠습니다.

 

ResNet18은 Basicblock을 사용하며, 각 layer 마다 block을 2개씩 사용합니다.

 

따라서, ResNet18을 만든다고 한다면 net = ResNet(BasicBlock, [2, 2, 2, 2])로 만들 수 있습니다.

 

먼저 input은 32x32x3이 될 것이고요, 첫 번째로 self.conv1 = nn.Conv2d(3, 64, k = 3, s = 1, p = 1)를 통과해줍니다. output은 32x32x64이 됩니다. 여기에 self.bn1 = nn.BatchNorm2d(64)를 통과해줍니다. BN은 shape에 영향을 안 주니 shape는 그대로입니다.

 

다음으로는 self.layer1 = self._make_layer(BasicBlock, 64, 2, stride = 1)을 통과합니다.

 

_make_layer 함수로 들어가서 보면,

 

strides = [1, 1]이 될 것이고, for stride in strides:의 반복문을 적용하게 되면 

 

layers라는 빈 list에 BasicBlock(64, 64, 1)와 BasicBlock(64, 64, 1)을 넣어주고 self.in_planes는 그대로 64입니다. (block.expansion = 1이고, planes = 64이기 때문이죠.)

 

_make_layer는 nn.Sequential(*layers)를 return 하므로, nn.Sequential(BasicBlock(64, 64, 1), BasicBlock(64, 64, 1)) 을 return 합니다. 이를 self.layer1으로 저장하는 것이죠.

 

BasicBlock은 in_planes, planes, stride라고 하는 3개의 parameter를 입력받게 되고, 이것을 각각 (64, 64, 1), (64, 64, 1)로 받았습니다.

 

먼저 self.conv1 = nn.Conv2d(64, 64, k=3, s= 1, p= 1) => self.bn1(nn.BatchNorm2d(64)) => self.conv2 = nn.Conv2d(64, 64, k=3, s=1, p=1) => self.bn2(nn.BatchNorm2d(64)) => self.shortcut으로 연결 => F.relu => out으로 연결되는 구조입니다. 

 

stride가 모두 1이므로, 가로와 세로의 사이즈는 변동되지 않으며, channel 수는 여전히 64가 됩니다.

 

BasicBlock이 두 개였으므로, 위의 과정을 동일하게 두 번 거쳐서 나오면 ResNet18 기준에서 self.layer1을 통과한 것이라고 보시면 되겠습니다.

 

 

다음으로는 self.layer2인데요, 이는 self._make_layer(BasicBlock, 128, 2, stride = 2)를 적용합니다.

 

_make_layer 함수로 들어가게 되면, strides가 이번에는 [2, 1]가 됩니다. 아까는 [1, 1]이었죠.

 

그리고 똑같이 for stride in strides:의 반복문을 이용해서 layers.append를 해주는데, BasicBlock(64, 128, 2)와 BasicBlock(128, 128, 1)을 append 해줍니다. 그리고 이 때는 self.in_planes = planes * block.expansion인데 planes가 128이었으므로, self.in_planes가 64에서 128로 값이 변경됩니다.

 

앞과 동일하게 def _make_layer는 nn.Sequential(*layers)를 return 해주므로, ResNet 기준에서 self.layer2는 nn.Sequential(BasicBlock(64, 128, 2), BasicBlock(128, 128, 1))이 될 것입니다.

 

 

다시 BasicBlock 쪽으로 가봐야겠죠?

 

이번에는 in_planes가 64, planes가 128, stride = 2입니다.

 

이번에는 BasicBlock의 input이 아까 ResNet 기준 self.layer1에서의 output인 32x32x64가 됩니다.

 

self.conv1 = nn.Conv2d(64, 128, k=3, s = 2, p= 1) 이므로, 이를 통과해주면 16x16x128이 되고, self.bn1 = nn.BatchNorm2d(128)을 통과해줍니다.

 

다음으로는 self.conv2 = nn.Conv2d(128, 128, k=3, s= 1, p= 1)를 통과해주고, 이 때는 shape의 변동이 없습니다. self.bn2 = nn.BatchNorm2d(128)도 마찬가지죠.

 

그리고 아까와 달라지는 점은, stride가 1이 아니므로, BasicBlock의 if문에 들어갑니다. 이 때는 self.shortcut이 달라지게 되는데요.

 

self.shortcut = nn.Sequential( nn.Conv2d(64, 128, k=1, s = 21), nn.BatchNorm2d(128))가 됩니다.

 

따라서 input이었던 32x32x64를 16x16x128로 바꾸어서 shortcut이 이루어질 수 있도록 바꿔주게 됩니다.

 

따라서 ResNet 기준 self.layer2의 output은 16x16x128가 될 것입니다.

 

ResNet 기준 self.layer3, self.layer4는 동일한 방법으로 리딩 할 수 있으니 생략하겠습니다.

 

ResNet으로 다시 돌아와서, def forward 부분을 보게 되면 self.layer4가 끝난 후, F.avg_pool2d를 적용하는 것을 확인할 수 있습니다.

 

self.layer2의 output은 16x16x128이고, self.layer3의 output은 8x8x256, self.layer4의 output은 4x4x512입니다.

 

따라서, 4x4x512에 average pooling을 적용해서 이를 1x1x512로 만들어줍니다.

 

그리고. view를 이용해서 이를 Batch sizex512로 바꿔주게 되고, 이를 self.linear와 연결합니다. self.linear는 nn.Linear(512, 10)이므로, 이를 통과하면 Batch size x 10가 되겠죠? 이를 최종 예측값으로 사용해줍니다.

 

여기까지가 ResNet class에 대한 설명이 되겠습니다.

 

 

net = ResNet(BasicBlock, [2, 2, 2, 2]) # ResNet18

net = net.to(device)
cudnn.benchmark = True

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.1,
                      momentum=0.9, weight_decay=5e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=200)

best_acc = 0

net이라는 변수로 ResNet을 생성해주고,. to를 이용해서 GPU로 모델을 이동시켜줍니다.

 

Loss는 Cross Entropy loss를 사용해주고, optimizer는 SGD를 사용합니다. 

 

scheduler는 CosineAnnealingLR이라고 하는 것을 사용했는데, 제가 참고한 자료에 있어서 한번 써봤습니다.

 

해당 scheduler를 사용하면, learning rate가 다음과 같은 형태로 변화합니다.

 

 

 

# Training
def train(epoch):
    print('\nEpoch: %d' % epoch)
    net.train()
    train_loss = 0
    correct = 0
    total = 0
    batch_count = 0
    for batch_idx, (inputs, targets) in enumerate(trainloader):
        inputs, targets = inputs.to(device), targets.to(device)
        optimizer.zero_grad()
        outputs = net(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

        train_loss += loss.item()
        _, predicted = outputs.max(1)
        total += targets.size(0)
        correct += predicted.eq(targets).sum().item()
        batch_count += 1

    print("Train Loss : {:.3f} | Train Acc: {:.3f}".format(train_loss / batch_count, 100.*correct/total))
    final_loss = train_loss / batch_count
    final_acc = 100.*correct / total
    return final_loss, final_acc

 

다음은 Training 코드입니다.

 

먼저 신경망을 train 모드로 만들어주고, 필요한 변수들을 지정해줍니다.

 

batch size 단위로 받은 이미지를 net에 투입해서 예측 결과인 outputs을 만들어냅니다.

 

그리고 정답 값인 targets와 loss를 계산해주고, 이를 backpropagation 해줍니다. 

 

계산된 loss는 train_loss라는 변수에 계속해서 누적해서 더해줍니다.

 

outputs.max(1)을 하면 두 개의 값이 나오게 되는데요, 이는 outputs에서 max값을 나타내는 값과 해당 값의 index가 나오게 됩니다. 

 

즉, batch size가 128이라면, outputs은 (128, 10)이며, outputs.max(1)을 통해 나오는 값은 차원이 (128), (128)이 되는 것입니다.

 

우리는 예측된 index만 필요하므로, 값 부분은 _로 처리해주고 predicted라는 변수로 index를 저장해줍니다.

 

그리고 이 predicted와 targets 값이 같은지를 predicted.eq(targets)를 이용해서 계산해주고, 이는 같은지 아닌지를 1과 0으로 저장하게 되므로 sum()을 해서 배치 사이즈인 128개 중에서 몇 개가 정답을 맞혔는지 계산합니다.

 

. item()은 torch.tensor에서 값만 빼주는 코드입니다. 따라서, 이를 통해 128개 중에서 몇 개가 정답을 맞혔는지를 숫자 값으로 저장하게 되죠.

 

 

def test(epoch):
    global best_acc
    net.eval()
    test_loss = 0
    correct = 0
    total = 0
    batch_count = 0
    with torch.no_grad():
        for batch_idx, (inputs, targets) in enumerate(testloader):
            inputs, targets = inputs.to(device), targets.to(device)
            outputs = net(inputs)
            loss = criterion(outputs, targets)

            test_loss += loss.item()
            _, predicted = outputs.max(1)
            total += targets.size(0)
            correct += predicted.eq(targets).sum().item()
            batch_count += 1


    print("Test Loss : {:.3f} | Test Acc: {:.3f}".format(test_loss / batch_count, 100.*correct/total))

    # Save checkpoint.
    acc = 100.*correct/total
    if acc > best_acc:
        print('Saving..')
        state = {
            'net': net.state_dict(),
            'acc': acc,
            'epoch': epoch,
        }
        if not os.path.isdir(os.path.join(saved_loc, 'checkpoint')):
            os.mkdir(os.path.join(saved_loc, 'checkpoint'))
        torch.save(state, os.path.join(saved_loc, 'checkpoint/ckpt.pth'))
        best_acc = acc
    
    final_loss = test_loss / batch_count
    return final_loss, acc

 

다음은 test 코드입니다.

 

train 하고 대부분은 큰 차이가 없으므로, 겹치는 부분은 설명을 생략하겠습니다.

 

test에는 checkpoint를 저장하는 부분이 있는데, 먼저 accuracy를 계산해줍니다.

 

그리고 best_acc보다 acc가 크면, best_acc를 acc로 바꿔줍니다. 

 

이때, 신경망의 가중치와, accuracy, epoch 값을 state로 저장하여 이를 torch.save로 저장해줍니다.

 

이를 통해, test 기준에서 가장 좋은 성능을 내는 모델을 저장하도록 합니다.

 

 

for epoch in tqdm(range(200)):
    train_loss, train_acc = train(epoch)
    test_loss, test_acc = test(epoch)

    writer.add_scalars('Loss', {"Train Loss" : train_loss, "Test Loss" : test_loss}, epoch)
    writer.add_scalars('Accuracy', {"Train acc" : train_acc, "test acc" : test_acc}, epoch)
    
    scheduler.step()

writer.close()

 

200 에폭 동안 train과 test를 진행합니다.

 

scheduler.step()은 각 epoch마다 적용되도록 해줍니다.

 

 

 

 

해당 코드를 돌려서 얻을 수 있는 결과는 다음과 같습니다.

 

 

 

위는 accuracy, 아래는 loss graph입니다.

 

epoch이 진행됨에 따라서, training accuracy와 test accuracy 모두 상승하는 것을 확인할 수 있습니다.

 

loss 또한 두 loss가 모두 잘 감소하고 있는 것을 확인할 수 있습니다.

 

 

 

이번 글에서는 ResNet을 코드로 구현한 것에 대해서 살펴보고, 결과까지 살펴보았습니다.

 

오늘 글에서 사용된 코드는 제 github에서 전체 내용을 확인하실 수 있습니다.

 

github.com/PeterKim1/paper_code_review

 

PeterKim1/paper_code_review

paper review with codes. Contribute to PeterKim1/paper_code_review development by creating an account on GitHub.

github.com

 

다음 글에서 뵙겠습니다.

 

감사합니다.

 

 

 

 

 

 

 

+ Recent posts