지금까지 만든 신경망은 층과 층을 Affine으로 이었다. Affine은 입력을 받아 가중치와 곱하고 편향을 더하는 완전연결(fully-connected) 계층이다. MNIST에서도 잘 동작했다. 그런데 그 신경망에 손글씨 이미지를 넣으려면 먼저 (28, 28)짜리 2차원 배열을 (784,)짜리 1차원 벡터로 펴야 했다. 이 한 줄의 평탄화(flatten)에 이 장 전체의 문제의식이 들어 있다 — 이미지를 한 줄로 펴는 순간, 우리는 가로로 이웃한 픽셀과 세로로 이웃한 픽셀이 원래 얼마나 가까웠는지를 잃는다.

이 장은 그 잃어버린 형상을 되찾는 합성곱 신경망(Convolutional Neural Network, CNN)을 밑바닥부터 세운다. CNN은 이미지를 편평하게 만들지 않고 3차원 그대로 흘려보낸다. 핵심은 두 개의 새 계층 — 합성곱(Convolution)과 풀링(Pooling) — 이고, 이 둘을 넘파이로 효율적으로 구현하는 열쇠가 im2col이라는 한 함수다. 끝에서는 필터가 무엇을 학습했는지를 눈으로 확인하며, 층이 깊어질수록 왜 표현이 추상화되는지를 본다.

완전연결이 이미지의 형상을 뭉갠다

Affine 계층의 입력은 벡터다. 그래서 (1, 28, 28)처럼 채널·세로·가로가 살아 있는 이미지를 넣으려면 (784,)로 펴야 한다. 이 평탄화가 버리는 것을 구체적으로 세어 보자. 원래 이미지에서 좌표 (3, 5) 픽셀과 (3, 6) 픽셀은 가로로 딱 붙어 있고, (3, 5)(4, 5)는 세로로 붙어 있다. 이 “붙어 있음”이 이미지가 담은 정보의 대부분이다 — 엣지, 곡선, 질감은 전부 이웃 픽셀들의 국소적 관계로 이루어진다.

그런데 (784,)로 펴 버리면, 세로로 이웃했던 두 픽셀은 벡터 안에서 28칸 떨어진 원소가 된다. 완전연결 계층은 784개 입력을 전부 평등하게 이어 버리므로, “28칸 떨어진 두 원소가 실은 세로 이웃”이라는 사실을 처음부터 알 방법이 없다. 공간 구조가 담긴 정보를 스스로 다시 발견해야 하는 셈이고, 그건 낭비다.

CNN은 형상을 유지한다. 합성곱 계층의 입출력은 벡터가 아니라 3차원 데이터 — 이 책에서 특징 맵(feature map)이라 부르는 (채널, 높이, 너비) 형태 — 이고, 이웃 관계가 계층을 통과하는 내내 살아 있다.

판단 기준: 입력이 공간적 구조(이미지·음성·시계열)를 가지고 그 국소 관계가 의미를 담는다면 형상을 유지하는 합성곱을 쓴다. 함정: 이미지를 처음부터 (784,)로 펴서 Affine에만 넣으면 동작은 하지만, 픽셀 이웃 관계를 네트워크가 데이터로부터 힘겹게 재발견해야 한다 — 표현력을 스스로 깎는 것이다.

합성곱 연산 — 필터를 미끄러뜨리다

합성곱은 입력 위에 작은 **필터(커널)**를 얹고, 겹친 영역끼리 원소별로 곱해 전부 더한 값 하나를 출력하는 연산이다. 그 필터를 한 칸씩 오른쪽으로, 아래로 미끄러뜨리며(sliding) 이 곱-합을 반복하면 출력 특징 맵이 채워진다.

(4, 4) 입력에 (3, 3) 필터를 예로 들자. 필터를 입력의 왼쪽 위에 겹치면 3×3=9쌍이 겹친다. 각 쌍을 곱해 9개를 더하면 출력의 (0, 0) 한 칸이 나온다. 필터를 오른쪽으로 한 칸 밀면 출력의 (0, 1)이 나온다. (4, 4) 입력에 (3, 3) 필터는 가로로 2번, 세로로 2번 놓을 수 있으므로 출력은 (2, 2)가 된다.

