CIFAR-10 정복 시리즈 1: ResNet
in CIFAR10 on Deep-Learning
CIFAR-10 정복하기 시리즈 소개
CIFAR-10 정복하기 시리즈에서는 딥러닝이 CIFAR-10 데이터셋에서 어떻게 성능을 높여왔는지 그 흐름을 알아본다. 또한 코드를 통해서 동작원리를 자세하게 깨닫고 실습해볼 것이다.
- CIFAR-10 정복하기 시리즈 목차(클릭해서 바로 이동하기)
- 관련 코드 링크
CIFAR-10 정복 시리즈 1: ResNet
ResNet은 진정한 Deep Learning의 시대를 가져왔다고 말할 정도로 영향력이 큰 네트워크이다. 이번 포스트에서는 ResNet 이전에 Deep Neural Network의 흐름과 유사한 선행 연구에 대해서 알아볼 것이다. 그 이후에 ResNet이 해결하려는 문제와 어떻게 문제를 해결했는지를 살펴본다. ResNet 저자의 후속논문을 살펴보면서 ResNet을 어떻게 더 깊게 쌓을 수 있는지 알아본다.
Toward Deeper Network
Deep Learning은 Deep Neural Network를 사용한 Machine Learning이라고 풀어서 말할 수 있다. MNIST와 같은 데이터셋에서는 3개의 층을 가지는 CNN으로도 높은 classification 성능을 얻을 수 있다. 하지만 CIFAR이나 ImageNet과 같이 좀 더 도전적인 데이터셋에서는 얕은 네트워크로는 한계가 있다. 따라서 연구자들은 과거부터 Layer를 더 깊게 쌓으려고 했다. 2012년 AlexNet은 8층이었으며 2014년의 VGG는 19층 GoogleNet은 22층이었다. 2014년까지는 가장 깊은 Neural Network가 몇십층 정도의 깊이를 가진 것이다. 하지만 2015년에 나온 ResNet은 152 층을 쌓았으며 2015년 ILSVRC 대회에서 우승하였다. 한마디로 진정한 Deep Learning의 시대를 연것이다.
ResNet 논문1에서는 152보다 더 깊은 1000 층 이상의 ResNet도 실험했다. 하지만 논문의 실험 결과에 의하면 110층의 ResNet보다 1202층의 ResNet이 CIFAR-10에서 성능이 낮다. 이런 문제를 지적하며 ResNet 저자인 Kaiming He는 2016년에 ResNet의 후속 논문을 발표했다. “Identity Mappings in Deep Residual Networks” 에서는 ResNet 내부 구조의 변경을 통해 110층, 164층의 ResNet보다 1001층의 ResNet의 성능을 높게 만들 수 있었다. 다음 표를 보면 CIFAR-10 데이터셋에서 ResNet-1001이 (1001층의 깊이를 가지는 ResNet을 의미한다) 기존의 다른 네트워크보다 성능이 좋은 것을 볼 수 있다.
위 결과는 ResNet이 Image Classification task에서 거둔 성과를 보여준다. 하지만 ResNet 또는 더 깊은 Network는 단지 image classification에서만 사용되는 것이 아니다. Detection, Segmentation, Pose Estimation, Depth Estimation 등에서 일명 Backbone으로 사용된다. Backbone은 등뼈라는 뜻이다. 등뼈는 뇌와 몸의 각 부위의 신경을 이어주는 역할을 한다. 뇌를 통해 입력이 들어온다고 생각하고 팔, 다리 등이 출력이라고 생각한다면 backbone은 입력이 처음 들어와서 출력에 관련된 모듈에 처리된 입력을 보내주는 역할이라고 생각할 수 있다. 여러가지 task가 몸의 각 부분이라고 생각하면 ResNet과 같은 classification model은 입력을 받아서 각 task에 맞는 모듈로 전달해주는 역할이다. 결국 객체를 검출하든 영역들을 나누든 Neural Network는 입력 이미지로부터 다양한 feature를 추출해야한다. 그 역할을 backbone 네트워크가 하는 것이다. 따라서 기본적으로 image classification 모델에 대한 이해가 필요하다.
Deep Learning이 학습을 잘하게 되는 것은 단순히 층을 쌓는다고 되는 것이 아니다. 10층을 쌓을 때까지 그리고 10층에서 100층, 100층에서 1000층은 각 단계마다의 문제가 존재한다. ResNet은 10층에서 100층 그리고 100층에서 1000층 사이에 해당한다. 10층 정도까지는 AlexNet, VGG 정도로 볼 수 있다. 이 네트워크에서는 10층을 쌓기 위해 어떤 노력을 했을까? 꽤나 최근까지 Deep Learning이 나올 수 없었던 이유는 Neural Network를 학습시키는 것이 어렵다는 사실이 큰 비중을 차지한다. Neural Network는 여러 weight와 bias라는 parameter를 가진다. Neural network를 학습시킨다는 것인 이 parameter를 stochastic gradient descent로 업데이트 한다는 것을 의미한다.
Gradient를 통해 weight를 업데이트 할 때 gradient가 explode하거나 vanishing 하는 경우가 많았다. Gradient가 안정적이지 않은 이유는 neural network에서 사용하는 activation function과 연관성이 있다. 다음 그림은 Neural network에서 많이 사용하는 tanh로 10개의 층을 가지는 간단한 뉴럴넷을 만들고 테스트한 과정이다. 작은 random number로 network의 weight를 initialize하면 모든 activation이 0이 되는 현상이 발생한다. 자세한 내용은 CS231n 강의를 참고하길 바란다.
모든 activation이 0이 되거나 -1 아니면 1로 saturate되는 문제를 해결하기 위해 Xavier initialization2과 He initialization3이 나왔다. He initilization은 ReLU를 activation function으로 사용하는 경우에 더 깊은 neural network가 학습가능하게 만든 방법이다. 즉, 깊은 neural network를 학습시키려면 신경써서 initialization을 해야했다. 하지만 Batch-Normalization4이 나오면서 이런 흐름을 바꾸었다. Batch normalization의 저자인 Sergey Ioffe와 Christian Szegedy는 Neural network가 학습하기 어려운 이유를 internal covariate shift라고 주장한다. Internal covariate shift는 neural network가 학습하면서 각 층의 입력 분포가 계속 변하는 현상이다. 따라서 근본적으로 neural network를 학습하기 어렵다고 판단했다.
Batch normalization 이름처럼 이 문제를 mini-batch마다 각 층의 input을 normalization하는 방법으로 어느정도 해결했다. Batch normalization을 사용하면 initialization을 크게 신경쓰지 않아도 된다. 또한 optimizer의 learning rate를 이전보다 더 높일 수 있다. 결과적으로 더 빠른 학습을 가능하게 한 것이다. Batch normalization 논문에서 저자는 GoogleNet5에 batch normalization을 적용해서 성능을 평가했다. 다음 그림을 보면 BN이라고 써져있는 네트워크(BN + GoogleNet)이 Inception(GoogleNet) 보다 훨씬 더 빠르게 학습하는 것을 볼 수 있다. 심지어 batch normalization은 regularization 역할도 하기 때문에 Dropout을 사용하지 않아도 학습이 잘 되는 특성이 있다. 이 때부터 많은 neural network에서 dropout을 사용하지 않기 시작했다.
From 10 to 100 Layers
Degradation
Initialization과 normalization은 graident가 vanishing하거나 exploding하는 문제를 잡아줘서 더 깊은 network의 학습이 가능하게 했다. 그보다 더 깊은 network를 학습할 때는 어떤 문제가 발생할까? VGG나 GoogleNet과 같이 잘 짜여진 neural network에 층을 더 쌓을 경우 Degradation 문제가 발생한다. Degradation은 neural network의 깊이는 증가하는데 training error가 증가하는 경우를 말한다. Degradation은 overfitting으로 인해 생기는 현상이 아니다. 다음 그림에서 보면 56 층의 network가 20층의 network보다 training error와 test error가 둘 다 높은 것을 볼 수 있다.
Degradation 문제는 Convolutional Neural Networks at Constrained Time Cost6 논문과 Highway Networks7 논문에서 소개되었다. “Convolutional Neural Networks at Constrained Time Cost”는 ResNet의 저자인 Kaiming He의 논문이다. 이 논문에서 학습이나 테스트 시간이라는 제약 조건 아래 여러 neural network 구조를 비교한다. Inference time을 유지하면서 네트워크의 깊이를 늘리려면 filter의 개수나 filter의 사이즈를 줄여야한다. 제약조건 아래에서 실험을 하면 성능에 어떤 요인이 영향을 주는지 확인하기 좋다. 이 논문에서 발견한 사실은 이러한 제약조건 아래에서 네트워크의 성능에 영향을 주는 것은 filter의 개수나 filter의 사이즈보다 네트워크의 깊이라는 것이다.
이렇게 네트워크 깊이가 중요한 만큼 네트워크의 층을 더 늘리려는 노력이 필요하다. 하지만 일정이상 네트워크의 깊이를 늘리면 네트워크의 정확도가 변화가 없거나 오히려 떨어지는 degradation 현상이 발생한다. 논문에서는 degradation 문제를 더 살펴보기 위해 time constraint 없이 네트워크 깊이만 늘려보면서 error rate의 변화를 살펴봤다. 논문에서 실험한 여러가지 모델 중에 D라는 모델을 사용해 실험하였다. ImageNet 데이터에 실험한 다음 표를 보면 D+4 까지는 error rate가 준다. 하지만 D+6, D+8의 경우 오히려 error rate가 늘어난다.
Highway Network
ResNet이 처음 Degradation 문제를 해결하기 위해 새로운 네트워크 구조를 제안한 것이 아니다. ResNet이 나온 2015년에 Highway Network 논문이 나왔다. Highway network 또한 “training deeper networks is not as straightforward as simply adding layers.” 이라고 언급하며 단순히 깊이를 늘리는 것 이외의 방법이 필요하다고 말한다. Highway network는 LSTM(Long Short-Term Memory Models)의 구조에서 영감을 받아서 만든 네트워크이다.
LSTM은 기존 RNN의 vanishing gradient 문제를 해결하기 위해 나타났다. RNN과 LSTM의 중요한 차이는 hidden state 이외의 cell state가 존재하는 것이다. Cell state는 일종의 information highway로 작동한다. 기존 RNN에서는 이전 step의 정보가 다음 step으로 넘어갈 때 많은 반드시 non-linear 연산을 거쳐야한다. 하지만 LSTM에서는 cell state에 저장된 정보가 다음 step으로 넘어갈 때 곱하기와 더하기 연산만 거친다. 따라서 처음의 cell state 정보가 오랫동안 남아있을 수 있다. 아래 그림에서 위가 RNN이고 아래가 LSTM이다. LSTM에서는 위를 관통하는 하나의 선이 있는 것을 볼 수 있는데 이게 cell state이다. 마치 고속도로와 같기 때문에 information highway라고 하기도 한다. 혹시 RNN과 LSTM의 작동 방식을 잘 모른다면 Illustrated Guide to LSTM’s and GRU’s: A step by step explanation 영상을 추천한다.
LSTM에서는 시간에 따라 정보가 유지되도록 했다면 Highway network에서는 앞단의 layer에서의 정보가 뒷 단의 layer로 유지되도록 한 것이다. Neural network가 L개의 layer로 이루어져있다고 하자. 각 layer에서는 \(H\)라는 non linear transformation을 input \(x\)에 적용한다. 이 때 출력을 \(y\)라고 하면 각 layer에서의 연산은 다음과 같이 쓸 수 있다. \[y = H(x, W_H)\]
Highway network에서는 \(T\)와 \(C\)라는 새로운 nonlinear transform을 사용한다. \(T\)는 transform gate를 의미하고 \(C\)는 carry gate를 의미한다. Transform gate는 \(H\) 연산을 거친 정보를 얼마나 반영할지에 대한 gate이다. Carry gate는 입력으로 들어왔던 \(x\)의 정보를 얼마나 유지할까에 대한 gate이다. 입력 \(x\)가 마치 LSTM의 cell state와 같다라고 생각하면 이해가 쉬울 것이다. 이 두 개의 gate는 0에서 1사이의 값을 가진다. 새로운 정보를 transform을 더 많이 하려면 input \(x\)의 정보를 더 줄여야하고 input \(x\)의 정보를 더 carry하려면 carry gate의 값이 커져야한다. 따라서 \(C = 1 - T\)로 정의할 수 있다. 이것을 반영해서 Highway Network의 한 layer 연산은 다음과 같이 쓸 수 있다. \(T\)는 0에서 1사이의 값을 가지기 때문에 sigmoid function을 사용한다. 즉, \(T(x) = \sigma(W_Tx + b_T)\)이다. 만약 \(T\)가 0이라면 입력인 \(x\)가 그대로 출력으로 나간다. \(T\)가 1이라면 \(H\)의 출력이 해당 layer의 출력이 된다. \[y = H(x, W_H)\cdot T(x, W_T) + x\cdot (1-T(x, W_T))\]
Highway network를 간단히 코드로 살펴보면 다음과 같다. self.gate와 self.nonlinear는 각각 \(W_T, W_H\)라고 볼 수 있다. 이 코드가 하나의 layer이고 이런 layer를 쭉 쌓으면 Highway network가 된다. 코드를 보면 직관적으로 Highway network를 이해할 수 있을 것이다.
gate = F.sigmoid(self.gate(x))
nonlinear = F.relu(self.nonlinear(x))
x = gate * nonlinear + (1 - gate) * x
Highway network의 경우 논문에서 fully connected layer에 대해서만 실험하였다. 이 논문에서는 plain network(information highway가 없는 일반 네트워크)와 highway 네트워크를 깊이를 다르게하며 성능을 비교했다. 아래 그림을 보면 네트워크의 깊이가 10에서 100으로 달라질 때 plain network와 highway network의 학습 과정이 어떻게 다른지 볼 수 있다. 네트워크의 깊이가 얕을 때는 plain network가 더 좋은 성능을 보인다. 하지만 네트워크가 깊어질수록 highway network의 성능이 plain network의 성능보다 더 높아진다. 사실 highway network만 본다면 깊이가 늘어나는 것에 따라 네트워크의 error rate가 거의 변하지 않는 것을 볼 수 있다. 이 실험결과를 통해 information highway와 같이 이전 layer의 정보를 다음 layer에 전해주는 방식이 layer를 더 깊게 쌓는데 효과가 있음을 알 수 있다.
ResNet
ResNet 논문 또한 Highway network와 같이 degradation 문제를 해결하기 위한 방법을 제안한다. ResNet 논문에서도 degradation 현상을 실험을 통해 확인했다. 다음 그림에서 20-layer와 56-layer(뒤에서 이 네트워크의 구조를 설명할 것이다. 학습은 CIFAR-10에 대해서 한 것이다) 의 training error와 test error의 차이를 볼 수 있다. 네트워크의 층이 더 깊어졌을 때 오히려 training error와 test error 둘 다 높아지는 것을 볼 수 있다. 따라서 이 현상은 overfitting이 아닌 degradation 문제이다. ResNet 논문과 Highway network 논문이 둘 다 degradation 문제를 해결하려 한 것이라면 어떤 점이 다를까? ResNet과 Highway network의 중요한 차이점은 (1) gate를 학습하는 것이 아니라 residual learning을 하는 것 (2) CNN에 적용했다는 점 이다.
왜 20-layer 네트워크보다 56-layer 네트워크의 성능이 저하되는 것일까? 이론상 만일 20층부터 56층까지의 layer가 identity mapping을 해준다면 성능은 동일해야한다. 하지만 여러 층의 non-linear function을 identity mapping이 되도록 학습시키는 것은 쉽지 않다. Plain 네트워크는 정보가 흐를 수 있는 길이 하나밖에 없다. 다음 그림은 일반적인 plain 네트워크의 하나의 layer이다. Weight layer는 convolution layer이며 activation function은 relu이다. 이 plain 네트워크는 간단히 \(H(x)\)라고 쓸 수 있다. 만약 이 layer가 identity mapping을 학습한다면 \(H(x) = x\)가 되도록 \(H(x)\)를 맞추면 된다. 하지만 층이 깊은 네트워크에서 여러 개의 layer를 identity mapping이 되도록 학습한다는 것은 쉽지 않다. Identity function 자체는 상당히 간단한 함수이다. ResNet은 이 문제를 간단한 방법으로 해결한다.
Residual 이라는 것은 일종의 오차이다. 기존의 neural network가 direct mapping을 학습했다면 Residual Learning은 입력으로부터 얼만큼 달라져야하는지를 학습한다. \(H(x)\)가 학습해야하는 mapping이라면 \(H(x)\)를 \(H(x) = F(x) + x\)으로 새롭게 정의한다. 네트워크 학습 목표를 \(H(x)\) 학습에서 \(F(x)\) 학습으로 바꾸는 것이다. 먄약 identity mapping을 학습하고자 한다면 간단히 \(F(x)\)를 0으로 만들면 된다. 다음 그림은 Residual mapping을 학습하는 neural network을 보여준다. 기존 plain network의 차이는 identity x라고 써져있는 shortcut connection 부분이다. ResNet이 지금처럼 널리 사용되는 것은 성능 때문일수도 있지만 방법이 상당히 간단하다는데 있다. Shortcut connection이라는 것은 몇 개의 layer를 건너뛰는 것(skip)을 말한다. ResNet에서 Shortcut connection은 2개의 layer를 건너뛴다. Layer에 들어오는 입력이 shortcut connnection을 통해서 건너뛰면 layer를 지난 출력과 element-wise addition 한다.
후속 논문인 “Identity Mappings in Deep Residual Networks”에서는 Residual block ResNet의 한 layer를 수식으로 나타내면 다음과 같다. 수식에서 \(w\) 밑에 \(i\)가 들어가는 것은 \(F\)가 하나의 layer가 아니라 여러 개의 layer라는 뜻이다. 위에서 언급했듯이 \(F\)는 2개의 layer로 이루어져있다. 수식으로는 \(F=W_2(\sigma (W_1 x))\)으로 사용한다. \(\sigma\)는 ReLU activation function을 의미한다. \[y = F(x, {W_i}) + x\]
보통은 Convolution layer 이후에 relu activation function 연산을 수행한다. ResNet 논문에서 중요한 점 중에 하나가 Batch Normalization을 사용한다는 것이다. Shortcut connection을 \(x\)라 했을 때 shortcut connection과 합쳐지는 것은 \(bn(conv(relu(bn(conv(x)))))\)가 된다. 이 연산과정을 다른 그림으로 보자면 다음과 같다. shortcut connection과 residual mapping을 더한 다음에 ReLU를 취하는 것을 볼 수 있다. 이렇게 하는 이유는 ReLU를 통과하면 +의 값만 남기 때문에 Residual의 의미가 제대로 유지되지 않기 때문이다. 이 연산을 하는 부분을 Residual Block이라고 부른다.
Residual block을 코드로 보자면 다음과 같다. ResNet의 전체 코드는 뒤에서 살펴볼 것이다. conv1과 conv2는 2d convolution을 의미한다. 처음 입력 x는 self.bn2까지 거친 out과 element-wise addition을 한다. ResNet은 이 Residual Block이 여러 층으로 쌓은 neural network를 의미한다.
shorcut = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out += shortcut
out = self.relu(out)
정확히 ResNet의 구조는 어떻게 되는 것일까? ResNet의 구조는 논문에서 ResNet과 비교하는 plain net과 거의 동일하다. 논문의 Plain net은 VGG의 구조에서 영감을 받아 만들었다. Plain net에서 여러 convolutional layer가 있는데 각 convolutional layer은 두 가지 규칙을 지킨다. (1) 만약 feature map size가 같다면 해당 convolutional layer의 filter 개수는 같다. (2) 만약 feature map size가 반으로 준다면 filter의 개수는 2배가 된다. 이 디자인 규칙은 ResNet도 그대로 가지고 있다. feature map size를 반으로 줄이는 것은 convolution을 할 때 stride를 2로 설정하는 것으로 한다. 마지막 convolutional layer의 output은 global average pooling을 통해서 pooling을 한다. 그 결과를 fully connected layer를 통해 class 개수만큼의 출력으로 변환한다.
위에서 Residual block을 설명할 때 언급했던 것처럼 plain net에서도 batch normalization을 relu activation function 전에 사용한다. ResNet은 Plain network에서 두 개의 convolutional layer에 shortcut connection을 추가한 것이다. VGG와 plain network 그리고 ResNet의 구조를 비교한 그림은 다음과 같다. Plain network는 VGG에서 몇 개의 layer를 더 추가한 것임을 알 수 있다. Plain network는 VGG의 초반 몇 개의 3x3 convolution을 7x7 convolution으로 대체했다. 또한 global average pooling을 마지막 convolutional layer 출력에 적용하기 때문에 VGG에 비해 fully connected layer가 적다. ResNet은 plain network에서 두 개의 convolutional block을 skipping하는 shortcut connection을 추가한 것이다. 그림의 shortcut connection 중에서 점선인 것은 feature map size가 반으로 줄어든 경우를 의미한다. 다른 실선의 shortcut connection은 모두 parameter가 없지만 점선의 shortcut connection의 경우 1x1 convolution과 batch normalization을 적용하기 때문에 학습 대상인 connection이다.
다음 표는 Plain network와 ResNet의 구조를 깊이마다 표로 정리한 것이다. 이 구조는 ImageNet 데이터에 대해 학습할 때 사용하는 구조이다. CIFAR 데이터의 경우 이미지 사이즈가 더 작기 때문에 몇가지 점이 다르다. ImageNet에서 사용된 네트워크 구조와 CIFAR 데이터에서 사용된 네트워크 구조가 어떻게 다른지는 뒤에서 이야기하겠다.
ResNet을 제안한 이유는 degradation이라는 문제를 해결하기 위해서이다. 논문에서는 ImageNet 데이터에 대해서 여러가지 깊이로 plain net과 ResNet을 학습했다. 그 결과는 다음 그래프와 같다. 왼쪽은 plain network에 대한 실험 결과이고 오른쪽은 ResNet에 대한 실험 결과이다. Plain net의 경우 18층에서 34층으로 깊이를 늘리면 training error가 늘어나는 것을 볼 수 있다. 하지만 ResNet의 경우 18층에서 34층으로 깊이를 늘렸을 때 training error가 줄어든다. 이 실험결과를 통해 ResNet이 degradation 문제를 어느 정도 해결했다는 것을 알 수 있다.
ResNet의 CIFAR-10에서의 성능을 살펴보자. 그 전에 하나 알아가야할 것이 있다. ResNet의 깊이가 점점 깊어지면 경우 parameter의 수가 너무 많아지기 때문에 residual block으로 다른 구조를 사용한다. Residual block의 원래 구조는 아래 그림의 왼쪽과 같다. 50층 이상인 ResNet에서는 오른쪽 그림과 같은 residual block을 사용한다. 1x1 convolution을 통해 filter size를 줄인 이후에 3x3 convolution을 하면 파라메터의 수를 아낄 수 있다. 이러한 구조의 residual block을 bottelnet block이라고 부른다. Bottleneck 구조를 사용해 ResNet의 내부 구조를 바꾸면 152 layer까지 쌓아도 vgg보다 모델 크기가 작다.
CIFAR-10에서 성능은 다음과 같다. 110 개의 층을 가지는 ResNet이 test data에 대해서 6.43 %의 error rate를 기록했다. 하나 유의해서 볼 점은 1202개의 층을 가지는 ResNet의 성능이다. ResNet은 기존 네트워크보다 훨씬 깊게 쌓을 수 있다는 점이 장점이다. 하지만 1000개 이상의 깊이를 가지게 되면 다시 degradation 문제를 만단다. ResNet-110이 6.43% error rate를 가지는 반면 ResNet-1202는 7.93% error rate를 가진다. 1000개의 층을 쌓아도 degradation 문제가 생기지 않게 할 수 있다. 그 부분은 “From 100 to 1000 Layers” 파트에서 살펴볼 것이다. 이제 코드로 ResNet의 구조와 학습 방법을 세세하게 보자.
ResNet code review
코드는 ResNet 모델이 정의되어있는 model.py와 모델을 불러와 학습시키는 train.py로 구성되어있다. 우선 model.py를 통해 ResNet의 구조를 코드를 통해 살펴보겠다. 대략적인 CIFAR 데이터에서 학습하는 ResNet의 흐름은 다음과 같다.
- input image에 대해 conv3x3 + bn + relu 적용(3 channel –> 16 channel)
- 2n 개의 layer (Residual block n개, 16 channel –> 16 channel)
- 2n 개의 layer (Residual block n개, 16 channel –> 32 channel)
- 2n 개의 layer (Residual block n개, 32 channel –> 64 channel)
- global average pooling + fully connected
n에 따라 전체 ResNet의 깊이가 달라진다. n은 3, 5, 7, 9, 18 중에 하나를 사용한다(논문에서 그렇다. 다른 숫자를 사용해도 무방하다). 각각 ResNet-20, ResNet-32, ResNet-44, ResNet-56, ResNet-110에 해당한다. 이 포스트에서는 n을 5로 사용해서 ResNet-32를 학습시켜봤다. ResNet 모델 내부 흐름이 위와 같기 때문에 ResNet의 forward 부분도 다음과 같다.
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.layers_2n(x)
x = self.layers_4n(x)
x = self.layers_6n(x)
x = self.avg_pool(x)
x = x.view(x.size(0), -1)
x = self.fc_out(x)
return x
처음 입력에 적용되는 self.conv1과 self.bn1, self.relu는 모든 ResNet에서 동일하다. 이 함수들을 정의하는 코드는 다음과 같다. 입력으로 RGB 이미지를 사용하기 때문에 convolution layer에 들어오는 input의 channel 수는 3이 된다. Convolution filter의 크기로 3을 사용하고 padding을 1, stride를 1로 사용하면 feature map의 사이즈는 유지된다. 처음 입력으로 3x32x32의 tensor가 들어오면 이 부분을 지나면 16x32x32의 feature map이 된다.
self.conv1 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3,
stride=1, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(16)
self.relu = nn.ReLU(inplace=True)
첫 convolutional layer를 지나면 세 개의 2n layer 짜리 함수를 지난다. 이렇게 2n개씩 구분한 이유는 2n개의 layer마다 feature map의 사이즈가 반이 되고 channel 수는 2배가 되기 때문이다. 처음 2n개의 layer에서 feature map은 16x32x32의 크기를 가진다. 그 다음 2n개의 layer에서는 feature map이 32x16x16의 크기를 가진다. 그 다음 2n개의 layer에서는 feature map이 64x8x8의 크기를 가진다. 각 2n개의 layer는 self.get_layers 함수를 통해 생성한다. self.get_layers의 인자에는 block, in_channels, out_channels, stride가 있다. 만약 in_channels와 out_channels가 다르면 feature map의 사이즈는 반으로 줄고 channel은 2배가 되는 연산이 일어나야한다. 그 부분은 뒤에서 설명하겠다.
# feature map size = 16x32x32
self.layers_2n = self.get_layers(block, 16, 16, stride=1)
# feature map size = 32x16x16
self.layers_4n = self.get_layers(block, 16, 32, stride=2)
# feature map size = 64x8x8
self.layers_6n = self.get_layers(block, 32, 64, stride=2)
self.get_layers의 인자인 block은 residual block을 의미한다. Residual block은 다음 그림과 같다고 위에서 이야기했었다. Shortcut connection이 있고 residual 부분은 conv3x3 + bn + relu + conv3x3 + bn의 연산을 한다. Shortcut connection과 residual은 element-wise addition을 수행한다. Addition을 하고 나서 한 번 더 ReLU를 취한다.
Residual block을 정의하는 코드는 다음과 같다. Residual block은 nn.Module을 상속받는 class로 정의한다. Residual block의 인자는 in_channels, out_channels, stride, down_sample이다. In_channels와 out_channels는 두 개의 convolutional layer 중에 첫 번째 convolution의 in, out channels를 의미한다. 두 번째 convolutional layer는 out_channels로 입력이 들어와서 out_channels로 출력이 나간다. In_channels와 out_channels가 다르면 첫 번째 convolutional layer에서 stride를 2로 사용한다. 이 경우에 shortcut connection으로 전달되는 x와 self.bn2를 지난 out이 크기가 다르다. 그 크기를 맞춰주기 위한 것이 down sample이다. ImageNet에 학습되는 ResNet의 경우 이 down sample로 1x1 convolution을 사용한다. 하지만 CIFAR에 학습되는 ResNet의 경우 down sample로 zero padding을 사용한다.
class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1, down_sample=False):
super(ResidualBlock, self).__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.relu = nn.ReLU(inplace=True)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3,
stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.stride = stride
if down_sample:
self.down_sample = IdentityPadding(in_channels, out_channels, stride)
else:
self.down_sample = None
def forward(self, x):
shortcut = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
if self.down_sample is not None:
shortcut = self.down_sample(x)
out += shortcut
out = self.relu(out)
return out
IdentityPadding을 정의하는 부분은 다음과 같다. Feature map의 사이즈를 줄이는 것은 max pooling을 통해서 하고 feature map의 channel을 늘리는 것은 zero padding을 통해서 해준다. F.pad 함수에서 두 번째 인자가 padding 어떻게 줄 것인지에 대한 것이다. 코드에 (0, 0, 0, 0, 0, self.add_channels)라고 되어있는데 이것은 feature map의 마지막 축에 대해서는 (0, 0)으로 padding하고 마지막에서 두 번째 축에 대해서는 (0, 0), 그리고 마지막에서 세 번째 축은 (0, self.add_channels)로 padding하라는 뜻이다. 따라서 channels 축에 대해서 한 방향으로 self.add_channels만큼 padding이 될 것이다.
class IdentityPadding(nn.Module):
def __init__(self, in_channels, out_channels, stride):
super(IdentityPadding, self).__init__()
self.pooling = nn.MaxPool2d(1, stride=stride)
self.add_channels = out_channels - in_channels
def forward(self, x):
out = F.pad(x, (0, 0, 0, 0, 0, self.add_channels))
out = self.pooling(out)
return out
block을 가지고 2n개의 layer를 만드는 get_layer 함수가 정의된 부분은 다음과 같다. Down sample이 일어날 경우(feature map 사이즈는 반이 되고 channel 수는 2배가 되는 첫 번째 residual block에서 down sample을 적용한다. 나머지 residual block에서는 일반적인 residual block의 구조를 가진다. nn.Sequential(*layers_list)는 layers_list에 들어있는 block들을 차례대로 연산하는 모듈을 생성한다.
def get_layers(self, block, in_channels, out_channels, stride):
if stride == 2:
down_sample = True
else:
down_sample = False
layers_list = nn.ModuleList(
[block(in_channels, out_channels, stride, down_sample)])
for _ in range(self.num_layers - 1):
layers_list.append(block(out_channels, out_channels))
return nn.Sequential(*layers_list)
이 ResNet은 다음과 같은 함수를 통해 정의된다. n을 5로 사용하면 6x5 + 2 = 32개의 층을 가지는 ResNet을 만드는 것이다.
def resnet():
block = ResidualBlock
# total number of layers if 6n + 2. if n is 5 then the depth of network is 32.
model = ResNet(5, block)
return model
이제 train.py의 내용을 살펴보자. CIFAR-10 데이터를 가지고 학습하려면 데이터를 불러오고 학습이나 테스트를 위해 mini-batch 형식으로 읽어와야 한다. PyTorch는 torchvision 안에 데이터셋으로 CIFAR10을 가지고 있다. 따라서 밑의 코드처럼 CIFAR10으로 함수를 정의하면 데이터를 사용할 수 있다. Download를 True로 설정하면 자동으로 ../data 폴더에 다운로드를 한다. Train이 True이면 training dataset을 불러오는 것이며 50000개의 이미지 데이터를 가져온다. Train이 False이면 testing dataset을 불러오는 것이고 10000개의 이미지 데이터를 가져온다. DataLoader는 mini batch 사이즈만큼 호출될 때마다 이미지와 라벨을 가져오는 함수이다. 어떤 데이터셋에서 mini batch를 가져올지 인자로 넣어주면 된다.
from torchvision.datasets import CIFAR10
from torch.utils.data import DataLoader
dataset_train = CIFAR10(root='../data', train=True,
download=True, transform=transforms_train)
dataset_test = CIFAR10(root='../data', train=False,
download=True, transform=transforms_test)
train_loader = DataLoader(dataset_train, batch_size=args.batch_size,
shuffle=True, num_workers=args.num_worker)
test_loader = DataLoader(dataset_test, batch_size=args.batch_size_test,
shuffle=False, num_workers=args.num_worker)
CIFAR10 데이터셋을 정의할 때 각 데이터셋에 적용될 augmentation을 정해준다. Training dataset은 4로 padding한 이후에 32의 크기로 random cropping을 하고 horizontal flip을 랜덤하게 수행한다. 그 이후에 이미지의 평균과 표준편차로 standardization 해준다. Testing dataset은 standardization만 수행한다.
transforms_train = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
transforms_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
PyTorch에서는 다음과 같은 코드로 모델에 포함되어있는 parameter의 수를 셀 수 있다. 논문에 따르면 ResNet-32의 경우 0.46M개의 parameter가 있다. 코드에서 출력한 parameter의 개수는 464154개이다. 따라서 모델의 구조를 제대로 설정했음을 알 수 있다.
net = resnet()
net = net.to(device)
num_params = sum(p.numel() for p in net.parameters() if p.requires_grad)
criterion은 loss function을 의미한다. Classification 문제이기 때문에 cross entropy를 사용한다. Optimizer는 SGD with momentum을 사용한다. SGD의 learning rate는 처음에 0.1로 설정하고 32000 번 업데이트를 하고나면 0.01로 48000번 업데이트를 하고나면 0.001로 설정한다. Pytorch에는 lr_scheduler가 있어서 이런 learning rate decay를 손쉽게 할 수 있도록 지원한다.
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.1,
momentum=0.9, weight_decay=1e-4)
decay_epoch = [32000, 48000]
step_lr_scheduler = lr_scheduler.MultiStepLR(optimizer,
milestones=decay_epoch, gamma=0.1)
Training 부분은 상당히 간단하다. 모델 net을 train()을 통해 학습 모드로 전환해준다. train_loader에서 mini batch씩 데이터를 꺼내와서 loss를 계산한다. 계산한 loss에 대해 batch propagation을 하고 optimizer가 net을 업데이트한다. learning rate scheduler가 update step 단위로 체크하기 때문에 매 mini batch마다 한 번씩 수행해준다. 테스트 코드도 이와 동일하기 때문에 설명은 생략한다.
def train(epoch, global_steps):
net.train()
train_loss = 0
correct = 0
total = 0
for batch_idx, (inputs, targets) in enumerate(train_loader):
global_steps += 1
step_lr_scheduler.step()
inputs = inputs.to(device)
targets = targets.to(device)
outputs = net(inputs)
loss = criterion(outputs, targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
_, predicted = outputs.max(1)
total += targets.size(0)
correct += predicted.eq(targets).sum().item()
acc = 100 * correct / total
print('train epoch : {} [{}/{}]| loss: {:.3f} | acc: {:.3f}'.format(
epoch, batch_idx, len(train_loader), train_loss/(batch_idx+1), acc))
ResNet-32를 64000 step 동안 업데이트한 학습 과정은 다음과 같다. 왼쪽은 test dataset에 대한 error rate(%)이고 오른쪽은 train dataset에 대한 error rate(%)이다. 가장 낮은 test error rate는 7.46%인데 논문의 결과는 7.51%이니 재현이 되었다고 본다. 밑의 그림은 논문의 학습 곡선이다. 실선이 test error rate이고 점선이 train error rate이다. ResNet-32로 표시된 결과와 비교해도 결과가 같다.
코드는 https://github.com/dnddnjs/pytorch-cifar10/tree/enas/resnet 에서 다운로드 받을 수 있다. Pytorch, torchvision, numpy, tensorboardx, tensorboard, tensorflow를 설치해야한다. 다음과 같은 명령어로 실행할 수 있으니 직접 학습시켜보기 바란다.
python train.py
From 100 to 1000 Layers
ResNet이 이전의 네트워크에 비해 상당히 깊은 네트워크를 학습시키며 degradation 문제를 해결했다. 하지만 1000 층 정도의 ResNet은 여전히 degradation 문제를 가지고 있다. ResNet의 저자인 Kaiming He는 다음 해에 Identity Mappings in Deep Residual Networks8이라는 논문을 발표했다. 이 논문에서는 새로운 Residual block의 구조를 제안하며 1001 층의 ResNet의 성능을 올렸다. ResNet-1001층의 CIFAR-10에서의 test error rate는 4.62%이다. 여기서는 간단히 어떻게 개선을 했는지만 소개할 것이다.
아래 그림에서 왼쪽이 어떻게 residual block의 구조를 바꿨는지 보여준다. 원래는 conv3x3 + bn + relu + conv3x3 + bn의 연산을 한 이후에 shortcut connection과 더하고 그 이후에 relu를 취했다. 하지만 새로운 residual block에서는 bn + relu + conv3x3 + bn + relu + conv3x3의 연산을 취한 뒤에 shortcut connection과 더한다. 기존 residual block을 post-activation이라고 부르고 새로운 residual block을 pre-activation이라고 부른다.
논문에서는 새로운 residual block의 구조가 더 높은 성능을 달성한 것은 두 가지 이유가 있다고 설명한다. (1) optimization을 더 쉽게 해주었다. 위 학습 그래프를 보면 기존 ResNet보다 Pre-activation ResNet이 training loss가 더 빠르게 주는 것을 볼 수 있다. 이것은 기존 residual block에서는 ReLU 이후에 다음 residual block으로 넘어가기 때문에 back-propagation 할 때 ReLU에 의해 truncated 될 수 있기 때문이다. 하지만 새로운 residual unit에서는 back-propagation 할 때 identity에 대한 부분은 출력부분부터 입력부분까지 유지될 수가 있다. 따라서 좀 더 optimization이 쉬워진다. (기존 ResNet 또한 100 층 정도까지는 optimization에 어려움이 생기지는 않는다)
(2) Batch-normalization으로 인한 regularization 효과 때문이다. 기존 Residual Block의 경우 BN을 한 이후에 identity mapping과 addition을 해주고 그 이후에 ReLU가 나온다. 따라서 Batch normalization의 regularization 효과를 충분히 보지 못한 것이다. 새로운 residual block에서는 Batch normalization 이후에 바로 ReLU가 나오므로 성능 개선의 여지가 있던 것이다.
이 논문 이후에 나오는 논문에서는 Pre-activation 구조를 사용하는 경우가 많다. 따라서 이 논문의 내용을 알고 있는 것이 좋다. 자세한 내용은 논문을 읽어보는 것을 추천한다.
참고문헌
https://arxiv.org/pdf/1512.03385.pdf ↩︎
http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf ↩︎
https://arxiv.org/pdf/1502.01852.pdf ↩︎
http://proceedings.mlr.press/v37/ioffe15.pdf ↩︎
https://arxiv.org/pdf/1409.4842.pdf ↩︎
https://www.cv-foundation.org/openaccess/content_cvpr_2015/papers/He_Convolutional_Neural_Networks_2015_CVPR_paper.pdf ↩︎
https://arxiv.org/pdf/1505.00387.pdf ↩︎
https://arxiv.org/pdf/1603.05027.pdf ↩︎