ResNet Bottleneck Block
첫 1x1은 줄이고, 끝 1x1은 늘리는 이유

1x1 conv · 채널 압축 · 잔차 연결 · 파라미터 절감

먼저 결론부터

"1x1 conv"는 채널을 줄이는 연산도, 늘리는 연산도 아닙니다. 그냥 채널 수를 내가 정하는 대로 바꿔주는 연산이에요. 출력 채널 수는 nn.Conv2d(in, out, 1)의 out에 내가 써넣는 숫자입니다.

그래서 첫 번째는 out=64로 써서 줄이는 것이고, 마지막은 out=256으로 써서 늘리는 것일 뿐, 연산의 종류가 다른 게 아니라 내가 넣은 숫자가 다른 것입니다. 이제 "왜 하필 줄였다 늘리냐"만 답하면 끝나요.

011x1 conv의 정체

3x3 conv는 "주변 픽셀을 본다"는 감각이 있어서 이해가 쉬운데, 1x1은 주변을 안 보니까 대체 뭘 하는 건지 애매하죠. 정확히 말하면 이렇습니다.

1x1 conv = 픽셀 하나하나마다 독립적으로 적용되는 행렬 곱

입력이 (256, H, W)라면, 각 위치 (h,w)에는 256차원 벡터가 하나씩 놓여 있습니다. 1x1 conv는 이 256차원 벡터에 (out × 256) 크기의 행렬을 곱해서 out차원 벡터로 바꿉니다. H, W는 건드리지 않고 채널 축만 재조합해요.

즉 1x1 conv는 공간 정보는 무시하고 채널끼리만 섞는 층입니다. "이 위치의 256개 특징을 적당히 조합해서 64개 특징으로 요약해라" 혹은 "64개를 256개로 펼쳐라" — 둘 다 그냥 행렬 하나 곱하는 것이고, 행렬의 세로 크기(out)를 몇으로 잡느냐가 축소냐 확장이냐를 결정합니다.

층가중치 shape하는 일
1x1 (첫 번째)(64, 256, 1, 1)256 → 64 압축
3x3 (가운데)(64, 64, 3, 3)64 → 64 공간 처리
1x1 (마지막)(256, 64, 1, 1)64 → 256 복원

두 1x1은 서로 다른 층입니다. 가중치를 공유하지 않고 각자 따로 학습돼요. 첫 번째가 한 압축을 마지막이 "되돌리는" 게 아니라, 마지막도 자기 나름대로 유용한 조합을 학습합니다.

02블록 전체 모습 (ResNet-50 stage2 기준)

입력 x256 × H × W
↓  여기서 skip 경로가 갈라져 나감 (256채널 그대로 보관)
1x1 conv · 축소256 → 64  |  16,384 params
↓ 64 × H × W  좁아진 구간
3x3 conv · 공간 처리64 → 64  |  36,864 params
↓ 64 × H × W
1x1 conv · 확장64 → 256  |  16,384 params
↓ 256 × H × W  — 여기서 보관해둔 skip과 덧셈
+ x (residual add) → ReLU256 × H × W
입력
256
1x1 후
64
3x3 후
64
1x1 후
256
출력
256

가운데가 잘록해서 bottleneck(병목)이라고 부릅니다. 이름이 구조를 그대로 설명하고 있어요.

03왜 먼저 줄이나 — 3x3이 너무 비싸서

핵심은 3x3 conv의 비용이 채널 수의 제곱에 비례한다는 점입니다.

파라미터 수 = k × k × Cin × Cout

256채널에서 그대로 3x3을 돌리면:

3x3 @ 256채널9 × 256 × 256 = 589,824
3x3 @ 64채널9 × 64 × 64 = 36,864
차이16배

채널을 1/4로 줄이면 Cin도 1/4, Cout도 1/4이라 비용은 1/16이 됩니다. 반면 1x1 conv는 k×k = 1이라 같은 채널 변화를 해도 9배 쌉니다. 그래서 싼 1x1로 채널을 미리 접어놓고, 비싼 3x3은 좁은 곳에서만 돌리는 전략이 성립해요.

비유

3x3 conv는 "모든 채널 × 모든 채널 × 9방향"을 다 따지는 회의 같은 겁니다. 참석자가 256명이면 회의비가 폭발하죠. 그래서 먼저 256명을 64명 대표로 압축해서 회의를 열고, 회의 결과를 다시 256명에게 펼쳐서 전달하는 거예요. 압축·전달은 싸고, 회의만 비싸니까요.

04왜 다시 늘리나 — 덧셈을 해야 하니까

여기가 진짜 답입니다. ResNet 블록의 마지막은 이 연산이에요:

out = F(x) + x

텐서 덧셈은 shape가 같아야 성립합니다. x는 256채널인데 F(x)가 64채널로 끝나버리면 더할 수가 없어요. 그래서 마지막 1x1이 반드시 256으로 되돌려 놓아야 합니다.

순서를 뒤집으면? "먼저 늘렸다가 나중에 줄이면 안 되나요?" → 그러면 3x3이 더 넓은 채널에서 돌아가서 비용이 오히려 폭증합니다. 절약하려는 목적과 정반대죠. (참고로 MobileNetV2는 일부러 반대로 하는 inverted residual을 쓰는데, 거기선 가운데가 depthwise conv라 채널이 넓어도 싸기 때문입니다. 전제가 다릅니다.)

정리하면 두 1x1의 역할 분담은 이렇습니다.

즉 256채널은 네트워크의 "간선도로"이고, 각 블록은 잠깐 64차선으로 내려와 일을 처리한 뒤 다시 간선도로에 합류하는 구조입니다. 간선도로의 폭(256)은 유지하면서 처리 비용만 깎는 거예요.

05숫자로 확인: Basic vs Bottleneck

같은 256채널 입출력을 처리하는 두 블록을 비교하면:

구성층파라미터
Basic block
ResNet-18/34
3x3, 256→256589,824
3x3, 256→256589,824
합계1,179,648
Bottleneck
ResNet-50/101/152
1x1, 256→6416,384
3x3, 64→6436,864
1x1, 64→25616,384
합계69,632

약 17배 절감. 파라미터가 아니라 연산량(FLOPs)으로 봐도 같은 비율입니다 — 두 블록 모두 H, W를 바꾸지 않으니 FLOPs ≈ 파라미터 × H × W로 비율이 그대로 유지돼요.

ResNet이 층을 34개에서 50개, 152개까지 늘릴 수 있었던 이유가 여기 있습니다. 블록 하나가 싸지니까 같은 예산으로 훨씬 깊게 쌓을 수 있게 된 거죠.

직접 만져보기

64
Basic
1,179,648
Bottleneck
69,632

입력·출력은 256채널 고정. 슬라이더를 256까지 올리면 bottleneck이 오히려 더 비싸집니다 — 압축을 안 하면 1x1 두 개가 순수 오버헤드가 되기 때문이에요. 논문이 쓰는 압축비는 1/4(=64)입니다.

06헷갈리기 쉬운 지점

Q. 채널을 줄이면 정보가 손실되는 거 아닌가?

손실은 맞습니다. 다만 256개 채널이 서로 독립적인 정보를 담고 있지 않다는 게 전제예요. 채널들은 상당히 중복(redundant)돼서, 64차원으로 요약해도 3x3이 필요로 하는 정보는 대부분 살아남습니다. 게다가 skip 경로가 원본 256채널을 손실 없이 그대로 전달하기 때문에, 압축된 경로가 놓친 정보는 덧셈에서 되살아납니다. bottleneck이 공격적으로 압축해도 되는 건 이 안전망 덕분이에요.

Q. 마지막 1x1은 첫 1x1의 역연산인가?