여기서 필터의 값이 곧 학습 대상이라는 점이 핵심이다. Affine의 가중치가 학습되듯, 합성곱 필터의 원소들이 오차역전파로 갱신된다. 학습이 끝나면 어떤 필터는 세로 엣지에, 어떤 필터는 가로 엣지에 강하게 반응하도록 스스로 자리를 잡는다 — 이건 이 장 끝에서 눈으로 확인한다. 편향은 필터를 적용한 뒤 출력 전체에 하나의 스칼라로 더해진다.

판단 기준: 합성곱은 “같은 필터를 입력 전 위치에 공유해 적용”한다 — 그래서 이미지 어디에 엣지가 있든 같은 필터가 잡아낸다(위치 불변적 특징 추출). 함정: 합성곱을 완전연결처럼 “위치마다 다른 가중치”로 오해하면 파라미터 공유라는 CNN의 핵심 이점을 놓친다. 필터 하나의 파라미터 수는 입력 크기와 무관하게 채널×FH×FW로 고정이다.

패딩과 스트라이드 — 출력 크기를 조절하는 두 손잡이

합성곱을 반복하면 출력이 입력보다 작아진다. (4, 4)(2, 2)로 줄었다. 깊은 층을 쌓다 보면 특징 맵이 계속 쪼그라들어 어느 순간 더는 합성곱을 못 하게 된다. 이를 막는 것이 패딩(padding) — 입력 가장자리를 0으로 둘러싸 크기를 인위적으로 키우는 것이다. 폭 1의 패딩은 상하좌우에 0을 한 겹 두르므로 (4, 4)(6, 6)으로 만든다.

**스트라이드(stride)**는 필터를 미끄러뜨리는 간격이다. 스트라이드 1이면 한 칸씩, 2면 두 칸씩 건너뛴다. 스트라이드를 키우면 필터를 놓는 위치가 성겨지므로 출력이 더 작아진다. 패딩이 출력을 키우는 손잡이라면, 스트라이드는 줄이는 손잡이다.

이제 출력 크기를 공식으로 못 박자. 입력 높이 , 필터 높이 , 패딩 , 스트라이드 라 하자. 패딩을 두르면 세로 방향으로 실제 사용 가능한 높이는 가 된다(위·아래 각각 씩). 그 위에서 높이 짜리 필터를 스트라이드 로 놓을 수 있는 횟수를 세면 된다. 필터의 맨 위 모서리가 놓일 수 있는 첫 위치는 0이고, 마지막 위치는 필터가 아래 끝에 딱 걸리는 이다. 이 구간을 보폭 로 몇 번 밟을 수 있는가 — 번을 밟고, 시작점 자신을 1 더한다. 그래서

가로도 대칭으로 이다. 앞의 (4,4) 입력, (3,3) 필터, , 을 넣으면 — 손으로 센 (2, 2)와 정확히 맞는다. 패딩 을 주면 가 되어 입력과 출력이 같아진다(그래서 “크기 보존” 패딩을 자주 쓴다).

판단 기준: 층을 쌓을 때 출력 크기를 미리 이 공식으로 계산해 설계하라. 입력 크기를 유지하려면 에서 로 잡는다(3×3 필터엔 ). 함정: 공식의 분자 로 나누어떨어지지 않으면 필터가 가장자리에 어정쩡하게 걸린다 — 넘파이 구현에서 소수 크기로 깨지거나 조용히 버림되어 shape가 예상과 어긋난다. 하이퍼파라미터는 나눠떨어지도록 고른다.

3차원과 배치로 확장하기 — 채널이라는 축

