딥러닝 이야기 / Convolutional Neural Network (CNN) & Residual Network (ResNet) / 4. ResNet 구현 및 CIFAR-10 분류

ResNet 구현 및 CIFAR-10 분류

작성자: 여행 초짜
작성일: 2022.08.04

시작하기 앞서 틀린 부분이 있을 수 있으니, 틀린 부분이 있다면 지적해주시면 감사하겠습니다.

이전글에서는 Residual Network (ResNet)에 대해 설명하였습니다. 이번글에서는 실제로 ResNet을 구현해보도록 하겠습니다. 먼저 PyTorch 등에 공개된 ResNet 모델은 ImageNet을 위한 모델입니다. 따라서 모델 크기도 크고, 본 내용에서 사용할 CIFAR-10 데이터와 크기도 맞지 않으므로 모델을 resize 한 customized ResNet을 구현해보도록 하겠습니다. 학습에 사용한 데이터는 10가지의 label로 분류 된 CIFAR-10 데이터를 사용하였으며, 구현은 python의 PyTorch를 이용하였습니다.

그리고 ResNet에 대한 글은 여기를 참고하시기 바랍니다. 이렇게 구현한 ResNet의 코드는 GitHub에 올려놓았으니 아래 링크를 참고하시기 바랍니다(본 글에서는 모델에 초점을 맞추고 있기 때문에, 데이터 전처리 및 학습을 위한 전체 코드는 아래 GitHub 링크를 참고하시기 바랍니다).

오늘의 컨텐츠입니다.

  1. Residual Block 구현
  2. ResNet 구현
  3. ResNet 학습
  4. ResNet 학습 결과


그리고 추가로 ResNet 구현을 위해서는 CNN에 대해 알고 있어야 합니다. CNN의 설명은 여기를, CNN을 이용한 MNIST 데이터 분류 코드의 구현은 여기를 참고하시기 바랍니다.

ResNet 데이터 분류기 모델

Residual Block 구현

여기서는 ResNet을 구성하는 작은 단위인 residual block을 제작하도록 하겠습니다. 그리고 ResNet과 성능을 비교하기 위한 CNN 모델을 구성하기 위해서 CNN block 제작 방법도 같이 살펴보겠습니다. 여기서 말하는 residual block은 아래 그림과 같이 shorcut을 이루는 단위를 뜻합니다. 즉 하나의 residual block은 2개의 convolutional layer로 이루어져있으며, 여기서 shorcut을 제외하면 단순한 CNN block이 됩니다.

ResNet을 이루는 residual block


코드는 PyTorch로 작성 되었으며, 자세한 모델의 구조는 아래 코드를 통해 확인할 수 있습니다. 한 줄씩 자세한 설명은 코드 아래쪽에 설명을 참고하시기 바랍니다.

