비전·음성·추천

DOMAIN / 1번째 글

합성곱·풀링·특징 맵: CNN이 이미지를 읽는 세 단계

CNN 한 블록이 하는 일을 세 부품으로 갈라 본다. 커널·스트라이드·패딩이 정하는 합성곱, 크기를 줄이는 풀링과 GAP, 그리고 그 결과인 특징 맵이 층마다 무엇을 담는지를 파라미터 수와 수용야 계산으로 따라간다.

PALDYN Team41 MIN READ

이미지 한 장이 신경망에 들어가 「고양이」라는 한 단어로 나오기까지, CNN 안에서는 같은 일이 되풀이된다. 작은 필터로 패턴을 찾고, 찾아 둔 것을 압축하고, 그 결과를 다음 층에 넘긴다. 이 글은 그 되풀이를 이루는 세 부품을 한자리에서 다룬다 — 패턴을 찾는 합성곱(convolution), 찾은 것을 줄이는 풀링(pooling), 그리고 둘이 만들어 내는 중간 산출물인 특징 맵(feature map)이다.

셋을 한 편에 묶는 이유가 있다. 세 부품이 결국 같은 하나의 값을 놓고 겨루기 때문이다. 그 값이 수용야(receptive field), 곧 뒤쪽 뉴런 하나가 원본 이미지에서 실제로 바라보는 넓이다. 합성곱은 커널 크기와 스트라이드로 이 값을 조금씩 넓히고, 풀링은 보폭을 곱해 단숨에 넓히며, 특징 맵이 층마다 무엇을 담고 있는지는 그 층의 수용야가 얼마나 넓은지로 대부분 설명된다. 세 부품을 따로 배우면 세 가지 기법으로 보이지만, 수용야를 축으로 놓으면 하나의 이야기가 된다.

완전연결층의 한계

파라미터 폭발

이미지를 신경망에 넣는 가장 단순한 방법은 픽셀을 한 줄로 펴서 완전연결층(fully connected layer, 모든 입력이 모든 뉴런에 이어지는 층)에 넣는 것이다. 다층 퍼셉트론에서 쓰던 방식 그대로다. 걸리는 것은 이미지의 크기다. 224×224 RGB 이미지는 224 × 224 × 3 = 150,528개의 숫자이고, 이것을 뉴런 1,024개짜리 첫 층에 연결하면 가중치만 1억 5천만 개가 넘는다. 층 하나가 그렇다.

숫자가 크다는 것보다 그 숫자가 어디서 왔는지가 문제다. 가중치 하나하나가 「이 픽셀 자리와 저 뉴런 사이의 관계」를 따로 들고 있고, 파라미터가 데이터보다 훨씬 많으면 모델은 규칙을 배우는 대신 훈련 데이터를 외운다. 과적합이다. 여기에 계산 비용이 얹힌다. 이미지 한 장마다 1억 5천만 번의 곱셈을 층 하나가 치러야 하니 학습도 추론도 느리다.

공간 정보의 소실

두 번째 문제는 파라미터를 줄여도 남는다. 픽셀을 한 줄로 펴는 순간 「이 픽셀 옆에 저 픽셀이 있었다」는 정보가 사라진다. 완전연결층에게 150,528개의 입력은 서로 아무 관계 없는 150,528개의 숫자이고, 어느 둘이 원본에서 이웃이었는지는 가중치가 학습으로 되찾아야 하는 정보가 된다.

그래서 위치가 바뀌면 처음부터 다시 배워야 한다. 고양이 귀가 왼쪽 위에 있는 사진과 오른쪽 아래에 있는 사진은 완전연결층에게 전혀 다른 입력 패턴이다. 같은 귀를 두 자리에서 알아보려면 두 벌의 가중치가 각각 그것을 배워야 하고, 자리가 백 군데면 백 벌이 필요하다. 데이터 증강으로 이미지를 이리저리 옮겨 학습시키는 것도 결국 이 낭비를 데이터로 메우는 일이다.

작은 필터와 귀납적 편향

합성곱은 두 문제를 한꺼번에 없앤다. 층 전체를 한 번에 잇는 대신 3×3짜리 작은 창을 하나 두고, 그 창을 이미지 위에서 옮겨 가며 같은 계산을 되풀이한다. 창이 작으니 가중치가 아홉 개뿐이고, 창이 옮겨 다니니 같은 가중치가 모든 자리를 담당한다. 앞 절의 「자리마다 따로 배운다」가 「자리와 무관하게 한 번 배운다」로 바뀐다.

