안녕하세요. 오랜만에 논문 리뷰를 하게 되었습니다. 오늘 리뷰할 논문은 CVPR2020에서 등재된 EfficientDet:Scalable and Efficient Object Detection이라는 논문입니다. 비록 제가 Object Detection과 관련된 공부는 거의 해보지는 않았지만 지식을 넓히는 차원에서 간단하게 정리해보도록 하겠습니다.
1. 요약
본 논문을 요약하면 "Object Detection에 특화된 효율적인 네트워크 구조 찾기"입니다. 혹시, EfficientNet이라는 논문을 아시나요? 해당 논문은 Image Classification에 중점을 맞추어 효율적인 네트워크 구조를 찾는 것을 목표로 합니다. 둘 다 목적은 효율적인 네트워크를 찾는 것입니다. 기본적으로 Object Detection은 연산량이 많은 task 중에 하나 입니다. 이에 대한 예시로 AmoebaNet을 기반으로 NAS-FPN 네트워크 같은 경우에는 167M개의 파라미터와 3045B FLOPs를 지니게 됩니다. 이와 같은 너무 연산량이 높은 경우에는 자원이 한정되어 있는 실생활에 적용할 수 없습니다. 따라서, 본 논문에서는 연산량은 줄이면서 성능은 최대한 높게 유지하려는 것을 목표로 삼습니다.
본 논문에서 중점적으로 제안하는 요소는 다음과 같습니다.
- Efficient Multi-Scale Feature Fusion을 위한 Bi-FPN
- 모델 스케일링을 위한 휴리스틱 기반의 Compond Scaling
이와 같은 방법으로 네트워크를 구성하는 다양한 요소 (Backbone, Feature Network, Box/Class Prediction Network)를 동시에 스케일링할 수 있게 되었습니다. 쉽게 말해 EfficientDet은 EfficientNet의 모델 스케일링 아이디어와 Bi-FPN을 통해 Multi-Scale Feature Fusion을 적용하여 Object Detection에 특화된 네트워크를 찾는 논문이라고 보시면 됩니다.
위 그림에서 실제로 동일한 FLOPs를 기준으로 가장 높은 성능을 얻었으며 AP를 기준으로 연산량이 가장 적어 실생활에 사용가능한 네트워크임을 알 수 있습니다.
연산량 뿐만 아니라 파라미터 개수, GPU/CPU 지연시간에서도 다른 네트워크와 비교했을 때 높은 성능을 보이고 있습니다.
2. Bi-Directional Feature Pyramid Network (Bi-FPN)
본 논문의 주요 기여 중 하나인 Bi-FPN을 보도록 하겠습니다. 기존에도 Feature Pyramid Network 구조는 사용되었습니다. 대표적으로 FPN(CVPR2017)과 PANet(CVPR2018)이 있습니다. 일단, 가장 단순한 FPN 구조를 보시면 top-down 방식으로 단방향으로 feature fusion이 적용되고 있습니다. 반면에 PANet은 bottom-up 방식으로 추가하여 양방향 feature fusion이 적용되고 있죠. 또한, NAS-FPN(CVPR2019)은 NAS를 통해 최적의 FPN 구조를 찾게 됩니다. 본 논문에서 지적하는 세 가지 방식의 문제점은 다음과 같습니다.
- FPN은 단방향으로의 정보흐름만 존재한다.
- PANet의 첫 계층은 입력 계층이기 때문에 가중치가 존재하지 않으며 이는 비효율적인 구조이다.
- NAS-FPN의 최종결과는 해석하기 어려운 구조로 되어있다.
- FPN/PANet/NAS-FPN은 모두 각 scale에 대해 동일한 가중치가 적용된다.
이를 바탕으로 Bi-FPN은 양방향으로의 정보흐름을 가지고 모든 계층이 가중치를 가지게 만들었으며 scale 간의 가중치를 조정해주는 방법을 제시하고 있습니다. 이를 식으로 표현하면 다음과 같습니다.
$$\begin{align*} &P_{7}^{out} = Conv(P_{7}^{in}) \\ &P_{6}^{out} = Conv(P_{6}^{in} + Resize(P_{7}^{out})) \\ &\vdots \\ &P_{3}^{out} = Conv(P_{3}^{in} + Resize(R_{4}^{out})) \end{align*}$$
여기서 $Resize$는 feature간 scale이 안맞을 때 resolution을 조정하는 upsampling과 downsampling을 의미하고 $Conv$는 합성곱 연산을 의미합니다. 가중치를 조정하는 방법은 다음과 같습니다.
- Unbounded Fusion : $O = \sum_{i} w_{i}I_{i}$
- Softmax-based Fusion : $O = \sum_{i} \frac{e^{w_{i}}}{\sum_{j} e^{w_{j}}}$
- Fast Normalized Fusion : $O = \sum_{i} \frac{w_{i}}{\epsilon + \sum_{j} w_{j}}$
본 논문에서는 Fast Normalized Fusion 방법을 채택하였습니다. Unbounded Fusion은 가중치의 범위가 정해지지 않아 학습 시 불안정해 질 수 있으며 Softmax-based Fusion은 GPU에 부하를 주어 느려질 수 밖에 없기 때문이죠.
3. EfficientDet
마지막으로 EfficientDet의 전체 구조를 보도록 하죠. Backbone은 EfficientNetB0부터 EfficientNetB6 중에서 한 가지를 사용하게 됩니다. Bi-FPN을 구성할 때는 채널의 수를 지수적으로, 계층의 수는 선형적으로 증가시키면서 학습을 진행하였습니다.
$$W_{Bi-FPN} = 64 \cdot 1.35^{\phi}, D_{Bi-FPN} = 3 + \phi$$
여기서 $\phi$는 $\{1.2, 1.25, 1.3, 1.35, 1.4, 1.45\}$ 중에서 그리드 검색을 통해 결정하게 됩니다. 다음으로 입력 영상의 해상도는 $2^{7}$으로 나누어질 수 있도록 다음과 같이 셋팅하였습니다.
$$R_{input} = 512 + \phi \cdot 128$$
마지막으로 Object Detection 시 class와 location을 예측하는 네트워크에서는 너비는 Bi-FPN과 동일하게 셋팅하고 계층의 개수를 선형적으로 증가시켰습니다.
$$D_{box} = D_{class} = 3 + \lfloor \frac{\phi}{3} \rfloor$$
이와 같은 조합으로 EfficientDet은 D0부터 D7x까지 총 9개의 서로 다른 네트워크를 학습하게 됩니다.
4. 실험결과
본 논문에서는 MS COCO2017 데이터셋을 이용해서 EfficientDet을 학습시켰습니다. 그 결과 AP가 유사한 다른 Object Detection 네트워크가 비교했을 때 훨씬 적은 파라미터와 연산량을 가지는 것을 볼 수 있습니다.
5. 학습 및 평가 코드 (Training and Evaluatoin Code)
마지막으로 EfficientDet의 실제 코드를 분석하고 학습 및 평가 코드를 분석하고 마치도록 하겠습니다. 전체 코드는 링크를 참조해주시길 바랍니다. 공식 코드 문제는 아니지만 학습 코드가 추가된 재구현 코드이므로 아마 큰 도움이 될 거 같습니다. 전체 폴더 구조는 다음과 같습니다.
제가 사용한 데이터셋은 PASCAL COCO2017 Object Detection 데이터셋입니다. 데이터셋까지 다운받았다면 바로 학습을 진행할 수 있습니다. 학습을 시작하게 되면 CocoDataset에서 훈련/검증 데이터셋으로 나위어 training_gernerator/val_generator를 정의합니다.
training_params = {'batch_size': opt.batch_size,
'shuffle': True,
'drop_last': True,
'collate_fn': collater,
'num_workers': opt.num_workers}
val_params = {'batch_size': opt.batch_size,
'shuffle': False,
'drop_last': True,
'collate_fn': collater,
'num_workers': opt.num_workers}
input_sizes = [512, 640, 768, 896, 1024, 1280, 1280, 1536, 1536]
training_set = CocoDataset(root_dir=opt.data_path, set=params.train_set,
transform=transforms.Compose([Normalizer(mean=params.mean, std=params.std),
Augmenter(),
Resizer(input_sizes[opt.compound_coef])]))
training_generator = DataLoader(training_set, **training_params)
val_set = CocoDataset(root_dir=opt.data_path, set=params.val_set,
transform=transforms.Compose([Normalizer(mean=params.mean, std=params.std),
Resizer(input_sizes[opt.compound_coef])]))
val_generator = DataLoader(val_set, **val_params)
여기서 중요한 것은 compound scaling 파라미터인 $\phi$는 직접 정의해주어야 합니다. 저자 코드는 $\phi = 0$으로 고정하여 사용하였습니다. 따라서 학습되는 파라미터는 다음과 같습니다.
- BackBone : EfficientNetB0
- Bi-FPN : $W_{Bi-FPN} = 64, D_{Bi-FPN} = 3$
- Input Resolution : $R_{input} = 512$
- Class/Box Localization : $D_{box} = D_{class} = 3$
다음으로 모델을 정의하게 됩니다.
model = EfficientDetBackbone(num_classes=len(params.obj_list), compound_coef=opt.compound_coef,
ratios=eval(params.anchors_ratios), scales=eval(params.anchors_scales))
backbone.py의 EfficientDetBackBone에 정의된 모델 클래스를 보면 compound scaling 파라미터 별로 모델 상세 파라미터가 적혀있는 것을 볼 수 있습니다.
self.backbone_compound_coef = [0, 1, 2, 3, 4, 5, 6, 6, 7]
self.fpn_num_filters = [64, 88, 112, 160, 224, 288, 384, 384, 384]
self.fpn_cell_repeats = [3, 4, 5, 6, 7, 7, 8, 8, 8]
self.input_sizes = [512, 640, 768, 896, 1024, 1280, 1280, 1536, 1536]
self.box_class_repeats = [3, 3, 3, 4, 4, 4, 5, 5, 5]
self.pyramid_levels = [5, 5, 5, 5, 5, 5, 5, 5, 6]
self.anchor_scale = [4., 4., 4., 4., 4., 4., 4., 5., 4.]
self.aspect_ratios = kwargs.get('ratios', [(1.0, 1.0), (1.4, 0.7), (0.7, 1.4)])
self.num_scales = len(kwargs.get('scales', [2 ** 0, 2 ** (1.0 / 3.0), 2 ** (2.0 / 3.0)]))
conv_channel_coef = {
# the channels of P3/P4/P5.
0: [40, 112, 320],
1: [40, 112, 320],
2: [48, 120, 352],
3: [48, 136, 384],
4: [56, 160, 448],
5: [64, 176, 512],
6: [72, 200, 576],
7: [72, 200, 576],
8: [80, 224, 640],
}
EfficientDet의 각 부분인 EfficientNet, Bi-FPN, regressor/classifier를 불러오게 되죠.
self.backbone_net = EfficientNet(self.backbone_compound_coef[compound_coef], load_weights)
self.bifpn = nn.Sequential(
*[BiFPN(self.fpn_num_filters[self.compound_coef],
conv_channel_coef[compound_coef],
True if _ == 0 else False,
attention=True if compound_coef < 6 else False,
use_p8=compound_coef > 7)
for _ in range(self.fpn_cell_repeats[compound_coef])])
self.num_classes = num_classes
self.regressor = Regressor(in_channels=self.fpn_num_filters[self.compound_coef], num_anchors=num_anchors,
num_layers=self.box_class_repeats[self.compound_coef],
pyramid_levels=self.pyramid_levels[self.compound_coef])
self.classifier = Classifier(in_channels=self.fpn_num_filters[self.compound_coef], num_anchors=num_anchors,
num_classes=num_classes,
num_layers=self.box_class_repeats[self.compound_coef],
pyramid_levels=self.pyramid_levels[self.compound_coef])
self.anchors = Anchors(anchor_scale=self.anchor_scale[compound_coef],
pyramid_levels=(torch.arange(self.pyramid_levels[self.compound_coef]) + 3).tolist(),
**kwargs)
Bi-FPN을 좀 더 자세히 보도록 하겠습니다.
class SeparableConvBlock(nn.Module):
"""
created by Zylo117
"""
def __init__(self, in_channels, out_channels=None, norm=True, activation=False, onnx_export=False):
super(SeparableConvBlock, self).__init__()
if out_channels is None:
out_channels = in_channels
# Q: whether separate conv
# share bias between depthwise_conv and pointwise_conv
# or just pointwise_conv apply bias.
# A: Confirmed, just pointwise_conv applies bias, depthwise_conv has no bias.
self.depthwise_conv = Conv2dStaticSamePadding(in_channels, in_channels,
kernel_size=3, stride=1, groups=in_channels, bias=False)
self.pointwise_conv = Conv2dStaticSamePadding(in_channels, out_channels, kernel_size=1, stride=1)
self.norm = norm
if self.norm:
# Warning: pytorch momentum is different from tensorflow's, momentum_pytorch = 1 - momentum_tensorflow
self.bn = nn.BatchNorm2d(num_features=out_channels, momentum=0.01, eps=1e-3)
self.activation = activation
if self.activation:
self.swish = MemoryEfficientSwish() if not onnx_export else Swish()
class BiFPN(nn.Module):
"""
modified by Zylo117
"""
def __init__(self, num_channels, conv_channels, first_time=False, epsilon=1e-4, onnx_export=False, attention=True,
use_p8=False):
"""
Args:
num_channels:
conv_channels:
first_time: whether the input comes directly from the efficientnet,
if True, downchannel it first, and downsample P5 to generate P6 then P7
epsilon: epsilon of fast weighted attention sum of BiFPN, not the BN's epsilon
onnx_export: if True, use Swish instead of MemoryEfficientSwish
"""
super(BiFPN, self).__init__()
self.epsilon = epsilon
self.use_p8 = use_p8
# Conv layers
self.conv6_up = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv5_up = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv4_up = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv3_up = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv4_down = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv5_down = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv6_down = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv7_down = SeparableConvBlock(num_channels, onnx_export=onnx_export)
if use_p8:
self.conv7_up = SeparableConvBlock(num_channels, onnx_export=onnx_export)
self.conv8_down = SeparableConvBlock(num_channels, onnx_export=onnx_export)
# Feature scaling layers
self.p6_upsample = nn.Upsample(scale_factor=2, mode='nearest')
self.p5_upsample = nn.Upsample(scale_factor=2, mode='nearest')
self.p4_upsample = nn.Upsample(scale_factor=2, mode='nearest')
self.p3_upsample = nn.Upsample(scale_factor=2, mode='nearest')
self.p4_downsample = MaxPool2dStaticSamePadding(3, 2)
self.p5_downsample = MaxPool2dStaticSamePadding(3, 2)
self.p6_downsample = MaxPool2dStaticSamePadding(3, 2)
self.p7_downsample = MaxPool2dStaticSamePadding(3, 2)
if use_p8:
self.p7_upsample = nn.Upsample(scale_factor=2, mode='nearest')
self.p8_downsample = MaxPool2dStaticSamePadding(3, 2)
self.swish = MemoryEfficientSwish() if not onnx_export else Swish()
# Weight
self.p6_w1 = nn.Parameter(torch.ones(2, dtype=torch.float32), requires_grad=True)
self.p6_w1_relu = nn.ReLU()
self.p5_w1 = nn.Parameter(torch.ones(2, dtype=torch.float32), requires_grad=True)
self.p5_w1_relu = nn.ReLU()
self.p4_w1 = nn.Parameter(torch.ones(2, dtype=torch.float32), requires_grad=True)
self.p4_w1_relu = nn.ReLU()
self.p3_w1 = nn.Parameter(torch.ones(2, dtype=torch.float32), requires_grad=True)
self.p3_w1_relu = nn.ReLU()
self.p4_w2 = nn.Parameter(torch.ones(3, dtype=torch.float32), requires_grad=True)
self.p4_w2_relu = nn.ReLU()
self.p5_w2 = nn.Parameter(torch.ones(3, dtype=torch.float32), requires_grad=True)
self.p5_w2_relu = nn.ReLU()
self.p6_w2 = nn.Parameter(torch.ones(3, dtype=torch.float32), requires_grad=True)
self.p6_w2_relu = nn.ReLU()
self.p7_w2 = nn.Parameter(torch.ones(2, dtype=torch.float32), requires_grad=True)
self.p7_w2_relu = nn.ReLU()
self.attention = attention
Bi-FPN은 기본적으로 속도 향상을 위해 Depthwise Convolution과 Pointwise Convolution을 결합한 Seperable Convolution Block을 사용하고 있습니다.
이렇게 오늘은 EfficientDet 훈련 코드를 살펴보았습니다. 실제로 학습을 돌려보려고 시도는 했으나 시간이 생각보다 오래걸려서 하지는 못했네요 ㅠㅠ 다음에는 순서가 조금 꼬였지만 EfficientNet 논문을 리뷰해보도록 하겠습니다.
참고자료
[1]. Tan, Mingxing, Ruoming Pang, and Quoc V. Le. "Efficientdet: Scalable and efficient object detection." Proceedings of the IEEE/CVF conference on computer vision and pattern recognition. 2020.