1x1 conv · 채널 압축 · 잔차 연결 · 파라미터 절감
먼저 결론부터
"1x1 conv"는 채널을 줄이는 연산도, 늘리는 연산도 아닙니다. 그냥 채널 수를 내가 정하는 대로 바꿔주는 연산이에요. 출력 채널 수는 nn.Conv2d(in, out, 1)의 out에 내가 써넣는 숫자입니다.
그래서 첫 번째는 out=64로 써서 줄이는 것이고, 마지막은 out=256으로 써서 늘리는 것일 뿐, 연산의 종류가 다른 게 아니라 내가 넣은 숫자가 다른 것입니다. 이제 "왜 하필 줄였다 늘리냐"만 답하면 끝나요.
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은 서로 다른 층입니다. 가중치를 공유하지 않고 각자 따로 학습돼요. 첫 번째가 한 압축을 마지막이 "되돌리는" 게 아니라, 마지막도 자기 나름대로 유용한 조합을 학습합니다.
가운데가 잘록해서 bottleneck(병목)이라고 부릅니다. 이름이 구조를 그대로 설명하고 있어요.
핵심은 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명에게 펼쳐서 전달하는 거예요. 압축·전달은 싸고, 회의만 비싸니까요.
여기가 진짜 답입니다. ResNet 블록의 마지막은 이 연산이에요:
out = F(x) + x
텐서 덧셈은 shape가 같아야 성립합니다. x는 256채널인데 F(x)가 64채널로 끝나버리면 더할 수가 없어요. 그래서 마지막 1x1이 반드시 256으로 되돌려 놓아야 합니다.
정리하면 두 1x1의 역할 분담은 이렇습니다.
즉 256채널은 네트워크의 "간선도로"이고, 각 블록은 잠깐 64차선으로 내려와 일을 처리한 뒤 다시 간선도로에 합류하는 구조입니다. 간선도로의 폭(256)은 유지하면서 처리 비용만 깎는 거예요.
같은 256채널 입출력을 처리하는 두 블록을 비교하면:
| 구성 | 층 | 파라미터 |
|---|---|---|
| Basic block ResNet-18/34 | 3x3, 256→256 | 589,824 |
| 3x3, 256→256 | 589,824 | |
| 합계 | 1,179,648 | |
| Bottleneck ResNet-50/101/152 | 1x1, 256→64 | 16,384 |
| 3x3, 64→64 | 36,864 | |
| 1x1, 64→256 | 16,384 | |
| 합계 | 69,632 |
약 17배 절감. 파라미터가 아니라 연산량(FLOPs)으로 봐도 같은 비율입니다 — 두 블록 모두 H, W를 바꾸지 않으니 FLOPs ≈ 파라미터 × H × W로 비율이 그대로 유지돼요.
ResNet이 층을 34개에서 50개, 152개까지 늘릴 수 있었던 이유가 여기 있습니다. 블록 하나가 싸지니까 같은 예산으로 훨씬 깊게 쌓을 수 있게 된 거죠.
입력·출력은 256채널 고정. 슬라이더를 256까지 올리면 bottleneck이 오히려 더 비싸집니다 — 압축을 안 하면 1x1 두 개가 순수 오버헤드가 되기 때문이에요. 논문이 쓰는 압축비는 1/4(=64)입니다.
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가 같아야 한다"는 같은 원리예요.
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를 썼느냐만 다릅니다.
| Stage | 블록 반복 | 채널 흐름 | 블록당 params |
|---|---|---|---|
| conv2_x | ×3 | 256 → 64 → 256 | 69,632 |
| conv3_x | ×4 | 512 → 128 → 512 | 278,528 |
| conv4_x | ×6 | 1024 → 256 → 1024 | 1,114,112 |
| conv5_x | ×3 | 2048 → 512 → 2048 | 4,456,448 |
모든 stage에서 압축비가 정확히 1/4로 유지됩니다. 3+4+6+3 = 16개 블록 × 3층 + stem/fc = 50층이라 ResNet-50이에요.
1x1 conv는 채널 수를 내가 정한 값으로 바꾸는 층이다. 첫 번째는 비싼 3x3을 싸게 돌리려고 64로 줄이고, 마지막은 skip과 더해야 하니까 다시 256으로 늘린다. 연산이 다른 게 아니라 내가 넣은 출력 채널 숫자가 다를 뿐이다.
out_channels 인자의 값 차이F(x) + x 덧셈의 shape를 맞추기 위한 필수 조건