9 minute read

Abstract

신경망의 깊이가 깊어질수록 학습은 더 어려워진다. 본 논문에서는 레이어를 잔차함수로 변환하는 residual learning framework를 적용하여 이전의 모델들보다 더 깊은 신경망일지라도 학습이 잘 되게 했다. 깊이가 최대 152로 VGG모델보다 8배 깊은 네트워크지만 더 낮은 복잡성을 가지고 ILSVRC 2015 Classification 부문에서 1위를 차지했다.



Details

도입부 & 관련 논문

  • 여러 연구를 통해 깊이가 깊어질수록 딥러닝 모델의 성능이 더 좋아진다는 것을 알 수 있었다.
  • 깊이의 중요성이 부각되면서 더 좋은 네트워크를 구축하기 위해 단순히 깊이만 증가시키면 되냐는 질문을 할 수 있는데 깊이가 깊어질수록 vanishing/exploding gradients 문제가 발생할 수 있고, 가중치 초기화나 배치 정규화 등을 통해 이 문제를 해결할 수 있었다.



image



  • 하지만, 깊이가 깊어질수록 위의 그래프처럼 정확도가 포화상태가 되는 현상이 발생할 수 있는데 과대적합과는 별개로 깊이때문에 training error가 크게 발생할 수 있다.
  • 얕은 모델에 레이어를 더해 보다 깊은 모델을 만들었으면 이 모델은 기존의 얕은 모델보다 더 높은 정확도를 가져야하는데 그렇지 못한 경우가 생긴다. 본 논문은 이러한 문제를 해결하기 위해 residual mapping을 적용한다.
  • residual mapping은 입력으로부터 출력을 예측하는 것이 아니라 입력이 출력이 되기 위한 가중치 layer를 예측하는 방법으로 아래와 같이 표현할 수 있다.



image



  • 예측하고자 하는 output을 H(x), input을 x, 가중치 layer를 F(x)라고 하면, H(x) = F(x) + x라고 할 수 있고, output인 H(x)를 도출하기 위해 F(x)를 학습한다.
  • F(x) = H(x) - x 라고 정의할 수 있고 이는 결국 output과 input의 차이를 학습하는 것이기때문에 residual mapping이라고 표현한다.
  • 극단적으로 identity mapping이 최적이라고 할 때 여러 개의 비선형 레이어를 쌓아 identity mapping에 fit하게 만드는 것보다 잔차를 0으로 만드는 방법이 더 쉬운 방법이다.
  • F(x)에 x를 더하는 방법을 shortcut connections이라고 표현하고(합성곱이 이루어지는 일반 경로와는 달리 입력인 x를 출력에 더하기 위해 이동시킬 때 큰 연산없이 바로 이동시키기 때문에 지름길 경로. skip connections이라고 부르기도 함)x를 더해줌으로써 input과 output의 차원의 크기가 같아지기때문에 identity mapping의 효과도 적용된다. 복잡한 연산이 사용되지 않고 단순히 가중치 레이어에 입력값을 더하는 해이기때문에 시간 복잡도나 파라미터 수가 크게 요구되지 않아서 결과적으로 깊이가 깊어지더라도 기존의 네트워크들보다 적은 연산으로 성능을 올릴 수 있는 방법이다.
  • VLAD 논문에서 이미 잔차 벡터를 인코딩한 vector quantization 방법이 효과적이라는 연구가 있었고, GoogLeNet에서는 기울기 소실/폭발 문제를 해결하기위해 auxiliary classifier를 활용해서 중간층에서도 출력값을 만들었다. 또한, highway networks 논문에서는 LSTM처럼 gate의 개념을 활용해서 residual을 적용시켰는데 본 논문은 gate가 닫히면(gate에 대한 입력이 0일 때) residual functions를 활용하지않은 highway networks와는 달리 언제나 residual functions을 사용하여 학습한다는 점에서 차이가 존재한다.



Deep Residual Learning

