깊은 네트워크 학습을 어렵게 만드는 대표적인 문제로 gradient vanishing/exploding이 있다. 하지만 ResNet 논문에서 강조한 degradation problem은 단순히 gradient vanishing 때문만은 아니며, 깊은 plain network가 identity mapping에 가까운 쉬운 해조차 잘 찾지 못하는 최적화 문제로 볼 수 있다.
“20층 네트워크 위에 Identity Mapping을 추가하면 최소한 20층과 동일한 성능은 나와야 하지만 그렇게 되지 않는 이유”
→ 추가된 층들이 항등 함수(Identity Mapping)조차 제대로 학습하지 못하는 것이 문제!
→ 기울기 소실 문제도 부분적인 원인이지만, 근본적으로 plain network가 identity mapping을 학습하기에 비효율적인 구조를 지니고 있기 때문에 발생하는 문제임.
→ 해결 : Residual Learning & Skip connection
역전파 과정에서 입력층으로 갈수록, 기울기가 점차적으로 작아지는 현상이 발생할 수 있다. 이러한 현상으로 입력층에 가까운 층들에서 가중치들이 업데이트가 제대로 되지 않으면 결국 최적의 모델을 찾을 수 없게 되고, 이것을 기울기 소실이라 한다.
기울기 소실과 반대로, 기울기가 입력층으로 갈수록 점차 커지다가 가중치들이 비정상적으로 큰 값이 되면서 발산되는 현상이다.
레이어가 깊어질수록 모델이 입력 데이터의 복잡한 패턴과 특징을 찾을 수 있고, 모델이 더 나은 일반화 성능을 보일 수 있기 때문에 레이어를 많이 쌓는 것(깊은 모델을 만드는 것)에 대한 중요성은 늘 인지하고 있었으나, 이를 막고 있던 것이 degradation 문제이다. 이는 딥러닝 모델의 레이어가 깊어졌을 때 모델이 수렴했음에도 불구하고 오히려 레이어 개수가 적을 때보다 모델의 training error가 더 커지는 현상이다.
이는 레이어가 깊다고 무조건 정확도가 증가하는 것이 아니라, 어느 정도 이상 깊으면 오히려 정확도가 떨어지는 문제로, 단순히 overfitting으로 인해 발생하는 문제가 아닌, 레이어가 깊어질수록 모델의 학습이 잘 되지 않아 발생하는 문제였다. 더 깊은 모델이 이론적으로는 얕은 모델의 성능을 최소한 따라갈 수 있어야 하지만, 실제 plain network에서는 optimizer가 identity mapping에 해당하는 쉬운 해조차 찾지 못해 training error가 증가했다.
과적합으로 인해 발생하는 문제라면 training error는 낮아지고 validation error는 높아지지만, Degradation은 training error 자체가 오르기에 overfitting과는 거리가 있다.
ResNet 논문에선 이를 해결하기 위해 residual learning을 제안한다. 원래 의도한 매핑에 skip connection을 추가하여 residual learning을 진행하면, 추가적인 파라미터나 계산 복잡성을 추가하지 않은 채로 기존보다 효율적으로 학습을 진행할 수 있고, degradation 문제를 해결해 레이어가 깊어질수록 training error 또한 줄어드는 것을 확인할 수 있었다.
Residual Learning이란 잔차 학습을 의미한다. ResNet 논문에서는 깊은 plain network가 단순히 층을 많이 쌓는다고 해서 항상 성능이 좋아지는 것이 아니며, 오히려 training error가 증가하는 degradation problem이 발생할 수 있다고 설명한다. 이는 단순한 overfitting 문제가 아니라, 깊은 네트워크가 이론적으로는 쉬워 보이는 identity mapping조차 실제 최적화 과정에서 잘 학습하지 못하는 문제로 볼 수 있다.
기존 plain network에서는 여러 층이 원하는 매핑 H(x)를 직접 학습해야 한다. 여기서 H(x)는 입력 x가 몇 개의 층을 통과한 뒤 만들어져야 하는 이상적인 출력 feature를 의미한다. 즉, plain network는 입력 x를 원하는 출력 H(x)로 직접 변환하도록 학습한다.
반면 ResNet은 H(x)를 직접 학습하는 대신, 입력과 원하는 출력의 차이인 잔차 F(x)를 학습하도록 구조를 바꾸었다. 이때 잔차는 F(x) = H(x) - x로 정의된다. 따라서 원래 학습해야 하는 매핑은 H(x) = F(x) + x로 표현할 수 있다. ResNet의 residual block은 내부 Conv 층을 통해 F(x)를 학습하고, skip connection을 통해 입력 x를 출력에 직접 더한다. 결과적으로 블록의 출력은 y = F(x) + x가 된다.

이 구조의 장점은 identity mapping이 필요한 상황에서 더 잘 드러난다. Identity mapping이란 입력 x를 아무런 변화 없이 그대로 출력하는 함수로, f(x) = x와 같은 형태를 의미한다. 네트워크 맥락에서 어떤 층이 identity mapping을 한다는 것은, 그 층이 입력 feature를 변형하지 않고 그대로 다음 층으로 전달한다는 뜻이다.
깊은 네트워크에서는 추가된 층들이 항상 새로운 정보를 학습할 필요는 없다. 어떤 경우에는 입력에 이미 유용한 정보가 포함되어 있어 그대로 전달하는 것이 더 좋을 수 있다. plain network에서는 여러 Conv 층이 H(x) = x라는 identity mapping을 직접 학습해야 하지만, ResNet에서는 skip connection이 입력 x를 이미 그대로 전달해주기 때문에 residual branch는 F(x)를 0에 가깝게 만들기만 하면 된다. 그러면 출력은 y = F(x) + x ≈ 0 + x = x가 되어 identity mapping에 가까운 동작을 할 수 있다.
즉, ResNet은 깊은 네트워크가 전체 출력을 처음부터 새로 학습하게 하는 대신, 입력에서 바뀌어야 할 부분만 학습하도록 만든다. 이를 통해 불필요한 층이 입력 정보를 망가뜨리는 문제를 완화하고, 깊은 네트워크에서도 최적화가 쉬워진다.
또한 skip connection은 정보 전달과 gradient flow 측면에서도 중요한 역할을 한다. 일반 CNN에서는 입력 정보와 gradient가 모든 층을 순차적으로 통과해야 하므로, 네트워크가 깊어질수록 정보가 손실되거나 gradient가 약해질 수 있다. 반면 ResNet에서는 입력 x가 skip connection을 통해 출력에 직접 더해지므로, 중요한 feature가 깊은 층까지 보존될 수 있고, 역전파 과정에서도 gradient가 앞쪽 층까지 더 안정적으로 전달될 수 있다.
따라서 Residual Learning의 핵심은 H(x)를 직접 학습하는 것이 아니라, 입력과 출력의 차이인 F(x) = H(x) - x를 학습하는 것이다. Skip Connection은 입력 x를 출력에 직접 더해주는 경로이며, 이를 통해 ResNet은 추가 파라미터를 거의 늘리지 않으면서도 깊은 네트워크의 학습 안정성과 최적화 성능을 높일 수 있다.
Shortcut Connection은 입력 x를 출력에 직접 더해주는 경로를 의미한다. 일반적인 딥러닝 네트워크에서는 입력 x가 여러 Conv 층을 순차적으로 통과한 뒤 출력이 만들어진다. 반면 ResNet에서는 이러한 일반 경로와 별도로, 입력 x가 중간 층들을 건너뛰어 출력 쪽으로 직접 전달되는 경로를 추가한다. 이후 Conv 층을 거친 출력 F(x)와 shortcut connection을 통해 전달된 입력 x를 더해 최종 출력 y = F(x) + x를 만든다.
기존 plain network에서는 여러 층이 원하는 매핑 H(x)를 직접 학습해야 한다. 만약 어떤 블록에서 입력을 그대로 출력하는 identity mapping이 최적이라면, plain network는 여러 Conv 층의 가중치를 조정하여 H(x) = x가 되도록 직접 학습해야 한다. 하지만 실제 깊은 plain network에서는 이러한 identity mapping조차 쉽게 학습하지 못해 training error가 증가하는 degradation problem이 발생할 수 있다.
ResNet에서는 shortcut connection이 입력 x를 출력에 직접 더해주기 때문에, identity mapping이 필요한 경우 Conv branch가 F(x)를 0에 가깝게 학습하면 된다. 그러면 최종 출력은 y = F(x) + x ≈ 0 + x = x가 되어 입력을 그대로 전달하는 효과를 낼 수 있다. 즉, ResNet은 전체 출력 H(x)를 처음부터 새로 학습하는 대신, 입력에서 바뀌어야 할 부분인 잔차 F(x)만 학습하도록 만든다.
Shortcut Connection의 장점은 크게 세 가지이다. 첫째, 입력과 출력의 차원이 같은 경우 identity shortcut은 입력 x를 그대로 전달하므로 별도의 학습 파라미터를 추가하지 않는다. 둘째, Conv 연산을 추가하는 것이 아니라 단순히 두 feature map을 더하는 방식이므로 연산량 증가가 매우 작다. 셋째, 역전파 과정에서 gradient가 Conv 층만을 거치지 않고 shortcut 경로를 통해 앞쪽 층으로 더 직접적으로 전달될 수 있다. 따라서 깊은 네트워크에서도 기울기 소실을 완화하고 학습을 안정화하는 데 도움을 준다.
다만 입력과 출력의 채널 수나 feature map 크기가 다를 경우에는 x를 그대로 더할 수 없기 때문에, 1×1 Conv나 downsample을 사용해 shortcut 경로의 차원을 맞춰준다. 이 경우에는 projection shortcut이 사용되므로 일부 파라미터와 연산이 추가될 수 있다.
Bottleneck Block은 ResNet-50 이상처럼 깊은 네트워크를 효율적으로 구성하기 위해 사용되는 Residual Block의 변형 구조이다. 기존 Basic Residual Block은 주로 3×3 Conv 두 개로 구성되지만, Bottleneck Block은 1×1 Conv, 3×3 Conv, 1×1 Conv의 세 개 합성곱 층으로 구성된다.
ResNet-50의 전체 구조를 보면, 가장 앞부분에는 Stem이 존재한다. Stem은 입력 이미지가 네트워크에 들어온 직후 처음으로 처리되는 부분으로, ResNet-50에서는 7×7 Conv와 Max Pooling으로 구성된다. 7×7 Conv는 비교적 큰 커널을 사용하기 때문에 초반에 넓은 영역의 정보를 한 번에 볼 수 있고, stride를 통해 이미지의 해상도를 빠르게 줄이는 역할도 한다. 즉, Stem은 초기에 큰 receptive field를 확보하면서 입력 이미지의 기본적인 특징을 추출하고, 이후 깊은 stage에서 처리할 수 있도록 feature map 크기를 줄여주는 역할을 한다.
ResNet-50 이상에서 Bottleneck 구조가 사용되는 이유는 네트워크가 깊어질수록 파라미터 수와 연산량이 크게 증가하기 때문이다. 특히 3×3 Conv는 입력 채널 수와 출력 채널 수가 클수록 연산량이 많이 증가한다. 따라서 채널 수가 큰 상태에서 바로 3×3 Conv를 수행하면 계산 비용이 커진다. 이를 해결하기 위해 Bottleneck Block을 활용한다.
여기서 1×1 Conv는 공간 크기를 크게 바꾸기보다는 주로 채널 축에 작용하는 연산이다. 즉, 입력 채널 수와 출력 채널 수를 어떻게 설정하느냐에 따라 채널을 압축하거나 확장할 수 있다. 예를 들어 C개의 채널을 더 적은 채널로 줄일 수도 있고, 반대로 C개를 2C 또는 4C처럼 더 많은 채널로 확장할 수도 있다. Bottleneck Block에서는 이 성질을 이용해 첫 번째 1×1 Conv로 채널을 축소하고, 마지막 1×1 Conv로 다시 채널을 확장한다.
구조를 단계별로 보면, 첫 번째 1×1 Conv는 채널 수를 축소하여 연산량을 줄이는 역할을 한다. 이후 Batch Normalization과 ReLU를 통해 학습을 안정화하고 비선형성을 추가한다. 두 번째 3×3 Conv는 실제 공간적 특징을 추출하는 핵심 연산을 수행한다. 이때 앞에서 채널 수를 줄였기 때문에 3×3 Conv의 연산량을 크게 줄일 수 있다. 마지막 1×1 Conv는 줄어든 채널을 다시 확장하여 더 풍부한 feature 표현을 만들 수 있도록 한다. ResNet-50에서는 일반적으로 expansion 값이 4로 설정되어 출력 채널 수가 기본 채널 수의 4배가 된다.
Bottleneck이라는 이름은 중간에 채널 수가 좁아지는 구간이 존재하기 때문에 붙은 이름이다. 이러한 방식은 큰 채널 차원에서 바로 3×3 Conv를 수행할 때 발생하는 과도한 연산량 문제를 줄이면서도, 마지막에 채널을 확장해 충분한 표현력을 확보할 수 있게 한다.
정리하면 Bottleneck Block의 흐름은 다음과 같다. 큰 차원에서 바로 3×3 Conv를 수행하면 연산량이 너무 크기 때문에, 먼저 1×1 Conv로 채널을 줄인다. 그다음 작아진 채널에서 3×3 Conv로 핵심 공간 특징을 추출한다. 마지막으로 1×1 Conv를 통해 채널을 다시 확장하여 표현력을 확보한다. 즉, Bottleneck Block은 연산량은 줄이되 깊은 네트워크가 필요한 표현 능력은 유지하기 위한 구조이다.
ResNet-50은 이러한 Bottleneck Block을 사용하는 대표적인 구조이다. 전체 구조는 초기 Stem 부분에서 7×7 Conv와 Max Pooling을 거친 뒤, Conv2_x, Conv3_x, Conv4_x, Conv5_x 단계로 구성된다. 각 단계에는 Bottleneck Block이 각각 3개, 4개, 6개, 3개 배치된다. 마지막에는 Global Average Pooling과 Fully Connected Layer를 사용해 최종 클래스를 분류한다.
Bottleneck Block 역시 Residual Block의 한 종류이므로 skip connection을 사용한다. 블록 내부의 Conv 층들은 잔차 F(x)를 학습하고, 입력 x는 shortcut connection을 통해 출력에 더해진다. 따라서 최종 출력은 y = F(x) + x가 된다. 만약 입력과 출력의 채널 수나 feature map 크기가 다르면, downsample 또는 1×1 projection shortcut을 사용해 identity의 차원을 맞춘 뒤 더한다.
문제점 : 큰 차원에서 바로 3×3 Conv 하면 연산량이 너무 큼
1×1 Conv로 채널을 줄임
↓
작아진 채널에서 3×3 Conv로 특징 추출
↓
1×1 Conv로 다시 채널을 확장해서 표현력 확보

| Basic Residual Block | Bottleneck Block | |
|---|---|---|
| 구성(conv) | 3x3 → 3x3 | 1x1 → 3x3 → 1x1 |
| 층 수 | 2개 | 3개 |
| 사용 모델 | ResNet-18, 34 | ResNet-50, 101, 152 |
| 목적(특징) | 단순하고 가벼운 구조를 가진다. | 연산량을 절감하면서 층을 깊게 쌓을 수 있다. |
ResNet의 skip connection은 정보 전달과 gradient flow 측면에서도 중요한 역할을 한다. 일반적인 CNN에서는 입력 정보가 모든 층을 순차적으로 통과해야 하므로, 네트워크가 깊어질수록 정보가 점차 손실되거나 변형될 수 있다. 또한 역전파 과정에서도 gradient가 여러 층을 거치며 전달되기 때문에, 앞쪽 layer로 갈수록 gradient가 약해지는 gradient vanishing 문제가 발생할 수 있다.
반면 ResNet은 입력 x를 shortcut connection을 통해 출력에 직접 더하는 구조를 사용한다. Residual Block의 출력은 H(x) = F(x) + x로 표현되며, 여기서 F(x)는 Conv 층들이 학습하는 잔차이고 x는 shortcut을 통해 그대로 전달되는 입력이다. 따라서 입력에 이미 유용한 정보가 포함되어 있고 이를 크게 변형하지 않는 것이 유리한 경우, residual branch는 F(x)를 0에 가깝게 학습할 수 있다. 그러면 H(x) = F(x) + x ≈ x가 되어, 블록은 입력을 크게 변형하지 않는 identity mapping에 가까운 동작을 할 수 있다.
이 구조 덕분에 불필요한 layer가 입력 정보를 망가뜨리는 문제를 완화할 수 있다. 즉, ResNet은 모든 층이 전체 출력을 처음부터 새로 학습하도록 만드는 것이 아니라, 입력 정보는 shortcut을 통해 보존하고 Conv 층은 입력에서 필요한 수정값만 학습하도록 한다. 이로 인해 깊은 네트워크에서도 정보 흐름이 안정적으로 유지된다.
또한 skip connection은 역전파 과정에서도 gradient가 흐를 수 있는 직접 경로를 제공한다. 일반 CNN에서는 gradient가 모든 층을 거쳐야 하므로 네트워크가 깊어질수록 gradient가 소실되거나 왜곡될 수 있다. 하지만 ResNet에서는 shortcut 경로를 통해 gradient가 앞쪽 layer까지 더 직접적으로 전달될 수 있으므로, 학습 초기에 gradient 흐름이 차단되는 문제를 완화할 수 있다. 따라서 identity shortcut은 추가 파라미터를 거의 늘리지 않으면서도 정보와 gradient의 흐름을 안정화하여 깊은 네트워크의 최적화를 쉽게 만든다.
정리
- 네트워크가 너무 깊으면 gradient가 앞쪽까지 가는 동안 계속 약해지는 gradient Vanishing 문제가 있는데, skip connection을 통해서 네트워크가 깊어질 때도 앞쪽 layer까지 전달되도록 함.
- F(x)가 0에 가까워지면 H(x)=F(x)+x≈x가 되므로, residual block은 최소한 입력을 크게 변형하지 않는 identity mapping에 가까운 동작을 할 수 있다. 따라서 불필요한 layer가 입력 정보를 망가뜨리는 문제를 완화하고, 깊은 네트워크의 최적화를 쉽게 만든다.
- 입력에 이미 유용한 정보가 포함되어 있고 이를 크게 변형하지 않는 것이 유리한 경우, ResNet은 residual branch의 출력인 F(x)를 0에 가깝게 학습하여 skip connection을 통해 입력 정보를 보존한 채 전달할 수 있다. 따라서 블록 내부의 Conv 층은 전체 출력을 처음부터 새로 만들 필요 없이, 입력에서 필요한 수정값만 학습하면 된다. 또한 identity shortcut은 추가 파라미터를 거의 사용하지 않으면서도 정보와 gradient가 흐를 수 있는 직접 경로를 제공하므로, 깊은 네트워크에서도 학습을 안정화하고 최적화를 쉽게 만든다. (즉, 정보량이 차있으면 학습하지 않고 흘러가므로 학습 파라미터 늘리지 않고도 효율적으로 학습 가능)
**일반 CNN과 ResNet 간의 Gradient Flow 비교
- 일반 CNN** : 네트워크가 깊어질수록 gradient가 소실되거나 왜곡되어 학습이 불안정해짐
- ResNet : skip connection 경로를 통해 gradient가 직접 앞쪽 층까지 전달되며, 학습 초기에 gradient 흐름이 차단되지 않음
Bottleneck Block은 ResNet-50에서 사용하는 핵심 블록이다. 구조는 다음과 같다.

1x1 Conv → BN → ReLU
3x3 Conv → BN → ReLU
1x1 Conv → BN
+ identity
ReLU
첫 번째 1x1 Conv는 채널 수를 줄여 연산량을 감소시킨다. 두 번째 3x3 Conv는 줄어든 채널 상태에서 공간적 특징을 추출한다. 마지막 1x1 Conv는 채널 수를 다시 확장한다. 이때 expansion = 4이므로 최종 출력 채널은 out_channels × 4가 된다. 예를 들어 out_channels = 64라면 Bottleneck Block의 최종 출력 채널은 64 × 4 = 256이 된다.
forward 흐름 이해에서 중요한 것은 identity = x 를 저장하는 부분이다. 이는 입력 x를 skip connection에 사용하기 위해 따로 저장한다. 그 다음 Conv 층들을 통과한 결과가 out이다. 이 out은 ResNet 관점에서 잔차 F(x) 에 해당한다. 마지막에 out += identity 를 수행함으로써 출력 = F(x) + x가 된다. 이게 ResNet의 핵심 구조다. 입력 x를 그대로 더해주기 때문에, 블록 내부 Conv 층은 전체 출력 H(x)를 처음부터 새로 만들 필요 없이 입력에서 바뀌어야 할 부분인 F(x)만 학습하면 된다.
아래의 코드에서도 identity = x로 입력을 저장하고, 필요한 경우 downsample을 수행한 뒤 out += identity로 skip connection을 구현한다.
또, downsample이 필요한 경우에서 두 텐서를 더하려면 shape이 같아야 한다. 예를 들어 main path의 출력이 [N, 256, 56, 56]인데 identity가 [N, 64, 56, 56]이면 채널 수가 달라 더할 수 없고, stride=2가 적용되어 main path의 feature map 크기가 줄어들면 아래와 같이 공간 크기도 달라질 수 있다.
out : [N, 512, 28, 28]
identity : [N, 256, 56, 56]
그래서 이런 경우 shortcut path에도 1x1 Conv + BN을 적용해서 identity의 크기를 맞춘다. 즉, downsample의 역할은 identity의 채널 수와 feature map 크기를 main path 출력과 맞추는 것이다.
import torch.nn as nn # 신경망 네트워크(레이어, 손실 함수 등)를 구성하는 기본 모듈
import torch.nn.functional as F # Pytorch에서 자주 쓰는 함수들(functional API). 예 : F.relu, F.conv2d 등.
class Bottleneck(nn.Module):
expansion = 4 # Bottleneck 확장 배수 (마지막 1*1 Conv를 통해 채널 수를 4배로 확장한다는 설정값)
# in_channels : 블록의 입력 특징 맵(feature map)의 채널 수. (이전 블록의 출력 채널 수)
# out_channels : 1*1과 3*3을 처리한 후, 마지막 단계에서 확장되기 전 기본 채널 수
# stride : 합성곱의 보폭. 보통은 1이지만, 블록에 따라 2로 설정되어 크기를 절반으로 줄이는 역할을 할 수도 있음.
# downsample : skip connection에서 입력(identify)의 차원을 맞춰주기 위한 추가 모듈(합성곱+BN 등)이 들어갈 수 있음.
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super(Bottleneck, self).__init__()
# 부모 클래스(nn.Module)의 초기화 메서드를 호출해 필요한 내부 구조 설정
# 1*1 Conv
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
# bias=False : 합성곱 후에 BatchNorm을 바로 쓰면, 합성곱의 bias가 불필요하다고 판단하여 생략하는 경우가 많음.
self.bn1 = nn.BatchNorm2d(out_channels) # Batch Normalization으로 학습을 안정화하고, 학습 속도를 높여준다.
# 왜? internal covariant shift 문제를 완화시켜 입력 분포를 맞춰주니까
# 3*3 Conv
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
# 1*1 Conv(채널 확장, 출력 채널을 out_channels * expansion(기본 4배)로 확장)
self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False)
self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)
# ResNet의 Bottleneck 구조는 이렇게 첫 번째 1*1 conv에서 채널을 줄였다가 (연산량 감소 효과),
# 3*3에서 특징 추출 후, 마지막에 다시 1*1 conv로 채널 확장
self.relu = nn.ReLU(inplace=True)
self.downsample = downsample
# skip connection에서 입력 x와 출력 out의 차원(채널수 및 가로/세로 크기)이 다를 경우,
# 이 값이 합성곱+BN 모듈 등이 되어 identity를 변환해줌
# (예를 들어 블록의 stride=2로 크기가 절반이 되면 identity도 크기를 맞춰야 하기 때문)
def forward(self, x): # 블록에서 입력 x가 들어왔을 때, 어떤 계산을 할지 정의
# 입력(x)를 identity로 복사
identity = x # skip connection에 사용할 원본 입력을 identity 라는 변수에 잠시 저장
# 1*1 conv + BN + ReLU -> 3*3 conv + BN + ReLU -> 1*1 Conv + BN 수행
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out = self.relu(out)
out = self.conv3(out)
out = self.bn3(out)
# downsample이 있으면 skip connection의 차원을 맞춤
# 필요 시(채널이나 해상도가 맞지 않을 때) Downsample을 통해 identity 크기 조정
# 이때, 입력(identity)을 별도 합성곱+BN으로 조정함.
if self.downsample is not None:
identity = self.downsample(x)
# 입력과 출력을 더한 뒤 ReLU를 적용해 잔차를 학습하는 형태 구현
out += identity # skip connection (잔차 연결) --> out.shape == identity.shape
# ResNet의 핵심 아이디어는 입력(x)을 출력에 더해 모델이 잔차(residual)만을 학습하도록 한 것
out = self.relu(out)
# 결과 반환
return out
ResNet-50은 크게 다음 구조로 이뤄진다. AdaptiveAvgPool2d((1, 1))은 feature map의 공간 크기가 얼마든 최종적으로 1×1 크기로 줄여준다. 그 다음 torch.flatten(x, 1)을 통해 [batch, channel, 1, 1] 형태를 [batch, channel] 형태로 바꾸고, FC layer에 넣어 최종 클래스 수만큼 출력한다.