지금까지는 2차원 흑백 이미지였다. 실제 이미지는 RGB 3채널을 갖고, 합성곱 중간의 특징 맵도 여러 채널을 갖는다. 그래서 입력은 (C, H, W) 3차원이다. 이때 필터도 입력과 같은 채널 수를 가져야 한다 — (C, FH, FW). 합성곱은 채널마다 2차원 합성곱을 따로 한 뒤, 채널 방향으로 전부 더해 채널이 1인 (1, OH, OW) 출력 하나를 만든다. 채널 축은 곱-합 과정에서 합쳐져 사라진다.

출력 채널을 여러 개 갖고 싶으면 필터를 여러 벌 두면 된다. 필터 개를 쌓으면 필터 뭉치의 shape는 (FN, C, FH, FW)가 되고, 출력은 (FN, OH, OW)가 된다 — 필터 개수가 곧 출력 채널 수다. 편향은 출력 채널마다 하나씩, (FN, 1, 1)로 브로드캐스트되어 더해진다.

마지막으로 배치. 신경망은 데이터 개를 한 번에 처리하므로 축을 하나 더 얹어 4차원 (N, C, H, W)로 흐른다. 정리하면 합성곱 한 층의 shape 흐름은 이렇다.

# 입력   x:      (N, C, H, W)      — 배치 N, 채널 C
# 필터   W:      (FN, C, FH, FW)   — 출력채널 FN개, 각 필터는 입력채널 C를 봄
# 편향   b:      (FN,)             — 출력채널마다 하나
# 출력   out:    (N, FN, OH, OW)   — OH, OW는 위 공식으로 결정

이 4차원 흐름이 CNN의 뼈대다. Affine에서는 데이터 하나가 벡터 (입력차원,)였다면, 여기서는 데이터 하나가 3차원 (C, H, W)이고 그게 개 쌓여 4차원이 된다.

판단 기준: 합성곱 필터의 채널 수는 항상 입력 채널 수와 같아야 하고, 필터 개수가 출력 채널 수를 정한다 — 이 두 규칙으로 shape를 설계하면 층 연결이 어긋나지 않는다. 함정: (FN, C, FH, FW)에서 를 입력 채널과 다르게 잡으면 채널 방향 합산이 성립하지 않는다. 4차원 축 순서(N, C, H, W)를 헷갈려 H·W를 뒤집으면 이미지가 전치된 채로 학습된다.

풀링 — 공간을 줄이고 미세한 이동을 흘려보낸다

풀링은 특징 맵의 세로·가로 공간을 줄이는 연산이다. 가장 흔한 최대 풀링(max pooling)은 예컨대 (2, 2) 영역을 훑으며 그 안의 최댓값 하나만 남긴다. (4, 4) 입력을 (2, 2) 풀링(스트라이드 2)으로 통과시키면 겹치지 않는 4개 영역에서 각각 최댓값을 뽑아 (2, 2)가 된다.

풀링에는 학습할 파라미터가 없다. 그저 최댓값을 고를 뿐이다. 그리고 채널마다 독립적으로 적용되므로 채널 수는 그대로, 세로·가로만 줄어든다. 이것이 첫 번째 이점 — 공간 축소로 이후 계산량과 파라미터가 준다.

두 번째 이점이 더 본질적이다. 최대 풀링은 미세한 위치 변화에 강하다(이동 불변성). 어떤 영역의 최댓값이 그 영역 안에서 한 칸 왼쪽으로 옮겨가도, 여전히 같은 최댓값이 뽑히므로 풀링 출력은 바뀌지 않는다. 손글씨 숫자가 몇 픽셀 흔들려도 같은 특징으로 읽히길 바라는데, 풀링이 그 작은 흔들림을 흡수해 준다.

판단 기준: 공간 해상도를 줄이면서 특징의 강도만 남기고 정확한 위치는 흐리고 싶을 때 풀링을 쓴다. 채널 수를 바꾸지 않는다는 점에서 합성곱과 역할이 다르다. 함정: 풀링 영역 크기와 스트라이드를 같게(보통 둘 다 2) 두지 않으면 영역이 겹치거나 빠져 예상과 다른 크기가 나온다. 최대 풀링의 역전파는 순전파에서 최댓값이었던 위치로만 기울기를 흘려보내야 한다 — 나머지 위치는 0이다.

