이번 포스팅에서는 이전 포스팅 "데이터 블록 만드는 법 (Siamese)"에서 만든 DataBlock을 수용할 수 있는 모델을 만드는 방법을 다룹니다. 기본적으로는 PyTorch의 nn.Module을 상속받아서 모델을 만들면 그만이지만, 이 때 fastai에서 제공하는 몇 가지 편리한 함수를 살펴볼 것입니다.

SiameseModel 개요

우선 Siamese 모델이 하는 일을 다시 한번 생각해 봅시다. 이 모델은 두 이미지를 입력받아서, 두 이미지가 같은 부류에 속하는지를 판단하여 같다면 True, 다르다면 False 라는 결과를 예측합니다.

아래의 코드는 SiameseModel 이라는 간단한 모듈을 보여줍니다. 이 모듈은 PyTorch의 nn.Module 대신, fastai의 Module을 상속받아서 구현되었습니다. nn.ModuleModule의 차이는 단순히 init 메서드 내에서, super.init 부모 메서드를 호출할 필요가 있는지 없는지 입니다. 따라서, super.init 을 호출한다면, nn.Module을 사용해도 무방한 것이죠.

class SiameseModel(Module):
    def __init__(self, encoder, head):
        self.encoder,self.head = encoder,head
    
    def forward(self, x1, x2):
        filters = torch.cat([self.encoder(x1), self.encoder(x2)], dim=1)
        return self.head(filters)

아래의 Module 닥스트링을 통해서, 해당 설명을 확인해 보기 바랍니다.

class Module[source]

Module() :: Module

Same as nn.Module, but no need for subclasses to call super().__init__

SiameseModel이 구현된 방식을 한번 살펴보겠습니다. 우선 생성자인 init 메서드는 두 개의 인자 (encoder, head)를 수용합니다. 이 둘은 각각 일반적인 CNN 모델에서 흔히 알고 있는 특징 추출을 담당하는 Convolutional Layers 와 분류를 담당하는 Fully Connected Layers 를 의미합니다.

이 두개를 입력받는 이유는 전이학습을 위해서 입니다. 일반적으로 전이학습은 사전에 훈련된 모델의 Convolutional Layers의 가중치를 그대로 활용합니다. 즉, 수 많은 이미지로부터 다양한 특징을 추출하는 능력을 이어받는 것이죠. 따라서, 이 부분이 encoder에 해당합니다.

하지만, 사전 훈련된 모델이 풀고자 했던 문제와 내가 현재 풀고자 하는 문제는 다릅니다. 분류 할 범주의 개수도 다르며, 종류도 다릅니다. 따라서 마지막 head 부분을 나의 문제에 맞게 구조를 잡은 다음, 이를 encoder와 결합해 주는 것입니다.

fastai 에서는 사전 훈련된 모델로부터 encoder 부분을 추출하는 편리한 메서드로, create_body를 제공합니다. 또한 일반적인 구조의 head를 만들어주는 create_head 메서드도 함께 제공합니다. 즉, create_bodyencoder를 추출한 다음, create_head로 생성된 부분을 encoder와 결합해 주면 되는 것입니다.

이 내용을 숙지한 상태로, 다시한번 SiameseModel의 구현 코드를 살펴봅시다.

class SiameseModel(Module):
    def __init__(self, encoder, head):
        self.encoder,self.head = encoder,head
    
    def forward(self, x1, x2):
        filters = torch.cat([self.encoder(x1), self.encoder(x2)], dim=1)
        return self.head(filters)

init 생성자는 단순히 encoderhead를 수용하여, 내부 변수에 저장합니다. 그리고, forwardx1x2 두 개의 인자를 수용하는데, 각각은 비교되어야 할 서로다른 이미지 두 개를 의미합니다. 각 이미지를 encoder에 전달한 다음, torch.cat 함수를 통해 그 결과를 이어 붙여줍니다. 즉, 원래 encoder가 출력하는 결과의 두 배의 양이되는 것이죠. 다만, 양은 두개지만 서로다른 encoder를 사용하는 것이 아니므로, 가중치는 공유됩니다.

그 다음, 이어 붙여진 결과를 단순히 head로 삽입해 주는것으로 SiameseModel의 역할은 끝이납니다.

encoder(body)와 head

그러면 이제 우리가 해야할 일은 encoderhead를 만들어 주는 것입니다. 직접 encoder를 만들어서 밑바닥부터 학습을 진행해도 좋지만, 이미 이미지넷을 통해서 사전에 학습된 훌륭한 모델들이 존재합니다. 가령 ResNet, xResNet, EfficientNet 등과 같은것이 있을 수 있겠죠.

fastai에서는 기본적으로 ResNet, xResNet, VGG, AlexNet, DenseNet, SqueezeNet 을 기본적으로 제공합니다. 그 중 ResNet은 사실 PyTorch에서 제공하는 모델을 그대로 활용하죠. 다만 fastai에서 제공되는 모델은 추가적인 메타데이터가 함께 딸려옵니다. 이 메타데이터가 의미하는바를 먼저 알아보도록 하겠습니다.

