07.4 합성곱/풀링 계층 구현하기
지금까지 합성곱 계층과 풀링 계층에 대해 자세히 설명했습니다. 이번 절에서는 이들 두 계층을 파이썬으로 구현해보겠습니다. “5장 오차역전파법”에서 설명한 것처럼 이번 절에서 구현하는 클래스에도 forward와 backward 메서드를 추가하여 모듈로 사용할 수 있도록 했습니다.
합성곱 계층과 풀링 계층은 복잡해 보이지만, 사실 ‘트릭’을 사용하면 쉽게 구현할 수 있습니다. 이번 절에서는 그 트릭을 활용해 문제를 간단히 하면서 합성곱 계층을 구현해보겠습니다.
7.4.1 4차원 배열
앞에서 설명한 대로 CNN에서 계층 사이를 흐르는 데이터는 4차원입니다. 예를 들어 데이터의 형상이 $(10, 1, 28, 28)$이라면, 이는 높이 28, 너비 28, 채널 1개인 데이터가 10개라는 이야기입니다. 이를 파이썬으로 구현하면 다음과 같습니다.
>>> x = np.random.rand(10, 1, 28, 28) # 무작위로 데이터 생성
>>> x.shape
(10, 1, 28, 28)
여기에서 (10개 중) 첫 번째 데이터에 접근하려면 단순히 x[0]이라고 씁니다(파이썬의 인덱스는 0부터 시작합니다). 마찬가지로 두 번째 데이터는 x[1] 위치에 있습니다.
>>> x[0].shape # (1, 28, 28)
>>> x[1].shape # (1, 28, 28)
또, 첫 번째 데이터의 첫 채널의 공간 데이터에 접근하려면 다음과 같이 적습니다.
>>> x[0, 0] # 또는 x[0][0]
이처럼 CNN은 4차원 데이터를 다릅니다. 그래서 합성곱 연산의 구현은 복잡해질 것 같지만, 다음 절에서 설명하는 im2col이라는 ‘트릭’이 문제를 단순하게 만들어줍니다.
7.4.2 im2col로 데이터 전개하기
합성곱 연산을 곧이곧대로 구현하려면 for 문을 겹겹이 써야겠죠. 생각만 해도 귀찮고, 또 넘파이에 for 문을 사용하면 성능이 떨어진다는 단점도 있습니다(넘파이에서는 원소에 접근할 때 for 문을 사용하지 않는 것이 바람직합니다). 이번 절에서는 for 문 대신 im2col이라는 편의 함수를 사용해 간단하게 구현해보겠습니다.
im2col은 입력 데이터를 필터링(가중치 계산)하기 좋게 전개하는(펼치는) 함수입니다. [그림 7-17]과 같이 3차원 입력 데이터에 im2col을 적용하면 2차원 행렬로 바뀝니다(정확히는 배치 안의 데이터 수까지 포함한 4차원 데이터를 2차원으로 변환합니다).
지니가 마법봉을 휘두르자 3차원의 입체 이미지 블록들이 행렬 연산 가속기에 들어가기 좋은 커다란 2차원 행렬(Matrix)로 깔끔하게 평평하게 펼쳐져 쏟아지고 있어요.
지니의 행렬 전개 마법 (im2col)
- 지니: “도로시, 합성곱 연산을 $N$중 루프(for문)로 직접 짜면 실행 속도가 너무 느려져. 그래서 입력 이미지에서 필터가 걸리는 모든 국소 영역들을 추출해 한 줄의 가로 행(Row)으로 눕혀서 펼쳐놓는 im2col(image to column) 트릭을 쓴단다.”
- 도로시: “아! 중복해서 전개하느라 메모리는 조금 더 쓰지만, 3차원 필터 뭉치들도 세로 1열로 세워두면 그냥 거대한 두 행렬의 행렬 곱셈(Matrix Multiplication) 하나로 전체 합성곱이 한 번에 초고속 계산되겠어!”
그림 7-17 (대략적인) im2col의 동작
im2col은 필터링하기 좋게 입력 데이터를 전개합니다. 구체적으로는 [그림 7-18]과 같이 입력 데이터에서 필터를 적용하는 영역(3차원 슬라이스 블록)을 한 줄로 늘어놓습니다. 이 전개를 필터를 적용하는 모든 영역에서 수행하는 게 im2col입니다.
그림 7-18 필터 적용 영역을 앞에서부터 순서대로 1줄로 펼친다.
[그림 7-18]에서는 보기에 좋게끔 스트라이드를 크게 잡아 필터의 적용 영역이 겹치지 않도록 했지만, 실제 상황에서는 영역이 겹치는 경우가 대부분입니다. 필터 적용 영역이 겹치게 되면 im2col로 전개한 후의 원소 수가 원래 블록의 원소 수보다 많아집니다. 그래서 im2col을 사용해 구현하면 평소보다 메모리를 많이 소비하는 단점이 있습니다. 하지만 컴퓨터는 큰 행렬을 묶어서 계산하는 데 탁월합니다. 예를 들어 행렬 계산 라이브러리(선형 대수 라이브러리) 등은 행렬 계산에 고도로 최적화되어 큰 행렬의 곱셈을 빠르게 계산할 수 있습니다. 그래서 문제를 행렬 계산으로 만들면 선형 대수 라이브러리를 활용해 효율을 높일 수 있습니다.
NOTE_
im2col은 ‘image to column’, 즉 ‘이미지에서 행렬로’라는 뜻입니다. 카페${}^{\text{Caffe}}$와 체이너${}^{\text{Chainer}}$ 등의 딥러닝 프레임워크는im2col이라는 이름의 함수를 만들어 합성곱 계층을 구현할 때 이용하고 있습니다.
im2col로 입력 데이터를 전개한 다음에는 합성곱 계층의 필터(가중치)를 1열로 전개하고, 두 행렬의 내적을 계산하면 됩니다([그림 7-19]). 이는 완전연결 계층의 Affine 계층에서 한 것과 거의 같습니다.
그림 7-19 합성곱 연산의 필터 처리 상세 과정 : 필터를 세로로 1열로 전개하고, im2col이 전개한 데이터와 행렬 내적을 계산합니다. 마지막으로 출력 데이터를 변형(reshape)합니다.
[그림 7-19]와 같이 im2col 방식으로 출력한 결과는 2차원 행렬입니다. CNN은 데이터를 4차원 배열로 저장하므로 2차원인 출력 데이터를 4차원으로 변형${}^{\text{reshape}}$합니다. 이상이 합성곱 계층의 구현 흐름입니다.
7.4.3 합성곱 계층 구현하기
이 책에서는 im2col 함수를 미리 만들어 제공합니다. 사실 그 구현은 간단한 함수 10개 정도를 묶은 것이니, 흥미가 있는 분은 common/util.py를 참고하세요.
자, im2col 함수의 인터페이스는 다음과 같습니다.
im2col(input_data, filter_h, filter_w, stride=1, pad=0)
input_data- (데이터 수, 채널 수, 높이, 너비)의 4차원 배열로 이뤄진 입력 데이터filter_h- 필터의 높이filter_w- 필터의 너비stride- 스트라이드pad- 패딩
이 im2col은 ‘필터 크기’, ‘스트라이드’, ‘패딩’을 고려하여 입력 데이터를 2차원 배열로 전개합니다. 그러면 이 im2col을 실제로 사용해봅시다.
import sys, os
sys.path.append(os.pardir)
from common.util import im2col
import numpy as np
x1 = np.random.rand(1, 3, 7, 7) # (데이터 수, 채널 수, 높이, 너비)
col1 = im2col(x1, 5, 5, stride=1, pad=0)
print(col1.shape) # (9, 75)
x2 = np.random.rand(10, 3, 7, 7) # 데이터 10개
col2 = im2col(x2, 5, 5, stride=1, pad=0)
print(col2.shape) # (90, 75)
여기에서는 두 가지 예를 보여주고 있습니다. 첫 번째는 배치 크기가 1(데이터 1개), 채널은 3개, $7 \times 7$의 데이터이고, 두 번째는 배치 크기만 10이고 나머지는 첫 번째와 같습니다. im2col 함수를 적용한 두 경우 모두 2번째 차원의 원소는 75개입니다. 이 값은 필터의 원소 수와 같죠(채널 3개, $5 \times 5$ 데이터). 또한, 배치 크기가 1일 때는 im2col의 결과의 크기가 $(9, 75)$이고, 10일 때는 그 10배인 $(90, 75)$ 크기의 데이터가 저장됩니다.
이제 이 im2col을 사용하여 합성곱 계층을 구현해보죠. 여기에서는 합성곱 계층을 Convolution이라는 클래스로 구현하겠습니다.
class Convolution:
def __init__(self, W, b, stride=1, pad=0):
self.W = W
self.b = b
self.stride = stride
self.pad = pad
def forward(self, x):
FN, C, FH, FW = self.W.shape
N, C, H, W = x.shape
out_h = int(1 + (H + 2*self.pad - FH) / self.stride)
out_w = int(1 + (W + 2*self.pad - FW) / self.stride)
col = im2col(x, FH, FW, self.stride, self.pad)
col_W = self.W.reshape(FN, -1).T # 필터 전개
out = np.dot(col, col_W) + self.b
out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)
return out
도로시가 대형 클립보드에 파이썬 넘파이 기반 Convolution 클래스의 초기화(__init__)와 forward 연산 구조를 필기하고 있어요.
도로시의 Convolution 클래스 분석
- 도로시: “지니! 필터 크기 $FH, FW$를 매개변수로 지정해서
im2col을 호출하면 $col$ 행렬이 나오고, 필터를reshape(FN, -1).T로 납작하게 세워서 내적곱np.dot을 해준 뒤 편향b를 더해주면 계산 끝이야!”- 지니: “맞아! 그 뒤에
transpose함수로 원래 축 배치인 (N, C, H, W) 순으로 모양을 이쁘게 원상 복구해 주는out.reshape부분까지 넣으면 순전파 구현이 완벽히 끝난단다.”
합성곱 계층은 필터(가중치), 편향, 스트라이드, 패딩을 인수로 받아 초기화합니다. 필터는 $(FN, C, FH, FW)$의 4차원 형상입니다. 여기서 $FN$은 필터 개수, $C$는 채널, $FH$는 필터 높이, $FW$는 필터 너비입니다.
앞의 합성곱 구현 코드에서 입력 데이터를 im2col로 전개하고 필터도 reshape을 사용해 2차원 배열로 전개합니다. 그리고 이렇게 전개한 두 행렬의 내적을 구합니다.
필터를 전개하는 부분(코드 중 col_W = self.W.reshape(FN, -1).T 부분)은 [그림 7-19]에서 보듯 각 필터 블록을 1줄로 펼쳐 세웁니다. 이때 reshape의 두 번째 인수를 -1로 지정했는데, 이는 reshape이 제공하는 편의 기능입니다. reshape에 -1을 지정하면 다차원 배열의 원소 수가 변환 후에도 똑같이 유지되도록 적절히 묶어줍니다. 무슨 말인고 하니, 앞의 코드에서 $(10, 3, 5, 5)$ 형상을 한 다차원 배열 W의 원소 수는 총 750개죠? 이 배열에 reshape(10, -1)을 호출하면 750개의 원소를 10묶음으로, 즉 형상이 $(10, 75)$인 배열로 만들어줍니다.
다음으로 forward 구현의 마지막에서는 출력 데이터를 적절한 형상으로 바꿔줍니다. 이때 넘파이의 transpose 함수를 사용하는데, 이는 다차원 배열의 축 순서를 바꿔주는 함수입니다. [그림 7-20]과 같이 인덱스(0부터 시작)를 지정하여 축의 순서를 변경합니다.
그림 7-20 넘파이의 transpose 함수로 축 순서 변경하기 : 인덱스(번호)로 축의 순서를 변경한다.
이상이 합성곱 계층의 forward 구현입니다. im2col로 전개한 덕분에 완전연결 계층의 Affine 계층과 거의 똑같이 구현할 수 있었습니다 (“5.6 Affine/Softmax 계층 구현하기” 참고).
다음은 합성곱 계층의 역전파를 구현할 차례지만, Affine 계층의 구현과 공통점이 많아 따로 설명하지 않겠습니다. 주의할 게 하나 있는데, 합성곱 계층의 역전파에서는 im2col을 역으로 처리해야 합니다. 이는 이 책이 제공하는 col2im 함수를 사용하면 됩니다(col2im의 구현은 common/util.py에 있습니다). col2im을 사용한다는 점을 제외하면 합성곱 계층의 역전파는 Affine 계층과 똑같습니다. 합성곱 계층의 역전파 구현은 common/layer.py에 있으니 궁금한 분은 참고하세요.
7.4.4 풀링 계층 구현하기
풀링 계층 구현도 합성곱 계층과 마찬가지로 im2col을 사용해 입력 데이터를 전개합니다. 단, 풀링의 경우엔 채널 쪽이 독립적이라는 점이 합성곱 계층 때와 다릅니다. 구체적으로는 [그림 7-21]과 같이 풀링 적용 영역을 채널마다 독립적으로 전개합니다.
그림 7-21 입력 데이터에 풀링 적용 영역을 전개($2 \times 2$ 풀링의 예)