class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride, down_sample, zero_padding):
        super(ResidualBlock, self).__init__()
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.stride = stride
        self.down_sample = down_sample
        self.zero_padding = zero_padding
        if self.down_sample and not self.zero_padding:
            self.conv1x1 = nn.Sequential(
                nn.Conv2d(in_channels=self.in_channels, out_channels=self.out_channels, kernel_size=1, stride=self.stride, padding=0, bias=False),
                nn.BatchNorm2d(self.out_channels)
            )
        self.conv1 = nn.Sequential(
            nn.Conv2d(in_channels=self.in_channels, out_channels=self.out_channels, kernel_size=3, stride=self.stride, padding=1, bias=False),
            nn.BatchNorm2d(self.out_channels),
        )
        self.conv2 = nn.Sequential(
            nn.Conv2d(in_channels=self.out_channels, out_channels=self.out_channels, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(self.out_channels)
        )
        self.relu = nn.ReLU()

    
    def downSampling(self, x):
        """
        pad the 3 dimensional tensor except batch
        padding = (left, rigt, top, bottom, front, back)
        """
        if self.zero_padding:   
            padding = (0, 0, 0, 0, 0, self.out_channels - self.in_channels)
            out = F.pad(x, padding)
            out = nn.MaxPool2d(kernel_size=2, stride=2)(out)
            return out
        return self.conv1x1(x)


    def forward(self, x):
        shortcut = self.downSampling(x) if self.down_sample else x

        # first conv layer
        out = self.conv1(x)
        out = self.relu(out)

        # second conv layer and residual connection
        out = self.conv2(out)          
        out = self.relu(out + shortcut)

        return out



class CNNBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride, down_sample, zero_padding):
        super(CNNBlock, self).__init__()
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.stride = stride

        self.conv1 = nn.Sequential(
            nn.Conv2d(in_channels=self.in_channels, out_channels=self.out_channels, kernel_size=3, stride=self.stride, padding=1, bias=False),
            nn.BatchNorm2d(self.out_channels),
        )
        self.conv2 = nn.Sequential(
            nn.Conv2d(in_channels=self.out_channels, out_channels=self.out_channels, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(self.out_channels)
        )
        self.relu = nn.ReLU()


    def forward(self, x):
        # first conv layer
        out = self.conv1(x)
        out = self.relu(out)

        # second conv layer
        out = self.conv2(out)          
        out = self.relu(out)

        return out

Residual Block
먼저 1 ~ 49번째 줄에 해당하는 Residual Block 부분입니다.

  • 4 ~ 6번째 줄: Residual block으로 들어오는 데이터의 channle 수, 사용할 필터 개수, convolutional 작업이 이루어질 stride.
  • 7번째 줄: Down sampling 여부. Stride가 2면 True, 1이면 False로 들어올 것.
  • 8번째 줄: Down-sampling을 zero padding으로 한다면 True, 1*1 conv.로 한다면 False.
  • 9 ~ 13번째 줄: 8번째 줄이 False라면 down-sampling을 할 1*1 conv. 정의.
  • 14 ~ 22번째 줄: 위 그림에서 보듯이 residual block 안에 있는 두 개의 conv. 레이어.
  • 25 ~ 35번째 줄: 데이터 down-sampling이 이루어지는 함수.
  • 38 ~ 49번째 줄: 데이터가 통과하는 부분. ResNet의 shortcut이 존재.
여기서는 ResNet의 shortcut이 존재하는 것을 알 수 있고, 차원이 안맞는 경우 shortcut을 하기 위한 down samplig 방법이 정의 되어있습니다. 그리고 down-sampling은 zero padding 혹은 1*1 conv. 레이어의 방법, 이렇게 두 가지가 정의 되어있습니다.


CNN Block
이제 먼저 53 ~ 80번째 줄에 해당하는 CNN Block 부분입니다. 이 부분은 위의 Residual Block과 동일합니다. 다만 shortcut이 없어짐에 따라 down sampling 관련 변수와 함수가 없다는 것을 볼 수 있습니다.

ResNet 구현

이제 위에서 정의한 낱개의 Residual block을 여러개 쌓아 전체의 ResNet을 구현 하는 코드입니다.

class ResNet(nn.Module):
    def __init__(self, config:Config, color_channel:int, num_layer:int, block):
        super(ResNet, self).__init__()
        self.height = config.height
        self.width = config.width
        assert self.height == self.width
        self.label = config.label
        self.color_channel = color_channel
        self.num_layer = num_layer
        self.block = block
        self.zero_padding = config.zero_padding

        # first conv layer
        self.conv1 = nn.Sequential(
            nn.Conv2d(in_channels=self.color_channel, out_channels=16, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(16),
            nn.ReLU()
        )

        # residual layers
        self.conv2x = self.get_layers(in_channels=16, out_channels=16, stride=1, block=self.block)
        self.conv3x = self.get_layers(in_channels=16, out_channels=32, stride=2, block=self.block)
        self.conv4x = self.get_layers(in_channels=32, out_channels=64, stride=2, block=self.block)

        # last conv layer
        self.avg_pool = nn.AvgPool2d(kernel_size=int(self.height/4), stride=1, padding=0)
        self.fc = nn.Linear(int(self.height/4)**2, self.label)

        # initialization
        self.init_wts()
        

    def init_wts(self):
        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):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)


    def get_layers(self, in_channels, out_channels, stride, block):
        down_sample = False if stride == 1 else True
        layer_list = [block(in_channels, out_channels, stride, down_sample, self.zero_padding)]
        for _ in range(self.num_layer-1):
            layer_list.append(block(out_channels, out_channels, 1, False, self.zero_padding))
        layer_list = nn.ModuleList(layer_list)
        return nn.Sequential(*layer_list)


    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2x(x)
        x = self.conv3x(x)
        x = self.conv4x(x)
        x = self.avg_pool(x)
        x = x.view(x.size(0), -1)
        x = self.fc(x)
        return x