여기서 얻는 것이 귀납적 편향(inductive bias), 곧 모델 구조에 미리 심어 둔 가정이다. 합성곱이 심는 가정은 두 개다. 의미 있는 패턴은 좁은 이웃 안에서 만들어진다는 것, 그리고 어느 자리에서 유용한 패턴은 다른 자리에서도 유용하다는 것. 자연 이미지에서는 둘 다 대체로 맞고, 맞는 가정을 구조에 넣어 두면 그만큼을 데이터로 배우지 않아도 된다. 아래에서 볼 세 부품이 모두 이 가정 위에 서 있다.

합성곱 연산의 구조

커널과 내적

합성곱의 계산은 한 문장으로 끝난다. 커널(kernel, 학습되는 작은 가중치 행렬. 필터라고도 한다)을 입력 위 한 자리에 겹치고, 겹친 값끼리 곱해 모두 더한다. 그리고 커널을 한 칸 옮겨 같은 일을 되풀이한다.

합성곱 연산 원리

(I∗K)[i,j]=∑m∑nI[i+m,j+n]⋅K[m,n](\mathbf{I} * \mathbf{K})[i,j] = \sum_{m}\sum_{n} \mathbf{I}[i+m, j+n] \cdot \mathbf{K}[m, n]

I\mathbf{I} 가 입력, K\mathbf{K} 가 커널이다. 각 자리의 출력값 하나는 그 자리의 입력 조각과 커널의 내적이고, 내적은 두 벡터가 얼마나 같은 방향인지를 재는 값이다. 그러니 출력이 크다는 것은 그 자리의 픽셀 배치가 커널이 들고 있는 패턴과 닮았다는 뜻이 된다. 합성곱 층이 하는 질문은 언제나 하나다 — 「내가 찾는 패턴이 여기 있는가」.

손으로 한 번 따라가 보면 감이 잡힌다. 아래 커널은 왼쪽 열에서 오른쪽 열을 빼므로 세로 방향의 밝기 변화, 곧 수직 가장자리에 반응한다.

import numpy as np

def conv2d(inp, kernel, stride=1, pad=0):
    if pad:
        inp = np.pad(inp, pad)
    K = kernel.shape[0]
    out_h = (inp.shape[0] - K) // stride + 1
    out_w = (inp.shape[1] - K) // stride + 1
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            patch = inp[i*stride:i*stride+K, j*stride:j*stride+K]
            out[i, j] = np.sum(patch * kernel)
    return out

edge = np.array([[1, 0, -1]] * 3, dtype=float)      # 왼쪽 − 오른쪽
img = np.array([[1, 2, 3, 0, 1],
                [0, 1, 2, 1, 0],
                [2, 1, 0, 2, 1],
                [1, 0, 1, 0, 2],
                [0, 2, 1, 0, 1]], dtype=float)

print(conv2d(img, edge))
# [[-2.  1.  3.]
#  [ 0. -1.  0.]
#  [ 1.  1. -2.]]

좌상단 칸만 짚어 보자. 겹치는 입력 조각은 [[1,2,3],[0,1,2],[2,1,0]]이고 커널과 원소별로 곱하면 [[1,0,-3],[0,0,-2],[2,0,0]], 다 더하면 −2-2 다. 왼쪽 열보다 오른쪽 열이 밝았다는 뜻이고, 부호가 방향까지 알려 준다. 커널을 좌우로 뒤집으면 같은 자리의 값이 +2+2 가 된다. 5×5 입력에서 3×3 출력이 나온 것도 눈여겨볼 것 — 커널이 밖으로 삐져나가지 않는 자리가 가로세로 세 곳뿐이라서다. 이 크기를 정하는 것이 다음 두 손잡이다.

스트라이드와 패딩

스트라이드(stride)는 커널이 한 번에 옮겨 가는 간격이다. stride=1이면 한 픽셀씩, stride=2면 두 픽셀씩 건너뛴다. 두 칸씩 뛰면 출력의 가로세로가 대략 절반이 되므로 뒤 층의 계산량은 4분의 1로 준다. 대신 건너뛴 자리는 아예 보지 않는다.

패딩(padding)은 입력 바깥에 값을 덧대는 것이고 보통 0을 쓴다. 덧대지 않는 쪽을 valid, 출력 크기가 입력과 같아지도록 덧대는 쪽을 same 이라 부른다. 패딩이 없으면 층을 지날 때마다 이미지가 조금씩 줄어드는 것도 문제지만, 더 큰 문제는 경계 픽셀이다. 가운데 픽셀은 커널이 지나갈 때 아홉 번 쓰이는데 모서리 픽셀은 한 번뿐이라, 층을 여러 개 쌓으면 테두리 정보가 안쪽보다 훨씬 옅게 전달된다. padding=1을 붙이면 3×3 커널에서 모든 자리가 동등해진다.

