`이미지 분류(classification)`는 특정 대상이 영상 내에 존재하는지 여부를 판단하는 것이다. 이미지 분류에서 주로 사용되는 합성곱 신경망의 유형은 다양하다.
LeNet-5
`LeNet-5`는 합성곱 신경망이라는 개념을 최초로 개발한 구조로, 현재 CNN의 초석이 되었다. LeNet-5는 `합성곱(convolutional)`과 `다운 샘플링(sub-sampling)`(혹은 풀링)을 반복적으로 거치면서 마지막에 완전연결층에서 분류를 수행한다. LeNet-5의 신경망 구조는 다음과 같다.
(32 x 32 x 1) 크기의 이미지에 합성곱층과 최대 풀링층이 쌍으로 두 번 적용된 후 완전연결층을 거쳐 이미지가 분류되는 신경망이다. 이러한 신경망 구조를 파이토치를 통해 구현하면 다음과 같다. 입력 이미지 데이터의 크기는 (3, 224, 224)로 지정했다.
# 모델의 네트워크 클래스
class LeNet(nn.Module):
def __init__(self):
super(LeNet, self).__init__()
self.cnn1 = nn.Conv2d(in_channels=3, out_channels=6, kernel_size=5, stride=1, padding=0)
self.relu1 = nn.ReLU()
self.maxpool1 = nn.MaxPool2d(kernel_size=2)
self.cnn2 = nn.Conv2d(in_channels=6, out_channels=32, kernel_size=5, stride=1, padding=0)
self.relu2 = nn.ReLU()
self.maxpool2 = nn.MaxPool2d(kernel_size=2)
self.fc1 = nn.Linear(32*53*53, 120)
self.relu3 = nn.ReLU()
self.fc2 = nn.Linear(120, 84)
self.relu4 = nn.ReLU()
self.fc3 = nn.Linear(84, 2)
self.output = nn.Softmax(dim=1)
def forward(self, x):
out = self.cnn1(x)
out = self.relu1(out)
out = self.maxpool1(out)
out = self.cnn2(out)
out = self.relu2(out)
out = self.maxpool2(out)
out = out.view(out.size(0), -1)
out = self.fc1(out)
out = self.relu3(out)
out = self.fc2(out)
out = self.relu4(out)
out = self.fc3(out)
out = self.output(out)
return out
AlexNet
`AlexNet`은 합성곱층 5개와 완전연결층 3개로 구성되어 있으며, 맨 마지막 완전연결층은 1000개를 분류하기 위해 소프트맥스 활성화 함수를 사용한다. 전체적으로 보면 GPU 2개를 기반으로 한 병렬 구조임을 제외하면 LeNet-5의 구조와 크게 다르지 않다. AlexNet의 합성곱층에서 사용된 활성화 한수는 ReLU이며 각 계층의 구조적 세부 사항은 다음과 같다.
네트워크에는 학습 가능한 변수가 총 6600만 개가 있으며, 입력 데이터는 (227 x 227 x 3) 크기의 RGB 이미지 데이터이다. (논문에 포함된 figure에는 224로 나와있지만, 이는 논문의 figure가 잘못 나온것이라고 한다.) 또한 각 클래스에 해당하는 (1000 x 1) 확률 벡터를 출력한다. 첫 번째 계층을 거치면서 GPU-1에서는 주로 컬러와 상관없는 정보를 추출하기 위한 커널이 학습되고, GPU-2에서는 주로 컬러와 관련된 정보를 추출하기 위한 커널이 학습된다. AlexNet을 파이토치 코드로 구현하면 다음과 같다.
class AlexNet(nn.Module):
def __init__(self):
super().__init__()
self.layer1 = nn.Sequential(
nn.Conv2d(3, 96, kernel_size=11, stride=4),
nn.ReLU(inplace=True),
nn.LocalResponseNorm(2),
nn.MaxPool2d(kernel_size=3, stride=2)
)
self.layer2 = torch.nn.Sequential(
nn.Conv2d(96, 256, kernel_size=5, stride=1, padding=2),
nn.ReLU(inplace=True),
nn.LocalResponseNorm(2),
nn.MaxPool2d(kernel_size=3, stride=2)
)
self.layer3 = nn.Sequential(
nn.Conv2d(256, 384, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True)
)
self.layer4 = nn.Sequential(
nn.Conv2d(384, 384, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True)
)
self.layer5 = nn.Sequential(
nn.Conv2d(384, 256, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2)
)
self.avgpool = nn.AdaptiveAvgPool2d((6,6))
self.classifier = nn.Sequential(
nn.Linear(6*6*256, 4096),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(4096, 2)
)
def forward(self, x):
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
x = self.layer5(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.classifier(x)
return x
VGGNet
`VGGNet`은 합성곱층의 파라미터 수를 줄이고 훈련 시간을 개선하려고 탄생했다. 즉, 네트워크를 깊게 만드는 것이 성능에 어떤 영향을 미치는지 확인하고자 나온 것이다. 네트워크 계층의 총 개수에 따라 VGGNet은 VGG16, VGG19 등 다양한 유형이 있다. VGGNet은 깊이의 영향만 최대한 확인하고자 하기 때문에 합성곱층에서 사용하는 필터의 크기는 가장 작은 (3x3) 크기로 고정한다. 또한 최대 풀링 크기의 필터 크기는 (2x2)이며, 스트라이드는 2이다. 또한 마지막 계층을 제외하고는 모두 ReLU 활성화 함수가 사용된다.
이제 VGGNet 모델을 파이토치를 통해 구현해보았다. 먼저, VGG의 모델들의 유형을 정의한다.
# VGG 모델 유형
vgg11_config = [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M']
vgg13_config = [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M']
vgg16_config = [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M']
vgg19_config = [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M']
- 숫자 : output channel을 의미하며, Conv2d 연산을 수행
- M : 최대 풀링(max pooling)을 수행
# VGG 모델 정의
class VGG(nn.Module):
def __init__(self, features, output_dim):
super().__init__()
self.features = features
self.avgpool = nn.AdaptiveAvgPool2d(7)
self.classifier = nn.Sequential(
nn.Linear(512*7*7, 4096),
nn.ReLU(inplace=True),
nn.Dropout(),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Dropout(),
nn.Linear(4096, output_dim)
)
def forward(self, x):
x = self.features(x)
x = self.avgpool(x)
h = x.view(x.shape[0], -1)
x = self.classifier(h)
return x, h
합성곱층의 경우 VGG 모델 유형별로 다르게 정의된다. 이 부분을 정의하기 위한 함수를 정의한다.
# VGG 계층 정의
def get_vgg_layers(config, batch_norm):
layers = []
in_channels = 3
for c in config:
assert c == 'M'or isinstance(c, int)
if c == 'M': # 맥스 풀링
layers += [nn.MaxPool2d(kernel_size=2)]
else: # 합성곱층
conv2d = nn.Conv2d(in_channels, c, kernel_size=3, padding=1)
if batch_norm: # 배치 정규화 수행 여부 판단
layers += [conv2d, nn.BatchNorm2d(c), nn.ReLU(inplace=True)] # 배치 정규화 + ReLU
else:
layers += [conv2d, nn.ReLU(inplace=True)] # ReLU
in_channels = c
return nn.Sequential(*layers)
- `assert` : 뒤의 조건이 True가 아니라면 에러 발생
- `isinstance()` : 주어진 조건이 True인지 아닌지 판단 → True/False 반환
- 만약 c = 'M' 혹은 숫자가 아니라면 에러가 발생
- c = 'M'일 경우 필터 크기 2로 맥스 풀링 수행
- c가 숫자일 경우 합성곱과 batch_norm + ReLU를 수행하거나 합성곱 + ReLU 수행
vgg11_layers = get_vgg_layers(vgg11_config, batch_norm=True)
print(vgg11_layers)
앞서 정의한 VGG11_config를 함수에 입력해 VGG11 모델의 layer를 구성했다. 이제 이 layer를 VGG에 입력하여 최종적인 모델을 생성한다. 푸는 분류 문제의 레이블 개수에 따라 OUTPUT_DIM을 설정한다. 또한 사전 훈련된 VGG 모델은 다음과 같이 불러들일 수 있다.
model = VGG(vgg11_layers, OUTPUT_DIM)
pretrained_model = models.vgg11_bn(pretrained=True)
GoogleNet
`GoogleNet`은 주어진 하드웨어 자원을 최대한 효율적으로 이용하면서 학습 능력은 극대화할 수 있는 깊고 넓은 신경망이다. 깊고 넓은 신경망을 위해 GoogleNet은 `인셉션(Inception)` 모듈을 추가했다. 인셉션 모듈에서는 특징을 효율적으로 추출하기 위해 (1x1), (3x3), (5x5)의 합성곱 연산을 각각 수행한다. 그러나 (3x3) 최대 풀링은 입력과 출력의 높이와 너비가 같아야 하므로 풀링 연산에서 잘 사용하지 않는 패딩을 추가해야 한다. 이를 해결하기 위해 GoogleNet에 적용된 해결 방법은 `희소 연결(sparse connectivity)`이다. 희소 연결이란 합성곱, 풀링, 완전연결층들이 서로 밀집(dense)하게 연결된 기존에 CNN과 다르게 관련성이 높은 노드끼리만 연결하는 방법이다. 이를 통해 연산량도 적어지고 과적합도 해결할 수 있다.
인셉션 모델에는 4가지의 연산이 있다.
- (1x1) 합성곱
- (1x1) 합성곱 + (3x3) 합성곱
- (1x1) 합성곱 + (5x5) 합성곱
- (3x3) 최대 풀링 + (1x1) 합성곱
대용량 데이터를 학습하는 과정에서 계층이 넓고(뉴런이 많음) 깊으면(계층이 많음) 인식률은 좋아지지만, 과적합이나 `기울기 소멸 문제(vanishing gradient problem)`를 비롯한 학습 시간 지연과 연산 속도 등의 문제가 있다. 특히 합성곱 신경망에서 이런 문제가 많이 발생하는데, 이를 GoogleNet 혹은 Inception 모델이 해결할 수 있다.
ResNet
ResNet은 마이크로소프트에서 개발한 알고리즘으로 깊어진 신경망을 효과적으로 학습하기 위한 방법으로 `레지듀얼(residual)` 개념을 고안했다. 일반적으로 신경망은 깊이가 깊어질수록 성능이 좋아지다가 일정 단계에 다다르면 오히려 성능이 나빠진다. ResNet은 이러한 문제를 해결하기 위해 `레지듀얼 블록(residual block)`을 도입했다. 레지듀얼 블록은 기울기가 잘 전파될 수 있도록 일종의 `숏컷(shortcut, skip connection)`을 만들어 준다. 이러한 개념이 필요한 이유는 GoogleNet의 22층과 비교했을 때 ResNet의 층은 총 152개로 구성되어 기울기 소멸 문제가 발생할 수 있기 때문이다. 여기서 `블록(block)`은 계층의 묶음을 말하며, 엄밀히 말해 합성곱층을 하나의 블록으로 묶은 것이다. 이렇게 묶인 계층들을 하나의 레지듀얼 블록이라고 하며 레지듀얼 블록을 여러 개 쌓은 것이 ResNet이다.
하지만 계층의 깊이가 깊어질수록 파라미터는 무제한으로 커진다. 이러한 문제를 해결하기 위해 `병목 블록(bottleneck block)`이라는 것을 두었다. `기본 블럭(basick block)`을 사용하는 ResNet34와 병목 블록을 사용하는 ResNet50을 비교해보면, 파라미터의 수는 깊이가 깊어졌음에도 ResNet50이 훨씬 적다. 이는 병목 블록을 사용한 ResNet50의 (3x3) 합성곱층 앞뒤로 (1x1) 합성곱층을 붙여 이를 통해 채널 수를 조절하면서 차원을 줄였다 늘리는 것이 가능하기 때문이다.
기본 블럭과 병목 블럭 모두에 사용되는 + 기호는 `아이덴티티 매핑(identity mapping)`이다. 이는 `숏컷(shortcut)` 혹은 `스킵 연결(skip connection)`이라고도 불린다. 아이덴티티 매핑이란, 입력 x가 어떤 함수를 통과하더라도 다시 x라는 형태로 출력되도록 하는 것이다. 또 다른 핵심 개념으로는 `다운샘플(downsample)`이 있다. 다운샘플은 특성 맵의 크기를 줄이기 위한 것으로 풀링과 같은 역할을 한다. 다음과 같은 ResNet 네트워크의 일부 중 보라색 영역과 노란색 영역의 형태가 다른데 이들 간의 형태를 맞추기 위해 아이덴티티에 대한 다운샘플이 필요하다. 이를 위해서는 스트라이드 2를 가진 (1x1) 합성곱 계층을 하나 연결해 주면 된다.
입력과 출력의 차원이 같은 것을 아이덴티티 블록이라고 하며, 입력 및 출력 차원이 동일하지 않고 입력의 차원을 출력에 맞추어 변경해야 하는 것을 `프로젝션 숏컷(projection shortcut)` 혹은 합성곱 블록이라고 부른다.
정리하자면 ResNet은 기본적으로 VGG19 구조를 뼈대로 하며, 합성곱을 추가해서 깊게 만든 후 숏컷들을 추가한 모델이다.
이러한 ResNet 모델을 파이토치를 통해 구현하면 다음과 같다. 먼저, Basicblock을 구현한다.
# BasickBlock
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)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False)
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
Basic Block은 ResNet18, ResNet34에서 사용되며 (3x3) 합성곱층이 두 개 구성되어 있다. 여기서 확인할 점은 다운샘플과 아이덴티티 매핑이 적용되는 부분이다. 다운샘플(downsample)은 입력 데이터의 크기와 네트워크를 통과한 후 출력 데이터의 크기가 다를 경우에 사용한다. 다운샘플을 위해서는 합성곱층에 스트라이드를 적용한다. 위 코드에선 스트라이드를 1로 적용하는데, 이는 입력과 출력의 공간적인 차원을 동일하게 유지하면서 블록 내에서 채널 수를 변경하거나 추가적인 비선형 활성화를 수행하고자 할 때 사용한다. 또한 아이덴티티 매핑을 위해 i를 정의하고 x에 conv1, bn1, relu, conv2, bn2 값이 더해지다가 초기의 x, 즉 i가 다시 더해진다.
다음은 Bottleneck Block으로 ResNet50, ResNet101, ResNet152에서 계층을 더 깊게 쌓으면서 파라미터 수를 줄이고 계산에 대한 비용을 줄이기 위해 사용되며 (1x1) 합성곱층, (3x3) 합성곱층, (1x1) 합성곱층으로 구성된다. 여기서 계층이 더 깊어진다는 것은 활성화 함수가 기존보다 더 많이 포함된다는 것을 의미하며 이는 곧 더 많은 비선형성(non-linearity)을 처리할 수 있음으로 다양한 입력 데이터에 대한 처리가 가능하다는 의미이다.
# Bottleneck
class Bottleneck(nn.Module):
expansion = 4
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)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.conv3 = nn.Conv2d(out_channels, self.expansion*out_channels, kernel_size=3, stride=1, bias=False)
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
여기서 확인할 점은 `expansion`이다. basicblock에선 expansion을 1을 사용했는데 bottleneck block에선 4를 사용한다. 세 번째 (1x1) 합성곱층에서 out_channels에 expansion을 곱하는데, 이는 블록 내부에서 차원을 크게 확장하여 더 복잡한 네트워크 구조를 지원하기 위함이다.
다음으로 ResNet 모델에 대한 네트워크를 정의한다.
# ResNet
class ResNet(nn.Module):
def __init__(self, config, output_dim, zero_init_residual=False):
super().__init__()
block, n_blocks, channels = config
self.in_channels = channels[0]
assert len(n_blocks) == len(channels) == 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:
downsample = True
else:
downsample = False
layers.append(block(block.expansion*channels, channels))
for i in range(1, 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)
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)
h = x.view(x.shape[0], -1)
x = self.fc(h)
return x, h
여기서 `zero_init_residual`은 각 레지듀얼 분기(residual branch)에 있는 마지막 BN(Batch Normalization)을 0으로 초기화해서 다음 레지듀얼 분기를 0에서 시작할 수 있도록 하는 것이다. 이 부분은 모델을 생성하고 학습시키는 것과는 상관없지만 BN을 0으로 초기화하면 모델 성능이 0.2%~0.3% 향상된다는 논문이 있으며, ResNet에서는 많이 사용된다.
다음으로 ResNetConfig 변수에 네임드튜플 데이터 형식으로 ['block', 'n_blocks', 'channels']를 저장한다.
ResNetConfig = namedtuple('ResNetConfig', ['block', 'n_blocks', 'channels'])
그리고 Basick Block을 사용하는 ResNet18과 ResNet34의 Config와 Bottleneck Block을 사용하는 ResNet50, ResNet101, ResNet152의 Config를 정의한다.
# 기본 블럭을 사용하는 모델 정의
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_config = ResNetConfig(block = Bottleneck,
n_blocks = [3, 4, 6, 3],
channels = [64, 128, 256, 512])
resnet101_config = ResNetConfig(block = Bottleneck,
n_blocks = [3, 4, 23, 3],
channels = [64, 128, 256, 512])
resnet152_config = ResNetConfig(block = Bottleneck,
n_blocks = [3, 8, 36, 3],
channels = [64, 128, 256, 512])
이렇게 정의한 ResNet50 Config를 통해 모델을 불러들이면 다음과 같다.
model = ResNet(resnet50_config, OUTPUT_DIM)
사전 훈련된 ResNet 모델은 다음과 같이 사용할 수 있다.
# 사전 훈련된 모델 사용
pretrained_model = models.resnet50(pretrained = True)
이 글은 '딥러닝 파이토치 교과서' 책을 공부하고 작성한 글입니다.(이미지 출처 - 더 북)