일단 이렇게 전개한 후, 전개한 행렬에서 행별 최댓값을 구하고 적절한 형상으로 성형하기만 하면 됩니다([그림 7-22]).
그림 7-22 풀링 계층 구현의 흐름 : 풀링 적용 영역에서 가장 큰 원소는 회색으로 표시

이상이 풀링 계층의 forward 처리 흐름입니다. 다음은 이를 파이썬으로 구현한 코드입니다.
class Pooling:
def __init__(self, pool_h, pool_w, stride=1, pad=0):
self.pool_h = pool_h
self.pool_w = pool_w
self.stride = stride
self.pad = pad
def forward(self, x):
N, C, H, W = x.shape
out_h = int(1 + (H - self.pool_h) / self.stride)
out_w = int(1 + (W - self.pool_w) / self.stride)
# 전개 (1)
col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
col = col.reshape(-1, self.pool_h*self.pool_w)
# 최댓값 (2)
out = np.max(col, axis=1)
# 성형 (3)
out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
return out
도로시가 Pooling 클래스의 forward 함수에서 im2col로 배열을 펼치고 np.max 행별 최댓값(axis=1)을 집어내 4차원으로 성형하는 3단계 구현을 흐뭇하게 바라보고 있어요.
도로시의 Pooling 구현 핵심 요약
- 도로시: “풀링 계층도
im2col로 영역을 쭉 펼친 뒤에, 한 행씩np.max(col, axis=1)을 적용해 각 영역의 최댓값들만 총총히 골라내고, 마지막에 다시 4차원transpose(0, 3, 1, 2)로 조립하면 깔끔하게 완성돼!”- 지니: “정답이야! 역전파 때는 ReLU 계층의 최댓값 스위치처럼 전파받은 값을 최댓값이 있던 자리에만 흘려주는
col2im구조를 그대로 쓰면 된단다.”- 토토: “왈왈! (코드 밑에서 꼬리 흔들며) 풀링 코딩 완료다 멍!”
풀링 계층 구현은 [그림 7-22]와 같이 다음의 세 단계로 진행합니다.
- 입력 데이터를 전개한다.
- 행렬 최댓값을 구한다.
- 적절한 모양으로 성형한다.
앞의 코드에서와같이 각 단계는 한두 줄 정도로 간단히 구현됩니다.
NOTE_ 최댓값 계산에는 넘파이의
np.max메서드를 사용할 수 있습니다.np.max는 인수로 축(axis)을 지정할 수 있는데, 이 인수로 지정한 축마다 최댓값을 구할 수 있습니다. 가령np.max(x, axis=1)과 같이 쓰면 입력x의 1번째 차원의 축마다 최댓값을 구합니다.*
* 옮긴이 _ 2차원 배열, 즉 행렬이라면 axis=0은 열 방향, axis=1은 행 방향을 뜻합니다.
이상이 풀링 계층의 forward 처리입니다. 이 절에서 선택한 전략을 따라 입력 데이터를 풀링하기 쉬운 형태로 전개해버리면 그 후의 구현은 간단합니다.
풀링 계층의 backward 처리는 관련 사항을 이미 설명했으니 여기에서는 설명을 생략합니다. ReLU 계층을 구현할 때 사용한 max의 역전파를 참고하세요(“5.5.1 ReLU 계층”). 풀링 계층의 전체 구현은 common/layer.py에 있으니 궁금하신 분은 한번 살펴보세요.