1. Residual Learning

  • output H(x)를 F(x) + x로 하고 H(x) - x라는 residual function을 찾기 위한 학습 방법이 왜 성능을 높이는 방법인지 직관적으로 와닿지 않을 수 있지만, 만약 추가되는 layer들이 identity mapping 되어있다면 모델이 깊어질수록 얕은 모델에 비해 에러가 더 깊어져서는 안 된다. 여기서 identity mapping이란, 입력이 출력에 그대로 전달되는 즉, 입출력이 동일한 것을 말한다. 이 identity mapping의 개념을 사용하기 위해 저자는 출력값에 입력값을 더하고 출력이 입력에 비해 얼마나 달라졌는지의 잔차(residual)를 학습하는 방법을 고안해냈다. 이 방법은 출력층에도 입력층의 정보가 사용되기 때문에 데이터의 특징이 보존되어 역전파 과정에서 층을 통과할 때 기울기가 손실되지 않아 기울기 소실 문제를 완화하는데 도움을 준다. 입력과 출력의 크기와 차원이 동일할 때 사용하고, 다르다면 zero padding과 같은 기법을 활용하여 크기를 맞춘 후 더해준다.
  • residual function을 사용할 때 입력과 출력이 동일한 identity mapping이 optimal하다고 가정하면, solvers는 layers들의 가중치를 0으로 만들어 최적의 해인 identity mapping을 만들 것이다. -> x = H(x)가 되어야하니까 H(x) = F(x) + x일 때, F(x)가 0이 되어서 x = H(x)가 됨.
  • 실제로 identity mapping이 최적의 해라는 보장이 없지만 완전히 새로운 함수(출력값)를 찾는 것보다 입출력의 차이를 학습하는 것이 연산과 error 크기의 측면에서 효과적일 것이고 결과적으로 residual function을 학습하는 방법이 성능을 높이게 됐다.

2. Identity Mapping by Shortcuts

image

  • residual function을 사용한 output은 위와 같이 정리할 수 있다. 학습에 사용될 F(x)는 입력 x와 가중치 W로 표현한다.

image

  • Figure 2의 함수 F(x)를 더 자세히 표현하면 위의 식처럼 표현할 수 있는데 $\sigma$ 는 ReLU를 의미한다(편의상 bias는 표시X). 입력 x와 가중치 $W_{1}$이 곱해지고 ReLU함수를 통과한 후 가중치 $W_{2}$와 연산이 수행되어 함수 F(x)가 정의된다.
  • 이렇게 입력과 출력의 차원이 같다면 차원의 변환없이 F(x)에 x를 더해 output을 도출할 수 있지만, 만약 출력해야 할 차원이 입력된 차원과 다르다면 projection과 같은 방법으로 차원을 변환시켜 원하는대로 입력의 차원을 변환시킬 수 있다. 식은 아래와 같고, $W_{s}$가 차원을 변환시키기 위한 연산이다.

image

  • F가 중첩되지 않고 사용되면 하나의 linear function과 다르지 않기때문에 Figure 2와 같이 F가 여러번 중첩되어 사용될 때 성능이 더 좋았다고 한다.

3. Network Architectures

기본 layer와 residual layer를 비교하기 위해 VGG를 기반으로 한 plain(기본. residual X) networks를 만들었다. 대부분의 layer에 3x3 filters를 사용했고, output feature map의 크기를 동일하게 하기 위해 모든 레이어에 동일한 필터수를 사용했다. 또한, 피처맵의 크기가 절반으로 줄어들면 필터를 두배로 해서 층마다의 시간 복잡도를 보존했다. 다운 샘플링을 위한 stride는 2를 사용했고 global average pooling과 1000-way fully-connected layer를 사용했다.

결과적으로, residual layer는 VGG보다 낮은 복잡도를 가졌고 VGG 19의 18% 밖에 되지 않은 연산속도를 기록했다.

image


Residual Network를 사용할 때는 대부분 입출력의 차원의 크기를 동일하게 했지만, 만약 출력 크기의 차원이 입력보다 클 경우 zero padding을 사용하여 차원의 크기를 맞추는 방법과 1x1 convolutional filter를 사용하여 projection 하는 방법을 사용할 수 있다.

4. Implementation

  • ImageNet 데이터에서 256 ~ 480 사이즈로 이미지를 랜덤하게 리사이징
  • 리사이징된 이미지를 224x224 크기로 자르고 수평 변환을 수행(RGB 값에서 평균값을 뺀 정규화도 수행)
  • 합성곱이 끝나고 활성화 함수를 통과하기 전 batch normalization을 수행
  • 가중치 초기화, SGD(256 batch size), learning rate 0.1(에러의 개선 없으면 10으로 나눔), iterations 600,000, weight decay 0.0001, momentum 0.9
  • Drop out은 사용X
  • 테스트를 진행할 때는 이미지를 리사이징하고, 리사이징된 이미지를 10개의 각각 다른 이미지로 crop하여 이 10개의 이미지의 정확도를 평균내어 성능을 평가

Experiments

1. ImageNet Classification


image


  • Plain Networks는 18-layer와 32-layer로 성능을 평가. 그 결과, 더 깊은 레이어를 사용한 네트워크의 성능이 더 좋지 않았음
  • 반면 Plain Networks에 단지 shorcuts만을 추가한 ResNet은 더 깊은 레이어가 얕은 레이어보다 성능이 좋았고, 수렴 속도 또한 빨랐음
  • 층이 깊어질수록 발생하는 기울기 소실로 인한 optimization difficulty에 대해 논쟁중 순전파든, 역전파든 signals이 손실되는 현상은 발생하지 않았음. 이를 통해, 최적화 기법에 따른 낮은 수렴율 등과 같은 다른 요인이 성능에 영향을 미칠 수 있다고 판단

