내 풀이 github LINK: https://github.com/qkrtmdtj04/CS231n-Assignment
GitHub - qkrtmdtj04/CS231n-Assignment
Contribute to qkrtmdtj04/CS231n-Assignment development by creating an account on GitHub.
github.com
지난 포스팅에서 CIFAR-10을 분류하면서 VGG와 ResNet을 간단히 비교해 봤는데, 10 Epoch에서는 두 모델의 성능 차이가 0.3% p에 불과했다. 그때 말했듯이, ResNet은 네트워크가 훨씬 깊어지고 학습이 길어질 때 드러난다. 이번 포스팅에서는 ResNet 원 논문(https://arxiv.org/abs/1512.03385)를 간략하게 요약 정리하고, 내가 직접 구현한 ResNet-110의 코드를 뜯어보겠다.
Deep Residual Learning for Image Recognition (He et al., 2015)
1. Introduction — 깊게 쌓으면 왜 성능이 떨어질까?
딥러닝의 직관은 단순하다. 레이어를 더 쌓을수록 더 복잡한 표현을 학습할 수 있다. 하지만 논문 저자들은 실험에서 충격적인 사실을 발견한다.

56층 네트워크가 20층 네트워크보다 훈련 에러가 더 높다.
Overfitting 문제가 아니다. 훈련 데이터에서조차 더 깊은 네트워크가 더 못한다. 이를 논문에서는 Degradation Problem(성능 저하 문제)이라고 부른다.
이론적으로는, 얕은 네트워크의 끝에 레이어를 더 쌓되 그 레이어들이 그냥 항등함수(Identity)처럼 동작하면, 최소한 얕은 네트워크만큼은 나와야 한다. 근데 현실에서는 그게 안 된다. Optimizer가 항등함수를 학습하는 것조차 어렵기 때문이다.
2. Deep Residual Learning
2-1. Residual Learning
해결책은 발상의 전환이다. 기존 네트워크가 학습하려는 함수를 H(x)라고 하자. ResNet은 네트워크가 H(x)를 직접 학습하는 대신, F(x) = H(x) - x 즉 잔차(Residual) 만 학습하도록 구조를 바꾼다. 최종 출력은 F(x) + x가 된다. => (학습할 잔차) + (입력 그대로)
왜 이게 더 쉬울까? 직관적인 비유를 들어보자.
예를 들어, 100점짜리 답안에서 틀린 부분만 찾아내는 것은 정답 전체를 처음부터 다시 쓰는 것보다 훨씬 쉽다.. 잔차 학습이 정확히 이 방식이다. 네트워크는 "입력에서 뭘 바꿔야 하는가(F(x))"만 집중적으로 학습하면 되고, 바꿀 게 없다면 그냥 0을 출력하면 된다.
반면 기존 방식은 레이어가 매번 H(x) 전체를 처음부터 새로 만들어내야 한다. 입력과 출력이 이미 비슷한 상황(깊은 레이어로 갈수록 이런 경우가 많다)에서는 이 방식이 오히려 비효율적이고, Optimizer 입장에서 수렴하기 어렵다.
2-2. Identity Mapping by Shortcuts

빌딩 블록의 기본 형태는 위에 사진과 같다. 여기서 F(x)는 잔차 함수이고, 입력 x는 shortcut을 통해 그대로 더해진다. 이 덧셈은 element-wise addition으로 수행되며, 추가적인 파라미터나 연산량이 거의 들지 않는다.
Shortcut은 입력과 출력의 차원이 같은 경우에는 identity mapping을 그대로 사용한다. 즉, 입력 x를 변형 없이 더해준다.
반면, 다운샘플링 등으로 인해 차원이 달라지는 경우에는 두 가지 방법이 사용된다.
- Option A: Zero-padding으로 부족한 차원을 0으로 채워 맞춘다 (추가 파라미터 없음)
- Option B: 1×1 convolution을 사용해 차원을 맞춘다. (Projection Shortcut)
논문의 CIFAR-10 설정에서는 Option A를 사용했지만, 본 구현에서는 학습 안정성을 고려해 Projection Shortcut을 사용하였다.
(self.conv3 = nn.Conv2d(in_channel, out_channel, 1, stride, 0, bias=False))
2-3. Network Architectures

Plain 네트워크는 VGG 철학을 따른다. 같은 출력 feature map 크기에서는 필터 수를 동일하게 유지하고, feature map 크기가 절반으로 줄면 필터 수를 두 배로 늘려 레이어당 연산량(time complexity)을 보존한다. 다운샘플링은 stride 2 Conv로 수행한다. 34층 baseline의 FLOPs는 3.6B으로 VGG-19(19.6B)의 18%에 불과하다.
Residual 네트워크는 이 plain 네트워크에 shortcut만 추가한 것이다. 차원이 같을 때는 identity shortcut(실선), 차원이 달라질 때는 option A 또는 B(점선)를 사용한다.
3. CIFAR-10 Experiments — 논문의 ResNet-110

논문에서 CIFAR-10 실험용 아키텍처는 ImageNet과 다르게 설계된다.
- 입력: 32×32 이미지
- 첫 레이어: 3×3 Conv, 16 채널
- 이후 세 Stage로 구성:
- Stage 1: 16 채널 × n개 블록, 공간 크기 32 ×32 유지
- Stage 2: 32 채널 × n개 블록, 공간 크기 16 ×16 (stride=2 다운샘플링)
- Stage 3: 64 채널 × n개 블록, 공간 크기 8 ×8 (stride=2 다운샘플링)
- 마지막: Global Average Pooling → FC(10)
n=18일 때, 각 Stage에 블록이 18개씩, 총 레이어 수는 1 + (18 ×2) ×3 + 1 = 110층이 된다.
+ 이번 구현에서는 원 논문의 Post-activation 대신, 후속 연구인 Identity Mappings in Deep Residual Networks (2016)에서 제안된 Pre-activation ResBlock을 사용하였다. 이 구조는 더 안정적인 gradient 전달을 위해 현재도 널리 사용된다.
기존 순서인 Conv → BN → ReLU 대신 BN → ReLU → Conv 순으로 배치하는 방식으로, 이론적으로 기울기 흐름이 더 깨끗해진다는 장점이 있다.
학습 설정은 다음과 같다:
- SGD + Momentum 0.9, Weight Decay 1e-4
- 초기 lr=0.1, 82 Epoch과 123 Epoch에서 각각 ×0.1 감소
- 총 164 Epoch 학습
구현 코드 리뷰
1. ResBlock: 첫 번째 버전 vs 업그레이드 버전
이전 포스팅(Q5-4)에서 처음 구현한 ResBlock과 이번 ResNet-110에 사용한 ResBlock 사이에는 중요한 차이가 있다.
[이전 버전] Conv → BN → ReLU (Post-activation)
class ResBlock(nn.Module):
def __init__(self, in_channel, out_channel, down_sample=False):
super().__init__()
stride = 1
if down_sample:
stride = 2
self.pro_cut = False
if in_channel != out_channel or down_sample:
self.pro_cut = True
self.conv3 = nn.Conv2d(in_channel, out_channel, 1, stride, 0)
self.bn3 = nn.BatchNorm2d(out_channel)
self.conv1 = nn.Conv2d(in_channel, out_channel, 3, stride, 1)
self.conv2 = nn.Conv2d(out_channel, out_channel, 3, 1, 1)
self.relu = nn.ReLU()
self.bn1 = nn.BatchNorm2d(out_channel)
self.bn2 = nn.BatchNorm2d(out_channel)
def forward(self, x):
# Conv → BN → ReLU → Conv → BN → Add → ReLU
x_conv1 = self.relu(self.bn1(self.conv1(x)))
x_conv2 = self.bn2(self.conv2(x_conv1))
if self.pro_cut:
pro_Identity_x = self.bn3(self.conv3(x))
scores = x_conv2 + pro_Identity_x
else:
scores = x_conv2 + x
scores = self.relu(scores)
return scores
[업그레이드 버전] BN → ReLU → Conv (Pre-activation)
class ResBlock(nn.Module):
def __init__(self, in_channel, out_channel, down_sample=False):
super().__init__()
stride = 2 if down_sample else 1
self.bn1 = nn.BatchNorm2d(in_channel) # ← 입력 채널 기준
self.relu = nn.ReLU(inplace=True)
self.conv1 = nn.Conv2d(in_channel, out_channel, 3, stride, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channel)
self.conv2 = nn.Conv2d(out_channel, out_channel, 3, 1, 1, bias=False)
self.pro_cut = (in_channel != out_channel or down_sample)
if self.pro_cut:
self.conv3 = nn.Conv2d(in_channel, out_channel, 1, stride, 0, bias=False)
def forward(self, x):
# BN → ReLU → Conv (Pre-activation)
out = self.conv1(self.relu(self.bn1(x)))
out = self.conv2(self.relu(self.bn2(out)))
shortcut = self.conv3(x) if self.pro_cut else x
return out + shortcut
코드의 실행 흐름을 따라가 보면 먼저 입력 x는 BatchNorm → ReLU → Conv 순서로 Residual Branch를 통과한다. 첫 번째 Convolution에서는 필요한 경우 stride=2를 적용하여 공간 크기를 절반으로 줄이고, 두 번째 Convolution에서는 stride를 1로 유지하면서 잔차 함수 (F(x))를 계산한다.
한편 Shortcut Branch는 훨씬 단순하다. 입력과 출력의 채널 수 및 Feature Map 크기가 동일하면 입력 x를 그대로 사용한다. 하지만 다운샘플링을 수행하거나 채널 수가 달라지는 경우에는 1 ×1 Convolution(conv3)을 사용해 크기와 채널 수를 맞춘 뒤 Residual Branch의 출력과 더한다. 즉, 두 Branch의 출력 Shape가 같아야만 Element-wise Addition이 가능하다.
최종적으로 두 Branch를 더한 값이 블록의 출력이 된다. 기존 Post-activation 구조와 달리 Add 이후에는 ReLU를 적용하지 않는다. 대신 다음 ResBlock의 BatchNorm → ReLU가 이를 처리한다. 이러한 Pre-activation 구조는 Identity Shortcut 경로를 가능한 한 순수하게 유지하여 매우 깊은 네트워크에서도 Gradient가 안정적으로 전달되도록 돕는다.
두 버전의 차이를 정리하면 다음과 같다.
| 항목 | 이전 버전 | 업그레이드 버전 |
| 활성화 순서 | Conv→BN→ReLU (Post-activation) | BN→ReLU→Conv (Pre-activation) |
| bias | True (기본값) | bias=False (BN이 있으면 bias 불필요) |
| Projection BN | self.bn3 포함 | 제거 (1×1 Conv만 사용) |
| 마지막 ReLU | Add 후 ReLU 적용 | 없음 (다음 블록의 BN→ReLU가 처리) |
이러한 변화의 핵심은 Identity Shortcut 경로를 최대한 순수하게 유지하는 것이다. Pre-activation에서는 Skip Connection에 BN이나 ReLU가 포함되지 않으므로 역전파 시 Gradient가 거의 그대로 전달된다. 덕분에 매우 깊은 네트워크에서도 gradient 문제가 완화된다. 또한 Batch Normalization이 Convolution의 Bias를 상쇄하므로 bias=False를 사용해 불필요한 파라미터를 줄였다.
2. ResNet-110 아키텍처
resnet110_update_model = nn.Sequential(
nn.Conv2d(3, 16, 3, 1, 1, bias=False), # Stem: 32×32, 16채널
# Stage 1: 16채널 × 18블록 (32×32)
ResBlock(16,16), ResBlock(16,16), ...(×18개),
# Stage 2: 32채널 × 18블록 (16×16), 첫 블록에서 stride=2 다운샘플링
ResBlock(16,32,True), ResBlock(32,32), ...(×18개),
# Stage 3: 64채널 × 18블록 (8×8), 첫 블록에서 stride=2 다운샘플링
ResBlock(32,64,True), ResBlock(64,64), ...(×18개),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d((1,1)), # Global Average Pooling
nn.Flatten(),
nn.Linear(64, 10)
)
전체 네트워크의 데이터 흐름을 정리하면 다음과 같다.
입력 이미지(32 ×32 ×3) → Stem Convolution → Stage 1(32 ×32, 16 채널) → Stage 2(16 ×16, 32 채널) → Stage 3(8 ×8, 64 채널) → Global Average Pooling → Fully Connected Layer → Class Score
Stage가 바뀌는 첫 번째 ResBlock에서는 stride=2를 사용하여 공간 크기를 절반으로 줄이는 동시에 채널 수를 두 배로 늘린다. 이후 같은 Stage 안에서는 Feature Map 크기를 유지한 채 Residual Learning만 수행한다. 이러한 구조는 공간 해상도는 점차 줄이면서도 채널 수를 증가시켜 더 풍부한 특징을 학습하도록 설계되어 있다.
3. 결과

Val Accuracy: 87.10%
Test Accuracy: 85.97%
원 논문의 ResNet-110은 CIFAR-10에서 약 93% 수준의 정확도를 보고했다. 이번 구현은 Data Augmentation 없이 학습했고, Projection Shortcut 및 Pre-activation 구조를 적용했음에도 약 86%의 정확도를 얻었다. 그래도 기존 과제에서 할 때와 비교한다면 10 Epoch 비교 실험에서 ResNet이 ~79%였던 것이 동일한 아키텍처(더 깊어졌지만)를 논문의 설정대로 164 Epoch 학습시키니 85%대까지 올라갔다. 이는 논문 수준에는 미치지 못하지만 학습 설정과 데이터 증강의 차이를 고려하면 합리적인 결과라고 볼 수 있다. (Colab에서 약 3시간 동안 학습이 진행되었다.)
학습 초반에는 loss가 빠르게 감소하고 validation accuracy가 급상승한다. 이후 82 epoch과 123 epoch에서 learning rate를 0.1배로 줄인 뒤, validation accuracy가 한 단계씩 더 상승하는 모습을 확인할 수 있다.
+ 논문의 learning rate schedule을 그대로 적용했다.
MultiStepLR Scheduler — 논문의 lr 감소 전략 구현
scheduler = optim.lr_scheduler.MultiStepLR( optimizer, milestones=[82, 123], gamma=0.1 )
논문에서 제시한 학습률 스케줄을 그대로 구현한 것이다. 82 Epoch과 123 Epoch에서 lr를 각각 ×0.1씩 줄인다. 처음에는 높은 lr로 빠르게 수렴하다가, 후반부로 갈수록 더 정밀하게 파인튜닝하는 방식이다. 학습 곡선을 그려보면 이 두 지점에서 validation accuracy가 확 뛰는 것을 확인할 수 있다. lr 감소가 모델 성능에 직접적인 영향을 준다는 증거다.
마치며
코드를 구현하면서 느낀 것은, 논문 아이디어를 이해하는 것과 실제로 잘 학습시키는 것 사이에는 꽤 큰 간격이 있다는 점이다. Pre-activation, bias=False, AdaptiveAvgPool, MultiStepLR... 이런 디테일들이 모여서 85%라는 숫자가 나온다. 또한 데이터셋 특성에 맞게 모델 구조를 달리 설계해야 한다는 점도 이번 구현에서 배운 중요한 내용이었다.