이번 장에서 배울 CNN은 이미지 인식 분야에서 거의 모든 기법의 기초이다.
CNN의 구조를 파악하고 어떻게 작동하는지 알아보자.
지금까지 본 신경망은 인접하는 계층의 모든 뉴런과 연결된 형태였다. 이를 완전연결(fully-connected; 전결합)이라 한다. 그리고 완전연결된 이 계층을 Affine 계층이라고 부른다.
기존의 신경망에서는 Affine 계층 다음에 활성화 함수로 연결되어 있다.
하지만 CNN에서는 활성화함수 앞뒤로 Conv(합성곱 계층)와 Pooling(풀링 계층)이 연결된다. (Pooling은 생략되기도 한다.)
이를 그림으로 나타내면 다음과 같다.
지금까지 본 신경망이 'Affine-ReLU(or 활성화 함수)'의 연결이었다면, CNN은 'Conv-ReLU-(Pooling)'의 연결구조를 가진다.
또한, 출력에 가까운 점에서는 기존의 형식을 사용할 수 있고 마지막 출력층에서는 기존과 같이 'Affine-Softmax'조합을 사용한다는 특징을 가진다. 이는 일반적인 CNN의 구조이다.
지금까지 사용한 Affine 계층에는 기존 데이터의 형상이 무시된다는 문제점이 존재한다.
가령 이미지 데이터를 예로 들면, input data가 3X3(1, 28, 28)이지만 이를 Affine 계층에 입력할 때는 (784,)로 평탄화해주어야 한다.
이때, 기존의 인접한 데이터간의 연관성과 같은 형상에 담긴 고유한 정보가 사라지게 된다.
하지만 Conv는 데이터의 형상을 유지한다. 그래서 기존의 정보를 그대로 유지할 가능성이 생긴다.
CNN에서 입출력 데이터를 각각 input feature map, output feature map이라 한다.
합성곱 연산은 이미지 처리에서 말하는 필터 연산에 해당한다. 또한, 필터를 커널이라고 하기도 한다.
그렇다면 연산이 어떻게 이루어지는지 다음의 그림을 예로 살펴보자.
단일 곱셈-누산(fused multiply-add;FMA)이라는 방식을 사용하는데, 다음과 같다.
그림의 첫번째 연산은 1*2 + 2*0 + 3*1 + 0*0 + 1*1 + 2*2 + 3*1 + 0*0 + 1*2=15가 된다.
입력 데이터에서 필터의 윈도우(그림의 회색 영역; 필터와 같은 형상을 가진다)를 움직이며 같은 인덱스의 숫자를 곱해 더한다.
Affine 계층에서는 weight와 bias가 존재한다.
Conv에는 필터의 값들이 weight에 해당하며 합성곱 연산의 결과의 모든 원소에 bias를 더해준다.
이를 그림으로 나타내면 다음과 같다.
이때, 입력 데이터의 주변을 0으로 채우기도 하며 이를 패딩이라고 한다.
아래의 그림은 패딩의 예인데, 패딩을 2나 3 등 원하는 정수로 할 수 있다.
앞의 예에서 (4,4)의 데이터에 (3,3)의 필터를 적용하면 (2,2)의 출력이 나왔다.
신경망에서는 이런 연산을 반복적으로 수행해야 하며, 어느 순간에는 출력의 크기가 1이 되어 연산을 수행할 수 없게 된다.
이때, 패딩을 적용하면 출력 데이터의 크기를 조절할 수 있다. 패딩을 통해 반복 연산이 가능하면서 다음 계층에 입력 데이터의 형상을 유지한채로 전달이 가능하게 한다.
윈도우(필터를 적용하는 영역)를 이동시키는 간격을 stride(스트라이드)라 하며, 이를 조정하는 것도 가능하다.
스트라이드가 2라면 윈도우는 2칸씩 이동한다.
이때, 스트라이드를 크게 하면 출력의 크기가 줄어들고 패딩을 크게 하면 출력의 크기는 커진다.
이를 관계식으로 나타내면 다음과 같다.
- 입력 크기: (H,W)
- 필터 크기: (FH, FW)
- 패딩: P
- 스트라이드: S
출력의 크기가 바로 (OH,OW)이다.
단, OH와 OW는 정수가 되도록 해야 하며 값이 정수가 되지 않을 때 반올림하여 가까운 정수를 크기로 결정하게 하는 경우도 있다.
지금까지 2차원 입력 데이터에 대해서 다뤄보았다.
3차원 데이터에 대하여 필터도 크기가 달라진다.
다음 그림을 살펴보자.
주목할 점은, 데이터의 차원이 늘어난 만큼(새로운 차원-3차원, 크기-3) 필터(새로운 차원-3, 크기-3)도 늘어났다는 것이다.
그래서 앞서 했던 것처럼 각 채널(필터의 개수 방향; 여기서는 4*4 입력데이터가 3개, 3*3필터가 3개 있다고 해석할 수 있다)에 맞는 필터를 적용하여 convolusion을 적용한 값들을 모두 더해 출력을 구한다.
이때 주의해야 할 점은, 필터의 크기 자체는 (3,3)이 아니라 (2,2) (4,4) 등으로 설정할 수 있지만 채널의 크기는 같아야 한다는 것이다. 즉, 입력 데이터의 형상이 (4,4,3)이므로 필터는 (x,x,3)이어야 한다는 것이다(0<x<5).
앞서 2차원 데이터에서 기존 입력 데이터의 형상을 유지하기 위해 패딩을 사용하였다.
그렇다면 3차원 데이터에서 위의 그림에서 데이터의 형상을 유지하려면 어떻게 해야 할지 알아보자.
답은 간단하다.
여러 개의 필터를 사용하면 여러 개의 채널(3차원축)을 가진 feature map을 얻을 수 있다.
FN개의 필터를 적용하여 출력 데이터에 FN의 크기를 갖는 새로운 차원이 생긴다.
합성곱 연산에서는 이처럼 필터의 형상을 적절하게 정하는 것이 중요하다.
여기서는 필터가 4차원 데이터여야 하며, (출력 채널 수, 입력 채널 수, 높이, 너비)의 형상을 가진다.
예를 들어 채널 수3, 크기 5X5인 필터가 20개가 있다면 그 필터는 (20, 3, 5, 5)의 형상을 가진다.
신경망에서 입력 데이터를 한 덩어리로 묶어 배치로 처리하여 효율을 높였다.
합성곱 연산에서도 배치 처리를 지원하기 위해 데이터의 차원을 한 차원 늘린다. 즉, 3차원을 4차원 데이터로 만들어주는 것이다.
(데이터 수, 채널 수, 높이, 너비)의 형상을 가지는데, 이렇게 되면 4차원 데이터가 입력될 때 데이터 N개에 대한 합성곱 연산이 이뤄지는 것과 같은 효과를 얻을 수 있다. 한 번의 데이터 입력으로 N회분의 처리를 수행하는 것이다.
지금까지 합성곱 연산에 대해 알아보았다.
이제 풀링 계층에 대해 알아보자. 풀링은 데이터의 세로, 가로 방향의 공간을 줄이는 연산이다.
다음 그림을 살펴보자.
위 그림은 2X2 최대 풀링(max pooling)으로, 2X2영역에 대해 최댓값을 반환하는 연산이다.
이때, 위의 예처럼 풀링의 크기와 스트라이드를 같게 하는 것이 일반적이다.
이외에도 평균 풀링(average pooling) 등이 추가로 존재하는데, 이미지 인식 분야에서는 주로 최대 풀링을 사용한다.
풀링은 다음과 같은 특징을 가진다.
1) 학습해야 할 매개변수가 없다. 풀링의 종류에 따라 연산을 잘 수행해주기만 하면 된다.
2) 채널 수가 변하지 않는다. 각 채널의 크기가 변할 뿐, 채널의 숫자에는 영향을 주지 않는다.
3) 입력의 변화에 영향을 적게 받는다(강건하다). 입력데이터가 변해도 큰 변화가 아니라면 풀링의 출력이 크게 변하지 않는다.
이제 합성곱과 풀링을 코드로 구현해야 한다.
이때, 4차원 데이터의 연산을 구현하는 것은 매우 복잡해보인다.
하지만 im2col이라는 함수를 사용하면 쉽게 구현이 가능하다.
이 함수는 입력 데이터를 필터링하기 좋게 전개한다(펼친다).
다음 사진을 예로 들어보자.
이 사진 속 데이터는 기존에 3X7X7의 데이터를 3X5X5의 필터와 stride=1로 적용하여 9개의 부분으로 나눈 것이다. 그렇게 나눠진 부분들을 2차원으로 펴주는 것이 im2col함수다.
즉, im2col함수를 적용하면 (데이터 수, 채널 수, 높이, 너비)의 입력 데이터가 2차원 행렬이 되는 것이다.
im2col함수는 이 전개를 필터를 적용하는 모든 영역에서 수행한다.
im2col함수를 통해 구한 출력 데이터는 2차원이다. 이를 reshape하여 다시 원래의 4차원 데이터로 바꾸면 합성곱 계층이 완성된다.
이를 이제 코드로 구현해보자.
이 함수는 다음과 같은 parameter를 필요로 한다.
im2col(입력데이터, 필터 x크기, 필터 y크기, stride, padding)
- 입력데이터: (데이터 수, 채널 수, 높이, 너비)의 4차원 배열
import sys, os
sys.path.append(os.pardir)
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)
col2 = im2col(x2, 5, 5, stride=1, pad=0)
print(col2.shape)
(9, 75)
(90, 75)
첫번째 data는 배치의 크기가 1이다.
col1의 2번째 차원의 원소 개수 75개는 3X5X5(채널 수: 3)인 필터의 원소 수는 75개와 같다.
이는 col2에 대해서도 마찬가지다.
하지만 배치의 크기가 1인 경우 (9, 75)이지만 배치의 크기가 10이면 (90, 75)로 10배 큰 데이터가 저장된다.
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 # 필터 (필터 개수, 채널 수, 필터 x, 필터 y)
N, C, H, W = x.shape
out_h = int(1 + (H + 2*self.pad - FH) / self.stride) # output x 크기
out_W = int(1 + (W + 2*self.pad - FW) / self.stride) # output y 크기
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 # 출력
# 0,3,1,2의 축 인덱스로 순서를 바꿔준다 => (N, -1, out_h, out_w)
out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)
return out
이때, reshape에서 인수에 -1을 넣어주었다.
(10, 3, 5, 5)의 배열을 reshape(10, -1)을 적용한다고 하자.
배열의 총 원소 수는 10*3*5*5 = 750이다. 이때, -1은 나머지 값을 고려하여 reshape해준다.
따라서 재배열된 배열의 형상은 (10, 75)이다.
im2col으로 펼쳐진 col과 마찬가지로 펼쳐진 col_W를 행렬곱하여 출력을 계산해준다.
이렇게 구한 출력을 원하는 형태로 다시 재배열하여 반환한다.
이렇게 im2col을 이용하여 비교적 간단하게 합성곱 계층의 계산을 구현해보았다.
역전파도 Affine계층과 똑같이 구현된다.
한 가지 주의할 점은 im2col을 역으로 처리해야 한다는 점인데, 이는 따로 설명하진 않겠다.
다음으로 풀링 계층을 코드로 구현하는 아이디어를 살펴보자.
데이터를 먼저 2차원 배열로 전개한다.
그런 다음 각 행의 최댓값만을 반환하고, 다시 적절한 형상으로 reshape해준다.
이를 코드로 구현하면 다음과 같다.
class Pooling:
def __init__(self, pool_h, pool_w, stride=1, pad=0):
self.pool_h = pool_h # pooling의 x크기
self.pool_w = pool_w # pooling의 y크기
self.stride = stride
self.pad = pad
def forward(self, x):
N, C, H, W = x.shape
out_h = int(1 + (H + 2*self.pad - FH) / self.stride) # output x 크기
out_W = int(1 + (W + 2*self.pad - FW) / self.stride) # output y 크기
# 전개
col = im2col(x, FH, FW, self.stride, self.pad)
col = col.reshape(-1, self.pool_h * self.pool_w) # 데이터의 형상을 바꿔준다
# 최댓값(pooling 적용)
out = np.max(col, axis=1)
# reshape
out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
return out
이처럼 풀링 계층을 코드로 구현하는 것은 매우 간단하다.
이제 합성곱 계층과 풀링 계층을 이용하여 간단한 CNN을 구현해보자.
코드가 너무 길기 때문에 세 파트로 나누겠다.
첫 번째 파트는 초기화이다.
import sys, os
sys.path.append(os.pardir) # 부모 디렉터리의 파일을 가져올 수 있도록 설정
import pickle
import numpy as np
from collections import OrderedDict
from common.layers import *
from common.gradient import numerical_gradient
class SimpleConvNet:
"""단순한 합성곱 신경망
conv - relu - pool - affine - relu - affine - softmax
Parameters
----------
input_size : 입력 크기(MNIST의 경우엔 784)
hidden_size_list : 각 은닉층의 뉴런 수를 담은 리스트(e.g. [100, 100, 100])
output_size : 출력 크기(MNIST의 경우엔 10)
activation : 활성화 함수 - 'relu' 혹은 'sigmoid'
weight_init_std : 가중치의 표준편차 지정(e.g. 0.01)
'relu'나 'he'로 지정하면 'He 초깃값'으로 설정
'sigmoid'나 'xavier'로 지정하면 'Xavier 초깃값'으로 설정
"""
def __init__(self, input_dim=(1, 28, 28),
conv_param={'filter_num':30, 'filter_size':5, 'pad':0, 'stride':1},
# hyperparameters가 dictionary형태로 전달
hidden_size=100, # hidden layer의 뉴런 수
output_size=10, # 출력층의 뉴런 수
weight_init_std=0.01): # 초기화 때의 weight 표준편차
filter_num = conv_param['filter_num'] # 필터 수
filter_size = conv_param['filter_size'] # 필터 크기
filter_pad = conv_param['pad']
filter_stride = conv_param['stride']
input_size = input_dim[1] # 28
conv_output_size = (input_size - filter_size + 2*filter_pad) / filter_stride + 1
pool_output_size = int(filter_num * (conv_output_size/2) * (conv_output_size/2))
# 가중치 초기화
self.params = {}
self.params['W1'] = weight_init_std * \ # 합성곱 계층의 가중치와 편향
np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
self.params['b1'] = np.zeros(filter_num)
self.params['W2'] = weight_init_std * \ # Affine 계층의 가중치와 편향
np.random.randn(pool_output_size, hidden_size)
self.params['b2'] = np.zeros(hidden_size)
self.params['W3'] = weight_init_std * \ # Affine 계층의 가중치와 편향
np.random.randn(hidden_size, output_size)
self.params['b3'] = np.zeros(output_size)
# 계층 생성
self.layers = OrderedDict() # 계층들을 OrderedDict에 순서대로 저장
self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'],
conv_param['stride'], conv_param['pad'])
self.layers['Relu1'] = Relu()
self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
self.layers['Relu2'] = Relu()
self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])
self.last_layer = SoftmaxWithLoss()
여기까지가SimpleConvNet의 초기화파트다.
이제 초기화된 값들을 바탕으로 추론과 손실 함수를 구현한다.
def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x) # 앞서 구현한 forward함수로 forward propagation을 적용
return x
def loss(self, x, t):
"""손실 함수를 구한다.
Parameters
----------
x : 입력 데이터
t : 정답 레이블
"""
y = self.predict(x)
return self.last_layer.forward(y, t) # 손실함수 적용하여 loss계산
마지막으로 오차역전파법으로 기울기를 구하는 코드다.
def gradient(self, x, t):
"""기울기를 구한다(오차역전파법).
Parameters
----------
x : 입력 데이터
t : 정답 레이블
Returns
-------
각 층의 기울기를 담은 사전(dictionary) 변수
grads['W1']、grads['W2']、... 각 층의 가중치
grads['b1']、grads['b2']、... 각 층의 편향
"""
# forward
self.loss(x, t)
# backward
dout = 1
dout = self.last_layer.backward(dout)
layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout)
# 결과 저장
grads = {}
grads['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
grads['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
grads['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affine2'].db
return grads
코드별로 설명이 있으므로, 천천히 따라가면 이해할 수 있다.
그렇다면, CNN의 합성곱 계층의 가중치가 의미하는 것은 어떤 것일까?
합성곱 계층(1층 째) 필터를 이미지로 나타내보자. 학습 전과 후의 가중치를 나타내면 다음과 같다.
학습 후의 필터는 어떤 규칙성을 보인다.
이는 크게 두 가지로 1) 흰색에서 검은색으로 점차 변화하는 필터, 2) 덩어리가 진 필터이다.
이는 1) 에지(색상이 바뀐 경계선)와 2) 블롭(국소적으로 덩어리진 영역) 등이다.
필터1은 세로 에지, 필터2는 가로 에지에 반응한다는 사실을 알 수 있다.
이처럼 필터의 가중치는 어떤 정보를 담고 있을지 모른다.
주목할 점은, 계층이 깊어질수록 뉴런이 반응하는 대상(정보)이 고급 정보가 된다는 것이다.
다음 그림을 살펴보자.
이 그림에서 첫번째 계층은 단순한 edge와 blob에 반응한다.
3번째 층은 텍스처를, 5번째 층은 사물의 일부, 마지막 층에서는 사물을 분류하는 class에 반응한다.
이처럼 계층의 깊이가 신경망의 성능을 좌우한다.
마지막으로, 위에서 예로 살펴본 AlexNet에 대해 간단히 알아보자.
기존의 (최초의)CNN인 LeNet에서 3가지를 바꿨는데, 현재도 많이 쓰이며 딥러닝의 발전을 이끈 구조이다.
바뀐 3가지는 다음과 같다.
1) 활성화 함수로 softmax가 아닌 ReLU를 사용한다.
2) LRN(Local Response Normalization)이라는 국소적 정규화를 실시하는 계층을 이용한다.
3) 드롭아웃을 사용한다.
이렇게 이번 장이 끝이 났다.
CNN은 기존의 계층에 Conv계층와 Pooling계층을 추가하여 보다 효율적인 처리를 가능하게 한다.
im2col함수로 계산을 간단하게 하였다.
CNN의 시각화를 통해 필터들의 가중치가 어떤 것을 의미하는지를 확인할 수 있었다.
CNN은 특히 이미지 처리 분야에서는 안 쓰이는 경우가 드물기 때문에 이번 장의 내용을 잘 기억하자.
'딥러닝' 카테고리의 다른 글
[Deep Learning from Scratch] 8. 딥러닝 (0) | 2023.06.26 |
---|---|
[Deep Learning from scratch] 6. 학습 관련 기술들 (0) | 2023.05.24 |
[Incarnate the Algorithm] PCA & K-means Clustering (0) | 2023.05.23 |
[Deep Learning from scratch] 5. 오차역전파법 (0) | 2023.05.15 |
[Deep Learning from scratch] 4. 신경망 학습 (0) | 2023.05.15 |