학습에 필요한 것들 선언
여기서 나오는 config 부분은 GitHub 코드에 보면 config.json이라는 파일에 존재하는 변수 값들을 모델에 적용하여 초기화 하는 것입니다.

  • 4 ~ 5번째 줄: 이미지 데이터의 가로, 세로 크기.
  • 7번째 줄: 분류 label 수.
  • 8번째 줄: 이미지 전처리를 하였을 때, color channel 수(흑백으로 처리를 했다면 1, 칼라로 처리 했다면 3).
  • 9번째 줄: Residual block 종류별 개수 설정(3이면 3*3*2=20, ResNet20 모델).
  • 10번째 줄: Block 종류(residual or CNN block).
  • 11번째 줄: Down-sampling을 zero padding으로 한다면 True, 1*1 conv.로 한다면 False.
  • 14 ~ 18번째 줄: Residual block을 거치기 전 맨 처음 convolutional layer. 여기서는 가로, 세로 크기를 유치하고 channel 수만 16으로 늘림.
  • 21 ~ 23번째 줄: 위에서 정해준 num_layer만큼 residual block을 쌓아주고 ResNet 구성.
  • 26 ~ 27번째 줄: ResNet 마지막 레이어. Average pooling 사용.
  • 30번째 줄: ResNet weight 초기화.
  • 33 ~ 39번째 줄: ResNet weight 초기화 함수.
  • 42 ~ 48번째 줄: Residual block을 쌓아 ResNet 구성하는 함수. 여기서 num_layer 개수 만큼 쌓은 레이어 중 맨 위의 레이어만 down-sampling 진행.
  • 51 ~ 59번째 줄: 쌓은 모든 block을 통과하여 전체 ResNet이 모델을 통과하는 부분.

ResNet 학습

이제 위에서 정의한 ResNet을 학습하는 코드입니다. 아래 코드에 self. 이라고 나와있는 부분은 GitHub 코드에 보면 알겠지만 학습하는 코드가 class 내부의 method이기 때문에 있는 것입니다. 여기서는 무시해도 좋습니다.

def model_select(config, color_channel, device):
    if config.model_mode.lower() == 'cnn':
        print('CNN{} will be trained..'.format(int(config.num_layer*2*3+2)))
        block = CNNBlock
        model = ResNet(config, color_channel, config.num_layer, block)
    elif config.model_mode.lower() == 'resnet':
        print('ResNet{} will be trained..'.format(int(config.num_layer*2*3+2)))
        block = ResidualBlock
        model = ResNet(config, color_channel, config.num_layer, block)
    else:
        print("model mode have to be cnn or resnet")
        raise AssertionError
    return model.to(device)

self.model = model_select(self.config, self.color_channel, self.device)
self.optimizer = optim.SGD(self.model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4)
decay_steps = [32000, 48000]
self.scheduler = optim.lr_scheduler.MultiStepLR(self.optimizer, milestones=decay_steps, gamma=0.1)
self.steps = 64000

while step < self.steps:
    for phase in ['train', 'val']:
        if phase == 'train':
            self.model.train()
        else:
            self.model.eval()

        for x, y in self.dataloaders[phase]:
            batch = x.size(0)
            x, y = x.to(self.device), y.to(self.device)
            self.optimizer.zero_grad()

            with torch.set_grad_enabled(phase=='train'):
                output = self.model(x)
                loss = self.criterion(output, y)
                acc = (torch.argmax(output, dim=1) == y).float().sum()/batch

                if phase == 'train':
                    step += 1
                    loss.backward()
                    self.optimizer.step()
                    self.scheduler.step()

