loading

cs231n 내용 요약 (10) - Visualizing, Transfer learning


Lecture summary

Published on November 11, 2022 by JunYoung

AI Deep learning cs231n

27 min READ

들어가며…

이 글이 아마도 cs231n과 관련된 마지막 포스팅이 될 것이다. 사실 깃허브 블로그를 오픈하고 기존 네이버 블로그에 작성했던 내용들을 다시 원래 강의 노트와 비교하면서 옮기고 있었는데, 예전에 작성했던 내용들을 보니 애매하게 적어둔 내용도 많고 잘 모르고 작성한 부분들도 은근 많았던 것 같다. 1년 전이기 때문에 지금도 그때와 비교해서 더 많이 아는 건 아니지만 정리하면서 최대한 예전에 공부했던 내용들을 다시 살펴보는 것이 기초를 다지는 과정에 효과적인 것 같다.
지금부터 다룰 내용은 이전에 신경망의 구조나 parameter, functionality에 대해 살펴본 것보다 수식적으로 증명하거나 풀어낼 부분이 많지는 않다.


Visualizing what convolutional neural networks learn

CNN이 처음으로 ImageNet 대회에서 우승한 이후로 deep learning, 그것도 neural network 기반의 알고리즘이 주목을 받기 시작했다.

표에서 보이는 XRCE라는 알고리즘은 딥러닝 기반이 아니었으며, 2011년 이전의 error가 표시되어있지는 않으나 사실상 2011년의 error rate가 딥러닝을 사용하지 않은 방식을 통해서는 얻을 수 있는 최소의 수렴값으로 인식되기 시작했다. 바로 이러한 인식과 알고리즘의 판도를 바꾼 game changer로 등장한 것이 AlexNet이었고, 무려 $10\%$의 성능 향상을 보이며 이후 대회에서는 모두 deep learning network가 우승하기 시작했다.
하지만 딥러닝 기반의 CNN이 ImageNet에서 우승한 이후에도 지속적인 의문점과 비판이 올라오기 시작했다. 대부분 예전 low-level vision task를 기계 학습과 관련된 여러 컴퓨터 알고리즘으로 해결하고자 했던 사람들이었고, 비판하는 내용은 대부분 deep learning 알고리즘은 연구의 판도를 바꿀 정도로 성능 향상에 큰 기여를 했으나 작동 원리에 대한 엄밀한 설명이 불가능하다는 주장이었다. 이전 포스팅까지 소개했었던 MLP(Multi-Layer Perceptron)과 같은 구조를 계속 설명했었고 네트워크가 학습되는 과정을 forward propagation, objective function 그리고 back-propagation의 순서대로 개념을 설명했었다. 결국 loss function을 최적화하는 방향으로 학습하는 과정을 이해하기 위해서는 gradient, linear algebra 등등 수학적인 지식이 필요하지만, 딥러닝이라는 분야가 하나의 연구 분야로 인정받기 위해서는 구체적으로 네트워크가 왜 이런 방식으로 학습을 하는 것이 기적적인 성능 향상을 이끌어냈는지 수학적 설명이 뒷받침될 필요가 있었다.
Computer science에서는 이러한 의문이 중요한 문제로 자리잡았다. Data-driven 알고리즘은 deterministic 알고리즘과는 다르게 원리를 파악하고 개선시키고자 하는 방향을 잡을 수 없기 때문이다. 단순히 인간의 neuron 구조를 모방한 perceptron이 MLP로, 더 나아가 computer vision task를 위한 CNN으로 발전되었고 좋은 성능을 보인다는 점에서 설명력이 부족하다는 비판이 올라오게 되었다.

이렇듯 Neural network 구조의 input과 output에 대한 수학적 설명은 가능하지만, 내부 parameter가 prediction에 generalized될 수 있는 근거를 설명할 수 없다는 사실을 'black box'라는 용어를 통해 표현하게 되었다. 단순히 neural network에서는 이러한 black box(explicit한 input, output을 제외하고는 implicit하게 학습되는 내부를 직접 관측할 수 없어 원리를 설명할 수 없음)의 문제점을 해결하지 못했다. 처음 ImageNet 대회에서 우승했던 AlexNet도 논문화 과정에서 해당 내용들을 자세히 서술하지 못해 문제가 되었다.