im2col — 4차원을 2차원으로 펴서 합성곱을 행렬곱으로

이제 구현이다. 합성곱을 곧이곧대로 짜면 필터를 미끄러뜨리는 이중 루프 안에, 채널·필터·배치 루프까지 겹쳐 4중~6중 루프가 된다. 동작은 하지만 파이썬 루프라 느리다. 넘파이의 진짜 힘은 행렬곱에 있으니, 합성곱을 행렬곱으로 바꾸면 C로 구현된 빠른 np.dot 하나로 전부 처리된다. 그 변환의 다리가 im2col(image to column)이다.

아이디어는 이렇다. 필터가 한 번 놓이는 자리마다 입력에서 겹치는 (C, FH, FW) 블록이 있다. 이 블록을 쭉 펴서 길이 C*FH*FW짜리 한 행으로 만든다. 필터를 놓는 자리가 N*OH*OW개이므로, 이 행들을 전부 쌓으면 (N*OH*OW, C*FH*FW)짜리 2차원 행렬이 된다. 이게 im2col의 출력이다.

# im2col의 shape 변환
# 입력  x:   (N, C, H, W)
# 출력  col: (N*OH*OW, C*FH*FW)
#   행 = 필터가 놓이는 모든 위치(배치 포함) 개수 = N*OH*OW
#   열 = 필터 한 벌이 한 번에 보는 원소 개수    = C*FH*FW

필터 쪽도 펴 준다. 필터 뭉치 (FN, C, FH, FW)(FN, C*FH*FW)로 reshape하고 전치해 (C*FH*FW, FN)으로 세운다. 이제 col과 이 필터 행렬을 곱하면

가운데 C*FH*FW 축이 맞물려 사라진다 — 이 곱-합이 정확히 합성곱이 각 위치에서 하던 “겹친 원소를 전부 곱해 더하기”다. 여기서 쓰는 shape 규칙 과 필터를 .T로 세우는 전치가 낯설면 먼저 수학부록3을 보고 오면 좋다 — 이 절 전체가 그 부록의 “복잡해 보이는 연산도 결국 행렬 곱으로 접는다”는 정신 그대로다. 마지막으로 결과 (N*OH*OW, FN)(N, OH, OW, FN)으로 reshape하고 축을 (N, FN, OH, OW)로 옮기면 우리가 원한 4차원 출력이 된다.

im2col은 겹치는 영역을 중복해 펼치므로 메모리를 더 쓴다. 그 대가로 합성곱이 단 한 번의 행렬곱이 되어 훨씬 빨라진다 — 5장의 Affine 역전파에서 봤던, 전치와 shape를 맞추는 그 감각이 여기서 그대로 재활용된다.

판단 기준: 합성곱·풀링을 넘파이로 빠르게 구현하려면 im2col로 2차원 행렬을 만든 뒤 행렬곱(또는 풀링은 행별 max)으로 처리하라. 역전파에서는 col2im으로 정확히 되돌린다. 함정: im2col 결과의 행은 N*OH*OW(위치 수)이고 열은 C*FH*FW(필터가 보는 원소 수)다 — 이 둘을 뒤집으면 행렬곱 축이 어긋난다. col2im은 im2col의 단순 역이 아니다: 겹쳐 펼친 원소를 되돌릴 때 같은 자리에 여러 번 온 기울기를 더해야 한다(덮어쓰면 틀린다).

숫자로 따라가기 — 4×4 입력에 3×3 필터

지금까지 합성곱·출력 크기 공식·im2col을 수식과 shape로 말했다. 이제 그 셋을 실제 숫자 하나로 처음부터 끝까지 굴려 보자. 종이와 펜을 들고 그대로 따라 적으면, 추상적으로만 읽히던 “슬라이딩하며 곱-합”과 “행렬 곱으로 접기”가 손끝에서 같은 값으로 만난다.

입력은 (4, 4) 한 채널짜리, 필터는 (3, 3) 하나, 패딩 , 스트라이드 로 둔다. 값은 이렇다.

