DataScience
article thumbnail
Published 2023. 1. 24. 03:03
ResNet 리뷰 영상처리/기초
728x90

ResNet은 마이크로소프트에서 개발한 알고리즘으로 “Deep Residual Learning for Image Recognition”이라는 논문에서 발표되었습니다. ResNet 핵심은 깊어진 신경망을 효과적으로 학습하기 위한 방법으로 레지듀얼(residual) 개념을 고안한 것입니다.

일반적으로 신경망 깊이가 깊어질수록 딥러닝 성능은 좋아질 것 같지만, 실상은 그렇지 않습니다. “Deep Residual Learning for Image Recognition” 논문에 따르면, 신경망은 깊이가 깊어질수록 성능이 좋아지다가 일정한 단계에 다다르면 오히려 성능이 나빠진다고 합니다.

다음 그림과 같이 네트워크 56층이 20층보다 더 나쁜 성능을 보임을 알 수 있습니다. 즉, 네트워크 깊이가 깊다고 해서 무조건 성능이 좋아지지는 않는다는 것을 보여 주고 있습니다. ResNet은 바로 이러한 문제를 해결하기 위해 레지듀얼 블록(residual block)을 도입했습니다. 레지듀얼 블록은 기울기가 잘 전파될 수 있도록 일종의 숏컷(shortcut, skip connection)을 만들어 줍니다.

 

이러한 개념이 필요한 이유는 2014년에 공개된 GoogLeNet은 층이 총 22개로 구성된 것에 비해 ResNet은 층이 총 152개로 구성되어 기울기 소멸 문제가 발생할 수 있기 때문입니다. 따라서 다음 그림과 같이 숏컷을 두어 기울기 소멸 문제를 방지했다고 이해하면 됩니다.

▲ 그림 6-25  ResNet 구조

 

블록은 계층의 묶음입니다. 엄밀히 말해서 합성곱층을 하나의 블록으로 묶은 것입니다. 그림 6-26에서 색상별로 블록을 구분했는데 이렇게 묶인 계층들을 하나의 레지듀얼 블록(residual block)이라고 합니다. 그리고 레지듀얼 블록을 여러 개 쌓은 것을 ResNet이라고 합니다.

▲ 그림 6-26  ResNet 모델 전체 네트워크

하지만 이렇게 계층을 계속해서 쌓아 늘리면 파라미터 수가 문제가 됩니다. 계층이 깊어질수록 파라미터는 증가합니다. 예를 들어 ResNet34는 합성곱층이 34개와 16개의 블록으로 구성되어 있습니다. 첫 번째 블록의 파라미터가 1152K라면 전체 파라미터 수는 2만 1282K입니다. 이와 같이 계층의 깊이가 깊어질수록 파라미터는 무제한으로 커질 것입니다. 이러한 문제를 해결하기 위해 병목 블록(bottleneck block)이라는 것을 두었습니다.

병목 블록을 두었을 때 어떤 현상이 발생할까요? 다음 그림은 ResNet34와 ResNet50입니다. ResNet34는 기본 블록(basic block)을 사용하며, ResNet50은 병목 블록을 사용합니다. 기본 블록의 경우 파라미터 수가 39.3216M인 반면, 병목 블록의 경우 파라미터 수가 6.9632M입니다. 깊이가 깊어졌음에도 파라미터 수는 감소한 것입니다.

▲ 그림 6-27  기본 블록과 병목 블록

병목 블록을 사용하면 파라미터 수가 감소하는 효과를 줄 수 있습니다. 합성곱층을 자세히 보면 ResNet34와는 다르게 ResNet50에서는 3×3 합성곱층 앞뒤로 1×1 합성곱층이 붙어 있는데, 1×1 합성곱층의 채널 수를 조절하면서 차원을 줄였다 늘리는 것이 가능하기 때문에 파라미터 수를 줄일 수 있었던 것입니다. 그리고 이 부분이 병목과 같다고 하여 병목 블록이라고 합니다.

이제 중요한 아이덴티티 매핑(identity mapping)(혹은 숏컷(shortcut), 스킵 연결(skip connection)이라고도 함)에 대해 알아보겠습니다. 그림 6-27의 아래쪽에 + 기호가 있습니다(기본 블록과 병목 블록 모두에서 사용됩니다). 이 부분을 아이덴티티 매핑이라고 합니다. 아이덴티티 매핑이란 입력 x가 어떤 함수를 통과하더라도 다시 x라는 형태로 출력되도록 합니다.

