CNN도 지금까지 구성한 신경망 같이 계층을 조합하여 만들 수 있는데 합성곱 계층(convolutional layer)과 풀링 계층(pooling layer)가 새로 등장한다.
지금까지의 신경망은 인접하는 계층의 모든 뉴런과 결합되어 있었고, 이를 완전연결(fully-connected)라고 하며, 완전히 연결된 계층을 Affine 계층이라는 이름으로 구현했다.
이렇게 Affine 계층을 사용하면 층이 5개인 완전 연결 신경망은 다음과 같이 구현된다.
위와 같이 완전연결 신경망은 Affine 계층 뒤에 활성화 함수계층이 이어진다. Affine-ReLU 조합이 4개가 쌓였고 마지막에 Affine 계층에 이어 소프트맥스 계층에서 최종 결과(확률)를 출력한다.
CNN 구조는 다음과 같이 구성된다.
CNN의 계층은 'Conv-ReLU-(Pooling)'의 흐름으로 연결된다. 풀링 계층은 생략하기도 한다.
CNN은 출력에 가까운 층에서는 지금까지의 'Affine-ReLU' 구성을 사용할 수 있다. 마지막 출력 계층에서는 'Affine-Softmax' 조합을 그대로 사용한다.
CNN에서는 패딩(padding), 스트라이드(stride) 등 CNN 고유의 용어가 존재한다. 각 계층 사이에는 3차원 데이터같이 입체적인 데이터가 흐른다.
완전연결 계층의 문제점은 '데이터의 형상이 무시' 된다는 사실이다.
입력 데이터가 이미지인 경우를 예로 들면, 이미지는 통상 세로, 가로, 채널로 구성된 3차원 데이터이지만 완전연결 계층에 입력할 때는 3차원 데이터를 1차원 데이터로 평탄화해줘야한다. 완전연결 계층은 형상을 무시하고 모든 입력 데이터를 동등한 뉴런(같은 차원의 뉴런)으로 취급하여 형상에 담긴 정보를 살릴 수 없다.
하지만 합성곱 계층은 형상을 유지한다. 이미지도 3차원 데이터로 입력받고, 다음 계층에도 3차원 데이터로 전달한다. 그래서 CNN에서는 형상을 가진 데이터를 제대로 이해할 가능성이 있다.
합성곱 계층의 입출력 데이터를 특징 맵(feature map)이라고 한다. 입력 데이터를 입력 특징 맵, 출력 데이터를 출력 특징 맵이라고 하는 것이다.
합성곱 계층에서는 합성곱 연산을 처리하는데 이는 이미지 처리에서 말하는 필터 연산에 해당한다.
여기서 *는 합성곱 연산을 의미한다.
합성곱 연산은 입력 데이터에 필터를 적용하는 것이다. 데이터와 필터의 형상은 (높이, 너비)로 표기한다. 위의 그림에서는 순서대로 (4, 4), (3, 3), (2, 2)가 되는 것이다.
다음은 합성곱 연산의 계산 순서이다.
필터의 윈도우(window)를 일정 간격으로 이동해가며 입력 데이터에 적용한다. 윈도우란 위 그림의 회색 3x3 부분을 말한다. 입력과 필터에서 대응하는 원소끼리 곱한 후 그 총합을 구한다. 이 계산을 단일 곱셈-누산(fused multiply-add, FMA)라고 한다. 나온 결과를 출력의 해당 장소에 저장하고, 이 과정을 모든 장소에서 수행하면 합성곱 연산의 출력이 완성된다.
CNN에서는 필터의 매개변수가 '가중치'에 해당한다. 편향 또한 존재하는데 이를 적용하면 다음과 같은 흐름이 된다.
편향은 필터를 적용한 후의 데이터에 더해지며, 편향은 항상 1x1 형태이다.
합성곱 연산을 수행하기 전에 입력 데이터 주변을 특정 값(예를 들면 0)으로 채우는 것을 패딩이라고 한다.
위 그림은 폭이 1인 패딩을 적용한 모습이다.
패딩은 주로 크기를 조정할 목적으로 사용한다. 입력 데이터의 공간적 크기를 고정한 채로 다음 계층에 전달할 수 있게 해준다.
필터를 적용하는 위치의 간격을 스트라이드라고 한다.
스트라이드를 키우면 출력 크기는 작아진다. 한편, 패딩을 크게하면 출력 크기가 커졌다. 입력 크기를 (H, W), 필터 크기를 (FH, FW), 출력 크기를 (OH, OW), 패딩을 P, 스트라이드를 S라고 하고, 이 관계를 수식화하면 다음과 같다.
예를 들어 입력이 (4, 4), 패딩이 1, 스트라이드가 1, 필터가 (3, 3)인 경우는 다음과 같다.
OH = {(4 + 21 - 3) / 1} + 1 = 4
OW = {(4 + 21 - 4) / 1} + 1 = 4
이 경우 출력이 (4, 4)가 된다는 것을 알 수 있다.
+1을 하기 정의 부분이 정수로 나눠떨어지는 값이어야 한다는 것을 주의해야 한다.
다음은 3차원 데이터의 합성곱 연산의 계산 순서이다.
입력 데이터의 채널 수와 필터의 채널 수가 같아야 한다는 것을 주의해야 한다. 대신 필터 자체의 크기는 원하는 값으로 설정할 수 있다. 하지만 모든 채널의 필터가 같은 크기여야 한다.
3차원의 합성곱 연산은 데이터와 필터를 직육면체 블록이라 생각하면 쉽다.
3차원 데이터와 필터를 다차원 배열로 (채널, 높이, 너비)로 표현하면 다음과 같이 나타낼 수 있다.
위 그림에서 출력 데이터는 한 장의 특징맵으로 다르게 말하면 채널이 1개인 특징 맵이다. 합성곱 연산의 출력으로 다수의 채널을 내보내려면 필터(가중치)를 다수 사용하면 된다.
필터를 FN개 적용하면 출력 뱁도 FN개가 생성되고, FN개의 맵을 모으면 형상이 (FN, OH, OW)인 블록이 완성된다. 이 완성된 블록을 다음 계층으로 넘기겠다는 것이 CNN의 처리 흐름이다.
합성곱 연산에서는 필터의 수도 고려해야 한다. 필터의 가중치 데이터는 4차원 데이터로 (출력 채널 수, 입력 채널 수, 높이, 너비)순으로 쓴다.
편향을 추가하면 다음과 같이 된다.
편향은 채널 하나에 값 하나씩이다.
합성곱 연산이 배치 처리를 지원하려면 각 계층을 흐르는 데이터의 차원을 하나 늘려 4차원 데이터로 저장한다. 데이터를 (데이터 수, 채널 수, 높이, 너비) 순으로 저장하는 것이다. 다음은 데이터가 N개일 때의 처리 흐름이다.
그림을 보면 각 데이터의 선두에 배치용 차원이 추가된 것을 볼 수 있다. 이처럼 데이터는 4차원 형상을 가진 채로 각 계층을 타고 흐른다. 신경망에 4차원 데이터가 하나 흐를 때마다 데이터 N개에 대한 합성곱 연산이 이뤄진다. 즉, N회 분의 처리를 한 번에 수행하는 것이다.
풀링은 세로, 가로 방향의 공간을 줄이는 연산이다.
위의 그림은 2x2 최대 풀링(max pooling)을 스트라이드 2로 처리하는 순서이다. 최대 풀링은 최댓값을 구하는 연산으로, 2x2는 대상 영역의 크기를 뜻한다.
풀링의 윈도우 크기와 스트라이드는 같은 값으로 설정하는 것이 보통이다.
평균 풀링(average pooling)은 대상 영역의 평균을 계산한다.
학습해야 할 매개변수가 없다.
풀링은 대상 영역에서 최댓값이나 평균을 취하는 명확한 처리이므로 특별히 학습할 것이 없다.
채널 수가 변하지 않는다.
풀링 연산은 입력 데이터의 채널 수 그대로 출력 데이터로 내보낸다. 채널마다 독립적으로 계산하기 때문이다.
입력의 변화에 영향을 적게 받는다.
입력 데이터가 조금 변해도 풀링의 결과는 잘 변하지 않는다.
데이터의 형상이 (10, 1, 28, 28)이면 높이 28, 너비 28, 채널 1개인 데이터가 10개라는 것으로 구현하면 다음과 같다.
x = np.random.rand(10, 1, 28, 28) # 무작위로 데이터 생성
x.shape
(10, 1, 28, 28)
n 번째 데이터에 접근
x[0].shape # 첫 번째 데이터
(1, 28, 28)
첫 번째 데이터의 첫 채널의 공간 데이터에 접근
x[0, 0] # 또는 x[0][0]
im2col은 입력 데이터를 필터링(가중치 계산)하기 좋게 전개하는(펼치는) 함수이다. 3차원 입력 데이터에 im2col을 적용하면 2차원 행렬로 바뀐다(정확히는 배치 안의 데이터 수까지 포함한 4차원 데이터를 2차원으로 변환한다).
im2col은 아래의 그림과 같이 입력 데이터에서 필터를 적용하는 영역(3차원 블록)을 한 줄로 늘어놓는다. 이 전개를 필터를 적용하는 모든 영역에서 수행한다.
필터 적용 영역이 겹치면 im2col로 전개한 후의 원소 수가 원래 블록의 원소 수보다 많아진다. 그래서 im2col을 사용해 구현하면 메모리를 더 많이 소비하는 단점이 있다. 하지만 컴퓨터는 큰 행렬을 묶어서 계산하는 데 탁월하다. 그래서 문제를 행렬 계산으로 만들면 선형 대수 라이브러리를 활용해 효율을 높일 수 있다.
im2col로 입력 데이터를 전개한 다음에는 합성곱 계층의 필터를 1열로 전개하고, 두 행렬의 곱을 계산하면 된다.
이렇게 출력한 결과는 2차원 행렬이되는데 CNN은 데이터를 4차원 배열로 저장하므로 출력 데이터를 변형(reshape)하는 것으로 마무리한다.
def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
"""다수의 이미지를 입력받아 2차원 배열로 변환한다(평탄화).
Parameters
----------
input_data : 4차원 배열 형태의 입력 데이터(이미지 수, 채널 수, 높이, 너비)
filter_h : 필터의 높이
filter_w : 필터의 너비
stride : 스트라이드
pad : 패딩
Returns
-------
col : 2차원 배열
"""
N, C, H, W = input_data.shape
out_h = (H + 2*pad - filter_h)//stride + 1
out_w = (W + 2*pad - filter_w)//stride + 1
img = np.pad(input_data, [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant')
col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))
for y in range(filter_h):
y_max = y + stride*out_h
for x in range(filter_w):
x_max = x + stride*out_w
col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]
col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1)
return col
위의 im2col 함수는 책에서 제공하는 코드로 필터 크기, 스트라이드, 패딩을 고려하여 입력 데이터를 2차원 배열로 전개한다.
from common.util import im2col
x1 = np.random.rand(1, 3, 7, 7) # (데이터 수, 채널 수, 높이, 너비)
col1 = im2col(x1, 5, 5, stride=1, pad=0)
print(col1.shape)
x2 = np.random.rand(10, 3, 7, 7) # 데이터 10개
col2 = im2col(x2, 5, 5, stride=1, pad=0)
print(col2.shape)
(9, 75)
(90, 75)
첫 번째는 배치 크기가 1, 채널은 3개, 7x7의 데이터이고, 두 번째는 배치 크기만 10이고 나머지는 첫 번째와 같다. im2col 함수를 적용한 두 경우 모두 2번째 차원의 원소는 75개이다. 이 값은 필터의 원소 수와 같다. 또한 배치 크기가 1일 때는 (9, 75)이고, 10일 때는 10배에 해당하는 (90, 75) 크기의 데이터가 저장된다.
합성곱 계층을 구현
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
합성곱 계층 필터(가중치), 편향, 스트라이드, 패딩을 인수로 받아 초기화 한다.
'전개' 부분에서 입력 데이터를 im2col로 전개하고 필터도 reshape를 사용해 2차원 배열로 전개하고, 이 전개한 두 행렬의 곱을 구한다.
'전개' 부분은 각 필터 블록을 1줄로 펼쳐 세운다. 이때 reshape의 두 번째 인수를 -1로 지정했는데, 이는 reshape의 편의 기능으로 -1을 지정하면 다차원 배열의 원소 수가 변환 후에도 똑같이 유지되도록 적절히 묶어 준다. 즉, (10, 3, 5, 5) 형상을 한 다차원 배열 W의 원소 수는 총 750개인데 이 배열에 reshape(10, -1)을 호출하면 750개의 원소를 10묶음으로, (10, 75)인 배열로 만들어 준다는 것이다.
forward 구현의 마지막에서는 출력 데이터를 적절한 형상으로 바꿔준다. 이때 다차원 배열의 축 순서를 바꿔주는 transpose 함수를 사용한다. 아래의 그림처럼 인덱스를 지정하여 축의 순서를 변경한다.
합성곱의 역전파는 Affine 계층과 공통점이 많다. 주의할 것은 역전파에서는 im2col을 역으로 처리해야 한다.
아래는 책에서 제공하는 im2col을 역으로 처리해주는 col2im이다.
def col2im(col, input_shape, filter_h, filter_w, stride=1, pad=0):
"""(im2col과 반대) 2차원 배열을 입력받아 다수의 이미지 묶음으로 변환한다.
Parameters
----------
col : 2차원 배열(입력 데이터)
input_shape : 원래 이미지 데이터의 형상(예:(10, 1, 28, 28))
filter_h : 필터의 높이
filter_w : 필터의 너비
stride : 스트라이드
pad : 패딩
Returns
-------
img : 변환된 이미지들
"""
N, C, H, W = input_shape
out_h = (H + 2*pad - filter_h)//stride + 1
out_w = (W + 2*pad - filter_w)//stride + 1
col = col.reshape(N, out_h, out_w, C, filter_h, filter_w).transpose(0, 3, 4, 5, 1, 2)
img = np.zeros((N, C, H + 2*pad + stride - 1, W + 2*pad + stride - 1))
for y in range(filter_h):
y_max = y + stride*out_h
for x in range(filter_w):
x_max = x + stride*out_w
img[:, :, y:y_max:stride, x:x_max:stride] += col[:, :, y, x, :, :]
return img[:, :, pad:H + pad, pad:W + pad]
다음은 역전파까지 구현한 합성곱 계층이다.
class Convolution:
def __init__(self, W, b, stride=1, pad=0):
self.W = W
self.b = b
self.stride = stride
self.pad = pad
# 중간 데이터(backward 시 사용)
self.x = None
self.col = None
self.col_W = None
# 가중치와 편향 매개변수의 기울기
self.dW = None
self.db = None
def forward(self, x):
FN, C, FH, FW = self.W.shape
N, C, H, W = x.shape
out_h = 1 + int((H + 2*self.pad - FH) / self.stride)
out_w = 1 + int((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)
self.x = x
self.col = col
self.col_W = col_W
return out
def backward(self, dout):
FN, C, FH, FW = self.W.shape
dout = dout.transpose(0,2,3,1).reshape(-1, FN)
self.db = np.sum(dout, axis=0)
self.dW = np.dot(self.col.T, dout)
self.dW = self.dW.transpose(1, 0).reshape(FN, C, FH, FW)
dcol = np.dot(dout, self.col_W.T)
dx = col2im(dcol, self.x.shape, FH, FW, self.stride, self.pad)
return dx
보면 col2im을 사용한다는 점을 제외하면 Affine 계층과 같은 것을 알 수 있다.
풀링 계층 구현도 im2col을 사용해 입력 데이터를 전개한다. 단, 풀링의 경우엔 풀링 적용 영역을 채널마다 독립적으로 전개한다.
이렇게 전개한 후, 전개한 행렬에서 행별 최댓값을 구하고 적절한 형상으로 성형하기만 하면 된다.
풀링 계층의 구현의 흐름은 다음과 같다.
회색 영역은 풀링 적용 영역에서 가장 큰 원소이다.
풀링 계층의 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
풀링 계층 구현의 단계
- 입력 데이터를 전개한다.
- 행별 최댓값을 구한다.
- 적절한 모양으로 성형한다.
풀링 계층의 backward 구현
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
self.x = None
self.arg_max = None
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)
col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
col = col.reshape(-1, self.pool_h*self.pool_w)
arg_max = np.argmax(col, axis=1)
out = np.max(col, axis=1)
out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
self.x = x
self.arg_max = arg_max
return out
def backward(self, dout):
dout = dout.transpose(0, 2, 3, 1)
pool_size = self.pool_h * self.pool_w
dmax = np.zeros((dout.size, pool_size))
dmax[np.arange(self.arg_max.size), self.arg_max.flatten()] = dout.flatten()
dmax = dmax.reshape(dout.shape + (pool_size,))
dcol = dmax.reshape(dmax.shape[0] * dmax.shape[1] * dmax.shape[2], -1)
dx = col2im(dcol, self.x.shape, self.pool_h, self.pool_w, self.stride, self.pad)
return dx
풀링은 영역 내의 최대값을 찾는 것으로 ReLU와 같은 max라고 할 수 있다. 결국엔 max의 역전파를 사용하면 된다는 것이다.
ReLU에서는 값이 0보다 크면 값을 그대로 하류로 흘려보내고 0 이하이면 0을 하류로 흘려보냈다. 풀링은 값이 최대값과 같으면 그대로, 아니면 0을 보내는 것이다.