[Paper review] Resnet - Deep Residual Learning for Image Recognition (2015, CVPR)

너무 늦은 리뷰이지만 공개 당시에 획기적인 방법과 높은 성능으로 인해

아직까지도 backbone으로 많이 응용되고 있는 Resnet 논문을 리뷰해보고자 한다.

 

[Deep Residual Learning for Image Recognition 원문 링크]

https://arxiv.org/abs/1512.03385


Introduction

Resnet은 Microsoft팀에서 2015년도에 공개되어 ILSVRC 2015 Classification Task 대회에서

1등을 차지한 모델이다. layer가 깊어질수록 발생하는 vanishing/exploding gradient 문제를

residual(잔차)의 개념을 활용한 것이 모델의 핵심이며 당시 SOTA였던

VGGNet, GoogLeNet을 제치고 3.57%라는 매우 적은 오차로 1등을 차지하였다.

 

2015년도 당시에는 작은 kernel size(3X3 size)를 여러 개 중첩한 VGGNet이 대세였다.

Resnet을 제안한 논문의 저자들은 이러한 생각을 하게 되었다고 한다.

 

과연 layer가 깊으면 깊을수록 성능이 좋아질까?

 

Changes in errors according to the number of layers

위의 그림 중 왼쪽이 training error, 오른쪽이 test error인데 layer의 수가 더 높은 빨간색 선을 보면

확실히 layer 수가 많은 즉, depth가 더 깊을수록 error가 증가하는 것을 발견하였다.

 

layer 깊이를 높여 실험한 과정 중 SGD (Stochastic Gradient Descent, 확률적 경사하강법)를 적용한

10개의 layer까지는 Batch Normalization과 같은 intermediate normalization layer로

vanishing/exploding gradient problem을 쉽게 해결할 수 있었지만

 

두 번째 문제점으로 성능이 최고점에 도달하였을 때 degradation problem이 발생하였는데

이는 test error 뿐만 아니라 training error 또한 함께 높아졌기에

overfitting의 문제가 아닌 layer 수의 증가로 발생하였다는 점을 발견하였다.

 

이 degradation problem을 해결하기위해 논문에서 제안하는 개념이

Deep residual learning framework이다.


Residual block

기존의 Convolution neural network (CNN)은 input x를 target y로

바로 mapping하는 함수 H(x)를 찾는 것이 목표였다.

 

이 과정에서 점차 깊어지는 convolution layer에 따라 초기의 input x에 대한 feature들의

정보들이 모호해져 정보의 손실이 발생하는데 이러한 변형을 줄이고자 CNN에서는

H(x) = x 가 되도록 학습하여 입력값이 변형되는 것을 최소화하고

feature들을 추출할 수 있도록 해왔었다.

 

여기서, H(x) = x 를 identity mapping (항등함수와 동일한 개념)라 하는데

Resnet 논문의 저자들은 기존의 CNN이 identity mapping의 역할을 잘 수행하지 못했기에

위에서 보았던 degradation 문제가 오는 것으로 생각하였다.

 

즉, layer를 깊게 쌓을수록 identity mapping의 역할을 제대로 소화해내지 못했기에

정보의 손실이 발생하였고 이는 degradation problem을 야기한 것으로 본 것이다.

 

그래서 정보를 손실하지 않으면서 feature들을 잘 추출해낼 수 있도록

논문의 저자들이 고안해낸 방법이 바로 Residual learning이다.

 

 

CNN VS Residul learning

 

기존 CNN에서는 identity mapping인 H(x) = x에서 최적의 H(x)를 찾도록 학습하였다면,

Residual learning에서는 H(x) = F(x) + x 로 두어 F(x)를 0에 가깝게 보내

H(x) = x가 되도록 학습하는 방법을 제시한다.

 

H(x) = F(x) + x 로 두었을 때, x를 좌항으로 보내 정리하면 F(x) = H(x) - x가 되는데

이는 잔차(Residual)와 동일한 개념이어서 Residual learning이란 이름이 붙게 되었다.

 

즉, H(x)가 desired underlying mapping에 바로 fit하도록 학습하지 않고,

F(x)가 0에 가깝도록 residual mapping에 fit하도록 관점을 새롭게 한 것이다.

 

기존 mapping 방식: H(x) = x 가 되도록 H(x)를 학습시켜 optimize

논문에서 제안하는 mapping 방식 (Residual mapping): F(x) = H(x) - x로 두어

F(x) = 0이 되도록 학습시켜 optimize

 

H(x) = F(x) + x 는 한 개 이상의 layer를 skip하고 input x 값을 바로 더해준다고 하여

shortcut connections라고도 한다.

 


Resnet의 장점

논문에서는 이를 통해 얻을 수 있는 장점이 두 가지라고 설명한다.

 

1. 합리적인 문제 재구성과 pre-conditioning은 optimization을 더 간단하게 수행

  • stacked layers가 H(x)에 근사할 것이라는 기대보다 F(x) = H(x) - x 즉, F(x) = 0이 되도록 학습할 방향이 미리 결정되있기에 convergence가 더욱 쉽다. 즉, 아무것도 정보가 주어지지 않은 상태에서 최적의 H(x)를 찾는 것보다 F(x) = 0이라는 명백한 pre-conditioning이 더 빠르고 쉽게 convergence할 수 있다.

2. Gradient vanishing problem 해결

  • layer의 output으로 H(x) = F(x) + x 로 정의하면 미분하여도 H´(x) = F´(x) + 1 로 gradient가 최소 1이상이 되어 gradient vanishing problem을 해결할 수 있다.

3. F(x) + x는 eliment-wise addition이기에 parameter도 증가하지않고 계산에 영향을 미치지 않는다.

  • F(x) + x는 요소들간의 행렬 덧셈이기 때문에 추가적인 weight layer를 통한 parameter 수의 증가를 야기하지 않기에 계산량에 전혀 부담이 되지 않는다.

 


H´(x) = F´(x) + 1 에서 gradient가 최소 1이상이어서 gradient vanishing problem을 해결..?

 

많은 논문 리뷰들을 찾아보니 chain rule에 따라 미분한 최소값이 1이상이기 때문에 gradient vanishing 문제를 해결할 수 있다고 하는데 F´(x)가 무조건 0 이상인지 의문점이 들었다. 개인적인 생각으로는 F´(x)가 0 이상일 수도 있고 0 이하일 수도 있기 때문에 H´(x)가 최소 1이상이라기 보다는 gradient가 1에서 조금 작아지든 크든 1을 기준점에서 시작하여 update되기 때문에 기존의 gradient vanishing 문제를 보다 효과적으로 해결하는 것으로 생각된다..

 

혹시 잘못된 생각이라면.. 알려주시면 감사하겠습니다ㅜ..



Resnet nework architecture

Resnet Architecture for ImageNet

 

논문에서는 layer의 개수에 따라 5가지 종류의 Resnet을 제안한다.

기본적으로 Resnet-18, Resnet-34, Resnet-50, Resnet-101, Resnet-152

5개의 architecture를 제시하고 있으며 Resnet의 확장판으로 WideResNet과

ResNeXt 또한 존재하나 이번 글에서는 다루지 않도록 하겠습니다..

 

Resnet은 VGGNet에서 영감을 받아 설계되었기 때문에 대부분의 convolution layer의

kernel size는 3X3 size이며 feature map size가 1/2씩 줄어들때마다 layer 당

time complexity를 보존하기 위해 filter의 개수를 2배로 늘린다.(64, 128, 256, ...)

 

크게 Resnet-18과 Resnet-34에서는 동일한

conv 3X3을 2개를 쌓은 단위가

Resnet-18에서는 [2,2,2,2]로 반복되고

Resnet-34에서는 [3,4,6,3]로 반복이 된다.

 

Resnet-50, Resnet-101, Resnet-152에서는

conv 1X1 + conv 3X3 + conv1X1로 이루어진 단위가

Resnet-50에서는 [3,4,6,3]

Resnet-101에서는 [3,4,23,3]

Resnet-152에서는 [3,8,36,3]으로 반복이 되는 것을 알 수 있는데

 

이 때, Resnet-18, Resnet-34에 사용된 conv 3X3 2개를 쌓은 단위를

Basic block이라 하고 Resnet-50, Resnet-101, Resnet-152에 사용된

conv 1X1 + conv 3X3 + conv 1X1를 쌓은 단위를 Bottleneck block이라 한다.

 


Basic block, Bottleneck block

Basic block (left) / Bottleneck block (right)

위에서도 알 수 있듯이 Resnet은 크게 2가지 종류의 residual block를 사용한다.

 

layer 수가 비교적 적은 Resnet-18과 Resnet-34은 Basic block만을 사용하고

layer 수가 많은 Resnet-50, Resnet-101, Resnet-152는 Bottleneck blcok을 사용한다.

 

Basic block: 3X3 convolution + 3X3 convolution

Bottleneck block: 1X1 convolution + 3X3 convolution + 1X1 convolution

 

ResNet의 깊이가 점점 깊어질경우 parameter의 수가 너무 많아지기 때문에 50층 이상인

ResNet에서는 Residual block으로 Basic block대신 Bottleneck block을 사용한다.

 

Bottleneck block은 input dimension이 256-d인 경우