▲ 그림 6-28  아이텐티티 매핑(숏컷)

다음은 코드입니다. 그중 forward() 부분에 대해서만 살펴보겠습니다.

def forward(self, x):
        i = x
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.conv2(x)
        x = self.bn2(x)

        if self.downsample is not None:
            i = self.downsample(i) ------ 다운샘플 적용

        x += I ------ 아이덴티티 매핑 적용
        x = self.relu(x)
        return x

코드를 살펴보면 입력 x i라는 변수에 저장했습니다. 입력 x는 합성곱층을 통과하다가 마지막 x I를 더해 주었습니다. 예를 들어 x가 (28, 28, 64)라고 가정해 봅시다. x i 변수에 저장했기 때문에 (28, 28, 64)가 될 것입니다. 그리고 합성곱층을 통과하면서 같은 형태를 더하기 때문에 최종 형태는 (28, 28, 64) 그대로가 될 것입니다.

 

 

이번에는 또 다른 핵심 개념인 다운샘플(downsample)에 대해 알아보겠습니다. 다운샘플은 특성 맵(feature map) 크기를 줄이기 위한 것으로 풀링과 같은 역할을 한다고 이해하면 됩니다. 다음 그림은 ResNet 네트워크의 일부를 가져온 것입니다.

▲ 그림 6-29  ResNet 네트워크의 일부

보라색 영역의 첫 번째 블록에서 특성 맵의 형상이 (28, 28, 64)였다면 세 번째 블록의 마지막 합성곱층을 통과하고 아이덴티티 매핑(identity mapping)까지 완료된 특성 맵의 형상도 (28, 28, 64)입니다. 이번에는 노란색 영역을 살펴볼까요? 노란색 영역의 시작 지점에서는 채널 수가 128로 늘어났고, /2라는 것으로 보아 첫 번째 블록에서 합성곱층의 스트라이드가 2로 늘어나 (14, 14, 128)로 바뀐다는 것을 알 수 있습니다.

즉, 보라색과 노란색의 형태가 다른데 이들 간의 형태를 맞추지 않으면 아이덴티티 매핑을 할 수 없게 됩니다. 그래서 아이덴티티에 대해 다운샘플이 필요합니다.

 

참고로 입력과 출력의 형태를 같도록 맞추어 주기 위해서는 스트라이드(stride) 2를 가진 1×1 합성곱 계층을 하나 연결해 주면 됩니다. 이와 같이 입력과 출력의 차원이 같은 것을 아이덴티티 블록이라고 하며, 입력 및 출력 차원이 동일하지 않고 입력의 차원을 출력에 맞추어 변경해야 하는 것을 프로젝션 숏컷(projection-shortcut) 혹은 합성곱 블록이라고 합니다.

▲ 그림 6-30  합성곱 블록
▲ 그림 6-31  아이덴티티 블록

 

정리하면 ResNet은 기본적으로 VGG19 구조를 뼈대로 하며, 거기에 합성곱층들을 추가해서 깊게 만든 후 숏컷들을 추가하는 것이 사실상 전부라고 생각하면 됩니다.

▲ 그림 6-32  VGG19와 ResNet 비교

그럼 이제 파이토치로 ResNet을 구현해 보겠습니다. 

필요한 라이브러리를 호출합니다.

 

#필요한 라이브러리 호출
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torch.utils.data as data
import torchvision
import torchvision.transforms as transforms
import torchvision.datasets as datasets
import torchvision.models as models

import matplotlib.pyplot as plt
import numpy as np

import copy
from collections import namedtuple ------ ①
import os
import random
import time

import cv2
from torch.utils.data import DataLoader, Dataset
from PIL import Image

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

① 네임드튜플(namedtuple)은 파이썬의 자료형 중 하나입니다. 말 그대로 튜플의 성질을 갖고 있는 자료형이지만 인덱스뿐만 아니라 키 값으로 데이터에 접근할 수 있습니다. 따라서 다음과 같이 사용 가능합니다.

from collections import namedtuple
Student = namedtuple('Student', ['name','age','DOB']) ------ 네임드튜플 정의
S = Student('홍길동', '19', '187') ------ 네임드튜플에 값을 추가

print("The Student age using index is : ", end="")
print(S[1]) ------ 인덱스를 이용한 데이터 접근

print("The Student name using keyname is : ", end="")
print(S.name) ------ 키 값을 이용한 데이터 접근

