Deep Residual Learning · 인터랙티브 가이드

ResNet은 어떻게 동작하는가

2015년, 층을 깊게 쌓을수록 오히려 성능이 나빠지던 시대에 단 하나의 수식이 판을 바꿨습니다.

y = F(x) + x

이 페이지는 원 논문 Deep Residual Learning for Image Recognition(He et al., 2015)과 후속 논문 Identity Mappings in Deep Residual Networks(2016)를 바탕으로, 잔차 연결(residual connection)이 실제로 어떻게 동작하는지 브라우저 안에서 직접 시뮬레이션하며 배우는 교육 자료입니다. 모든 실험은 여러분의 브라우저에서 JavaScript로 실시간 계산됩니다.

1깊이의 역설 — 층을 쌓을수록 나빠진다?

AlexNet(2012)은 8층, VGG(2014)는 19층, GoogLeNet(2014)은 22층. 깊이가 곧 성능이던 시절, 자연스러운 질문이 나옵니다. “그냥 층을 더 쌓으면 더 좋아지지 않을까?”

ResNet 저자들은 CIFAR-10에서 잔차 연결이 없는 plain 네트워크를 20층과 56층으로 쌓아 비교했습니다. 결과는 반대였습니다 — 56층 네트워크가 20층보다 훈련 오류(training error)조차 더 높았습니다.

Plain 네트워크의 훈련 오류 (CIFAR-10) 원 논문 Figure 1을 재구성한 근사 곡선 · 마우스를 올려 값 확인

이것이 열화 문제(degradation problem)입니다. 핵심은 이 현상이 과적합(overfitting)이 아니라는 점입니다 — 테스트 오류가 아니라 훈련 오류 자체가 높으므로, 모델이 데이터를 “외우지 못하는” 것입니다.

왜 이상한가: 이론상으로는 깊은 쪽이 절대 지면 안 됩니다

20층 모델이 있다면, 그 위에 36개의 항등(identity) 층 — 입력을 그대로 통과시키는 층 — 을 얹어 56층 모델을 만들 수 있습니다. 이렇게 “만들어진 해(solution by construction)”는 20층과 정확히 같은 성능을 냅니다. 즉 깊은 모델의 해공간은 얕은 모델을 포함합니다. 그런데도 SGD는 이런 해를 찾지 못합니다. 결론: 이것은 표현력의 문제가 아니라 최적화의 문제입니다. 여러 겹의 비선형 층으로 항등 사상을 근사하는 것이 생각보다 어렵기 때문입니다.

ImageNet에서도 같은 현상이 재현됩니다. 잔차 연결 유무만 다르고 나머지는 동일한 네트워크의 검증 오류(top-1, 10-crop)입니다.

깊이plainResNet효과
18층27.94%27.88%비슷 (얕으면 plain도 학습됨)
34층28.54% ▲ 더 깊은데 더 나쁨25.03%깊이가 이득으로 전환

2핵심 아이디어 — 목표 대신 “차이”를 학습한다

어떤 층 묶음이 배워야 할 이상적인 변환을 H(x)라고 합시다. ResNet의 발상은 층이 H(x)를 통째로 배우게 하지 말고, 입력과의 차이(잔차, residual)만 배우게 하자는 것입니다.

F(x) := H(x)x ← 층이 실제로 학습하는 것 (잔차)
y = F(x) + x ← 출력 = 잔차 + 입력 (shortcut으로 구현)

두 형태는 수학적으로 동등하지만 배우기 쉬운 정도가 다릅니다. 만약 항등 사상이 최적에 가깝다면 — 깊은 네트워크의 많은 층이 실제로 그렇습니다 — plain 층은 여러 비선형 층으로 H(x) ≈ x를 힘겹게 근사해야 하지만, 잔차 층은 그냥 가중치를 0으로 밀어 F(x) ≈ 0을 만들면 됩니다. 0을 배우는 건 쉽습니다.

비유: 전화 게임(말 전달하기)

여러 사람을 거쳐 귓속말로 문장을 전달하면 끝에 가서는 원문이 뭉개집니다. 층이 깊은 plain 네트워크가 정확히 이 상황입니다. 잔차 연결은 중간 사람들을 건너뛰어 쪽지를 직접 전달하는 지름길입니다. 각 사람(층)은 이제 문장 전체를 다시 말할 필요 없이, 쪽지에 덧붙일 수정 사항만 말하면 됩니다.

잔차 블록 해부 데이터가 두 갈래 경로로 흐르는 과정 · 버튼으로 모드를 바꿔보세요

구현은 놀랄 만큼 단순합니다. shortcut은 파라미터도, 추가 연산도 거의 없는 단순 덧셈입니다(element-wise addition). 그래서 plain 34층과 ResNet 34층은 파라미터 수와 FLOPs가 사실상 동일합니다 — 성능 차이는 순수하게 최적화가 쉬워진 것에서 옵니다.

3신호 전파 시뮬레이터 — 곱셈의 세계 vs 덧셈의 세계

잔차 연결이 왜 최적화를 쉽게 만드는지는 후속 논문 Identity Mappings in Deep Residual Networks가 수식으로 보여줍니다. shortcut이 순수한 항등이면, 잔차 블록을 재귀적으로 풀었을 때:

xL = xl + Σ F(xi) ← 순전파: 어떤 깊은 층도 얕은 층 + 잔차의 합 (plain은 가중치의 Π Wix)
∂E/∂xl = ∂E/∂xL · ( 1 + ∂/∂xl Σ F ) ← 역전파: 1 덕분에 그래디언트가 어떤 층에도 직통으로 전달

plain 네트워크에서 신호와 그래디언트는 층마다 가중치가 곱해지며 전달됩니다. 곱이 1보다 조금만 작아도 지수적으로 소멸하고, 조금만 커도 폭발합니다. 잔차 네트워크는 더하기의 세계라 항상 “1”이라는 안전한 직통 경로가 남습니다. 아래에서 무작위 가중치를 가진 네트워크를 실제로 만들어 확인해 보세요.

층별 그래디언트 크기 (실시간 계산) 폭 32의 무작위 네트워크를 생성해 역전파를 실제로 수행한 결과 · y축은 로그 스케일

관찰: 초기화 배율을 1.0에서 조금만 내려보세요(예: 0.85). plain(주황)의 그래디언트는 층마다 가중치가 곱해지므로 입력 쪽으로 갈수록 지수적으로 소멸하고, 배율을 올리면 반대로 폭발합니다 — 정확히 맞는 초기화(1.0×)에서만 아슬아슬하게 유지되는 “외줄타기”입니다. residual(파랑)은 어떤 배율에서도 “1” 직통 경로 덕분에 그래디언트가 1 아래로 소멸하지 않습니다. (시뮬레이션은 실제 ResNet의 BN처럼 잔차 브랜치 입력을 정규화합니다. residual이 입력 쪽에서 완만하게 커지는 것은 여러 블록의 잔차 그래디언트가 계속 더해지기 때문 — 곱셈이 아닌 덧셈이라 폭발하지 않습니다.)

shortcut을 λ배로 스케일하면? — identity가 유일한 정답인 이유 xl+1 = λ·xl + F(xl) 로 바꿔 실험 (Identity Mappings 논문 §3)

shortcut 경로의 기여는 λ의 거듭제곱(Π λ = λL)으로 쌓입니다. λ = 0.95처럼 살짝만 줄여도 100층 뒤에는 0.6%만 남고, 1.05면 130배로 폭발합니다. 실제로 논문에서 λ = 0.5로 스케일한 110층 ResNet은 오류가 6.61% → 12.35%로 악화됐고, shortcut에 게이트·1×1 conv·dropout을 끼워 넣은 변형은 모두 원본보다 나빴습니다. “깨끗한” 항등 shortcut이 유일하게 안전합니다.

4미니 학습 실험 — 같은 깊이, 다른 운명

이론은 충분합니다. 이제 실제로 두 네트워크를 훈련시켜 봅시다. 나선형 2클래스 데이터를 분류하는 완전연결 네트워크(폭 12)를, 층수·파라미터 수가 동일한 plain 버전과 residual 버전으로 만들어 같은 데이터·같은 학습률로 동시에 훈련합니다. 전부 지금 여러분의 브라우저에서 계산됩니다.

plain vs residual 실시간 훈련 동일 조건 · SGD(momentum) · 손실 y축은 로그 스케일
은닉층 깊이:
plain
residual

사용법: 깊이를 고르고 훈련을 시작하세요. 배경색은 각 네트워크가 학습한 결정 경계입니다. 4층·20층에서는 대개 둘 다 학습에 성공합니다 (얕으면 plain도 문제없음 — 1절에서 plain-18이 ResNet-18과 비슷했던 것과 같은 이치입니다. 다만 plain 쪽이 더 느리거나 불안정한 경우가 많습니다). 48층을 눌러보세요 — plain은 대개 정확도 50%(동전 던지기 수준)에서 전혀 벗어나지 못하는 반면, residual은 같은 깊이에서도 문제없이 학습됩니다. 무작위 초기화라 실행마다 결과가 조금씩 다르니 여러 번 실행해 보세요. (48층은 계산량이 많아 20초쯤 걸립니다.)

5전체 아키텍처 — 블록을 쌓아 네트워크 만들기

ResNet은 하나의 잔차 블록을 모듈처럼 반복해 쌓는 구조입니다. ImageNet용 ResNet은 공통적으로: 7×7 conv(stride 2) → 3×3 max pool → 4개의 스테이지(conv2_x ~ conv5_x) → global average pooling → 1000-way fc. 스테이지가 바뀔 때마다 해상도는 절반, 채널은 2배가 됩니다.

아키텍처 탐색기 모델을 선택하고 블록에 마우스를 올려 구성 확인 · ⤓ 표시는 stride-2 다운샘플 블록(점선 projection shortcut)

차원이 바뀌는 경계(각 스테이지의 첫 블록)에서는 shortcut도 차원을 맞춰야 합니다. 논문은 1×1 conv로 차원을 사상하는 projection shortcut(option B)을 차원 증가 시에만 쓰고, 나머지는 전부 파라미터 없는 identity를 사용합니다 — 실험 결과 projection을 늘려도 이득이 미미했고, identity가 병목 구조의 효율에 필수적이기 때문입니다.

Basic 블록 vs Bottleneck 블록

50층 이상에서는 연산량을 아끼기 위해 병목(bottleneck) 블록을 씁니다. 1×1 conv로 채널을 압축(256→64)하고, 그 좁은 공간에서 3×3 conv를 수행한 뒤, 다시 1×1 conv로 복원(64→256)합니다. 비싼 3×3 연산이 좁은 차원에서만 일어나므로, 거의 같은 비용으로 층은 3개, 입출력 채널은 4배가 됩니다.

두 블록의 파라미터 비교 숫자에 마우스를 올려 계산식 확인

이 효율 덕분에 ResNet-152(11.3 GFLOPs)조차 VGG-19(19.6 GFLOPs)보다 가볍습니다. 8배 깊지만 연산량은 42% 줄었습니다.

ImageNet 검증 오류 (top-5, 10-crop) 원 논문 Table 3 · 낮을수록 좋음

plain-34는 VGG-16보다도 나빴지만, 같은 구조에 shortcut만 더한 ResNet-34는 훌쩍 앞섭니다. 그리고 잔차 연결 덕분에 깊이를 152층까지 늘리는 것이 계속 이득이 됩니다. 최종적으로 ResNet 앙상블은 top-5 오류 3.57%로 ILSVRC 2015 분류 부문에서 우승했고, 검출·위치추정·세그멘테이션 부문까지 석권했습니다.

6ResNet v2 — “깨끗한” 지름길의 완성

원조 ResNet(v1)에도 옥의 티가 있었습니다. 덧셈 뒤에 ReLU가 오기 때문에 shortcut 경로가 완전한 항등이 아니었던 것입니다. 1000층급 초심층에서는 이 작은 방해도 누적되어 문제가 됩니다. 후속 논문은 BN과 ReLU를 conv 으로 옮긴 pre-activation 구조를 제안했습니다.

v1 · post-activation
convBNReLU convBN ⊕ 덧셈ReLU ← 덧셈 뒤에 있어 identity 경로를 방해
v2 · pre-activation
BNReLUconv BNReLUconv ⊕ 덧셈 — 뒤에 아무것도 없음: 완전한 identity 경로

2절에서 해부했던 블록을 v2 구성으로 다시 그리면 차이가 분명합니다. 덧셈 노드 뒤가 텅 비어 있고, 역전파로 바꿔 보면 그래디언트가 어떤 연산도 거치지 않고 shortcut을 타고 그대로 빠져나갑니다.

잔차 블록 해부 — ResNet v2 (pre-activation) BN·ReLU가 conv 앞으로 이동 · 덧셈 뒤에는 아무 연산도 남지 않습니다

shortcut을 “더 똑똑하게” 만들려는 시도는 전부 실패했습니다. 110층 ResNet(CIFAR-10)으로 수행한 절제 실험(ablation):

shortcut 변형테스트 오류결과
순수 identity (원본)6.61%기준
0.5배 상수 스케일12.35%크게 악화
게이트 (highway 스타일)8.70%악화
1×1 conv shortcut12.22%크게 악화
dropout shortcut수렴 실패실패

pre-activation의 위력은 극단적 깊이에서 드러납니다. 1001층 ResNet(CIFAR-10)에서 v1은 7.61%에 그쳤지만 v2는 4.92%를 달성하며 훨씬 쉽게 훈련됐습니다. “깊이 자체는 더 이상 적이 아니다 — 최적화를 방해하지만 않는다면”이 증명된 순간입니다.

한 걸음 더 — 왜 경로가 2n개인가

후속 연구(Veit et al., 2016)는 ResNet을 완전히 다른 각도에서 봤습니다. 잔차 블록을 “층을 쌓은 사슬”이 아니라 “경로가 갈라지는 갈림길”로 보면, n개 블록짜리 ResNet에는 입력에서 출력까지 가는 길이 2n 존재합니다.

이유는 단순합니다. 잔차 블록은 y = x + F(x), 즉 신호가 블록마다 두 갈래로 갈라졌다가 덧셈에서 다시 합쳐집니다. 블록 하나를 지날 때마다 “잔차 브랜치 F를 통과할까, shortcut으로 건너뛸까”라는 이지선다가 한 번씩 생기는 셈입니다. 선택이 n번 반복되니 가능한 조합은 2 × 2 × … × 2 = 2n. plain 네트워크에는 갈림길이 없으므로 경로가 딱 하나뿐입니다.

경로 펼쳐보기 (unraveled view) 블록 수를 바꿔가며 경로가 어떻게 두 배씩 늘어나는지 확인해 보세요
블록 수
F 잔차 브랜치를 통과 shortcut으로 건너뜀

길이별 경로 수가 1 · 3 · 3 · 1처럼 나오는 것을 눈치채셨나요? 길이가 k인 경로는 “n개 블록 중 k개를 고르는 경우의 수”, 즉 이항계수 C(n, k)개입니다. 그리고 이 값들을 모두 더하면 정확히 2n이 됩니다.

역전파로 보면 이 “2n개”가 비유가 아니라 수식 그대로라는 게 분명해집니다. 블록의 야코비안은 ∂(x + F(x))/∂x = I + ∂F/∂x — 즉 두 항의 합이고, n개 블록을 통과한 그래디언트는 이 합들의 곱입니다.

∂xn/∂x0 = ( I + ∂F1 )( I + ∂F2 ) ⋯ ( I + ∂Fn ) ← 괄호마다 “건너뛰기(I)”와 “잔차 브랜치(∂F)” 중 하나를 고른다
= Σ (고른 ∂F들의 곱) ← 전개하면 항이 정확히 2n개. ∂F를 k개 고른 항 = 길이 k 경로, C(n, k)
plain: ∂xn/∂x0 = W1W2 ⋯ Wn ← 항이 단 하나뿐. 이 곱이 줄어들면 그래디언트가 통째로 소멸

다만 2n개 경로가 모두 똑같이 기여하지는 않습니다. 경로가 잔차 브랜치를 한 번 더 지날 때마다 전달되는 그래디언트는 지수적으로 작아집니다. 그래서 경로의 개수는 중간 길이에 몰려 있는데도, 실제 학습에 기여하는 경로는 훨씬 짧은 쪽에 몰립니다.

경로 길이 분포 vs 실제 그래디언트 기여 경로 하나가 블록을 지날 때마다 그래디언트가 ρ = 0.22배로 준다고 본 단순 모델 · 논문은 이 지수적 감쇠를 실측했습니다

기본값인 블록 54개는 CIFAR-10용 110층 ResNet과 같은 규모입니다. 경로는 254개(약 1.8경 개)나 되고 그중 대부분은 20~30블록짜리 긴 경로인데, 정작 그래디언트의 대부분은 5~17블록(약 10~34층)짜리 짧은 경로에서 옵니다. 110층을 쌓았지만 실질적으로 훈련되는 것은 훨씬 얕은 네트워크들인 셈입니다.

앙상블이란 — 그리고 ResNet이 왜 그렇게 보이는가

앙상블(ensemble)은 여러 모델의 예측을 모아 하나로 쓰는 기법입니다. 투표를 시키거나 평균을 내죠. 랜덤 포레스트(결정 트리 수백 개의 투표)가 대표적입니다. 핵심은 구성원들이 서로 다른 실수를 한다는 점입니다. 실수가 제각각이면 모아 놓았을 때 서로 상쇄되어, 평균이 개별 구성원보다 안정적으로 좋아집니다.

사슬 vs 합창단

plain 네트워크는 사슬입니다. 고리 하나가 끊어지면 전체가 끊어집니다. 앙상블은 합창단입니다. 한 명이 빠져도 노래는 계속되고, 소리만 조금 얇아집니다. 깊은 네트워크가 어느 쪽에 가까운지는 층을 하나 빼 보면 알 수 있습니다.

Veit et al.이 실제로 그 실험을 했습니다. 훈련이 끝난 네트워크에서 층을 지워 본 것입니다.

그래서 1000층 ResNet은 “1000층짜리 사슬”이라기보다 가중치를 나눠 쓰는 얕은 네트워크 수없이 많은 것들의 모음에 가깝습니다. 깊이를 늘려도 최적화가 무너지지 않는 이유도 여기서 다시 설명됩니다 — 블록을 하나 더 쌓으면 경로만 늘어나는 게 아니라 짧은 경로도 함께 늘어나기 때문입니다.

주의: “앙상블이다”가 아니라 “앙상블처럼 동작한다”

2n개의 경로는 진짜 앙상블의 구성원과는 다릅니다. 독립적으로 훈련된 별개의 모델이 아니라 같은 가중치를 공유하고, 비선형 때문에 출력이 경로별 결과의 깔끔한 합으로 분해되지도 않습니다 (역전파의 2n개 항 전개는 정확하지만, 순전파는 그렇지 않습니다). 그래서 논문 제목도 “ResNet은 앙상블이다”가 아니라 Residual Networks Behave Like Ensembles…(앙상블처럼 동작한다)입니다.

7실제 구현 — PyTorch 코드로 확인하기

지금까지 본 모든 것이 실제 코드에서는 몇 줄로 표현됩니다. 아래는 사실상의 표준 구현인 torchvision의 resnet.py 원본 코드입니다. 핵심은 forward()의 단 두 줄입니다.