Identity vs Projection Shortcuts shorcuts connection에서 identity mapping을 사용할 것인가 vs projection을 사용할 것인가?

  • option A): shortcuts에 identity mapping을 활용, 차원이 증가하면 zero-padding을 활용한 identity mapping
  • option B): shortcuts에 identity mapping을 활용, 차원이 증가하면 projection 활용
  • option C): 모든 shortcuts에 projection 활용
  • option C가 가장 성능이 좋았지만, 이걸 필수적으로 사용해야할만큼 개선율이 높은 것은 아님. 기본적으로 identity mapping을 사용할 것을 추천.
  • Identity shortcuts은 파라미터수를 증가시키지 않기때문에 아래 오른쪽 그림처럼 차원을 감소시켜 파라미터 수를 줄이고 원하는 연산을 수행후 다시 차원을 원래대로 복구시키는 bottlneck architetecture에 대해서는 identity mapping을 사용할 것을 추천
  • 깊이가 깊어졌는데도 성능은 좋아지고 VGG보다 낮은 복잡도와 연산속도를 가졌다.


image


layer 깊이가 34이하면 왼쪽과 같은 Block을 34보다 크면 오른쪽처럼 Bottleneck 형식의 block을 사용.

2. CIFAR-10 and Analysis

image


  • 32x32 사이즈의 이미지로 ImageNet에 비해 훨씬 작은 크기의 이미지가 input으로 사용된다.
  • 3x3 convolutional layer를 시작으로 6n layers의 크기이고, 각 conv layer마다 {32,16,8} 사이즈의 피처맵을 구성했다.
  • 6n + 2 만큼의 layers를 가지고 ImageNet과 약간 다르지만 거의 유사한 형태로 CIFAR 데이터셋에 맞게 변환시켰다.


image


  • CIFAR 데이터셋에서도 ResNet은 깊이가 깊어질수록 더 적은 에러율을 기록했는데 데이터셋의 크기가 그렇게 크지않기때문에 1202개의 layers를 사용했을 때는 과적합이 발생하여 오히려 110개를 사용했을떄보다 성능이 더 좋지 않았다. layers의 깊이는 데이터셋의 크기를 고려하며 쌓아야한다.

3. Object Detection on PASCAL and MS COCO


image


  • Object Detection을 위해 PASCAL VOC 2007과 COCO 2012 데이터셋을 사용했을 때도 ResNet이 VGG보다 더 높은 mAP를 기록했다.

ResNet은 Classification, Detection, Localization 분야에서 모두 1등을 차지한 획기적인 네트워크였다.



개인적인 생각

  • ResNet은 깊이를 기존의 모델들보다 더 증가시키면서 포화상태에 멈춰있던 성능 또한 향상 시킬 수 있다는 것을 보여준 획기적인 연구였다.
  • Shortcuts 개념을 사용하여 해를 F(x) + x로 바꾸고, 기존의 해와는 달리 F(x)라는 잔차를 학습하는 생각의 전환이 매우 돋보였다.
  • 입력과 출력의 차원이 달라질때 identity mapping과 projection 중 어떤 방법을 사용하냐에 따라 성능이 약간 달라졌는데 projection처럼 파라미터 수를 증가시켰을때 그에 따라 identity mapping보다 훨씬 더 좋은 성능을 내는 차원 변환 방법은 없는지 궁금했다.
  • layers의 깊이가 무조건 깊다고 성능이 좋은 것이 아니고 데이터셋의 크기를 고려하며 쌓아야한다. 데이터셋의 크기가 크지 않다면 layers를 굳이 100개 이상까지 쌓아서 복잡도를 크게 높일 필요는 없을 것이다.



구현

ResNet을 pytorch로 구현해보자(참고).

import torch
import torch.nn as nn
# 기본적으로 사용되는 3x3 합성곱필터
def conv3x3(in_planes, out_planes, stride = 1, padding = 1):
    return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding = padding, bias = False)

# bottleneck 처럼 파라미터 수를 줄이기 위해 중간중간 사용되느 1x1 필터
def conv1x1(in_planes, out_planes, stride = 1):
    return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias = False)