네임드튜플에 대한 출력 결과는 다음과 같습니다.

The Student age using index is : 19
The Student name using keyname is : 홍길동

출력 결과처럼 인덱스뿐만 아니라 키 값을 이용해도 데이터에 접근할 수 있음을 확인할 수 있습니다.

다음은 이미지 데이터 전처리에 대한 코드입니다. 각각의 파라미터는 앞에서 충분히 살펴보았기 때문에 설명은 생략합니다.

#이미지 데이터 전처리
class ImageTransform():
    def __init__(self, resize, mean, std):
        self.data_transform = {
            'train': transforms.Compose([
                transforms.RandomResizedCrop(resize, scale=(0.5, 1.0)),
                transforms.RandomHorizontalFlip(),
                transforms.ToTensor(),
                transforms.Normalize(mean, std)
            ]), ------ 훈련 이미지 데이터에 대한 전처리
            'val': transforms.Compose([
                transforms.Resize(256),
                transforms.CenterCrop(resize),
                transforms.ToTensor(),
                transforms.Normalize(mean, std)
            ]) ------ 검증과 테스트 이미지 데이터에 대한 전처리
        }

    def __call__(self, img, phase):
        return self.data_transform[phase](img)
변수에 대한 값 정의
size = 224
mean = (0.485, 0.456, 0.406)
std = (0.229, 0.224, 0.225)
batch_size = 32

torchvision.datasets.ImageFolder를 이용하여 훈련과 테스트 데이터셋을 불러옵니다. 데이터셋은 개와 고양이에 대한 예제를 계속 사용합니다. 추가 학습을 위해 캐글에서 이미지 데이터셋을 내려받아 학습해도 좋습니다. 혹은 이미지를 찾기 어렵다면 책에서 사용하는 이미지 데이터셋을 이용해도 좋습니다.

훈련과 테스트 데이터셋 불러오기
cat_directory = r'../chap06/data/dogs-vs-cats/Cat/'
dog_directory = r'../chap06/data/dogs-vs-cats/Dog/'

cat_images_filepaths = sorted([os.path.join(cat_directory, f) for f in os.listdir(cat_directory)])
dog_images_filepaths = sorted([os.path.join(dog_directory, f) for f in os.listdir(dog_directory)])
images_filepaths = [*cat_images_filepaths, *dog_images_filepaths]
correct_images_filepaths = [i for i in images_filepaths if cv2.imread(i) is not None]
데이터셋을 훈련, 검증, 테스트 용도로 분리
random.seed(42)
random.shuffle(correct_images_filepaths)
train_images_filepaths = correct_images_filepaths[:400]
val_images_filepaths = correct_images_filepaths[400:-10]
test_images_filepaths = correct_images_filepaths[-10:]
print(len(train_images_filepaths), len(val_images_filepaths),
    len(test_images_filepaths))

훈련, 검증, 테스트 데이터셋에 대한 이미지 수는 다음과 같습니다.

400 92 10

훈련 데이터셋은 400개, 검증 데이터셋은 92개, 테스트 데이터셋은 열 개로 구성되어 있습니다.

데이터를 가져와서 전처리를 적용합니다. 또한, 가져온 데이터가 개이면 레이블 ‘1’을 부여하고, 고양이라면 레이블 ‘0’을 부여합니다.

#이미지에 대한 레이블 구분
class DogvsCatDataset(Dataset):
    def __init__(self, file_list, transform=None, phase='train'):
        self.file_list = file_list
        self.transform = transform
        self.phase = phase

    def __len__(self):
        return len(self.file_list)

    def __getitem__(self, idx):
        img_path = self.file_list[idx]
        img = Image.open(img_path)
        img_transformed = self.transform(img, self.phase)

        label = img_path.split('/')[-1].split('.')[0]
        if label == 'dog':
            label = 1
        elif label == 'cat':
            label = 0
        return img_transformed, label
#이미지 데이터셋 정의
train_dataset = DogvsCatDataset(train_images_filepaths, transform=ImageTransform(size, mean, std), phase='train')
val_dataset = DogvsCatDataset(val_images_filepaths, transform=ImageTransform(size, mean, std), phase='val')

index = 0
print(train_dataset.__getitem__(index)[0].size())
print(train_dataset.__getitem__(index)[1])

다음은 훈련 데이터셋 index 0의 이미지 크기와 레이블에 대한 출력 결과입니다.

torch.Size([3, 224, 224])
0