모델의 메타데이터

다음은 resnet34에 대한 메타데이터가 가진 정보를 보여줍니다. fastai에서 제공하는 model_meta 딕셔너리 정보를 통해서, 각 모델의 메타데이터 정보를 조회할 수 있습니다.

model_meta[resnet34]
{'cut': -2,
 'split': <function fastai.vision.learner._resnet_split(m)>,
 'stats': ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])}

보다시피 세 개의 키값(cut, split, stats) 에 대한 정보를 가지고 있습니다. 각 키값이 의미하는바는 다음과 같습니다.

  • cut
    • CNN 구조에서, Fully Connected Layers가 시작되는 지점의 인덱스를 의미합니다. 즉, 개념적으로 생각해 보자면 resnet34[-2] 와 같이 접근하면, Fully Connected Layers를 제외한 나머지 Convolutional Layers 들 만을 가지고 올 수 있게 되는 것이죠. 이는 사전학습된 모델에서 Fully Connected Layer를 제거하고, 나만의 문제에 적합한 Fully Connected Layers를 추가하는 전이학습을 수행할 때 매우 도움이 되는 정보입니다.

  • split
    • split은 전이학습시 freeze되어야 하는 부분을 포함해서, 차별적 학습률이 적용된 파라미터 그룹을 구분짓습니다. fastai가 제공하는 모델 학습 시스템은 계층별로 차별적인 학습률을 둘 수 있도록 설계되어 있고, split 정보에는 차별적인 학습률이 적용된 계층 그룹에 대한 정보가 담겨 있습니다.

  • stats
    • 사전 학습된 모델이 학습한 데이터의 통계적 정보(평균, 표준편차)를 저장합니다. 특정 모델이 학습한 데이터를 구성하는 값의 분포를 알고, 새로운 데이터를 그 분포에 맞도록 변형해 주면 전이학습의 효과를 좀 더 끌어올릴 수 있는 전략에 사용됩니다. 이 정보는 DataBlock 구성시 batch_tfms에 자동으로 삽입됩니다.

encoder 만들기

모델의 메타데이터 정보를 가지고 있으므로, 이를 활용하여 encoder 부분을 잘라낼 수 있을 것입니다 (그렇지 않다면, 직접 모델의 구조를 파악한 후 자르는 지점을 결정해야만 합니다). 특히 cut 정보가 있을 때, fastai에서 제공하는 create_body 함수를 활용하면 쉽게 자를 수 있습니다.

먼저 create_body 함수의 원형을 살펴보죠.

create_body[source]

create_body(arch, n_in=3, pretrained=True, cut=None)

Cut off the body of a typically pretrained arch as determined by cut

여기서의 arch는 모델이다. 즉 resnet18, resnet50과 같은 것이 됩니다. 유념해야 하는 인자는 cut 입니다. 바로 이 부분에 모델의 메타데이터의 cut 정보를 넣어주면 됩니다.

다음은 resnet34에 대하여 create_body 함수를 수행하여 얻은 결과로부터, 가장 마지막 인덱스에 해당하는 것을 가져옵니다. 이름에서 알 수 있듯이 34계층으로 구성되어 있기 때문에, 다소 많은 출력을 피하기 위한 목적과, 가장 마지막 계층이 Convolutional Layer로 끝난다는 사실을 확인하기 위함입니다.

encoder = create_body(resnet34, cut=model_meta[resnet34]['cut'])
encoder[-1]
Sequential(
  (0): BasicBlock(
    (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
    (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (relu): ReLU(inplace=True)
    (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (downsample): Sequential(
      (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
      (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (1): BasicBlock(
    (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (relu): ReLU(inplace=True)
    (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  )
  (2): BasicBlock(
    (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (relu): ReLU(inplace=True)
    (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  )
)

head 만들기

head = create_head(512*4, 2, ps=0.5)

create_head[source]

create_head(nf, n_out, lin_ftrs=None, ps=0.5, concat_pool=True, bn_final=False, lin_first=False, y_range=None)

Model head that takes nf features, runs through lin_ftrs, and out n_out classes.

head
Sequential(
  (0): AdaptiveConcatPool2d(
    (ap): AdaptiveAvgPool2d(output_size=1)
    (mp): AdaptiveMaxPool2d(output_size=1)
  )
  (1): Flatten(full=False)
  (2): BatchNorm1d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (3): Dropout(p=0.25, inplace=False)
  (4): Linear(in_features=2048, out_features=512, bias=False)
  (5): ReLU(inplace=True)
  (6): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (7): Dropout(p=0.5, inplace=False)
  (8): Linear(in_features=512, out_features=2, bias=False)
)

SiameseModel과 Learner의 생성

model = SiameseModel(encoder, head)
def siamese_splitter(model):
    return [params(model.encoder), params(model.head)]
def loss_func(out, targ):
    return CrossEntropyLossFlat()(out, targ.long())