# shorcuts이 이루어지는 하나의 layer block(3x3 -> 3x3 필터 형태로 이루어져 있음)
# 34 layer까지만 BasicBlock을 사용하고 층이 깊어지면 Bottleneck 구조 사용.
class BasicBlock(nn.Module):
    expansion = 1 # 34 layer 전에는 block 내의 필터들의 채널 수가 같아서 expansion이 필요없지만 밑에서 ResNet구현 시 코드에 참고하기 위해 생성.
    def __init__(self, inplanes, outplanes, stride = 1, downsample = None):
        super(BasicBlock, self).__init__()
        self.conv1 = conv3x3(inplanes, outplanes, stride)
        self.bn1 = nn.BatchNorm2d(outplanes)
        self.relu = nn.ReLU(inplace=True) # ReLU를 거친 후
        self.conv2 = conv3x3(outplanes, outplanes) # 다시 합성곱
        self.bn2 = nn.BatchNorm2d(outplanes)
        self.downsample = downsample # 잔차블록 내 입출력의 크기가 다를 때 projection을 통해 downsampling
        self.stride = stride
    
    def forward(self, x):
        identity = x

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)

        if self.downsample is not None:
            identity = self.downsample(x) # 입출력의 크기가 달라 downsample이 필요하면 입력의 크기를 줄이는 과정 추가.

        out += identity # 출력에 입력값을 더해줌. Identity mapping(입출력의 크기와 차원이 같음)
        out = self.relu(out)

        return out
# 34 layer보다 크면 BottleNeck 구조 활용(1x1 -> 3x3 ->1x1)
class Bottleneck(nn.Module):
    expansion = 4 # 각각의 residual block마다 마지막 1x1 필터의 채널은 입력 필터 채널의 4배이기 때문에 몇배커질 것인지 미리 변수 정의.
    
    def __init__(self, inplanes, outplanes, stride = 1, downsample = None):
        super(Bottleneck, self).__init__()

        self.conv1 = conv1x1(inplanes, outplanes)
        self.bn1 = nn.BatchNorm2d(outplanes)
        self.conv2 = conv3x3(outplanes, outplanes, stride)
        self.bn2 = nn.BatchNorm2d(outplanes)
        self.conv3 = conv1x1(outplanes, outplanes * self.expansion)
        self.bn3 = nn.BatchNorm2d(outplanes * self.expansion)
        self.relu = nn.ReLU(inplace = True)
        self.downsample = downsample
        self.stride = stride
    
    def forward(self, x):
        identity = x

        # 1x1 필터
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        # 3x3 필터
        out = self.conv2(out)
        out = self.bn2(out)
        out = self.relu(out)

        # 1x1 필터
        out = self.conv3(out)
        out = self.bn3(out)

        if self.downsample is not None:
            identity = self.downsample(x)
        
        out += identity
        out = self.relu(out)

        return out
class ResNet(nn.Module):
    def __init__(self, block, layers_num, num_classes = 1000):
        super(ResNet, self).__init__()
        self.inplanes = 64
        
        self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3)
        self.bn1 = nn.BatchNorm2d(self.inplanes)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding = 1)
        self.layer1 = self._make_layer(block, 64, layers_num[0]) # 논문보면 처음 잔여블록에서는 크기가 maxpooling 했을 때와 같고,
        self.layer2 = self._make_layer(block, 128, layers_num[1], stride = 2) # 그 이후부터 피처맵 크기가 절반으로 줄어들어서 stride = 2
        self.layer3 = self._make_layer(block, 256, layers_num[2], stride = 2)
        self.layer4 = self._make_layer(block, 512, layers_num[3], stride = 2)
        self.avgpool = nn.AdaptiveAvgPool2d((1,1))
        self.fc = nn.Linear(512 * block.expansion, num_classes)

        # 가중치 초기화
        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 _make_layer(self, block, outplanes, blocks_num, stride = 1):
        # block: block 종류(basic or bottleneck)
        # block_num: 필요한 잔여 블록 갯수
        downsample = None

        # stride가 1이 아니면 출력의 크기가 입력보다 작아지게 되므로 입출력의 크기가 다르게 됨.
        # 크기가 같으면 identity mappindg. 다르면 identity mapping이 아님.
        if stride != 1 or self.inplanes != outplanes * block.expansion:
            downsample = nn.Sequential(
                conv1x1(self.inplanes, outplanes * block.expansion, stride),
                nn.BatchNorm2d(outplanes * block.expansion)
            )
        
        layers = []
        layers.append(block(self.inplanes, outplanes, stride, downsample))  
        self.inplanes = outplanes * block.expansion # 입력의 채널을 출력된 층의 채널로 바꿈. 이 출력 채널이 다시 입력 채널로 사용
        for _ in range(1, blocks_num):
            layers.append(block(self.inplanes, outplanes)) # 입력으로 받은 stride대로 연산하고, 이후 잔여블록 내에서는 피처맵 크기가 모두 똑같기때문에
            # stride=1이니까 굳이 지정해주지 않아도 됨.

        return nn.Sequential(*layers)
        
    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)

        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)

        return x
# ResNet50 생성 예시
# convolution group당 잔차 블록 갯수 3,4,6,3
def ResNet50():
    return ResNet(Bottleneck, [3,4,6,3])

이미지 출처

Categories:

Updated: