본문 바로가기

책/파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습

08 이미지 분류 (3) ResNet

ResNet(Residual Network)은 2015년 카이밍 허가 이끄는 마이크로소프트 연구팀이 발표한 모델이다. 인식 오류율 3.57%를 달성해 ILSVRC 대회에서 우승했다.

 

VGG 모델은 더 작은 크기의 필터를 사용해 계산 효율성을 향상 시켰지만, 깊은 신경망 구조로 인해 기울기 소실 문제가 발생했다.

 

레즈넷은 이를 해결하기 위해 잔차 연결(Residual Connection), 항등 사상(Identity Mapping), 잔차 블록(Residual Block) 등을 통해 기울기 소실 문제를 해결하고 계산 효율성을 높였다.

 

ResNet은 계층의 수에 따라 ResNet-18, 34, 50, 101, 152 형태로 제공된다.

 

 

1. 특징

ResNet-34 구조

 

레즈넷은 두 개의 합성곱 계층과 스킵 연결로 이뤄져 있다. 스킵 연결은 이전 계층의 출력값을 현재 계층의 입력값에 더해주는 방식으로 구현된다. 

 

이전 계층에서 발생한 정보를 계속 전달하면 모델이 깊어지더라도 기울기 소실 문제가 발생하지 않고 정보가 손실되는 현상을 방지할 수 있다. 또한 모델이 특정 가중치에 수렴하는 속도를 단축시킬 수 있다.

 

 

1.1 기울기 저하 문제

 

위 그래프는 Plain CNN의 계층을 20개와 56개로 구성하여 학습 에러와 텍스트 에러를 나타낸 것이다. Plain CNN은 일반적인 형태의 합성곱 신경망을 의미한다.

 

깊은 구조의 모델을 설계한다면 모델의 표현력 향상으로 이뤄진다고 생각하였지만, 실험 결과, 일정 수준 이상으로 계층을 깊게 쌓으면 오히려 학습되지 않는 현상인 기울기 저하 문제(Degration Problem)이 발생하였다. 이는 기울기 폭주나 소실 문제를 해결했던 방식으로는 극복할 수 없었다. 레즈넷은 입출력 사이의 차이만 학습하는 방식으로 문제를 해결하였다.

 

 

1.2 잔차 학습

Single Residual Block

 

잔차 학습(Residual Learning)이란 모델이 입력과 출력 사이의 차이만 학습하게 하는 방법이다. 위와 같은 구조를 Building Block이라고 하는데, 모델이 최적화 하기 위한 $H(x)$를 $H(x) = F(x) + x$로 변경한 것이다. 

 

 

1.3 잔차 연결

잔차 연결(Residual Connection)은 skip connection, shortcut connection이라고도 부르며 빌딩 블록 구조에서 보았듯이 이전 계층의 출력값을 입력값에 더해지는 연결을 의미한다.

 

잔차 연결 시 $F(x)$와 $x$의 차원이 동일하면 그냥 덧셈 연산이 가능하지만, 차원이 다르다면 $x$를 $W_s$라는 가중치와 곱하여 $F(x)$와 차원을 맞춰준다.

 

 

1.4 병목 블록 

레즈넷은 깊은 모델 구조를 유지하면서 연산량을 줄이기 위해 병목 블록(Bottleneck Block)을 추가했다.

 

기본 블록과 병목 블록

 

기본 블록은 3 x 3 합성곱 계층이 2개가 있는 구조지만 병목 블록은 층이 하나 더 생겼지만 1 x 1 합성곱 계층을 2개 사용하기 때문에 파라미터 수가 감소하여 연산량이 줄어들었다. 또한 VGG에서 보았듯이 작은 크기의 합성곱 계층을 더 많이 사용하면 활성화 함수를 더 많이 적용하여 모델의 비선형성이 증가하고 이는 모델의 표현력이 풍부해지는 결과를 이끈다.

 

 

2. 모델 구현

 

레즈넷은 1개의 input stem과 4개의 stage로 구성돼 있다. 위의 테이블에서 모든 유형에 공통적으로 들어가는 7x7, 64, stride 2와 3x3 max pool, stride 2 부분이 input stem이고 나머지가 각각 4개의 스테이지를 나타낸다. 스테이지는 여러 개의 잔차 블록(Residual block)으로 구성된다.

 

레즈넷 18, 34는 블록으로 기본 블록을 사용하고 50부터는 병목 블록을 사용한다.

 

 

2.1 기본 블록

  • 기본 블록은 3x3 합성곱, 배치 정규화, ReLU를 두 번 반복해 연결한 구조를 갖는다.
  • 초기화시 inplanes(입력 특징 맵 차원수), planes(출력 특징 맵 차원수)를 입력 받고 stride는 기본적으로 1이다. stride를 2로 사용할 때가 있는데, 이는 2~4 스테이지의 첫 번째 합성곱 계층에서 stride를 2로 키워 이미지의 크기를 줄인다.
  • 그래서 conv1에서는 입력 받은 stride 값을 사용하고, conv2에서는 stride=1로 고정한다.
  • stride를 2로 사용하는 경우에 입출력의 차원수가 달라지기 때문에 shortcut을 조정해주는 코드를 포함한다.

 

class BasicBlock(nn.Module):
  expansion = 1

  def __init__(self, inplanes, planes, stride=1):
    super().__init__()

    self.conv1 = nn.Conv2d(
        inplanes, planes,
        kernel_size=3,
        stride=stride,
        padding=1,
        bias=False,
    )
    self.bn1 = nn.BatchNorm2d(planes)
    self.relu = nn.ReLU(inplace=True)
    self.conv2 = nn.Conv2d(
        planes, planes,
        kernel_size=3,
        stride=1,
        padding=1,
        bias=False,
    )
    self.bn2 = nn.BatchNorm2d(planes)

    self.shortcut = nn.Sequential()

    # 입출력의 차원이 다른 경우
    if stride != 1 or inplanes != self.expansion * planes:
      self.shortcut = nn.Sequential(
          nn.Conv2d(
              inplanes, self.expansion * planes,
              kernel_size=1,
              stride=stride,
              bias=False,
          ),
          nn.BatchNorm2d(self.expansion * planes),
      )

  def forward(self, x):
    out = self.conv1(x)
    out = self.bn1(out)
    out = self.relu(out)
    out = self.conv2(out)
    out = self.bn2(out)
    out += self.shortcut(x)
    out = self.relu(out)
    return out

 

 

2.2 병목 블록

1.4의 병목 블록 그림을 보면 특징 맵의 차원이 256인 입력이 들어온 뒤 첫 번째, 두 번째 합성곱에서 64로 4배 줄었다가 다시 세 번째 합성곱에서는 256으로 증가한다. expansion=4로 설정하여 이러한 입출력의 차원을 조절한다.

 

class BottleneckBlock(nn.Module):
  expansion = 4

  def __init__(self, inplanes, planes, stride=1):
    super().__init__()

    self.conv1 = nn.Conv2d(
        inplanes, planes,
        kernel_size=1,
        bias=False,
    )
    self.bn1 = nn.BatchNorm2d(planes)
    self.conv2 = nn.Conv2d(
        planes, planes,
        kernel_size=3,
        stride=stride,
        padding=1,
        bias=False,
    )
    self.bn2 = nn.BatchNorm2d(planes)
    self.conv3 = nn.Conv2d(
        planes, self.expansion * planes,
        kernel_size=1,
        bias=False,
    )
    self.bn3 = nn.BatchNorm2d(self.expansion * planes)
    self.relu = nn.ReLU(inplace=True)

    self.shortcut = nn.Sequential()
    if stride != 1 or inplanes != self.expansion * planes:
      self.shortcut = nn.Sequential(
          nn.Conv2d(
              inplanes, self.expansion * planes,
              kernel_size=1,
              stride=stride,
              bias=False,
          ),
          nn.BatchNorm2d(self.expansion * planes),
      )

    def forward(self, 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)
      out += self.shortcut(x)
      out = self.relu(out)
      return out

 

 