이미지는 컬러(채널 3) 상태에서 224×224 크기를 갖고 있으며 레이블이 0이므로 고양이를 의미합니다.

데이터로더를 이용하여 데이터를 메모리로 불러옵니다. 불러올 때는 배치 크기만큼 나누어서 불러옵니다.

#데이터셋의 데이터를 메모리로 불러오기
train_iterator = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
valid_iterator = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
dataloader_dict = {'train': train_iterator, 'val': valid_iterator}

batch_iterator = iter(train_iterator)
inputs, label = next(batch_iterator)
print(inputs.size())
print(label)

다음은 데이터로더를 이용하여 메모리로 불러온 훈련 데이터셋의 이미지 크기와 레이블에 대한 출력 결과입니다.

torch.Size([32, 3, 224, 224])
tensor([0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1])

이제 ResNet의 전체 네트워크 구성을 위해 그것을 구성하는 기본 블록과 병목 블록에 대한 코드를 먼저 살펴보겠습니다. 먼저 기본 블록은 ResNet18, ResNet34에서 사용되며 합성곱(3×3) 두 개로 구성됩니다.

#기본블록 정의
class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_channels, out_channels, stride=1, downsample=False):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3,
                               stride=stride, padding=1, bias=False) ------ 3×3 합성곱층
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3,
                               stride=1, padding=1, bias=False) ------ 3×3 합성곱층
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
 
        if downsample: ------ ①
            conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False)
            bn = nn.BatchNorm2d(out_channels)
            downsample = nn.Sequential(conv, bn)
        else:
            downsample = None
        self.downsample = downsample

    def forward(self, x):
        i = x
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.conv2(x)
        x = self.bn2(x)

        if self.downsample is not None:
            i = self.downsample(i)

        x += I ------ ②
        x = self.relu(x)

        return x

① 다운샘플(downsample)이 적용되는 부분입니다. 다운샘플은 입력 데이터의 크기와 네트워크를 통과한 후 출력 데이터의 크기가 다를 경우에 사용합니다. 다운샘플을 위해서는 다음과 같이 합성곱층에 스트라이드를 적용합니다.

conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False)

(예제에서는 stride=stride로 되어 있지만, 클래스 속성으로 stride=1이 정의되었기 때문에 stride=1로 작성했습니다)

② 아이덴티티 매핑이 적용되는 부분입니다. 특정 층에 존재하는 출력 결과를 다음 합성곱층을 통과한 출력 결과에 더해 준다고 하여 스킵 연결(skip connection)이라고도 합니다. 예를 들어 다음과 같이 x conv1, bn1, relu, conv2, bn2, relu, conv3, bn3 값이 더해지다가 초기의 x가 다시 더해지는 것을 아이덴티티 매핑 혹은 숏컷 혹은 스킵 연결이라고 합니다.

def forward(self, x):
        i = x
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.conv2(x)
        x = self.bn2(x)
        x = self.relu(x)
        x = self.conv3(x)
        x = self.bn3(x)

        if self.downsample is not None:
            i = self.downsample(i)

        x += i
        x = self.relu(x)
        return x

병목 블록은 ResNet50, ResNet101, ResNet152에서 사용되며 1×1 합성곱층, 3×3 합성곱층, 1×1 합성곱층으로 구성됩니다.

 

#병목 블록 정의
class Bottleneck(nn.Module):
    expansion = 4 ------ ResNet에서 병목 블록을 정의하기 위한 하이퍼파라미터입니다.

    def __init__(self, in_channels, out_channels, stride=1, downsample=False):
        super().__init__()
         self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False) ------ 1×1 합성곱층
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) ------ 3×3 합성곱층
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.conv3 = nn.Conv2d(out_channels, self.expansion*out_channels, kernel_size=1, stride=1, bias=False) ------ 1×1 합성곱층, 또한 다음 계층의 입력 채널 수와 일치하도록 self.expansion*out_channels를 합니다.
        self.bn3 = nn.BatchNorm2d(self.expansion*out_channels)
        self.relu = nn.ReLU(inplace=True)

        if downsample:
            conv = nn.Conv2d(in_channels, self.expansion*out_channels, kernel_size=1, stride=stride, bias=False)
            bn = nn.BatchNorm2d(self.expansion*out_channels)
            downsample = nn.Sequential(conv, bn)
        else:
            downsample = None
            self.downsample = downsample

    def forward(self, x):
        i = x
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.conv2(x)
        x = self.bn2(x)
        x = self.relu(x)
        x = self.conv3(x)
        x = self.bn3(x)

        if self.downsample is not None:
            i = self.downsample(i)

        x += i
        x = self.relu(x)
        return x

