안녕하세요. 지난 포스팅의 [IC2D] ShuffleNet: An Extreme Efficient Convolutional Neural Network for Mobile Devices (CVPR2018) 에서는 depthwise separable convolution을 깊게 쌓으면 생기는 문제점을 해결하기 위해 pointwise group convolution과 channel shuffle 연산을 적용한 ShuffleNet을 제안하였습니다. 이를 통해, 기존의 효율적인 대표 모델인 MobileNet보다 훨씬 효율적인 모델을 구현하였습니다. 오늘은 새로운 어텐션 기반의 모델로 영상 분류에서 굉장히 유명한 SE (Squeeze-and-Excitation) Net에 대해서 소개해드리도록 하겠습니다.
Background
영상 처리 및 컴퓨터 비전 분야에서 핵심은 무엇일까요? 공부해보신 분들은 알겠지만 다양한 환경에 강건한 특징을 추출하는 것을 목표로 합니다. 기존의 SIFT, HoG와 같은 방법들은 알고리즘 기반으로 추출하여 스케일, 밝기, 회전과 같은 변화에 강건한 특징을 추출하였죠. 딥 러닝은 이러한 특징 추출을 CNN에게 맡기게 됩니다. 따라서, CNN은 기존의 특징 추출기보다 훨씬 강력하고 다양한 변화에 강건한 표현력을 갖추어 영상에서 중요한 영역인 saliency map을 정확하게 추출하고 이를 기반으로 성능 향상을 도모하게 됩니다.
최근 다양한 영상 분류 모델들이 제안되었습니다. 대표적으로 AlexNet, VGGNet, ResNet, Wide ResNet, ResNext, Inception 등이 있었죠. 하지만, 이러한 새로운 모델들의 등장은 새로운 하이퍼파라미터 (깊이, 너비, cardinality, diversity)를 추가시키고 layer configuration을 복잡하게 만들었습니다.
오늘 소개해드릴 SENet은 어텐션 기반의 모델로써 추출된 특징 맵의 각 채널 간 관계성을 파악하는 Squeeze-and-Excitation Block을 제안하고 이 모듈을 기존의 다양한 모델에 쉽게 추가할 수 있도록 제안합니다. 심지어, 성능은 크게 오르면서 연산량도 높지 않은 모듈이기 때문에 효율성도 갖추었죠.
Squeeze-and-Excitation Blocks
그림1은 본 논문에서 제안하는 SE Block의 블록 다이어그램입니다. 생각보다 굉장히 단순해보이는 이 모듈은 기존 모델 성능 향상에 굉장히 큰 영향을 미치게 됩니다. 이제 좀 더 자세히 알아보도록 하죠.
이전 계층의 출력인 $\mathbf{X} \in \mathbb{R}^{H^{'} \times W^{'} \times C^{'}}$는 $1 \times 1$ 합성곱인 $\mathbf{F}_{tr}$을 통과하여 새로운 특징 맵인 $\mathbf{U} \in \mathbb{R}^{H \times W \times C}$를 만들게 됩니다. 이 특징 맵 $U$는 2개의 branch로 나누어 연산이 진행됩니다.
윗쪽 branch로 흘러가면 2개의 연산이 진행됩니다. 각각 squeeze 연산 $\mathbf{F}_{sq}(\cdot)$과 excitation 연산 $\mathbf{F}_{ex}(\cdot ; \mathbf{W})$이죠. squeeze 연산 $\mathbf{F}_{sq}(\cdot)$은 channel descriptor를 만들어내는 연산으로 각 채널에서 Global Average Pooling (GAP)를 적용한 결과 입니다. 이를 통해 총 $C$개의 설명자가 생기게 되겠죠. 즉, squeeze 연산은 각 채널에서 대표적인 값을 추출하는 과정이라고 보시면 될 거 같습니다. 수학적으로는 GAP와 동일하기 때문에 다음과 같이 정의됩니다.
$$z_{c} = \mathbf{F}_{sq}(\mathbf{u}_{c}) = \frac{1}{H \times W} \sum_{i = 1}^{H} \sum_{j = 1}^{W} u_{c}(i, j)$$
추출된 대표값인 $z_{c}$는 excitation 연산 $\mathbf{F}_{ex}(\cdot ; \mathbf{W})$을 수행합니다. excitation 연산은 self-gating machanism을 기반으로 제안된 방법으로 추출된 $z_{c}$를 특징으로 생각하고 채널별 가중치를 다시 한번 추출하게 됩니다. 이를 위해, 가중치 $\mathbf{W}$를 가지는 2개의 완전연결 계층 (fully-connected layers)로 구성하여 학습을 진행합니다. 이 과정을 수학적으로 적으면 다음과 같습니다.
$$\mathbf{s} = \mathbf{F}_{ex}(\mathbf{z}, \mathbf{W}) = \sigma (g(\mathbf{z}, \mathbf{W})) = \sigma (\mathbf{W}_{2} \delta(\mathbf{W}_{1} \mathbf{z}))$$
여기서 $\mathbf{W}_{1} \in \mathbb{R}^{\frac{C}{r} \times C}$과 $\mathbf{W}_{2} \in \mathbb{R}^{C \times \frac{C}{r}}$는 각각 첫번째 완전연결계층과 두번째 완전연결계층의 가중치를 의미합니다. $\delta$는 ReLU 활성화 함수로 정의되고 마지막 $\sigma$는 Sigmoid 함수입니다. 이때, 중요한 하이퍼파라미터인 reduction ratio $r$를 추가합니다. 이 하이퍼파라미터는 연산량을 줄이기 위해 도입하였으며 완전연결계층에서 발생할 수 있는 연산 오버헤드를 어느정도 상쇄시켜줍니다. 최종적으로 추출된 채널별 가중치인 $\mathbf{s}$는 기존의 특징 맵 $\mathbf{U}$에 채널 별로 곱해줍니다.
$$\tilde{\mathbf{x}}_{c} = \mathbf{F}_{scale}(\mathbf{u}_{c}, s_{c}) = s_{c} \mathbf{u}_{c}$$
이를 모든 채널에 적용하면 각 채널의 값이 재조정 (recalibration)된 새로운 특징 맵 $\tilde{\mathbf{X}} = \left[ \tilde{\mathbf{x}}_{1}, \dots, \tilde{ \mathbf{x} }_{C} \right]$가 만들어지게 됩니다.
이제 SE Block을 다음과 같이 기존의 다양한 모델에 추가해볼 수 있습니다.
그림2와 그림3은 그 예시를 보여주고 있습니다. 추가되는 위치는 Incpetion Module 및 Residual path 이후에 적용되는 것을 볼 수 있습니다. SE Block이 추가되는 위치에 따른 성능변화는 이후에 Ablation Study에서 확인할 수 있습니다.
SENet Architecture
표1은 SE Block을 기존의 ResNet과 ResNext에 추가한 모습니다. 보시면 기존의 Bottleneck 구조에서 마지막에 $fc$가 추가된 것을 볼 수 있죠? 이 부분이 SE Block이 추가된 결과입니다. 나머지 부분은 전부 동일하게 구성되어있습니다. 이와 같이 SE Block은 기존의 모델에 유연하게 추가하여 활용될 수 있는 점도 장점입니다.
Experiment Results
본 논문에서는 영상 분류 (ImageNet-1K(2012), CIFAR, Places365, ImageNet-1K(2017)), 객체 탐지 (COCO)에 대한 실험을 진행합니다.
1). ImageNet-1K (2012) Classification Results
본격적으로 실험비교에 앞서 연산량 비교를 위한 표2를 제시합니다. 보시면 가장 좌측열은 기존 모델들이고 original과 re-implementation을 기존 논문들의 성능을 의미합니다. 여기서, SENet이라고 적힌 게 기존 모델들에 SE Block을 추가했을 때 실험 결과로 GFLOPs를 보시면 전체적으로 연산량 차이가 0.01~0.02 GFLOPs 정도 차이가 납니다. 이는 수치적으로 약 0.26%의 차이밖에 나지 않습니다. 하지만, SE Block을 추가했을 때 모든 모델에서 성능 향상을 달성한 것을 볼 수 있습니다. 여기서, 괄호안에 있는 숫자는 SE Block을 추가했을 때와 re-implementation 사이의 성능 차이를 적은 것입니다.
그림4는 SE Block을 추가했을 때와 추가하지 않았을 때 손실함수의 경향성을 보여주고 있습니다. 과적합 현상이 덜 한것을 볼 수 있죠.
표3은 대표적인 효율성을 강조한 두 모델 MobileNet과 ShuffleNet에 SE Block을 추가했을 때 성능 향상 정도를 보여주고 있습니다. 이 역시 높은 마진으로 성능이 향상된 것을 볼 수 있죠.
2). CIFAR Classification Results
표4와 표5는 각각 CIFAR10과 CIFAR100에서 실험한 결과로 0.4~1% 정도의 성능 향상을 보이고 있습니다.
3). Places365 Classification Results
Places365 데이터셋은 다양한 해상도의 크기를 가지는 영상으로 Indoor 영상과 Nature 영상으로 구성된 데이터셋입니다. 약 8M개의 훈련 데이터셋이 포함되어 있습니다. 해당 데이터셋은 long-tailed 영상 분류 연구에서 자주 사용되는 데이터셋으로 클래스별 불균형이 큰 데이터이기 때문에 꽤 어려운 문제 입니다. 해당 실험결과 역시 0.8%의 성능 향상을 보여주고 있습니다.
4). ImageNet-1K (2017) Classification Results
표8은 2017년 ILSVRC 대회를 기반으로 학습된 실험 결과입니다. DPN과 비교했을 때 1%가 넘는 마진으로 성능이 향상된 것을 볼 수 있습니다.
5). COCO Object Detection Results
표7은 ImageNet으로 학습시킨 ResNet 및 SE-ResNet backbone 모델을 이용하여 Fast R-CNN을 COO 데이터셋에 학습시킨 뒤 minival 데이터셋으로 평가한 결과 입니다.
6). Ablation Study
마지막으로 SENet은 Reduction ratio, Squeeze Operator, Excitation Operator, SE Block을 추가하는 위치에 따른 Ablation Study를 제시합니다.
(1). Reduction ratio
표10은 Excitation Operator 사이에 추가된 reduction ratio에 따른 성능을 보여주고 있습니다. 일단, 모든 결과가 original 보다 높습니다. 그리고 reduction ratio를 증가시키거나 감소시켜도 성능 변화가 뚜렷하지 않기 때문에 어느정도 연산량을 줄인다는 측면에서 $r = 16$으로 셋팅하였습니다.
(2). Squeeze Operator and Excitation Operator
표11는 squeeze 연산에서 GAP 또는 GMP에 따른 성능 변화입니다. 표12는 excitation 연산에서 ReLU, TanH, Sigmoid를 사용했을 때 성능 변화입니다.
(3). Different SE Block Integration Strategy
표14는 SE Block을 어디에 추가했을 때 성능이 오르는 지 측정한 결과 입니다.
Implementation Code
import torch
import torch.nn as nn
class SELayer(nn.Module):
def __init__(self, in_channels, reduction=16):
super(SELayer, self).__init__()
self.squeeze = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Linear(in_channels, in_channels // reduction, bias=False),
nn.ReLU(inplace=True),
nn.Linear(in_channels // reduction, in_channels, bias=False),
nn.Sigmoid())
def forward(self, x):
batch_size, channel, _, _ = x.size()
y = self.squeeze(x).view(batch_size, channel)
y = self.fc(y).view(batch_size, channel, 1, 1)
return x * y.expand_as(x)
class SEBottleNeck(nn.Module):
"""Residual block for resnet over 50 layers
"""
expansion = 4
def __init__(self, in_channels, out_channels, stride=1, reduction=16):
super(SEBottleNeck, self).__init__()
self.residual_function = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, stride=stride, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels * SEBottleNeck.expansion, kernel_size=1, bias=False),
nn.BatchNorm2d(out_channels * SEBottleNeck.expansion),
SELayer(out_channels * SEBottleNeck.expansion, reduction=reduction)
)
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels * SEBottleNeck.expansion:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels * SEBottleNeck.expansion, stride=stride, kernel_size=1, bias=False),
nn.BatchNorm2d(out_channels * SEBottleNeck.expansion)
)
def forward(self, x):
return nn.ReLU(inplace=True)(self.residual_function(x) + self.shortcut(x))
class SEBasicBlock(nn.Module) :
"""Basic Block for resnet 18 and resnet 34
"""
#BasicBlock and BottleNeck block
#have different output size
#we use class attribute expansion
#to distinct
expansion = 1
def __init__(self, in_channels, out_channels, stride=1, reduction=16):
super(SEBasicBlock, self).__init__()
# residual function
self.residual_function = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=(3, 3), stride=(stride, stride), padding=(1, 1), bias=False),
nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels * SEBasicBlock.expansion, kernel_size=(3, 3), padding=(1, 1), bias=False),
nn.BatchNorm2d(out_channels * SEBasicBlock.expansion),
SELayer(out_channels * SEBasicBlock.expansion, reduction=reduction)
)
# shortcut
self.shortcut = nn.Sequential()
#the shortcut output dimension is not the same with residual function
#use 1*1 convolution to match the dimension
if stride != 1 or in_channels != SEBasicBlock.expansion * out_channels :
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels * SEBasicBlock.expansion, kernel_size=(1, 1), stride=(stride, stride), bias=False),
nn.BatchNorm2d(out_channels * SEBasicBlock.expansion)
)
def forward(self, x):
return nn.ReLU(inplace=True)(self.residual_function(x) + self.shortcut(x))
class SEResNet(nn.Module) :
def __init__(self, block, num_block, num_classes=100, num_channels=3):
super(SEResNet, self).__init__()
self.in_channels = 64
self.conv1 = nn.Sequential(
nn.Conv2d(num_channels, 64, kernel_size=(3, 3), padding=(1, 1), bias=False),
nn.BatchNorm2d(64),
nn.ReLU()
)
self.conv2_x = self._make_layer(block, 64, num_block[0], 1)
self.conv3_x = self._make_layer(block, 128, num_block[1], 2)
self.conv4_x = self._make_layer(block, 256, num_block[2], 2)
self.conv5_x = self._make_layer(block, 512, num_block[3], 2)
self.avg_pool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
def _make_layer(self, block, out_channels, num_blocks, stride):
strides = [stride] + [1] * (num_blocks - 1)
layers = []
for stride in strides :
layers.append(block(self.in_channels, out_channels, stride))
self.in_channels = out_channels * block.expansion
return nn.Sequential(*layers)
def forward(self, x, feature=False):
output = self.conv1(x)
output = self.conv2_x(output)
output = self.conv3_x(output)
output = self.conv4_x(output)
output = self.conv5_x(output)
if feature: return output
output = self.avg_pool(output)
output = output.view(output.size(0), -1)
output = self.fc(output)
return output
def seresnet18(num_classes, num_channels) :
""" return a ResNet 18 object
[?, 3, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 128, 112, 112]
[?, 128, 112, 112] -> [?, 256, 56, 56]
[?, 256, 56, 56] -> [?, 512, 28, 28]
[?, 512, 28, 28] -> [?, 512, 1, 1]
"""
return SEResNet(SEBasicBlock, [2, 2, 2, 2], num_classes, num_channels)
def seresnet34(num_classes, num_channels) :
""" return a ResNet 34 object
[?, 3, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 128, 112, 112]
[?, 128, 112, 112] -> [?, 256, 56, 56]
[?, 256, 56, 56] -> [?, 512, 28, 28]
[?, 512, 28, 28] -> [?, 512, 1, 1]
"""
return SEResNet(SEBasicBlock, [2, 4, 6, 3], num_classes, num_channels)
def seresnet50(num_classes, num_channels) :
""" return a ResNet 50 object
[?, 3, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 256, 224, 224]
[?, 256, 224, 224] -> [?, 512, 112, 112]
[?, 512, 112, 112] -> [?, 1024, 56, 56]
[?, 1024, 56, 56] -> [?, 2048, 28, 28]
[?, 2048, 28, 28] -> [?, 2048, 1, 1]
"""
return SEResNet(SEBottleNeck, [2, 4, 6, 3], num_classes, num_channels)
def seresnet101(num_classes, num_channels) :
""" return a ResNet 101 object
[?, 3, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 256, 224, 224]
[?, 256, 224, 224] -> [?, 512, 112, 112]
[?, 512, 112, 112] -> [?, 1024, 56, 56]
[?, 1024, 56, 56] -> [?, 2048, 28, 28]
[?, 2048, 28, 28] -> [?, 2048, 1, 1]
"""
return SEResNet(SEBottleNeck, [3, 4, 23, 3], num_classes, num_channels)
def seresnet152(num_classes, num_channels):
""" return a ResNet 152 object
[?, 3, 224, 224] -> [?, 64, 224, 224]
[?, 64, 224, 224] -> [?, 256, 224, 224]
[?, 256, 224, 224] -> [?, 512, 112, 112]
[?, 512, 112, 112] -> [?, 1024, 56, 56]
[?, 1024, 56, 56] -> [?, 2048, 28, 28]
[?, 2048, 28, 28] -> [?, 2048, 1, 1]
"""
return SEResNet(SEBottleNeck, [3, 8, 36, 3], num_classes, num_channels)
if __name__ == '__main__':
model = seresnet152(num_classes=1000, num_channels=3)
inp = torch.rand(2, 3, 224, 224)
oup = model(inp)
print(oup.shape)