처음 1X1 conv에서 in_planes=64로 두어 dimension을 축소하고

3X3 conv에서는 kernel size를 3으로 두어 연산 부담량을 줄인 후

마지막 1X1 conv에서 임의의 expansion 계수(논문에서는 expansion = 4로 설정)를

planes * expansion을 통해 dimension을 다시 늘리는 방식이다.

 


Projection shortcut (Downsample)

 

Resnet-34 architecture

 

Resnet-34의 architecture를 보면 input x를 더해주는 shortcut이

검은색 선으로 되어있는 것을 알 수 있는데

어떤 것은 검은색 실선이고 어떤 것은 검은색 점선인 것을 알 수 있다.

왜 그런지 알아보자.

 

Feature map size를 줄이는 방식으로 기존 VGGNet에서는 max pooling을 사용하여

feature map size를 줄였지만 이는 정보의 손실이 발생할 수 있고

그다지 도움이 되지 않기 때문에 feature map size가 감소하는

첫 번째 convolution layer마다 stride=2로 두어

stride convolution을 통해 feature map size를 감소시킨다.

 

dimension이 바뀌는 block의 첫 번째 convolutional layer

즉 conv3_1, conv4_1, and conv5_1에만 stride=2를 적용한다.

(conv2에서는 max pooling 적용하고 conv3 이후부터 stride convolution 적용) 

 

이 과정에서 feature map size가 감소될 때마다 이전 output의 결과를 identity로써

element-wise addition 할 경우 차원 수가 맞지 않기 때문에 

feature map size를 바꾸는 시점마다 identity의 차원을 맞춰줘야만 하는 과정이 필요시된다.

 

논문에서는 projection shortcut 즉, downsample 과정을

conv 1X1 + Batch Norm 으로 비교적 간단하게 구현한다.

 

일반적인 identity mapping
차원 수가 다를 경우를 위한 projection shortcut

 

이 projection shortcut이 필요한 구간이 바로 검은색 점선 부분이며

일반적인 identity mapping이 적용되는 부분이 검은색 실선 부분인 것이다.

 


Resnet low-level 구현 (pytorch)

import torch.nn as nn
import torch.utils.model_zoo as model_zoo

# import할 수 있는 module 정의
__all__ = ['ResNet', 'resnet18', 'resnet34', 'resnet50', 'resnet101',
           'resnet152']

# 불러올 pretrained weight path
model_urls = {
    'resnet18': 'https://download.pytorch.org/models/resnet18-5c106cde.pth',
    'resnet34': 'https://download.pytorch.org/models/resnet34-333f7ec4.pth',
    'resnet50': 'https://download.pytorch.org/models/resnet50-19c8e357.pth',
    'resnet101': 'https://download.pytorch.org/models/resnet101-5d3b4d8f.pth',
    'resnet152': 'https://download.pytorch.org/models/resnet152-b121ed2d.pth',
}

# conv3x3 과 conv1x1의 bias=False로 설정
# bn(BatchNorm2d)의 기본값으로 bias=True이기 때문

# conv3x3
def conv3x3(in_planes, out_planes, stride=1):
    """3x3 convolution with padding"""
    return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,
                     padding=1, bias=False)

# conv1x1
def conv1x1(in_planes, out_planes, stride=1):
    """1x1 convolution"""
    return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False)


class BasicBlock(nn.Module):
    expansion = 1 # block의 확장계수, bottleneck에서 차원을 늘리고 줄이기위해 설정

    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super(BasicBlock, self).__init__()
        self.conv1 = conv3x3(inplanes, planes, stride)
        self.bn1 = nn.BatchNorm2d(planes)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = conv3x3(planes, planes)
        self.bn2 = nn.BatchNorm2d(planes)
        self.downsample = downsample # projection shortcut
        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이 필요할 경우 downsample 진행

        out += identity # 여기서 input x를 더해줌
        out = self.relu(out)

        return out