학습에 필요한 것들 선언
먼저 학습에 사용할 모델, optimizer, loss function을 선언합니다.

  • 1 ~ 13번째 줄: CNN 모델과 ResNet 모델을 config.json의 model_mode에 의해 결정.
  • 15 ~ 19번째 줄: 학습에 필요한 모델, optimizer, scheduler, 학습 step 결정. ResNet 학습은 epoch 단위가 아닌 step 단위로 학습하며, 학습이 정체 되는 구간마다 scheduler를 이용하여 learning rate 조정.


ResNet 학습
  • 21 ~ 42번째 줄: 64,000 step 동안 학습 진행.
  • 36번째 줄: 예측 accuracy 계산.

ResNet 학습 결과

아래 결과들은 모두 ResNet20에 대한 결과입니다. 아래는 CIFAR-10의 training set의 훈련 동안의 loss history 입니다. ResNet with zero padding, ResNet with 1x1 conv. layer, CNN 모델을 비교하였습니다. CNN 모델은 ResNet과 레이어 개수 등은 모두 같으며, ResNet에서 shortcut만 제거한 모델입니다. 아래 그림에서 CNN과 성능 차이는 많이 나며, ResNet끼리의 차이는 거의 없는 것을 볼 수 있습니다.

Training loss history


아래는 훈련 중 validation set의 loss history 입니다. CNN과 성능은 많이 차이나지만 ResNet끼리는 별로 차이가 나지 않습니다.

Validation loss history


아래는 훈련 동안의 training set의 label 예측 accuracy 변화입니다. CNN과 차이가 좀나지만 ResNet끼리는 차이가 미비한 것을 볼 수 있습니다.

Training accuracy history


아래는 훈련 동안의 validation set의 label 예측 accuracy 변화입니다. 이또한 CNN과 차이가 좀나지만 ResNet끼리는 차이가 미비한 것을 볼 수 있습니다. 위에서 loss도 ResNet끼리 차이는 많이 나지 않았고, accuracy도 거의 동일합니다. 실제로 논문에서 zero padding과 1x1 convolutional layer의 차이가 별로 나지 않는다고 하였는데 여기서 확인할 수 있습니다.

Validation accuracy history


마지막으로 CIFAR-10 test set에 대한 최종 accuracy입니다. 실제로 ResNet 끼리의 차이가 적은 것을 확인할 수 있고, CNN과의 차이도 크게 나지 않습니다. ResNet끼리 차이가 나지 않는 부분은 위에서 언급하였고, CNN과 차이가 많이 나지 않는 경우 이는 ResNet의 model의 깊이가 그리 깊지 않기 때문이라고 추측합니다. 이번에 ResNet20으로 실험 하였지만 이보다 더 깊은 모델로 구성한다면 CNN은 gradient vanishing 현상이 심해질 것이고 그만큼 ResNet과 차이가 많이 날 것으로 예상합니다.

  • ResNet with zero padding shortcut: 0.899000
  • ResNet with 1x1 conv. shortcut : 0.902700
  • CNN : 0.898200




지금까지 ResNet 구현 코드를 살펴보았습니다. 깊은 CNN이 가진 문제를 잔차 학습과 shortcut으로 훌륭하게 해소한 모델이 바로 ResNet입니다. 실제로 이 논문이 나온 후, shortcut을 구성하는 것은 모든 모델의 기본이 되었으며, transformer, U-Net 등 다양한 모델에서 활용합니다. 그만큼 딥러닝 역사에 영향을 많이 끼친 연구 결과라고 볼 수 있습니다.

다음에는 자연어 연구에 대한 첫 시작글인 word2vec에 대해 소개하겠습니다.

태그 #ResNet #CIFAR-10
⟨ 이전글
Residual Network (ResNet)