BasicBlock — ResNet-18/34의 기본 블록 (2절의 다이어그램 그대로)
class BasicBlock(nn.Module):
    expansion: int = 1

    def __init__(self, inplanes, planes, stride=1, downsample=None, ...):
        super().__init__()
        self.conv1 = conv3x3(inplanes, planes, stride)   # 3x3 conv
        self.bn1 = norm_layer(planes)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = conv3x3(planes, planes)             # 3x3 conv
        self.bn2 = norm_layer(planes)
        self.downsample = downsample
        self.stride = stride

    def forward(self, x):
        identity = x                  # ← 입력을 기억해 둔다 (shortcut)

        out = self.conv1(x)           # ── 잔차 함수 F(x) 시작
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out)
        out = self.bn2(out)           # ── F(x) 끝

        if self.downsample is not None:
            identity = self.downsample(x)   # 차원이 바뀌면 1x1 conv로 사상 (projection)

        out += identity               # ★ y = F(x) + x  — ResNet의 전부
        out = self.relu(out)          #   (v1: 덧셈 뒤 ReLU)
        return out
Bottleneck — ResNet-50/101/152의 병목 블록 (1×1 압축 → 3×3 → 1×1 복원)
class Bottleneck(nn.Module):
    expansion: int = 4                # 출력 채널 = planes × 4

    def __init__(self, inplanes, planes, stride=1, downsample=None, ...):
        super().__init__()
        self.conv1 = conv1x1(inplanes, width)            # 1x1: 채널 압축 (256→64)
        self.bn1 = norm_layer(width)
        self.conv2 = conv3x3(width, width, stride, ...)  # 3x3: 좁은 공간에서 연산
        self.bn2 = norm_layer(width)
        self.conv3 = conv1x1(width, planes * self.expansion)  # 1x1: 복원 (64→256)
        self.bn3 = norm_layer(planes * self.expansion)
        ...

    def forward(self, x):
        identity = x
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
        if self.downsample is not None:
            identity = self.downsample(x)
        out += identity               # ★ 여기서도 핵심은 같은 한 줄
        out = self.relu(out)
        return out
_make_layer — 블록을 쌓아 스테이지를 만드는 곳 (5절 탐색기의 코드 버전)
def _make_layer(self, block, planes, blocks, stride=1):
    downsample = None
    # 스테이지 첫 블록: 해상도를 절반으로(stride 2), 채널이 달라지면
    # shortcut에도 1x1 conv(projection)를 달아 차원을 맞춘다
    if stride != 1 or self.inplanes != planes * block.expansion:
        downsample = nn.Sequential(
            conv1x1(self.inplanes, planes * block.expansion, stride),
            norm_layer(planes * block.expansion),
        )

    layers = []
    layers.append(block(self.inplanes, planes, stride, downsample, ...))
    self.inplanes = planes * block.expansion
    for _ in range(1, blocks):        # 나머지 블록은 전부 순수 identity shortcut
        layers.append(block(self.inplanes, planes, ...))
    return nn.Sequential(*layers)

# resnet50 = ResNet(Bottleneck, [3, 4, 6, 3])   ← 스테이지별 블록 수가 모델을 결정
# resnet101 = ResNet(Bottleneck, [3, 4, 23, 3])
# resnet152 = ResNet(Bottleneck, [3, 8, 36, 3])

직접 써보는 건 세 줄이면 됩니다:

import torch, torchvision
model = torchvision.models.resnet50(weights="IMAGENET1K_V2")  # 사전훈련 가중치
model.eval()
logits = model(torch.randn(1, 3, 224, 224))   # → [1, 1000]

참고할 만한 공개 구현체

8정리 — 그리고 그 이후

이 자료가 참고한 문헌

  1. He, Zhang, Ren, Sun. Deep Residual Learning for Image Recognition. CVPR 2016. arXiv:1512.03385
  2. He, Zhang, Ren, Sun. Identity Mappings in Deep Residual Networks. ECCV 2016. arXiv:1603.05027
  3. Yu, Koltun. Multi-Scale Context Aggregation by Dilated Convolutions. ICLR 2016. arXiv:1511.07122 — ResNet류 백본을 세그멘테이션 등 밀집 예측으로 확장할 때 해상도를 지키는 방법
  4. Veit, Wilber, Belongie. Residual Networks Behave Like Ensembles of Relatively Shallow Networks. NeurIPS 2016. arXiv:1605.06431