병목 블록에 대한 코드를 살펴보았는데 기본 블록과 병목 블록에 대한 차이가 있었습니다. 기본 블록이 3×3 합성곱층 두 개를 갖는 반면, 병목 블록은 1×1 합성곱층, 3×3 합성곱층, 1×1 합성곱층의 구조를 갖습니다. 기본 블록을 병목 블록으로 변경하는 이유는 계층을 더 깊게 쌓으면서 계산에 대한 비용을 줄일 수 있기 때문입니다. 그리고 계층이 많아진다는 것은 곧 활성화 함수가 기존보다 더 많이 포함된다는 것이고, 이것은 더 많은 비선형성(non-linearity)을 처리할 수 있음을 의미하기도 합니다. 즉, 다양한 입력 데이터에 대한 처리가 가능하다는 의미입니다.

결국 아이덴티티 매핑과 병목 블록으로 ResNet 네트워크에 더욱 깊은 계층을 쌓을 수 있게 된 것입니다.

이제 ResNet 모델에 대한 네트워크를 정의해 봅시다. ResNet 역시 VGG처럼 다양한 모델이 있습니다.

#ResNet 모델 네트워크
class ResNet(nn.Module):
    def __init__(self, config, output_dim, zero_init_residual=False):
        super().__init__()

        block, n_blocks, channels = config ------ ResNet을 호출할 때 넘겨준 config 값들을 block, n_blocks, channels에 저장
        self.in_channels = channels[0]
        assert len(n_blocks) == len(channels) == 4 ------ 블록 크기 = 채널 크기 = 4

        self.conv1 = nn.Conv2d(3, self.in_channels, kernel_size=7, stride=2, padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(self.in_channels)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

        self.layer1 = self.get_resnet_layer(block, n_blocks[0], channels[0])
        self.layer2 = self.get_resnet_layer(block, n_blocks[1], channels[1], stride=2)
        self.layer3 = self.get_resnet_layer(block, n_blocks[2], channels[2], stride=2)
        self.layer4 = self.get_resnet_layer(block, n_blocks[3], channels[3], stride=2)

        self.avgpool = nn.AdaptiveAvgPool2d((1,1))
        self.fc = nn.Linear(self.in_channels, output_dim)

        if zero_init_residual: ------ ①
            for m in self.modules():
                if isinstance(m, Bottleneck):
                    nn.init.constant_(m.bn3.weight, 0)
                elif isinstance(m, BasicBlock):
                    nn.init.constant_(m.bn2.weight, 0)

    def get_resnet_layer(self, block, n_blocks, channels, stride=1): ------ 블록을 추가하기 위한 함수
        layers = []
        if self.in_channels != block.expansion * channels: ------ in_channels와 block.expansion*channels가 다르면 downsample 적용
            downsample = True
        else:
            downsample = False

        layers.append(block(self.in_channels, channels, stride, downsample)) ------ 계층(layer)을 추가할 때 in_channels, channels, stride뿐만 아니라 다운샘플 적용 유무도 함께 전달
        for i in range(1, n_blocks): ------ n_blocks만큼 계층 추가
            layers.append(block(block.expansion*channels, channels))

        self.in_channels = block.expansion * channels
        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x) ------ 224×224
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x) ------ 112×112
        x = self.layer1(x) ------ 56×56
        x = self.layer2(x) ------ 28×28
        x = self.layer3(x) ------ 14×14
        x = self.layer4(x) ------ 7×7
        x = self.avgpool(x) ------ 1×1
        h = x.view(x.shape[0], -1)
        x = self.fc(h)
        return x, h

① 각 레지듀얼 분기(residual branch)에 있는 마지막 BN(Batch Normalization)을 0으로 초기화해서 다음 레지듀얼 분기를 0에서 시작할 수 있도록 합니다. 이 부분은 모델을 생성하고 학습시키는 것과는 상관없지만, 다음 URL 논문에 의하면 BN을 0으로 초기화할 경우 모델 성능이 0.2~0.3% 정도 향상된다고 합니다. 따라서 ResNet에서는 많이 사용되고 있습니다.

https://arxiv.org/abs/1706.02677

참고로 레지듀얼 분기란 프로그램에서의 조건에 따라 A, B, C 등으로 분기하는 것과 같습니다.

▲ 그림 6-33   레지듀얼 분기

 

ResNetConfig 변수에 네임드튜플 데이터 형식으로 ['block', 'n_blocks', 'channels']를 저장합니다.

 

#ResNetConfig 정의
ResNetConfig = namedtuple('ResNetConfig', ['block', 'n_blocks', 'channels'])

먼저 기본 블록을 사용하는 ResNet18과 ResNet34의 Config를 정의합니다. 즉, 블록은 기본 블록(BasicBlock)을 사용하도록 하며, 블록(n_blocks)과 채널(channels)의 크기를 각각 지정합니다.

 

#기본 블록을 사용하여 ResNetConfig 정의
resnet18_config = ResNetConfig(block=BasicBlock,
                               n_blocks=[2,2,2,2],
                               channels=[64,128,256,512])

resnet34_config = ResNetConfig(block=BasicBlock,
                               n_blocks=[3,4,6,3],
                               channels=[64,128,256,512])

이번에는 병목 블록을 사용하는 ResNet50, ResNet101, ResNet152의 Config를 정의합니다. 즉, 블록은 병목 블록(Bottleneck)를 사용하도록 하며, 블록(n_blocks)과 채널(channels)의 크기를 각각 지정합니다.

기본적인 합성곱층을 사용하는 것도 복잡한데 아이덴티티 매핑, 다운샘플 및 병목 블록까지 정의하여 사용하려니 꽤 번거롭습니다.

ResNet 역시 사전 훈련된 모델이기 때문에 코드 한 줄로 간단하게 사용할 수 있습니다. 이제 그 방법에 대해 잠깐 살펴보겠습니다.

#사전 훈련된 ResNet 모델 사용
pretrained_model = models.resnet50(pretrained=True)
#사전 훈련된 ResNet 모델 사용을 위해서는 pretrained=True로 설정

우리가 만든 ResNet 네트워크와 사전 훈련된 ResNet 네트워크가 동일한지 확인해 봅시다.

#사전 훈련된 ResNet 네트워크 확인
print(pretrained_model)