스트라이드 패딩 파라미터

두 값이 정하는 출력 크기는 공식 하나로 나온다.

Hout=⌊Hin−K+2PS⌋+1H_{out} = \left\lfloor\frac{H_{in} - K + 2P}{S}\right\rfloor + 1

5×5 입력에 3×3 커널, 패딩 0, 스트라이드 1이면 ⌊(5−3+0)/1⌋+1 = 3, 위 코드가 낸 3×3이 맞다. 같은 입력에 padding=1을 주면 ⌊(5−3+2)/1⌋+1 = 5로 크기가 그대로다. 바닥 함수가 붙어 있다는 점도 기억해 둘 만하다 — 나누어떨어지지 않으면 마지막 자투리는 조용히 버려진다.

가중치 공유

커널 하나는 이미지의 모든 자리에서 같은 가중치를 쓴다. 이것이 가중치 공유(weight sharing)이고, 앞에서 본 두 한계가 여기서 함께 무너진다. 파라미터 수부터 보면 이렇게 계산된다.

파라미터 수 = K × K × C_in × C_out + C_out (bias)

Conv2d(3, 64, 3, padding=1)이면 3×3×3×64 + 64 = 1,792개다. 32×32 RGB 이미지를 완전연결층으로 받아 출력 64개를 내면 3,072 × 64 + 64 = 196,672개이니 110배 차이다. 게다가 공평한 비교도 아니다. 완전연결층은 숫자 64개를 내놓고 끝이지만 합성곱은 32×32짜리 지도를 64장 내놓는다.

두 번째 이득은 위치 동변성(translation equivariance)이다. 입력이 오른쪽으로 세 칸 밀리면 출력도 정확히 세 칸 밀린다. 고양이 귀가 어디에 있든 같은 커널이 같은 세기로 반응하고, 반응한 자리가 원래 자리를 그대로 알려 준다. 한 자리에서 배운 것이 모든 자리에 그대로 적용되는 셈이라 데이터가 덜 든다.

학습도 이 공유를 따라간다. 역전파에서 커널 가중치의 기울기는 입력과 출력 기울기의 상관 연산 dL/dW=X∗δdL/dW = X * \delta 로, 입력 쪽으로 흘려보낼 기울기는 커널을 180도 뒤집어 합성곱한 dL/dX=Wflip∗δdL/dX = W^{flip} * \delta 로 계산된다. 여기서 중요한 것은 결과의 모양이다. 가중치 아홉 개가 수천 개 자리에서 쓰였으므로, 그 자리 전부에서 온 기울기가 한 벌의 가중치 위에 누적된다. 커널 하나가 이미지 한 장에서 수천 번의 학습 신호를 받는다는 뜻이고, 파라미터가 적은데도 잘 학습되는 이유가 여기에 있다.

필터 수와 채널

커널 하나는 패턴 하나만 찾는다. 그래서 실제 합성곱 층은 커널을 C_out개 두고 동시에 굴린다. 각 커널이 서로 다른 패턴 — 수직 가장자리, 수평 가장자리, 특정 색의 덩어리 — 을 맡도록 학습되고, 출력은 (C_out, H_out, W_out) 모양의 3차원 텐서가 된다. 이 텐서가 특징 맵(feature map)이다. 채널 하나가 「이 패턴이 어디에 얼마나 있는가」를 그린 지도 한 장이고, 채널 수만큼 지도가 겹쳐 있다.

입력 쪽 채널도 같이 봐야 한다. RGB 이미지에 3×3 커널을 쓴다고 할 때 실제 커널은 3×3이 아니라 3×3×3이다. 커널은 언제나 입력 채널 전부를 뚫고 지나가며, 세 채널에서 얻은 값을 더해 출력 한 장을 만든다. 공간 방향으로는 작지만 채널 방향으로는 늘 완전연결이라는 뜻이다.

import torch
import torch.nn as nn

conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, padding=1)
print(sum(p.numel() for p in conv.parameters()))   # 1792

x = torch.randn(1, 3, 32, 32)      # (batch, C_in, H, W)
print(conv(x).shape)               # torch.Size([1, 64, 32, 32])

3채널이 64채널이 되고 공간 크기는 그대로다. 그리고 여기서 다음 부품이 필요해진다. 이대로 층을 쌓으면 채널은 계속 늘어나는데 32×32라는 넓이는 줄지 않아, 메모리와 계산량이 층마다 불어난다.

풀링

풀링의 역할

풀링(pooling)은 특징 맵을 정해진 크기의 칸으로 나누고 각 칸을 대표값 하나로 줄이는 연산이다. 학습되는 가중치가 없다는 점에서 합성곱과 다르다 — 규칙이 고정되어 있고, 그래서 파라미터도 늘지 않고 과적합에도 기여하지 않는다.

풀링을 두는 이유는 두 가지인데, 첫째는 방금 본 비용이다. 2×2 풀링은 넓이를 4분의 1로 줄이므로 그 뒤에 오는 모든 층의 계산량과 메모리가 함께 줄고, 그래서 채널 수를 늘릴 여유가 생긴다. 실제로 초기 CNN들이 「크기는 절반, 채널은 두 배」를 되풀이한 것은 층마다 대략 비슷한 계산량을 유지하려는 배분이었다.

둘째 이유가 더 중요하다. 고수준 특징 — 얼굴 전체, 자동차 한 대 — 을 알아보려면 뒤 층의 뉴런 하나가 원본의 넓은 영역을 봐야 하는데, 공간 크기를 줄이지 않으면 그 넓이가 아주 천천히만 자란다. 얼마나 천천히인지는 아래 수용야 절에서 숫자로 확인한다.

최대값과 평균값

대표값을 고르는 방식이 두 가지다. 최대 풀링(max pooling)은 칸 안의 가장 큰 값을, 평균 풀링(average pooling)은 평균을 취한다.

최대 풀링 vs 평균 풀링

x = torch.tensor([[[[1., 3., 2., 4.],
                    [5., 2., 6., 1.],
                    [7., 4., 3., 8.],
                    [2., 1., 5., 2.]]]])          # (1, 1, 4, 4)

nn.MaxPool2d(kernel_size=2, stride=2)(x)   # [[5., 6.], [7., 8.]]
nn.AvgPool2d(kernel_size=2, stride=2)(x)   # [[2.75, 3.25], [3.50, 4.50]]

같은 입력에서 나온 두 결과를 나란히 놓으면 성격 차이가 보인다. 좌상단 칸의 값 1, 3, 5, 2에서 최대 풀링은 5를, 평균 풀링은 2.75를 남긴다. 특징 맵의 값이 「그 자리에 이 패턴이 얼마나 강하게 있는가」였다는 것을 떠올리면 최대 풀링의 논리가 분명해진다. 「이 근방에 고양이 귀가 있는가」라는 질문에 대해서는 가장 강하게 반응한 자리 하나가 답이고, 약하게 반응한 세 자리는 그 답을 흐릴 뿐이다.

평균 풀링은 반대로 강한 값 하나에 휘둘리지 않는다. 특정 위치의 존재보다 넓은 영역의 전반적인 분포가 중요한 경우 — 질감이나 배경 — 에 어울린다. 분류 네트워크의 중간 층에서는 최대 풀링이 대체로 더 나은 결과를 내지만, 마지막 층에서는 뒤에서 볼 이유로 평균 쪽이 표준이 되었다.

위치 불변성

풀링이 만드는 성질을 위치 불변성(translation invariance)이라 부른다. 앞에서 본 동변성과 한 글자 차이인데 뜻은 반대다. 동변성은 입력이 움직이면 출력도 따라 움직이는 것이고, 불변성은 입력이 움직여도 출력이 그대로인 것이다. 2×2 최대 풀링에서 특징이 한 픽셀 움직여도 같은 칸 안에 머물면 최대값은 변하지 않는다.

분류에서는 이것이 순수한 이득이다. 「이 사진에 고양이가 있는가」라는 질문에 귀가 3픽셀 왼쪽에 있었다는 사실은 방해만 된다. 층을 지날수록 불변성이 조금씩 쌓이면서 모델은 자리에 둔감해지고, 그만큼 새로운 사진에 잘 일반화한다.

같은 성질이 다른 태스크에서는 손해가 된다. 객체 탐지는 경계 상자의 좌표를, 분할은 픽셀 단위의 경계를 내놓아야 하므로 자리 정보를 버리면 안 된다. 그래서 이런 네트워크는 풀링을 적게 쓰거나, 줄인 크기를 다시 키우면서 앞쪽 층의 세밀한 특징 맵을 끌어다 붙인다. 풀링은 자리 정보를 조금 버리고 안정성을 얻는 거래이고, 그 거래가 남는지는 태스크가 정한다.

글로벌 평균 풀링

글로벌 평균 풀링(global average pooling, GAP)은 칸을 나누지 않고 채널 하나의 특징 맵 전체를 평균 하나로 줄인다. (C, H, W)가 (C,)가 되므로 공간 정보가 통째로 사라지는 대신, 채널마다 「이 패턴이 이미지 전체에 평균 얼마나 있었나」 하는 숫자 하나가 남는다. 마지막 합성곱 층과 분류기 사이에 넣는다.

Global Average Pooling vs Flatten

gap = nn.AdaptiveAvgPool2d(1)          # 공간 크기를 1×1로
feat = torch.randn(1, 512, 7, 7)
v = gap(feat).flatten(1)               # (1, 512)
head = nn.Linear(512, 1000)

예전 방식은 7×7×512 = 25,088개를 한 줄로 펴서 완전연결층에 넣는 것이었다. FC(25088→4096) 하나가 1억 파라미터를 먹는다. GAP를 거치면 512차원 벡터가 되고 FC(512→1000)은 51만 개다 — 200배 차이이고, 그 자리는 대개 네트워크 전체에서 파라미터가 가장 많이 몰린 곳이었다. 절약만이 아니라 세 가지가 함께 온다.

Flatten + FC GAP + FC
분류기 파라미터 약 1억 51만
입력 해상도 학습 때와 같아야 함 무관
과적합 위험이 큼 평균이 정규화로 작용

가운데 줄이 실무에서 자주 걸리는 자리다. Flatten은 7×7이라는 크기를 전제로 가중치 개수가 정해지므로 다른 해상도의 이미지를 넣으면 바로 터진다. GAP는 몇 곱하기 몇이 들어오든 평균 하나를 내므로 학습은 224×224로 하고 추론은 더 큰 이미지로 하는 것이 가능하다. 이 방식은 Network in Network(2013)가 완전연결층의 대안으로 제안했고, GoogLeNet(2014)이 채택하며 널리 알려졌으며, ResNet 이후로는 사실상 표준이다. 그리고 GAP가 남긴 「채널 하나 = 숫자 하나」라는 구조가 뒤에서 볼 CAM을 가능하게 한다.

수용야와 다운샘플링

수용야의 성장 속도

수용야는 출력의 뉴런 하나가 원본 이미지에서 의존하는 영역의 크기다. 3×3 합성곱 한 층 뒤의 뉴런은 원본 3×3을 본다. 그 위에 3×3을 하나 더 쌓으면, 두 번째 층의 뉴런은 첫 층 출력 3×3을 보고 그 각각이 원본 3×3을 보므로 겹치는 부분을 빼고 5×5가 된다. 층마다 커널 크기 빼기 1만큼씩 늘어난다.

이 속도가 문제다. 3×3 층을 계속 쌓으면 수용야는 3, 5, 7, 9로 2씩 자라므로 224×224 이미지 전체를 보는 뉴런을 만들려면 백 층이 넘게 필요하다. 「이 사진에 무엇이 있는가」를 답하려면 최소한 물체 하나가 통째로 들어오는 넓이는 봐야 하는데, 합성곱만으로는 거기까지 가는 길이 너무 멀다.

보폭

풀링이 바꾸는 것은 늘어나는 양이 아니라 보폭(jump)이다. 보폭은 출력에서 한 칸 옮길 때 원본에서 몇 픽셀이 옮겨지는가이고, 스트라이드 2짜리 연산을 지날 때마다 두 배가 된다. 그다음 층부터는 커널이 한 칸 움직일 때 원본을 두 픽셀씩 건너뛰므로, 같은 3×3 커널이 원본 기준으로는 두 배 넓은 영역을 훑는다.

def receptive_field(layers):        # layers: (kernel, stride) 목록
    rf, jump = 1, 1
    for k, s in layers:
        rf += (k - 1) * jump
        jump *= s
    return rf, jump

vgg = [(3, 1), (3, 1), (2, 2), (3, 1), (3, 1), (2, 2)]
print(receptive_field(vgg))         # (16, 4)

패딩이 인자에 없는 것이 실수가 아니다. 패딩은 출력의 크기와 경계 처리에 영향을 주지만 뉴런 하나가 보는 창의 넓이는 바꾸지 않는다. VGG 스타일 여섯 층을 한 줄씩 따라가면 이렇게 자란다.

지나는 층 보폭 수용야
입력 1 1
conv 3×3 1 3
conv 3×3 1 5
pool 2×2 s2 2 6
conv 3×3 2 10
conv 3×3 2 14
pool 2×2 s2 4 16

풀링 자체가 늘리는 폭은 5에서 6으로 겨우 1이다. 값이 뛰는 자리는 그 다음 두 합성곱이고, 각각 2가 아니라 4씩 늘렸다. 풀링의 일은 자기가 넓히는 것이 아니라 뒤에 오는 층들의 한 걸음을 키우는 것이다. 그래서 초기 CNN들이 합성곱 두세 개마다 풀링을 하나씩 끼워 넣었다. 이 배치를 빼면 같은 넓이를 얻는 데 훨씬 많은 층이 든다.

스트라이드 합성곱

크기를 줄이는 방법이 풀링만 있는 것은 아니다. 합성곱 자체에 stride=2를 주면 다운샘플링과 특징 추출이 한 층에서 함께 일어난다.

nn.Sequential(nn.Conv2d(64, 128, 3, padding=1), nn.MaxPool2d(2, 2))   # 풀링 방식
nn.Conv2d(64, 128, 3, stride=2, padding=1)                            # 스트라이드 합성곱

둘의 차이는 규칙이 고정인가 학습되는가다. 최대 풀링은 언제나 최대값을 고르지만, 스트라이드 합성곱은 어떻게 줄일지를 가중치가 배운다. 그만큼 유연하고 그만큼 파라미터와 과적합 위험이 늘어난다. 현대 아키텍처는 스트라이드 합성곱 쪽으로 기운 편이고 — ResNet 계열이 단계를 넘어갈 때 쓰는 것도 스트라이드 2짜리 합성곱이다 — 최대 풀링은 첫 단계처럼 특정 자리에 남는 경우가 많다. 어느 쪽이든 수용야 계산은 같다. 위 함수에 (3, 2) 한 줄로 들어가고, 보폭을 두 배로 만드는 효과도 그대로다.

Vision Transformer

Vision Transformer는 아예 다른 길을 간다. 이미지를 16×16 같은 패치(patch)로 잘라 각각을 하나의 토큰처럼 다루고, 토큰들 사이를 어텐션으로 잇는다. 어텐션은 첫 층부터 모든 토큰을 서로 보게 하므로 수용야를 조금씩 넓힐 필요가 없다 — 시작부터 이미지 전체다.

대신 합성곱이 공짜로 주던 것들을 잃는다. 이웃한 픽셀이 서로 관계있다는 가정이 구조에 없으므로 위치 정보를 따로 인코딩해 넣어야 하고, 위치 불변성도 저절로 생기지 않는다. 이 차이가 데이터 양에 따라 유불리를 뒤집는다. 데이터가 적으면 맞는 가정을 미리 박아 둔 CNN이 유리하고, 데이터가 아주 많으면 가정 없이 데이터에서 직접 배우는 쪽이 더 나은 표현에 도달한다는 것이 지금까지의 대체적인 관찰이다. 두 계열이 서로의 장치를 가져다 쓰면서 경계는 계속 흐려지고 있다.

특징 맵

채널 축과 공간 축

이제 앞에서 정의만 해 둔 특징 맵으로 돌아온다. 합성곱 층의 출력은 (C, H, W) 텐서이고, 그 안의 값 하나 (c,i,j)(c, i, j) 를 문장으로 옮기면 이렇다 — 「cc 번 커널이 찾는 패턴이, 원본 이미지에서 (i,j)(i, j) 에 대응하는 자리에, 얼마나 강하게 있는가」.

두 축을 갈라 보는 것이 중요하다. 채널 축은 무엇을 찾았는지이고, 공간 축은 어디서 찾았는지다. 층을 지날수록 채널 수는 늘고 공간 크기는 줄어드는 것이 전형적인 배치인데, 이 배치가 곧 「무엇」의 해상도를 올리고 「어디」의 해상도를 내리는 거래다. 첫 층의 특징 맵은 원본에 가까운 넓이에 채널 64개, 마지막 층은 7×7에 채널 2,048개인 식이다. 앞쪽은 자리를 정확히 알지만 아는 것이 가장자리뿐이고, 뒤쪽은 자리가 뭉개졌지만 무엇인지를 안다.

그리고 「(i,j)(i, j) 에 대응하는 자리」의 크기가 바로 그 층의 수용야다. 같은 좌표라도 첫 층에서는 3×3 픽셀을, 여섯 층 뒤에서는 16×16 픽셀을 요약한 값이다.

첫 층의 가장자리와 색

학습이 끝난 네트워크의 첫 합성곱 층 커널을 그림으로 그려 보면 규칙적인 무늬가 나온다. 여러 방향의 밝기 경계를 잡는 커널들과 특정 색 대비에 반응하는 커널들이다. 방향성 가장자리를 잡는 이 모양은 딥러닝 이전에 사람이 손으로 설계하던 Gabor 필터와 닮았다.

이것이 흥미로운 이유는 아무도 그렇게 하라고 시키지 않았기 때문이다. 손실 함수는 「고양이인지 개인지 맞혀라」였을 뿐인데, 그 목표를 잘 풀려고 경사하강법이 도달한 자리가 수십 년 동안 사람이 다듬어 온 특징 추출기와 거의 같았다. 자연 이미지를 다루는 첫 단계로 가장자리와 색 대비를 뽑는 것이 거의 최적이라는 사실을, 두 방법이 따로따로 발견한 셈이다.

그래서 첫 층은 어떤 데이터로 학습했든 하는 일이 대체로 같다. 이 보편성이 어디까지 이어지는지가 다음 두 소절의 주제다.

깊이별 추상화

층이 깊어질수록 특징은 추상적이고 의미론적으로 바뀐다. 수용야가 넓어지는 속도와 함께 보면 자연스러운 변화다. 3×3 픽셀만 보는 뉴런이 알아볼 수 있는 것은 가장자리 조각뿐이고, 바퀴를 알아보려면 최소한 바퀴가 들어오는 넓이를 봐야 한다.

CNN 계층적 특징 맵

깊이 반응하는 것 공유 범위
층 1~2 수평·수직·대각 가장자리, 색 덩어리 모든 자연 이미지
층 3~4 질감, 모서리, 곡선, 격자 무늬 대부분의 도메인
층 5 이상 눈, 바퀴, 창문, 털 같은 의미 단위 학습한 도메인에 특화

오른쪽 열이 뒤에서 쓸 정보다. 앞쪽 특징은 어떤 이미지에나 쓸모가 있지만 뒤쪽 특징은 그 데이터셋에서 구분해야 했던 것들에 맞춰져 있다. ImageNet으로 학습한 네트워크의 마지막 층에는 개 품종을 가르는 데 유용한 특징들이 잔뜩 들어 있는데, 이는 그 데이터셋에 개 품종이 백 종 넘게 들어 있었기 때문이다.

이 층위 구조가 생물의 시각 피질이 정보를 처리하는 방식(V1→V2→V4→IT)과 닮았다는 점도 자주 언급된다. 단순한 방향 검출에서 시작해 점점 복잡하고 의미 있는 표현으로 올라가는 순서가 같다.

훅과 중간 출력

특정 이미지에 대해 중간 층이 실제로 무엇을 내놓는지는 훅(hook, 특정 모듈의 순전파가 끝날 때 호출되도록 등록해 두는 함수)으로 꺼내 본다. 모델 구조를 고치지 않고 원하는 층의 출력을 가로챌 수 있다.

import torchvision.models as models

feats = {}
model = models.resnet50(weights='IMAGENET1K_V1').eval()
model.layer1[0].conv1.register_forward_hook(
    lambda m, inp, out: feats.__setitem__('x', out.detach()))

with torch.no_grad():
    model(torch.randn(1, 3, 224, 224))

print(feats['x'].shape)      # torch.Size([1, 64, 56, 56])

64장의 56×56 지도가 나온다. 각 장을 흑백 이미지로 그려 보면 어떤 채널은 고양이의 윤곽에, 어떤 채널은 배경 질감에 밝게 반응하는 것이 눈에 보인다. 이 그림은 장식이 아니라 디버깅 도구다. 학습이 잘 안 될 때 중간 특징 맵이 전부 0이면 죽은 ReLU를, 전부 비슷한 모양이면 채널들이 같은 것을 배우고 있는 중복을 의심한다. 손실 곡선만 보면서 학습률을 바꾸는 것보다 원인에 훨씬 가깝다.

특징 맵의 쓰임

동결의 범위

특징이 층마다 다른 성격을 갖는다는 사실은 곧바로 실무 결정 하나를 만든다. 사전학습된 모델을 새 문제에 쓸 때 어디까지를 그대로 두고 어디부터 다시 배울 것인가다.

특징 맵 시각화와 전이학습

기준은 두 축이다. 하나는 가진 데이터의 크기, 다른 하나는 새 도메인이 원래 학습 도메인과 얼마나 닮았는가. 데이터가 적으면 많은 층을 다시 배울 여력이 없으니 앞쪽을 얼리고 분류기만 새로 붙인다 — 앞 절에서 본 대로 앞쪽 특징은 어차피 도메인을 가리지 않으므로 잃는 것이 적다. 반대로 데이터가 넉넉하고 도메인이 많이 다르면 — 위성 사진이나 의료 영상처럼 — 뒤쪽의 의미 특징이 그대로는 안 맞으므로 낮은 학습률로 전체를 다시 조정한다. 그 사이는 뒤쪽 몇 블록만 푸는 절충이다.

세 방식의 실제 코드와 학습 레시피는 이미지 분류 파이프라인에서 다룬다. 여기서 기억할 것은 그 선택이 취향이 아니라 특징 맵의 층위 구조에서 나온 결론이라는 점이다.

CAM

CAM(Class Activation Mapping)은 모델이 어느 자리를 보고 그렇게 분류했는지를 그림으로 보여 주는 방법이다. 원리가 GAP에서 곧장 나온다. GAP를 쓰는 네트워크에서 마지막 분류기의 가중치는 「채널 cc 가 클래스 kk 에 얼마나 기여하는가」를 담고 있으므로, 그 가중치로 마지막 특징 맵들을 가중 합산하면 클래스별 근거 지도가 된다.

def cam(model, x, class_idx):
    feats = {}
    model.layer4.register_forward_hook(
        lambda m, inp, out: feats.__setitem__('f', out))
    with torch.no_grad():
        model(x)
    w = model.fc.weight[class_idx]                      # (2048,)
    return torch.einsum('c,chw->hw', w, feats['f'][0]).relu()

나오는 것은 마지막 층 크기(예컨대 7×7)의 지도이고, 원본 크기로 늘려 사진 위에 겹치면 모델이 근거로 삼은 영역이 밝게 보인다. 여기서 자주 드러나는 것이 단축 학습(shortcut learning)이다. 배가 든 사진을 물 때문에 배로 분류하고 있었다거나, 의료 영상에서 병변이 아니라 특정 장비가 남긴 표식을 보고 있었다는 식이다. 정확도 숫자만 보면 알 수 없고 근거 지도를 봐야 잡힌다. 설명 가능성이 요구되는 분야에서 CAM 계열 도구가 표준으로 자리 잡은 이유다.

블록의 배치

세 부품을 다시 한 줄로 놓으면 이렇다. 합성곱이 「무엇이 어디에」를 찾고, 풀링이 「어디에」를 조금 버려 「무엇」을 넓은 범위에서 볼 수 있게 하고, 그 결과인 특징 맵이 층마다 점점 추상적인 표현을 담는다. 하나의 블록이 하는 일은 여기까지다.

남는 질문은 이 블록을 어떻게 배치하느냐다. 커널을 3×3으로 할지 7×7로 할지, 채널을 언제 두 배로 늘릴지, 풀링을 몇 층마다 넣을지, 그리고 층을 몇 개까지 쌓을 수 있는지. 마지막 질문이 특히 까다롭다. 층을 늘리면 수용야가 넓어지고 표현력도 올라가야 할 것 같은데, 어느 지점을 넘기면 깊은 쪽이 오히려 얕은 쪽보다 못한 성적을 내는 일이 실제로 벌어졌다.

다음 글에서는 이 배치 문제를 두고 1998년부터 2022년까지 쌓인 답들을 따라간다. 손글씨 숫자를 읽던 첫 구조에서 시작해, 깊이의 한계를 우회한 잔차 연결, 계산량 대비 정확도를 저울질한 설계, 그리고 Transformer의 아이디어를 되가져온 최근 구조까지 — 각 세대가 무엇이 막혀서 무엇을 바꿨는지가 이야기의 뼈대다.


읽어주셔서 감사합니다. 😊

LATEST

비전·음성·추천의 최신 글

비전·음성·추천2026.09.07

오프라인 강화학습 — 쌓인 로그만으로 정책을 배우기

실제 서비스에서 탐색은 곧 사용자에게 나쁜 행동을 해 보는 일입니다. 이미 쌓인 로그만으로 정책을 배우려 할 때 왜 Q값이 혼자 부풀어 오르는지, 그 부풀음을 누르는 세 갈래 대응, 행동 복제라는 기준선의 무게, 그리고 배포 전에 성능을 재는 일이 왜 가장 어려운지를 정리합니다.

18 MIN
비전·음성·추천2026.09.07

다국어 전이 — 라벨 없는 언어에서 모델이 동작하는 이유

영어 라벨만으로 학습한 분류기가 한국어 문장을 그대로 처리하는 일이 실제로 일어납니다. 여러 언어가 한 표현 공간에 겹쳐 놓이는 원리, 그 겹침이 무너지는 조건, 번역해서 학습할지 번역해서 추론할지 고르는 기준, 그리고 언어별로 나눠 재야 하는 이유를 정리합니다.

16 MIN
비전·음성·추천2026.09.07

정보 추출 — 글 한 덩이를 표 한 줄로 바꾸는 일

계약서와 이메일을 데이터베이스에 넣으려면 글에서 값을 뽑아 칸에 채워야 합니다. 개체명·관계·사건의 세 층위, 값을 정규화하는 일이 왜 절반인지, 근거 위치를 함께 남겨야 하는 이유, 규칙·전용 모델·언어 모델의 갈림길, 그리고 필드별로 재는 평가법을 정리합니다.

17 MIN