class Bottleneck(nn.Module):
    expansion = 4 # layer가 깊을 경우 차원을 줄이고 다시 늘리기 위해 expansion을 4로 설정함

    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super(Bottleneck, self).__init__()
        self.conv1 = conv1x1(inplanes, planes) # 차원을 inplanes=64로 하여 줄인뒤
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = conv3x3(planes, planes, stride)
        self.bn2 = nn.BatchNorm2d(planes)
        self.conv3 = conv1x1(planes, planes * self.expansion) # 여기서 다시 planes * 4 하여 늘림
        self.bn3 = nn.BatchNorm2d(planes * self.expansion)
        self.relu = nn.ReLU(inplace=True)
        self.downsample = downsample
        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)
        out = self.relu(out)

        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_classes=1000, zero_init_residual=False):
        super(ResNet, self).__init__()
        self.inplanes = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3,
                               bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 초기에는 maxpool 사용
        
        self.layer1 = self._make_layer(block, 64, layers[0])
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2) # feature map을 stride=2로 하여 줄이는 대신 layer 수를 2배씩 증가하여 time complexity 보존
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
        self.layer4 = self._make_layer(block, 512, layers[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)
		
        # zero_init_residual=True로 설정할 경우 마지막 bn 층의 weight을 0으로 초기화하는 부분
        # Zero-initialize the last BN in each residual branch,
        # so that the residual branch starts with zeros, and each residual block behaves like an identity.
        # This improves the model by 0.2~0.3% according to https://arxiv.org/abs/1706.02677
        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 _make_layer(self, block, planes, blocks, stride=1):
        # 우선 downsample = None으로 초기화한 후
        downsample = None
        # stride=2이기 때문에 차원이 달라지거나
        # inplanes != planes * block.expansion 일 경우 identity의 차원을 늘려줌
        if stride != 1 or self.inplanes != planes * block.expansion:
            downsample = nn.Sequential(
                conv1x1(self.inplanes, planes * block.expansion, stride),
                nn.BatchNorm2d(planes * block.expansion),
            )
	# layer에 차곡차곡 쌓아서 sequential 진행
        layers = []
        layers.append(block(self.inplanes, planes, stride, downsample))
        self.inplanes = planes * block.expansion
        for _ in range(1, blocks):
            layers.append(block(self.inplanes, planes))

        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 = x.view(x.size(0), -1)
        x = self.fc(x)

        return x

# 논문에서 제안한 block의 개수를 그대로 반영하여 5개의 network 생성
# pretrained = True일 경우 위에서 선언한 weight path를 적용
def resnet18(pretrained=False, **kwargs):
    """Constructs a ResNet-18 model.
    Args:
        pretrained (bool): If True, returns a model pre-trained on ImageNet
    """
    model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)
    if pretrained:
        model.load_state_dict(model_zoo.load_url(model_urls['resnet18']))
    return model


def resnet34(pretrained=False, **kwargs):
    """Constructs a ResNet-34 model.
    Args:
        pretrained (bool): If True, returns a model pre-trained on ImageNet
    """
    model = ResNet(BasicBlock, [3, 4, 6, 3], **kwargs)
    if pretrained:
        model.load_state_dict(model_zoo.load_url(model_urls['resnet34']))
    return model


def resnet50(pretrained=False, **kwargs):
    """Constructs a ResNet-50 model.
    Args:
        pretrained (bool): If True, returns a model pre-trained on ImageNet
    """
    model = ResNet(Bottleneck, [3, 4, 6, 3], **kwargs)
    if pretrained:
        model.load_state_dict(model_zoo.load_url(model_urls['resnet50']))
    return model


def resnet101(pretrained=False, **kwargs):
    """Constructs a ResNet-101 model.
    Args:
        pretrained (bool): If True, returns a model pre-trained on ImageNet
    """
    model = ResNet(Bottleneck, [3, 4, 23, 3], **kwargs)
    if pretrained:
        model.load_state_dict(model_zoo.load_url(model_urls['resnet101']))
    return model


def resnet152(pretrained=False, **kwargs):
    """Constructs a ResNet-152 model.
    Args:
        pretrained (bool): If True, returns a model pre-trained on ImageNet
    """
    model = ResNet(Bottleneck, [3, 8, 36, 3], **kwargs)
    if pretrained:
        model.load_state_dict(model_zoo.load_url(model_urls['resnet152']))
    return model

 


여기까지 Resnet을 알아보았다.

 

layer가 깊으면 과연 performance가 좋은지에 대한 의문점으로 시작하여

vanishing gradient problem을 해결하고 단순히 input x 값을 더함으로써

layer를 더 깊게 쌓은 Resnet을 보면서 정말 경외심이 들었다.

 

학부 때 배워 비교적 쉬운 개념이라 생각했던 잔차가

이렇게 응용되어 뛰어난 성능을 보이는 것을 보니 이런 발상은 어떻게 하셨는지..

MS team에게 경외심이 들며 뭔가 내가 알고 있는 지식이...

어쩌면... Resnet 처럼 세상을 바꿔 뒤흔들어... 나중에 나의 논문이 publish되서

거리를 걸어다니기만 해도.. '어... XXX 만든 사람 아니야..?'

하면은 기분이 좋겠다.. 라는 생각이 든다.

 

변태같다.

근데 꿈은 자유롭게 꿀 수 있는 거니까?

 

일단, 공부하자!

댓글

Designed by JB FACTORY