먼저 출력이 몇 칸짜리인지 공식으로 못 박는다. , , , 에 그대로 넣으면

가로도 대칭이라 . 그러니 출력은 (2, 2) — 필터를 세로로 2번, 가로로 2번 놓을 수 있다는 뜻이고, 아래에서 그 4개 위치를 하나씩 채운다.

필터를 한 칸씩 미끄러뜨리며 손계산

위치 (0, 0) — 필터를 의 왼쪽 위에 얹는다. 겹치는 3×3은 의 02행, 02열이다.

원소별로 곱해(는 같은 자리끼리의 곱) 9개를 전부 더한다. 행별로 끊어 보면 .

위치 (0, 1) — 필터를 오른쪽으로 한 칸() 민다. 겹치는 곳은 02행, 13열이다.

위치 (1, 0) — 다시 맨 왼쪽으로 돌아가 아래로 한 칸 내린다. 13행, 02열이다.

위치 (1, 1) — 오른쪽 아래. 13행, 13열이다.

(마지막 겹침이 위치 (0,0)과 같은 숫자 배열이라 15가 다시 나온다 — 우연이지만 손계산을 한 번 더 검산해 주는 셈이다.) 네 칸을 자리에 놓으면 출력이 완성된다.

편향은 여기에 스칼라 하나로 더해진다. 만약 이면 네 칸 모두에 1을 더해 이 된다 — 편향은 위치와 무관하게 출력 전체를 같은 값만큼 들어올릴 뿐이다.

같은 계산을 im2col 행렬 곱으로

이제 같은 입력·필터를 im2col로 펴서, 방금 손으로 센 [15, 16, 6, 15]단 한 번의 행렬 곱에서 그대로 떨어지는지 본다. 여기서는 , , , , 이므로 col의 shape는 다. 행은 필터가 놓이는 4개 위치, 열은 각 위치에서 겹친 9개 원소다. 위에서 손으로 얹은 네 블록을 그 순서대로 한 행씩 펴면

첫 행이 위치 (0,0)의 3×3을 왼→오, 위→아래로 편 [1,2,3, 0,1,2, 3,0,1]이다. 넷째 행이 첫 행과 같은 건 위치 (1,1)의 겹침이 (0,0)과 같았기 때문 — 손계산과 정확히 대응한다. 필터도 편다. (FN, C, FH, FW) = (1, 1, 3, 3)(1, 9)로 reshape하고 전치해 (9, 1)로 세운다.

이제 이다. 가운데 9가 맞물려 사라지고 결과는 (4, 1) — shape 규칙 가 그대로 들어맞는다(수학부록3의 짝 맞추기가 이 자리다). 첫 행만 손으로 곱해 보면

위치 (0,0)에서 손으로 센 15와 같다. 나머지 세 행도 곱하면 결과는

마지막으로 이 (4, 1)(N, OH, OW, FN) = (1, 2, 2, 1)로 reshape하고 축을 (N, FN, OH, OW) = (1, 1, 2, 2)로 옮기면

손으로 슬라이딩해 만든 출력과 한 글자도 다르지 않다. 4중 루프로 16번(위치 4개 × 곱-합) 따로 세던 것을, im2col이 (4, 9) 행렬 하나로 펼친 뒤 np.dot 한 번으로 접은 것이다. 이 작은 예를 넘파이로 두 방식 다 돌려 np.allclose로 맞춰 보는 것이, 다음 절에서 계층 클래스를 믿고 쓰기 위한 첫 검산이다.

판단 기준: 새 합성곱 구현이 미덥지 않으면 이렇게 작은 (4,4)·(3,3)을 골라 손계산과 코드를 대조하라 — shape·축 순서·전치 실수는 큰 배치에선 안 보이지만 이 크기에선 한눈에 잡힌다. 함정: im2col의 행을 펼치는 순서(위치 순)와 열을 펼치는 순서(원소 순)를 손계산의 순서와 다르게 두면, 행렬 곱은 여전히 (4,1)을 내지만 값이 조용히 틀린다 — shape가 맞는다고 값이 맞는 건 아니다.