2.3 ResNet 모델

  • self.inplanes=64 : 레즈넷의 모든 유형은 stem의 합성곱 계층의 출력 차원이 64이다.
  • self.stem : 레즈넷의 모든 유형의 stem은 합성곱(7x7, 64, stride=2, padding=3), 배치 정규화, ReLU, MaxPool(3x3, stride=2, padding=1)로 구성되어 있다.
  • self.stage : 스테이지는 _make_layer 메서드로 구성한다. block(기본 블록 or 병목 블록), planes, num_blocks, stride를 인자로 받는다.
  • self.avgpool, self.fc : AdaptiveAvgPool2d와 Linear 클래스로 구성한다.
  • _make_layer : 레즈넷 모든 유형의 각 스테이지는 항상 64, 128, 256, 512 차원으로 시작한다. 그리고 각 스테이지의 첫 번째 블록에 대해서 첫 번째 스테이지만 stride=1이고 두 번째 스테이지부터는 stride=2이다. 참고로 block의 stride=1이 기본값이다. 첫 번째 블록의 입력 차원은 self.inplanes를 활용한다. 그러나 두 번째, 세 번째로 갈수록 이 값은 커지므로 메서드를 호출할 때 마다 self.inplanes = block.expansion * planes로 갑을 조정해준다.
  • 예를 들어 ResNet-152의 두 번째 스테이지의 입력 차원은 첫 번째 스테이지의 출력 차원이 256이기 때문에 256으로 시작한다. 첫 번째 스테이지를 구성하기 위해 _make_layer가 호출되었으므로 self.inplanes 값은 64에서 256으로 조정되었다.

 

class ResNet(nn.Module):
  def __init__(self, block, layers, num_classes=1000):
    super().__init__()

    self.inplanes = 64

    self.stem = nn.Sequential(
        nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3, bias=False),
        nn.BatchNorm2d(self.inplanes),
        nn.ReLU(inplace=True),
        nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
    )
    self.stage1 = self._make_layer(block, 64, layers[0], stride=1)
    self.stage2 = self._make_layer(block, 128, layers[1], stride=2)
    self.stage3 = self._make_layer(block, 256, layers[2], stride=2)
    self.stage4 = self._make_layer(block, 512, layers[3], stride=2)

    self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
    self.fc = nn.Linear(512 * block.expansion, num_classes)

  def _make_layer(self, block, planes, num_blocks, stride):
    layers = []
    layers.append(block(self.inplanes, planes, stride))
    self.inplanes = planes * block.expansion
    for _ in range(num_blocks - 1):
      layers.append(block(self.inplanes, planes))
    return nn.Sequential(*layers)

  def forward(self, x):
    out = self.stem(x)
    out = self.stage1(out)
    out = self.stage2(out)
    out = self.stage3(out)
    out = self.stage4(out)
    out = self.avgpool(out)
    out = torch.flatten(out, 1)
    out = self.fc(out)
    return out

 

 

2.4 ResNet 모델 비교

torchvision에서 제공하는 사전 학습된 모델과 직접 구현한 모델의 매개변수 개수를 비교하여 잘 구현이 되었는지 확인한다.

 

resnet18 = ResNet(BasicBlock, [2, 2, 2, 2], 1000)
resnet34 = ResNet(BasicBlock, [3, 4, 6, 3], 1000)
resnet50 = ResNet(BottleneckBlock, [3, 4, 6, 3], 1000)
resnet101 = ResNet(BottleneckBlock, [3, 4, 23, 3], 1000)
resnet152 = ResNet(BottleneckBlock, [3, 8, 36, 3], 1000)
torch_model = models.resnet34(weights="ResNet34_Weights.IMAGENET1K_V1")

resnet34_info = summary(resnet34, (1, 3, 224, 224), verbose=0)
torch_model_info = summary(torch_model, (1, 3, 224, 224), verbose=0)

print(resnet34_info.total_params)
print(torch_model_info.total_params)
21797672
21797672

 

잘 구현되었다.