다음은 사전 훈련된 ResNet 네트워크를 출력한 결과입니다.

ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(64, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(256, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
... 중간 생략 ...
  (layer4): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(1024, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(1024, 2048, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(2048, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=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)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (2): Bottleneck(
      (conv1): Conv2d(2048, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=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)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=2048, out_features=1000, bias=True)
)

직접 작성한 ResNet 네트워크로 다시 돌아오겠습니다. ResNet50 모델을 사용하기 위해 Config부분에 resnet50_config를 지정하고 OUTPUT_DIM에는 테스트 데이터셋의 클래스(개와 고양이)를 입력합니다.

#ResNet50 Config를 사용한 ResNet 모델 사용
OUTPUT_DIM = 2 ------ 두 개의 클래스 사용(개와 고양이)
model = ResNet(resnet50_config, OUTPUT_DIM)
print(model)

다음은 수동으로 작성한 ResNet 모델의 네트워크 구조입니다. 앞에서 살펴보았던 사전 훈련된 ResNet 네트워크와 비교해서 살펴보기 바랍니다(네트워크 깊이가 깊기 때문에 책에서는 중간 생략합니다).

ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(64, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(256, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
... 중간 생략 ...
  (layer4): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(1024, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(1024, 2048, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(2048, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=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)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (2): Bottleneck(
      (conv1): Conv2d(2048, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=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)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=2048, out_features=2, bias=True)
)

앞으로 ResNet을 사용할 필요가 있을 경우 사전 훈련된 모델을 코드 한두 줄로 불러와 사용하게 될 것입니다. 하지만 그 구조를 알고 사용하는 것과 모르고 사용하는 것은 큰 차이가 있습니다. 추후 네트워크를 변경하거나 입출력 텐서의 크기를 알고자 할 경우 네트워크를 알고 있다면 수정이 어렵지 않을 것입니다. 따라서 향후 논문에서 새로운 모델의 네트워크가 발표되더라도 그 구조부터 익히는 훈련이 필요합니다.

모델 학습을 위해 옵티마이저와 손실 함수를 정의합니다.

# 옵티마이저와 손실 함수 정의
optimizer = optim.Adam(model.parameters(), lr=1e-7) ------ lr=1e-7은 1*10의 -7승을 의미
criterion = nn.CrossEntropyLoss()

model = model.to(device)
criterion = criterion.to(device)

모델이 얼마나 잘 학습되었는지 측정하기 위한 함수를 정의합니다.

 tensor.topk torch.argmax와 같은 효과입니다. 주어진 텐서에서 가장 큰 값의 인덱스(index)를 얻기 위해 사용합니다. 즉, 네트워크의 출력에서 가장 확률이 높은 값의 인덱스를 반환합니다. 다음 예시로 사용 방법을 확인할 수 있습니다.

import torch
x = torch.arange(1., 6.)
print(x)
print('-----------------')
print(torch.topk(x, 3)) ------ x 입력에서 가장 큰 값 세 개를 선택하여 그 값과 인덱스를 출력

출력 결과는 다음과 같습니다.

tensor([1., 2., 3., 4., 5.])
-----------------
torch.return_types.topk(
values = tensor([5., 4., 3.]),
indices = tensor([4, 3, 2]))

 t()는 차원 0과 1을 전치(transpose)하겠다는 의미입니다. 예를 들어 다음과 같이 사용됩니다.

코드를 실행하면 다음과 같이 출력됩니다.

tensor(0.7357)
tensor(0.7357)
------------
tensor([[-0.3894, 0.6999, -0.6733],
        [ 1.7850, 0.1961,  0.2701]])
tensor([[-0.3894, 1.7850],
        [ 0.6999, 0.1961],
        [-0.6733, 0.2701]])

③ 텐서를 비교하는 함수로, 텐서가 서로 같은지를 비교한다면 torch.eq, 다른지를 비교한다면 torch.ne, 크거나 같은지를 비교한다면 torch.ge를 사용합니다. torch.eq는 다음과 같은 형식을 이용하여 사용합니다.

torch.eq(비교 대상 텐서, 비교할 텐서)

torch.eq의 결과는 각 텐서의 요소들을 비교해서 같으면 True, 다르면 False를 반환합니다.

예를 들어 다음과 같이 사용합니다.

torch.eq(torch.tensor([[1, 2], [3, 4]]), torch.tensor([[1, 1], [4, 4]]))

1과 1을 비교했을 때 서로 같으므로 True, 2와 1을 비교했을 때 서로 다르므로 False, 3과 4를 비교했을 때 서로 다르므로 False, 4와 4를 비교했을 때 서로 같으므로 True를 반환합니다. 코드를 실행하면 다음과 같이 출력될 것입니다.

tensor([[ True, False],
        [False,  True]])

모델을 학습시키는 방법에 대한 함수를 정의합니다.

#모델 학습 함수 정의
def train(model, iterator, optimizer, criterion, scheduler, device):
    epoch_loss = 0
    epoch_acc_1 = 0
    epoch_acc_5 = 0

    model.train()
    for (x, y) in iterator:
        x = x.to(device)
        y = y.to(device)

        optimizer.zero_grad()
        y_pred = model(x)
        loss = criterion(y_pred[0], y)

        acc_1, acc_5 = calculate_topk_accuracy(y_pred[0], y)
        loss.backward()
        optimizer.step()

        epoch_loss += loss.item()
        epoch_acc_1 += acc_1.item() ------ 모델이 첫 번째로 예측한 레이블이 붙여집니다.
        epoch_acc_5 += acc_5.item() ------ 이미지에 정확한 레이블이 붙여질 것이기 때문에 정확도가 100%일 것입니다.

    epoch_loss /= len(iterator)
    epoch_acc_1 /= len(iterator)
    epoch_acc_5 /= len(iterator)
    return epoch_loss, epoch_acc_1, epoch_acc_5

 

모델을 학습시키는 데 어느 정도의 시간이 걸리는지 측정하기 위한 함수를 정의합니다.

# 모델 학습 시간 측정 함수 정의
def epoch_time(start_time, end_time):
    elapsed_time = end_time - start_time
    elapsed_mins = int(elapsed_time/60)
    elapsed_secs = int(elapsed_time-(elapsed_mins*60))
    return elapsed_mins, elapsed_secs

이제 본격적으로 훈련과 검증 데이터셋을 이용하여 모델을 학습시킵니다.

다음은 모델 학습에 대한 결과입니다.

Epoch: 01 | Epoch Time: 4m 4s
 Train Loss: 0.698 | Train Acc @1: 50.24% | Train Acc @5: 100.00%
 Valid Loss: 0.699 | Valid Acc @1: 51.19% | Valid Acc @5: 100.00%
Epoch: 02 | Epoch Time: 4m 10s
 Train Loss: 0.693 | Train Acc @1: 50.48% | Train Acc @5: 100.00%
 Valid Loss: 0.699 | Valid Acc @1: 51.19% | Valid Acc @5: 100.00%
Epoch: 03 | Epoch Time: 4m 13s
 Train Loss: 0.701 | Train Acc @1: 49.04% | Train Acc @5: 100.00%
 Valid Loss: 0.696 | Valid Acc @1: 47.17% | Valid Acc @5: 100.00%
Epoch: 04 | Epoch Time: 4m 11s
 Train Loss: 0.696 | Train Acc @1: 49.52% | Train Acc @5: 100.00%
 Valid Loss: 0.698 | Valid Acc @1: 48.96% | Valid Acc @5: 100.00%
Epoch: 05 | Epoch Time: 4m 2s
 Train Loss: 0.695 | Train Acc @1: 49.52% | Train Acc @5: 100.00%
 Valid Loss: 0.700 | Valid Acc @1: 50.00% | Valid Acc @5: 100.00%
Epoch: 06 | Epoch Time: 4m 3s
 Train Loss: 0.686 | Train Acc @1: 58.41% | Train Acc @5: 100.00%
 Valid Loss: 0.693 | Valid Acc @1: 49.85% | Valid Acc @5: 100.00%
Epoch: 07 | Epoch Time: 4m 7s
 Train Loss: 0.686 | Train Acc @1: 52.64% | Train Acc @5: 100.00%
 Valid Loss: 0.690 | Valid Acc @1: 51.93% | Valid Acc @5: 100.00%
Epoch: 08 | Epoch Time: 4m 7s
 Train Loss: 0.688 | Train Acc @1: 57.45% | Train Acc @5: 100.00%
 Valid Loss: 0.693 | Valid Acc @1: 49.55% | Valid Acc @5: 100.00%
Epoch: 09 | Epoch Time: 4m 0s
 Train Loss: 0.690 | Train Acc @1: 55.29% | Train Acc @5: 100.00%
 Valid Loss: 0.692 | Valid Acc @1: 54.32% | Valid Acc @5: 100.00%
Epoch: 10 | Epoch Time: 4m 1s
 Train Loss: 0.690 | Train Acc @1: 57.69% | Train Acc @5: 100.00%
 Valid Loss: 0.693 | Valid Acc @1: 51.93% | Valid Acc @5: 100.00%

역시 모델 학습 결과에 대한 오차와 정확도 측면에서 성능이 좋지 않습니다. 계속 언급하지만 이미지 데이터를 늘린다면 성능은 좋아질 수 있습니다. 이 예제의 목적은 성능 향상이 아닌 CNN 관련 네트워크의 사용 방법이므로, 빠른 학습을 위해 데이터 개수를 제한시켰기 때문에 성능은 좋지 않습니다

모델이 얼마나 잘 학습되었는지 알아보기 위해 테스트 데이터셋을 이용하여 예측 결과를 ResNet.csv로 저장합니다.

 

#테스트 데이터셋을 이용한 모델 예측
import pandas as pd
id_list = []
pred_list = []
_id = 0
with torch.no_grad():
    for test_path in test_images_filepaths:
        img = Image.open(test_path)
        _id = test_path.split('/')[-1].split('.')[1]
        transform = ImageTransform(size, mean, std)
        img = transform(img, phase='val')
        img = img.unsqueeze(0)
        img = img.to(device)

        model.eval()
        outputs = model(img)
        preds = F.softmax(outputs[0], dim=1)[:, 1].tolist()
        id_list.append(_id)
        pred_list.append(preds[0])

res = pd.DataFrame({
    'id': id_list,
    'label': pred_list
})

res.sort_values(by='id', inplace=True)
res.reset_index(drop=True, inplace=True)

res.to_csv('../chap06/data/ResNet.csv', index=False)
res.head(10)

출처 : 더북 딥러닝 파이토치 교과서

'영상처리 > 기초' 카테고리의 다른 글

GAN(Generative Adversarial Networks)  (92) 2023.02.15
VGG-Net 리뷰  (60) 2023.01.25
Python slowfast설치 windows환경  (23) 2023.01.20
Instance Segmentation MASK R-CNN  (13) 2023.01.07
Face swapping-Swap faces(part 5)  (6) 2023.01.06
profile

DataScience

@Ninestar

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!