나이브 루프에서 im2col 행렬곱으로

합성곱 순전파를 두 방식으로 짜 본다. 먼저 정의를 그대로 옮긴 4중 루프 — 느리지만 무엇을 계산하는지가 투명하다. 그 다음 같은 결과를 im2col + 행렬곱으로 바꿔, 계층 클래스로 완성한다. 각 스텝의 주석에 shape를 끝까지 붙였다.

Refactoring Step 나이브 합성곱 — 정의를 그대로 옮긴 4중 루프
1/4
import numpy as npdef conv_naive(x, W, b, stride=1, pad=0):    # x: (N, C, H, W),  W: (FN, C, FH, FW),  b: (FN,)    N, C, H, Wd = x.shape    FN, _, FH, FW = W.shape    OH = (H + 2 * pad - FH) // stride + 1       # 출력 높이 (유도한 공식)    OW = (Wd + 2 * pad - FW) // stride + 1       # 출력 너비    xp = np.pad(x, [(0, 0), (0, 0), (pad, pad), (pad, pad)], "constant")    out = np.zeros((N, FN, OH, OW))              # 채울 출력    for n in range(N):                           # 배치마다        for f in range(FN):                      # 출력 채널(필터)마다            for i in range(OH):                  # 세로 위치마다                for j in range(OW):              # 가로 위치마다                    h0, w0 = i * stride, j * stride                    region = xp[n, :, h0:h0 + FH, w0:w0 + FW]  # (C, FH, FW)                    out[n, f, i, j] = np.sum(region * W[f]) + b[f]    return out# 옳지만 파이썬 루프가 N*FN*OH*OW번 돈다 — 큰 배치에서 견딜 수 없이 느리다.

나이브 버전과 im2col 버전은 정확히 같은 값을 낸다(작은 입력에 둘을 돌려 np.allclose로 확인하는 것이 이 구현을 믿는 첫걸음이다). 달라진 건 속도뿐이다. 4중 루프의 N*FN*OH*OW번 파이썬 반복이, im2col의 FH*FW번 반복 + 넘파이 행렬곱 한 방으로 바뀌었다. 이것이 5장에서 벡터화가 준 교훈 — 루프를 배열 연산으로 접는 것 — 을 합성곱에 그대로 적용한 결과다.

판단 기준: 합성곱 계층을 검증할 땐 나이브 구현을 정답지로 삼아 im2col 구현과 np.allclose로 대조하라. backward는 5장처럼 수치 미분과 대조(gradient check)한다. 함정: col_W.reshape(FN, -1) 후 전치를 빠뜨리면 축이 (FN, C*FH*FW)인 채로 곱해져 shape가 안 맞거나 조용히 틀린다. backward의 dW도 마지막에 (FN, C, FH, FW)로 되돌리는 reshape를 잊으면 옵티마이저가 엉뚱한 축으로 갱신한다.

풀링 계층 구현 — 채널마다 독립적으로 최댓값

풀링도 im2col을 재활용한다. 다만 합성곱과 결정적으로 다른 점 — 풀링은 채널을 섞지 않는다. 그래서 펼친 뒤 채널을 합치지 않고, 채널마다 따로 풀링 영역을 한 행으로 만들어 그 행의 최댓값을 뽑는다.

class Pooling:
    def __init__(self, PH, PW, stride=2, pad=0):
        self.PH, self.PW, self.stride, self.pad = PH, PW, stride, pad
 
    def forward(self, x):
        N, C, H, W = x.shape
        OH = (H - self.PH) // self.stride + 1
        OW = (W - self.PW) // self.stride + 1
 
        col = im2col(x, self.PH, self.PW, self.stride, self.pad)  # (N*OH*OW, C*PH*PW)
        col = col.reshape(-1, self.PH * self.PW)   # (N*OH*OW*C, PH*PW) — 채널을 행으로 분리
        self.arg_max = np.argmax(col, axis=1)      # 역전파용: 어디가 최댓값이었나
        out = np.max(col, axis=1)                  # 행마다 최댓값 하나
 
        # (N*OH*OW*C,) → (N, OH, OW, C) → (N, C, OH, OW)
        out = out.reshape(N, OH, OW, C).transpose(0, 3, 1, 2)
        self.x = x
        return out

핵심은 col.reshape(-1, PH*PW) 한 줄이다. 합성곱은 C*FH*FW를 한 행에 뭉쳐 채널을 합쳤지만, 풀링은 PH*PW만 한 행으로 두어 채널을 행 방향으로 흩어 놓는다. 그래서 각 행이 “한 채널, 한 위치의 풀링 영역”이 되고, 그 최댓값을 뽑으면 채널이 보존된다. 역전파는 arg_max로 기억해 둔 최댓값 위치에만 기울기를 흘려보내고 나머지는 0으로 둔다.

판단 기준: 풀링에서는 채널을 합치지 않도록 reshape(-1, PH*PW)로 채널을 행으로 분리하는 것을 잊지 말라. 합성곱과 풀링의 유일한 shape 차이가 여기다. 함정: 풀링 역전파에서 모든 위치에 기울기를 균등 분배하면 틀린다 — 최대 풀링은 순전파에서 이긴 위치(arg_max)로만 기울기가 흐른다.

단순 CNN으로 MNIST를 인식한다

계층이 다 모였으니 조립한다. 이 책의 단순 CNN은 Conv - ReLU - Pool - Affine - ReLU - Affine - Softmax 구조다. 앞쪽 합성곱·풀링이 이미지에서 공간적 특징을 뽑고, 뒤쪽 Affine 두 개가 그 특징으로 숫자를 분류한다. 5장에서 만든 ReLU·Affine·SoftmaxWithLoss 계층을 그대로 재사용하고, 앞에 Convolution·Pooling만 새로 얹으면 된다.

# 단순 CNN의 shape 흐름 (입력이 MNIST 한 배치일 때)
# x       : (N, 1, 28, 28)                입력 이미지, 채널 1(흑백)
# Conv    : 필터 (30, 1, 5, 5), pad=0, stride=1
#   → (N, 30, 24, 24)                     OH=(28-5)/1+1=24
# ReLU    : shape 그대로 (N, 30, 24, 24)
# Pool    : (2, 2), stride=2
#   → (N, 30, 12, 12)                     공간만 절반, 채널 30 유지
# flatten : (N, 30*12*12) = (N, 4320)     Affine에 넣으려 여기서 편다
# Affine1 : (4320, 100) → (N, 100)
# ReLU    : (N, 100)
# Affine2 : (100, 10)   → (N, 10)         클래스 10개(0~9)
# Softmax : (N, 10)                       확률 분포 → 손실

주목할 지점 — 이미지를 벡터로 펴는 flatten이 네트워크 맨 앞이 아니라 합성곱·풀링을 다 지난 뒤에 일어난다. 형상을 유지한 채 공간 특징을 다 뽑고 나서야 편다. 이것이 이 장 첫머리에서 지적한 “형상을 뭉개는 평탄화”를 뒤로 미룬 것 — CNN이 완전연결과 갈리는 지점이 바로 이 순서다.

학습은 5장·6장과 똑같다. 오차역전파로 기울기를 구하고 옵티마이저(예: Adam)로 갱신하는 루프를 돌린다. 이 단순 CNN은 MNIST에서 99% 안팎의 정확도에 도달한다 — 완전연결만 쌓은 신경망보다 파라미터는 오히려 적으면서(필터 공유 덕에) 더 높이 올라간다.

판단 기준: 합성곱·풀링으로 공간 특징을 다 뽑은 뒤 flatten하고 Affine으로 분류한다 — 이 순서가 CNN의 표준 골격이다. 함정: 4차원 특징 맵을 Affine에 넣으려면 반드시 flatten이 필요하다. Affine 계층의 forward/backward가 입력 형상을 기억했다가 backward에서 원래 4차원으로 되돌리도록 해야, 앞단 합성곱으로 기울기가 올바른 shape로 전달된다.

필터는 무엇을 학습하는가 — 엣지에서 객체로