그리고 단순히 학계에 있는 사람들을 설득해야할 필요성 뿐만 아니라 네트워크의 성능에 대한 설명이 필요했던 이유는 neural network 설계 과정에서 성능을 높이거나 error가 발생한 sample에 대해 explainablilty가 있어야 추후 딥러닝 연구가 가능했기 때문이다. 결국 deep learning에서 explainablilty는 기존 학계에 있던 사람들을 설득하는 과정에서도, 본인들의 연구를 발전시키기 위한 기반으로도 필요했던 부분인 것이다.

그렇기 때문에 deep learning network 내부에서 학습하는 형태를 간접적으로 확인하고자 weight visualization 혹은 특정 input $x$에 대한 layer activation $(f_l \circ f_{l-1} \circ \cdots \circ f_1(x))$ visualization과 같이 성능에 대한 설명력을 뒷받침할 연구들이 진행되었다.


Layer activations

Visualizing 기술 중 하나는 forward pass에서 input에 대한 layer의 activation 결과를 보여주는 것이다. ReLU가 달려있는 네트워크에서는 activation을 관찰하게 되면 초반에는 blobby하고 dense한 모습을 보여주지만 training이 진행되면 될수록 sparse하고 localized된 모습을 보여준다. 실제로 결과를 확인해보기 위해 다음과 같이 임시 코드를 작성해보았다.

import torch
import torchvision
import torchvision.transforms as transforms

# Define CIFAR10 dataset
batch_size = 16

transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size,
                                          shuffle=True, num_workers=2)
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                       download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size,
                                         shuffle=False, num_workers=2)

학습은 CIFAR-10에 대해 진행할 예정이고, 네트워크는 주어진 조건대로 ReLU를 activation function으로 사용하되 batchnormalization도 추가해주었다.

# Let's define simple CNN model
import torch
import torch.nn as nn
import torch.nn.functional as F

class SMPCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.feature1 = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True)
        )
        self.feature2 = nn.Sequential(
            nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True)
        )
        self.feature3 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True)
        )
        self.feature4 = nn.Sequential(
            nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(inplace=True)
        )
        self.feature5 = nn.Sequential(
            nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(inplace=True)
        )

        self.classifier = nn.Sequential(
            nn.Linear(256*2*2, 64),
            nn.Dropout(0.5),
            nn.Linear(64, 10)
        )

    def forward(self, x):
        f1 = self.feature1(x)
        f2 = self.feature2(F.max_pool2d(f1, kernel_size=2, stride=2))
        f3 = self.feature3(F.max_pool2d(f2, kernel_size=2, stride=2))
        f4 = self.feature4(F.max_pool2d(f3, kernel_size=2, stride=2))
        f5 = self.feature5(F.max_pool2d(f4, kernel_size=2, stride=2))
        f5 = f5.view(f5.size(0), -1)
        score = self.classifier(f5)

        return score, (f1, f2, f3, f4, f5)

그리고 각 layer의 output을 시각화하기 위해 score 말고 추가로 return해주었다. Training configuration에 따라 학습하는 코드는 함수를 통해 구현하였다.

# Let's define trainer function
from collections import defaultdict

def trainer(model, epochs, train_loader, val_loader, optimizer, criterion, device):
    features = defaultdict(list)
    model.to(device)
    for epoch in range(epochs):
        model.train()
        training_loss = 0.0
        training_acc = 0.0
        for i, (image, label) in enumerate(train_loader):
            optimizer.zero_grad()
            image, label = image.to(device), label.to(device)
            score, activations = model(image)
            loss = criterion(score, label)
            loss.backward()
            optimizer.step()
            _, prediction = torch.max(score, axis=1)
            accuracy = float(torch.sum(torch.eq(prediction, label)))/len(prediction)
            training_loss += loss.item()
            training_acc += accuracy

            if i%200 == 0:
                print(f"Epoch [{epoch+1}/{epochs}] (Iter [{i+1}/{len(train_loader)}]) ===> Training loss : {training_loss/(i+1):.6f}, Training accuracy : {100*training_acc/(i+1):.2f}%")
            
            if i%1000 == 0:
                features[epoch] += [activations]
      
        model.eval()
        with torch.no_grad():
            validation_loss = 0.0
            validation_acc = 0.0
            for i, (image, label) in enumerate(val_loader):
                image, label = image.to(device), label.to(device)
                score, _ = model(image)
                loss = criterion(score, label)
                _, prediction = torch.max(score, axis=1)
                accuracy = float(torch.sum(torch.eq(prediction, label)))/len(prediction)
                validation_loss += loss.item()
                validation_acc += accuracy                    

            print(f"Epoch [{epoch+1}/{epochs}] ===> Validation loss : {validation_loss/len(val_loader):.6f}, Validation accuracy : {100*validation_acc/len(val_loader):.2f}%")
    return features

함수를 보게 되면 training dataset에 대해 일정 iteration 마다 출력된 feature map을 저장하고, 저장된 feature를 리스트가 임베딩된 딕셔너리 형식으로 리턴하게 된다. 코드와 함수를 보면 확인할 수 있듯이 학습 과정에는 batch size를 $16$으로 사용하였고 epoch는 $20$을 사용하였다. 학습 시 learning rate는 스케쥴링 없이 $10^{-3}$을 고정값으로 학습하였다.

# Train configurations
model = SMPCNN()
epochs = 20
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=5e-4)
criterion = nn.CrossEntropyLoss()
device = "cuda" if torch.cuda.is_available() else "cpu"

Classification task에 대한 학습 코드이기 때문에 criterion은 cross entropy loss를 사용한다. 학습 결과 test set에 대한 accuracy가 $79.49\%$, training set에 대한 accuracy는 거의 $90\%$를 보였다. 사실상 약간 overfitting이 발생했지만, data augmentation과 같은 regularization을 추가해주면 충분히 개선될 사항이고, 확인하고자 하는 것은 네트워크의 성능이 아니라 학습 단계에서의 feature map activation이기 때문에 넘어가도록 하겠다. Visualize는 pytorch 모듈의 make_grid 메소드를 사용하였고, 자세한 코드는 따로 첨부하지 않고 결과를 분석해보도록 하겠다. 우선 각 layer에 대해 $16$개의 batch image에 대한 평균 feature map을 나타내면 다음과 같다. 다음 세 이미지는 학습이 진행된 직후(epoch $0$)에서의 feature map activation이다.

학습 초기의 이미지를 보게 되면 위와 같이 나오게 된다. 앞서 말했던 바와 같이 학습 초기에는 dense하고 blobby한 형태를 보여준다고 했는데, 보는 것과 같이 전반적으로 feature map의 형태가 매끄럽지는 않은 것을 확인할 수 있었다. 그와는 다르게 학습이 진행되면 진행될수록 sparse하고 localized된 모습을 보여주는 것을 확인할 수 있다(아래 그림 참고).

평균 activation map으로는 차이가 명확하게 보이지 않았기 때문에 feature map을 각각 분리하여 각 채널 별로 어떤 특징을 잡아내는지 시각화했다.

위의 세 그림은 각 레이어의 activation map을 채널 별로 분리하여 나타낸 모습이고, 해당 activation map은 학습 초기 단계의 네트워크의 출력에서 나온 것이다. 결과를 보면 알 수 있듯이 각 채널 별로 유의미한 feature의 차이를 잡아내지 못하고 있으며(서로 유사한 형태를 가짐), prediction 시에 여전히 training set에 의존하는 형태의 feature를 출력하는 것을 확인할 수 있다. 사실상 이미지의 윤곽 형태를 그대로 필터링하고 있는 것을 볼 수 있고, 이러한 특징들은 딥러닝이 아닌 일반적인 알고리즘으로도 충분히 가능한 feature extraction임을 알 수 있다.

그와는 다르게 학습이 어느 정도 진행된 후의 activation map 모습은 위와 같다. 앞서 확인했던 activation과는 다르게 sparse(일부 channel에만 유의미한 feature가 잡힘)한 특징이 그대로 드러나고, 이는 ReLU가 학습되는 과정에서 무의미하다고 간주되는 signal value를 출력하지 않는 형태로 학습이 진행되는 것과 어느 정도 부합하게 된다. 이러한 visualization을 통해 분석할 수 있는 사항은 ReLU activation의 단점이라고도 할 수 있는 dead activation making이며, 설계한 모든 filter channel를 유의미하게 활용할 수 없다는 단점이 있다.


Visualize convolutional, fully-connected filters

Input에 대한 activation을 확인하는 방법도 있고, 다른 방법은 학습된 weight를 확인해보는 방법이다. 보통 초반 convolution layer를 확인하게 되면 input이 raw pixel에 해당되는 image이므로 interpret하기는 좋지만, network layer 상 깊은 곳의 fiter weight도 visualize가 가능하다. 잘 학습된 네트워크에서는 noisy한 패턴 없이 smooth한 형태를 보여주는 것이 일반적이다. 앞서 확인했던 네트워크의 구조를 일부 수정해서 학습시킨 뒤 최종 학습된 네트워크의 첫번째 convolutional layer와 두번째 convolutional layer의 weight를 보면 다음과 같다.

사실 weight visualization을 위해서는 kernel size가 큰 convolutional filter가 필요한데, 요즘은 deeper layer를 구성하면서 receptive field를 키우는 형태의 네트워크가 많기 때문에 weight를 직접 visualize하는 것으로는 유의미한 관찰이 이루어질 것 같지는 않다. 이렇듯 visualization에 대해서 가장 처음으로 나왔던 연구라고 할 수 있는 ZFNet이 있는데, AlexNet을 기반으로 visualization을 통해 성능 향상을 이루어내고 바로 다음 해의 ImageNet 대회에서 우승했다.


Retrieving images that maximally activate a neuron

다른 visualization 기술에는 대량의 dataset image를 가지고, 이들을 network에 통과시키며 각 neuron의 activation을 확인해보는 것이다. 통과시키다 보면 각 neuron이 감당하는 receptive field 내에서 이미지의 어떤 부분에 집중하는지 확인할 수 있게 되고 이러한 방법은 딥러닝 기반 object detection 논문의 조상격인 R-CNN에서 소개되었다.

이런 방법의 문제점은 ReLU 뉴런이 사용되었을 경우 해당 뉴런은 semantic meaning을 가지지 않는다는 점이다. 오히려 여러 층의 ReLU 뉴런을 특정 space의 basis vector로 간주하여(신호를 끄고 키는 switch 역할을 축으로 생각해볼 수 있음) 이미지를 나타내는 좌표계로 생각해볼 수 있다. 다르게 표현하자면 visualization은 representation의 집합이라고 볼 수 있는 계에서 edge(boundary)에 속하는 요소들이 되고, 필터 weight에 해당되는 방향으로만 탐색하게 된다. Input에 대해 convolutional neural network는 선형 함수이며, trajectory를 탐색할 수 없다는 점이 representation space에서 다양한 방향을 searching할 수 없다는 한계점으로 이어진다(참고 링크).


Embedding code with t-SNE

Convolutional Neural Network는 image를 linear classifier에 의해서 여러 class로 분리될 수 있는 score map을 추출한다. 여기서 생각해볼 수 있는 것은 다차원의 이미지를 저차원으로 embedding하여 분류하기 때문에, 저차원에서의 representation과 고차원에서의 위상이 대략 비슷할 것이다. 위에서 학습시킨 network를 그대로 사용하여 t-SNE의 결과를 비교해보도록 하자. 우선 딥러닝 네트워크가 적용되지 않은 상태에서 단순히 CIFAR-10 dataset을 t-SNE 방식으로 2차원의 manifold로 mapping하는 코드는 다음과 같다.

cls = []
embedding = []
for data in testloader:
    images, labels = data[0].to(device), data[1].to(device)
    embedding += images.view(images.shape[0], -1).cpu().numpy().tolist()
    cls += labels.cpu().numpy().tolist()

tsne = TSNE(n_components=2, random_state=0)
points = np.array(tsne.fit_transform(np.array(embedding)))
classes = np.array(cls)

plt.figure(figsize=(10, 10))
cifar = ['plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']
for i, label in zip(range(10), cifar):
    idx = np.where(classes == i)
    plt.scatter(points[idx, 0], points[idx, 1], marker='.', label=label)

plt.legend()
plt.show()

위의 결과로 나온 그림을 보면 알 수 있듯이, 각 class에 대한 고차원 이미지가 단순히 2차원으로 임베딩될 경우 구분이 잘 안되는 모습을 확인할 수 있다.

이번에는 classification에 대해 학습된 네트워크를 통과한 feature map의 가장 마지막 부분($256$ dimension)을 t-SNE 시각화하는 코드를 작성해보았다.

cls = []
deep_features = []
model.eval() # resnet18
with torch.no_grad():
    for data in testloader:
        images, labels = data[0].to(device), data[1].to(device)
        _, features = model(images)
        deep_features += features[-1].cpu().numpy().tolist()
        cls += labels.cpu().numpy().tolist()

tsne = TSNE(n_components=2, random_state=0)
points = np.array(tsne.fit_transform(np.array(deep_features)))
classes = np.array(cls)

plt.figure(figsize=(10, 10))
cifar = ['plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']
for i, label in zip(range(10), cifar):
    idx = np.where(classes == i)
    plt.scatter(points[idx, 0], points[idx, 1], marker='.', label=label)

plt.legend()
plt.show()

앞서 시각화했던 것과는 다르게 어느 정도 잘 분류하고 있는 것을 확인할 수 있다. 임의로 설계한 네트워크에 대해서도 classification 성능을 직접 시각화할 수 있는 것을 확인할 수 있다.


Occluding parts of the image

또다른 방법으로는 input 이미지를 일부 가린 후 정답 class에 대한 confidence(확률)을 측정하는 것이다. 실험을 해보기 위해 이번엔 ImageNet에 대해 학습된 ResNet18을 사용해보았다. 코드는 다음과 같다.

import torch
import cv2
import matplotlib.pyplot as plt
import torchvision.transforms.functional as TF
from torchvision.models import ResNet18_Weights
from tqdm import tqdm
import numpy as np

model = torch.hub.load('pytorch/vision:v0.10.0', 'resnet18', weights=ResNet18_Weights.DEFAULT)
model.to("cuda")
model.eval()

사전 학습된 ResNet18은 torchvision 모듈에서 쉽게 가져올 수 있다. 가져온 model을 gpu에 올려주고, 학습이 아닌 evaluation을 진행할 것이기 때문에 network layer를 validation 용도로 바꿔준다. Evaluation에 사용한 이미지는 다음과 같다.

ImageNet의 경우 CIFAR와는 다르게 class가 $1000$개로 구분된다. 그러다보니 단순히 고양이 사진이라도 같은 클래스로 구분하는 것이 아닌, 각 품종에 따라 구분이 가능한 것을 확인해볼 수 있다. 직접 사전 학습된 ResNet18에 위의 이미지를 넣기 위해 데이터 전처리를 다음과 같이 수행한다.

test_img = cv2.cvtColor(cv2.imread("test_img.jpg"), cv2.COLOR_BGR2RGB)
resized = cv2.resize(test_img, (224, 224))
input_image = TF.to_tensor(resized)
input_image = TF.normalize(input_image, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
input_image = input_image.unsqueeze(0)

ImageNet의 경우 이미지 resolution이 $224 \times 224$로 학습되었기 때문에 classifier의 dimension을 맞춰주기 위해서는 이를 고려해서 resize를 진행해야한다. 또한 numpy 배열의 이미지를 tensor로 변환해주면서 normalize를 함께 진행해준다.

예측을 하더라도 해당 클래스의 index가 어떤 class인지 알 수 없으면 파악할 수 없기 때문에 직접 index를 class로 매핑하는 파일을 가져와서 사용했다(참고 링크).

with open("idx2label.txt") as f:
    idx2label = eval(f.read())

with torch.no_grad():
    output = model(input_image.to("cuda"))
    _, prediction = torch.max(output, axis=1)
    print(idx2label[prediction.item()])

모델이 예측한 score를 softmax 확률값으로 치환했을때 최댓값을 가지는 index를 해당 이미지에 대한 class로 예측하게 되며, prediction 결과로 매칭된 class의 이름은 'Siamese cat, Siamese'으로 제대로 나온 것을 확인할 수 있다. 이제는 원본 이미지에 perturbation을 가한 뒤, prediction에 어떤 변화가 있는지 확인해보았다.

masked_img = []
mask_size = 32
for i in range(224):
    for j in range(224):
        left = int(max(0, j-mask_size//2))
        right = int(min(224, j+mask_size//2))
        top = int(max(0, i-mask_size//2))
        bottom = int(min(224, i+mask_size//2))
        
        masked = resized.copy()
        masked[top:bottom, left:right, :]=0
        masked_img.append(masked)

confidences = np.zeros(224*224)
correct = 0
total = 0

loading = tqdm(enumerate(masked_img))
for i, img in loading:
    input_image = TF.to_tensor(img)
    input_image = TF.normalize(input_image, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    input_image = input_image.unsqueeze(0)
    with torch.no_grad():
        output = model(input_image.to("cuda"))
        output = torch.softmax(output, dim=1)
        _, estimate =  torch.max(output, axis=1)
        total += 1
        if estimate.item() == 284:
            correct += 1
        confidences[i] = output[:, 284].item()
        loading.set_description(f"Accuracy : [{correct}/{total}]")

Mask의 크기는 $32 \times 32$로 설정하였다. 검증 과정에서 confidence의 기준은 mask의 중심 좌표로 하였으며 validation 결과 $224 \times 224$의 각 위치에 적용된 mask 중에 $43,348$개 만큼이 원래 class대로 예측이 된 것을 확인할 수 있었다. Confidence를 heatmap으로 확인하면 다음과 같다.

실제로 샴 고양이가 위치한 중앙부가 masking이 되는 상황에서 prediction이 떨어지는 것을 확인할 수 있다. 네트워크가 잘못 예측한 부분만 따로 binary로 표시하면 다음과 같다. 잘못 예측된 부분이란 masking되어 해당 class의 confidence가 떨어지는 상황에서 다른 class에 대한 confidence가 정답 label보다 커지는 상황을 의미한다.

좌측에 검은색으로 표시된 부분에 mask의 중앙부가 위치할 경우 prediction이 잘못되는 것을 확인하였고, 이를 원본 이미지에 적용하게 되면 네트워크가 고양이의 얼굴 부분을 참고하여 prediction을 한다는 형태로 explainability가 충족된다.


Transfer learning and Fine-tuning

인공지능을 공부하게 된다면 학문 특성상 논문을 많이 읽을 수 밖에 없다. 사실상 cs231n과 같이 리뷰한 내용은 인공지능의 개요에 해당되고, deep learning이라는 분야에는 정말 다양한 specific task들이 존재하고 각 task마다 사용되는 방법론이나 연구 방향이 다르기 때문에 이런 트렌드를 읽어내는 것은 쉬운 일이 아니다. 그러나 다양한 task를 막론하고 연구를 시작하는 과정에서, 공통적으로 transfer learning과 fine tuning이라는 중요한 개념을 알아야하며 사실상 항상 사용되는 개념이기 때문에 알아두는 것이 중요하다.

ImageNet에서 우승한 이력이 있는 AlexNet, VGGNet 그리고 ResNet과 같은 network는 매우 큰 dataset에 대해 빠르면 며칠 혹은 몇 주 동안 training한 네트워크이다. ImageNet의 데이터셋은 120만개의 이미지를 가지고 있으며, 이보다 큰 대용량 데이터셋 ImageNet-22k의 경우에는 $14,197,122$개의 데이터셋으로 구성된다. 원하는 task에 맞게 사용하고 싶은데, 굳이 네트워크를 처음부터 학습시킬 필요 없이(이를 training from scratch라 부른다), 대용량 dataset에 대해 유의미한 representation을 학습한 네트워크를 사용한다면 오랜 학습 기간을 들이지 않고도 새로운 task에 적용하기 쉬울 수 있다는 것이다. 이렇게 이미 training된 모델의 representation을 새로운 task에 전이시키는 작업을 transfer learning, 그리고 transfer learning의 한 방법론 중 파라미터를 미세 조정하는 방식을 fine-tuning이라고 부른다. Fine tuning이란 이미 training된 모델을 그대로 사용하거나 backbone에 task specific한 아키텍쳐를 추가한 후에 freezing(파라미터를 고정), training(파라미터를 미세 조정)되는 레이어를 분리하여 원하는 목적에 맞게끔 수정하는 작업을 의미한다.

Convolutional Neural Network를 고정된 feature extractor로 생각하는 경우

ConvNet이 ImageNet에서 pretrained된 AlexNet이라고 가정하자, 가장 말단의 Fully connected layer는 classifier고, 이 부분은 $1000$개의 class에 대한 score를 추출하게 되므로 만약 1000개의 class를 가지는 또다른 classificatioin task가 아니라면 굳이 사용될 아키텍쳐가 아니다. 따라서 이 부분을 제거하게 되고, 결국 남는 부분은 feature extraction만 진행하게 된다. 이 부분은 파라미터를 고정시킨(freeze) 채로, 새로운 데이터셋에 대해서 새로운 classifier를 학습시키면 된다. Classifier는 앞서 배웠던 내용을 다시 생각해보면 linear SVM(Support Vector Machine)이 될 수도 있고, Softmax classifier가 될 수도 있다. 위의 그림에서 세번째 그림에 해당된다고 보면 된다. 실제로 R-CNN에서는 ImageNet에서의 모델을 가져온 뒤, 해당 representation을 object detection task에 transfer learining하는 방법론으로 SVM training 등 여러 fine-tuning을 사용하였다.

Convolutional Neural Network를 fine tuning 하는 경우

물론 convolutional 네트워크(feature extraction)를 고정하지 않고, backpropagation으로 weight 조정을 하는 경우도 있다. 새로운 dataset에 대한 Overfitting을 피하기 위해서 몇몇 레이어는 고정된 상태에서 일부 레이어만 weight 조정을 할 수도 있다. 위의 그림에서 첫번째와 두번째 그림이 이를 나타낸다고 할 수 있다.


How to fine-tune?

결국 fine-tuning할 때 미세 조정을 할 범위를 설정하는 과정에서 전략을 세워야 하는데, 수행하고 싶은 task가 있을 때 어떤 기준으로 fine-tuning하는 레이어를 선택해야할 지 정해진 가이드라인은 없다. 하지만 사람들이 일반적으로 fine-tuning하는 과정에서는 현재 가지고 있는 데이터셋의 규모, 그리고 fine-tuning을 진행할 source network가 학습한 데이터와의 distribution 차이를 고려한다. 다시 언급하지만 아래에 있는 내용들은 일반적인 경우에는 적용되지만 항상 옳은 것은 아니라는 것을 짚고 넘어가도록 하자.

새로운 Dataset 수가 적은데 기존 Dataset과 유사한 경우

데이터 수가 작기 때문에 convolutional network를 fine tuning하는 것은 overfitting의 우려가 있다. 하지만 새로운 데이터와 기존 데이터가 유사하기 때문에, high level feature(coarse feature map)이 비슷할 확률이 높다. 따라서 feature extractor는 그대로 사용(freeze)한 채로 linear classifier 부분만 training 하는 방법이 좋다.

새로운 Dataset 수가 많은데 기존 Dataset과 유사한 경우

가장 이상적인 상황이기 때문에 모델 전체를 학습해도 상관없고, 만약 기존에 training된 weight를 활용하고 싶다면 레이어의 일부를 fine-tuning 하여도 상관없다. 데이터 수가 충분하기 때문에 이런 경우에는 over-fitting에 대한 걱정이 적다. 따라서 충분히 성능을 높일 수 있기 때문에 네트워크 대부분을 fine-tuning하여 task-specific한 performance를 얻는 방법이 좋다.

새로운 Dataset 수가 적은데 기존 Dataset과 매우 다른 경우

가장 어려운 경우에 해당된다. Dataset이 매우 작기 때문에 첫번째 케이스와 같이 classifier에 대해서만 fine-tuning을 진행해야 하는 것은 맞는데, 해당 케이스와는 다르게 dataset이 상이하다는 문제가 있어 feature map이 유사하다는 assumption을 사용할 수 없게 된다. 그렇기 때문에 high level feature로부터 나온 feature map에 대해 classifier를 따로 학습하여 network의 head를 채우는 것 보다는, network 초반부의 feature acitvation에 대한 SVM을 학습하는 것이 도움이 될 수 있다고 한다. 사실 이런 경우에는 어떠한 방식을 쓰는지에 따라 성능 변화가 크기 때문에 정답이라고는 말을 못하겠다.

새로운 Dataset 수가 많은데 기존 Dataset과 매우 다른 경우

Dataset이 기존과 아예 다르다면 그냥 scratch(처음부터) training하는 것이 나아보이지만 그래도 실제로 성능 비교를 했을 때나 성능 수렴을 확인해보면 pre-training된 모델에 대해서 가중치 조정을 하는 것이 더 효과적인 것을 볼 수 있다. 이 경우에는 데이터셋 수가 많기 때문에 네트워크 전체를 fine-tuning하는 것이 도움이 될만한 high level feature를 얻을 수 있는 방법이다.

Fine tuning 과정에서 고려해야할 사항은 다음과 같다. 우선 pre-training된 모델을 사용하면 구조에 제한이 걸리기 때문에 다양한 metric이나 task에 최적화된 네트워크를 구성할 수 없다는 단점이 있다. 그리고 학습 과정에서 네트워크를 처음부터 학습하는 것이 아닌 optimization point를 옮기는 과정이기 때문에 learning rate을 작게 설정하여 미세 조정을 거치게 된다. 보통 처음부터 학습하는 네트워크를 기준으로 그보다 $1/100$이나 $1/1000$ 만큼 더 적은 learning rate를 적용하여 task에 최적화하게 된다.

A n o t h e r p o s t i n c a t e g o r y