아닙니다. 서로 다른 가중치를 가진 독립적인 층이고, 역행렬 같은 관계도 아니에요. 애초에 256→64는 정보를 잃으므로 되돌릴 수도 없습니다. 마지막 1x1은 "64개 특징을 어떻게 256개 방향으로 뿌릴지"를 스스로 학습합니다.

Q. 왜 하필 1/4로 줄이나?

실험적으로 정한 값입니다. ResNet 논문이 expansion = 4를 쓰고, 모든 stage에서 같은 비율을 유지해요 (512→128→512, 1024→256→1024, 2048→512→2048). 더 줄이면 싸지지만 정확도가 떨어지고, 덜 줄이면 절감 효과가 사라집니다.

Q. skip 경로에 1x1이 붙는 경우는 뭔가?

블록의 입력 채널과 출력 채널이 다를 때(stage가 바뀌는 첫 블록) 또는 stride로 크기가 줄 때입니다. 이때는 x를 그대로 못 더하니까 skip 쪽에도 1x1 conv(downsample / projection shortcut)를 하나 끼워 shape를 맞춥니다. 이것도 "덧셈하려면 shape가 같아야 한다"는 같은 원리예요.

07코드로 보기

class Bottleneck(nn.Module):
    expansion = 4   # 출력 = width * 4

    def __init__(self, in_ch, width, stride=1, downsample=None):
        super().__init__()
        out_ch = width * self.expansion          # 64 * 4 = 256

        # ① 축소: out을 width(64)로 지정 → 채널이 줄어듦
        self.conv1 = nn.Conv2d(in_ch, width, kernel_size=1, bias=False)
        self.bn1   = nn.BatchNorm2d(width)

        # ② 공간 처리: 좁아진 64채널에서만 3x3 → 여기가 비용 절감 지점
        self.conv2 = nn.Conv2d(width, width, kernel_size=3,
                               stride=stride, padding=1, bias=False)
        self.bn2   = nn.BatchNorm2d(width)

        # ③ 확장: out을 out_ch(256)으로 지정 → 채널이 늘어남
        #    ①과 완전히 같은 nn.Conv2d(..., 1)인데 숫자만 다름!
        self.conv3 = nn.Conv2d(width, out_ch, kernel_size=1, bias=False)
        self.bn3   = nn.BatchNorm2d(out_ch)

        self.relu = nn.ReLU(inplace=True)
        self.downsample = downsample   # shape 안 맞을 때만 사용

    def forward(self, x):
        identity = x                                    # 256채널 보관

        out = self.relu(self.bn1(self.conv1(x)))        # 256 → 64
        out = self.relu(self.bn2(self.conv2(out)))      #  64 → 64
        out = self.bn3(self.conv3(out))                 #  64 → 256

        if self.downsample is not None:
            identity = self.downsample(x)               # shape 맞추기

        out = out + identity        # ← 여기서 shape가 같아야 함 (256 == 256)
        return self.relu(out)       # ReLU는 덧셈 "후에"

conv1과 conv3를 나란히 보면 명확합니다. 둘 다 kernel_size=1로 똑같고, 두 번째 인자(출력 채널)에 width를 썼느냐 out_ch를 썼느냐만 다릅니다.

08ResNet-50 전체 구조에서의 위치

Stage블록 반복채널 흐름블록당 params
conv2_x×3256 → 64 → 25669,632
conv3_x×4512 → 128 → 512278,528
conv4_x×61024 → 256 → 10241,114,112
conv5_x×32048 → 512 → 20484,456,448

모든 stage에서 압축비가 정확히 1/4로 유지됩니다. 3+4+6+3 = 16개 블록 × 3층 + stem/fc = 50층이라 ResNet-50이에요.


✓한 문장 요약

1x1 conv는 채널 수를 내가 정한 값으로 바꾸는 층이다. 첫 번째는 비싼 3x3을 싸게 돌리려고 64로 줄이고, 마지막은 skip과 더해야 하니까 다시 256으로 늘린다. 연산이 다른 게 아니라 내가 넣은 출력 채널 숫자가 다를 뿐이다.