Stem은 네트워크의 초반 처리 부분이다. ResNet-50에서는 다음과 같이 구성된다.
7×7 Conv(stride=2) + BN + ReLU + MaxPool
초반에 7×7처럼 큰 커널을 사용하는 이유는 이미지의 넓은 영역을 한 번에 보면서 초기 특징을 뽑기 위해서다. 또한 stride=2와 MaxPool을 사용해 feature map 크기를 빠르게 줄여 이후 연산량을 줄인다.
정리하면 Stem = 초반에 넓은 영역을 보고, 해상도를 줄이며, 기본 특징을 추출하는 부분이다.
1×1 Conv는 공간 크기보다는 채널 축에 주로 작용한다.
예를 들어 입력이 [N, C, H, W]일 때, out_channels를 어떻게 설정하느냐에 따라
C → 2C
2C → C
C → C/4
처럼 채널 수를 확장하거나 축소할 수 있다.
Bottleneck Block에서는 이 성질을 이용한다.
1×1 Conv로 채널 축소
→ 3×3 Conv로 특징 추출
→ 1×1 Conv로 채널 확장
즉, 1×1 Conv는 Bottleneck에서 연산량을 줄이고 표현력을 조절하는 핵심 역할을 한다.
Bottleneck Block의 핵심 목적은 연산량은 줄이고, 표현력은 유지하는 것이다.
큰 채널 수에서 바로 3×3 Conv를 하면 연산량이 너무 커진다. 그래서 먼저 1×1 Conv로 채널을 줄이고, 줄어든 채널에서 3×3 Conv를 수행한 뒤, 마지막에 다시 1×1 Conv로 채널을 확장한다.
문제점:
큰 채널에서 바로 3×3 Conv를 하면 연산량이 큼
해결:
1×1 Conv로 채널 축소
↓
작아진 채널에서 3×3 Conv로 공간 특징 추출
↓
1×1 Conv로 채널 확장
Bottleneck이라는 이름도 중간에 채널 수가 좁아지는 구간이 있기 때문에 붙은 이름이다.
기존 plain network는 입력 x를 원하는 출력 H(x)로 직접 바꾸려고 한다.
Plain Network:
x → H(x)
ResNet은 다르게 접근한다.
ResNet:
H(x)를 직접 학습하지 않고,
F(x) = H(x) - x 를 학습
그리고 출력은 H(x) = F(x) + x로 만든다
즉, 전체 출력을 처음부터 새로 만드는 것이 아니라, 입력에서 바뀌어야 할 차이만 학습하는 방식이다.
Skip Connection은 입력 x를 중간 Conv 층을 건너뛰어 출력에 직접 더해주는 연결이다.
출력 = F(x) + x
이 구조의 장점은 두 가지다. 첫째, 입력에 이미 유용한 정보가 있으면 그대로 보존할 수 있다. 둘째, 역전파 과정에서 gradient가 shortcut 경로를 통해 앞쪽 층까지 더 안정적으로 전달될 수 있다. 그래서 깊은 네트워크에서 발생하는 degradation problem과 gradient vanishing 문제를 완화하는 데 도움을 준다.
class MyResNet50(nn.Module):
def __init__(self, num_classes=1000):
super(MyResNet50, self).__init__()
# 초기 Stem (보통 ResNet에서는 첫 번째 Conv+MaxPool을 거쳐 이미지 크기를 빠르게 줄이는 과정을 Stem이라고 부름.)
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
self.bn = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
# inplanes (현재 블록의 입력 채널 수를 추적하는 변수) 설정
# 처음에는 64(Stem의 출력 채널)로 설정
self.inplanes = 64
# 레이어 구성
self.layer1 = self._make_layer(Bottleneck, 64, 3, stride=1)
self.layer2 = self._make_layer(Bottleneck, 128, 4, stride=2)
self.layer3 = self._make_layer(Bottleneck, 256, 6, stride=2)
self.layer4 = self._make_layer(Bottleneck, 512, 3, stride=2)
# 분류기 (Head) 부분
self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 입력 Feature Map 크기와 상관 없이 (1,1) 크기로 만듦
self.fc = nn.Linear(512*Bottleneck.expansion, num_classes)
# 레이어 구성
# 각 레이어의 첫 번째 블록 : 이전 layer 출력 채널 -> out_channels -> out_channels -> out_channels*4
# 나머지 블록 : out_channels*4 -> out_channels -> out_channels -> out_channels*4
# layer1 : Bottleneck 3개
# layer2 : Bottleneck 4개
# layer3 : Bottleneck 6개
# layer4 : Bottleneck 3개
# Bottleneck Block 여러 개를 묶어서 하나의 stage를 만드는 함수
def _make_layer(self, block, out_channels, blocks, stride=1):
downsample = None
# stride!=1이거나, 채널 수가 맞지 않으면 다운 샘플 구성
# 다운 샘플(downsample) : stride가 2가 되면(또는 채널 수가 달라지면),
# skip connection에서 입력 x와 출력의 크기를 맞춰줘야 함.
# downsample은 1*1 conv와 BN으로 구성되며, stride를 조정해 공간 크기를 줄이거나, 채널 수를 맞춰줌.
# Feature Map 크기가 바뀌거나 채널 수가 바뀔 경우 다운 샘플 수행
if stride != 1 or self.inplanes != out_channels * block.expansion:
# downsample 수행
# 1. kernel_size=1 Conv로 채널 수 맞춤 (out_channels * block.expansion == Bottleneck 블록의 최종 출력 채널 수)
# 입력 채널 수 != 출력 채널 수 확
# 2. stride=stride로 feature map 크기 맞춤
# main path 말고 shortcut path에서 사용할 layer 생성 --> out.shape == identity.shape 이어야 하므로
downsample = nn.Sequential(
nn.Conv2d(self.inplanes, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels * block.expansion)
)
layers = [] # Bottleneck 블록을 담을 빈 리스트
# 첫 번째 Bottleneck 블록 추가
layers.append(block(self.inplanes, out_channels, stride, downsample)) # 입력이 줄어들거나 채널이 변경되는 지점
self.inplanes = out_channels * block.expansion # self.inplanes를 업데이트 (새로운 블록의 출력 채널 / 다음 Bottleneck 블록들의 입력 채널 수)
# 나머지 블록은 stride=1이므로 downsample 없이 이어짐
# ResNet 블록들을 하나씩 담음
# 3 / 4 / 6 / 3
for _ in range(1, blocks):
layers.append(block(self.inplanes, out_channels))
# Bottleneck 여러 개를 하나의 layer로 묶어 반복
return nn.Sequential(*layers) # 마지막에 nn.Sequential(*layers)로 묶어 하나의 큰 레이어로 반환
def forward(self, x):
# Stem
x = self.conv1(x)
x = self.bn(x)
x = self.relu(x)
x = self.maxpool(x)
# 4개의 레이어
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
# 분류
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
# 모델 예시 생성 (최종 출력)
model = MyResNet50(num_classes=1000)
print(model)