마지막으로 학습된 CNN의 속을 들여다본다. 1층 합성곱 필터를 학습 전과 후로 나눠 이미지로 그려 보면, 학습 전에는 무작위 잡음이던 (5, 5) 필터가 학습 후에는 규칙적인 무늬로 자리 잡는다. 어떤 필터는 한쪽이 밝고 반대쪽이 어두운 — 즉 세로 엣지에 강하게 반응하는 모양이 되고, 어떤 필터는 가로 엣지에, 어떤 필터는 색이 뭉친 블롭(blob)에 반응하는 모양이 된다.

이것이 합성곱이 하는 일의 정체다. 필터는 “자기 무늬와 닮은 국소 패턴이 입력의 어디에 있는지”를 찾아내는 검출기다. 세로 엣지 필터는 이미지에서 세로 경계가 있는 위치마다 큰 값을 낸다. 아무도 “세로 엣지를 찾아라”라고 가르치지 않았는데, 손실을 줄이는 방향으로 갱신하다 보니 스스로 그런 검출기가 됐다.

더 깊은 층으로 가면 추상화의 사다리가 드러난다. 1층 필터는 엣지·블롭 같은 저수준 조각에 반응한다. 그 출력을 입력으로 받는 2층 필터는 엣지들의 조합인 텍스처(줄무늬·격자)에 반응하고, 더 깊은 층은 텍스처들의 조합인 사물의 부분(눈·바퀴)에, 마지막 층은 그것들이 모인 객체 전체(개·자동차)에 반응한다. 층이 깊어질수록 뉴런이 반응하는 대상이 저수준 조각에서 고수준 의미로 추상화된다 — 이 계층적 추상화가 딥러닝이 이미지를 이해하는 방식의 핵심이고, 다음 장에서 깊이를 더 밀어붙이는 동기가 된다.

판단 기준: CNN을 디버깅·이해할 땐 1층 필터를 이미지로 그려 보라 — 엣지·블롭 같은 구조가 나타나면 학습이 제대로 특징을 잡고 있다는 신호다. 함정: 1층 필터가 학습 후에도 잡음처럼 보이면 학습이 안 됐거나(학습률·초깃값 문제) 데이터가 잘못 들어간 것이다. “정확도만 보고 넘어가지 말고 필터를 그려 보는” 습관이 조용한 학습 실패를 잡는다.

정리 — 형상을 지켜 얻은 것

  • 완전연결은 이미지를 (784,)로 펴며 픽셀의 공간적 이웃 관계를 버린다. CNN은 형상을 3차원 (C, H, W)으로 유지한 채 국소 패턴을 필터로 뽑아, 그 정보를 살린다.
  • 합성곱은 같은 필터를 입력 전 위치에 공유해 미끄러뜨리는 연산이다. 파라미터 수는 입력 크기와 무관하게 채널×FH×FW로 고정이고, 패딩과 스트라이드로 출력 크기를 조절한다.
  • 출력 크기는 로 결정된다 — 패딩 후 높이 위에서 필터를 보폭 로 놓을 수 있는 횟수를 센 것이다. 나눠떨어지도록 하이퍼파라미터를 고른다.
  • 풀링은 채널을 보존한 채 공간을 줄이고, 최대 풀링은 미세한 이동을 흡수해 이동 불변성을 준다. 역전파는 최댓값 위치로만 기울기를 흘린다.
  • im2col은 4차원 입력을 (N*OH*OW, C*FH*FW) 2차원으로 펴서 합성곱을 단 한 번의 행렬곱으로 만든다. Affine 역전파의 전치·shape 감각이 그대로 재활용되고, backward는 col2im으로 되돌리되 겹친 기울기는 더한다.
  • 단순 CNN(Conv-ReLU-Pool-Affine-ReLU-Affine-Softmax)은 형상을 다 지난 뒤 flatten해 MNIST에서 99%에 이른다. 학습된 필터는 엣지·블롭에서 출발해 층이 깊어질수록 텍스처·객체로 추상화된다.

다음장으로 8장