본문 바로가기

밑바닥부터 시작하는 딥러닝

[밑바닥부터 시작하는 딥러닝1: Chapter 7] 합성곱 신경망(CNN)

728x90

본 포스팅은 밑바닥부터 시작하는 딥러닝1을 토대로 공부한 내용을 정리하기 위한 포스팅입니다.
해당 도서에 나오는 Source Code 및 자료는 GitHub를 참조하여 진행하였습니다.
https://github.com/WegraLee/deep-learning-from-scratch




합성곱 신경망(Convolutional neural network, CNN)은 이미지 인식과 음성 인식 등 다양한 곳에서 사용된다. 특히 이미지 인식 분야에서 딥러닝을 활용한 기법은 거의 다 CNN을 기초로 하고 있다.

7.1 전체구조


CNN은 합성곱 계층(convolutional layer)풀링 계층(pooling layer)로 구성된다. 지금까지 공부한 신경망은 인접하는 계층의 모든 뉴런과 결합되어 있었다. 이를 완전연결(fully-connected)라 하며, Affine 계층이라는 이름으로 구현했다. [그림 7-1]은 완전연결 계층으로 이뤄진 네트워크 구조이고, [그림 7-2]는 CNN으로 이뤄진 네트워크 구조이다.

[그림 7-1] 완전연결 계층으로 이뤄진 네트워크
[그림 7-2] CNN으로 이뤄진 네트워크


[그림 7-2]와 같이 CNN의 계층은 'Conv-ReLU-(Pooling)' 흐름으로 연결된다. 풀링 계층은 생략하기도 한다. 또, 출력에 가까운 층에서는 지금까지의 ’Affine-ReLU'구성을 사용할 수 있다. 마지막 출력 계층에서는 ‘Affine-Softmax' 조합을 그대로 사용하기도 한다. 이는 일반적인 CNN에서 흔히 볼 수 있는 구조이다.


7.2 합성곱 계층


CNN에서는 패딩(padding), 스트라이드(stride) 등 CNN 고유의 용어가 존재한다. 또한 각 계층 사이에서는 3차원 데이터같이 입체적인 데이터가 흐른다는 점에서 완전연결 신경망과 다르다.

완전연결 계층은 ‘데이터의 형상이 무시’된다는 문제가 있다. 완전연결 계층에 입력할 때는 3차원 데이터를 평평한 1차원 데이터로 평탄화해줘야 한다. MNIST 데이터셋을 예로 들면 형상이 (1, 28, 28)인 이미지를 1줄로 세운 784개의 데이터로 Affine 계층에 입력했다. 그러나 이미지는 3차원 형상이기 때문에 형상을 유지해 데이터를 입력해야만 형상에 담긴 정보를 온전히 살릴 수 있다.

한편, 합성곱 계층은 형상을 유지한다. 이미지도 3차원 데이터로 입력받으며, 마찬가지로 다음 계층에도 3차원 데이터로 전달한다. 그래서 CNN에서는 이미지처럼 형상을 가진 데이터를 제대로 이해할 수 있는것이다. CNN에서는 합성곱 계층의 입출력 데이터를 특징 맵(feature map)이라고도 한다. 합성곱 계층의 입력 데이터를 입력 특징 맵(input feature map), 출력 데이터를 출력 특징 맵(output feature map)이라 하는 식이다.

7.2.2 합성곱 연산

합성곱 연산은 입력 데이터에 필터를 적용한다. 데이터와 필터의 형상을(height, width)로 표기하며, 문헌에 따라 필터를 커널이라 칭하기도 한다. 합성곱 연산은 필터의 윈도우(window)를 일정 간격으로 이동해가며 입력 데이터에 적용한다. 여기에서 말하는 윈도우는 [그림 7-4]의 회색 3x3 부분을 가리킨다. 이 그림에서처럼 입력과 필터에서 대응하는 원소끼리 곱한 후 그 총합을 구한다. 이 계산을 단일 곱셈-누산(fused multiply-add, FMA)라 한다.

[그림 7-4] 합성곱 연산의 계산 순서

완전연결 신경망에서는 가중치 매개변수와 편향이 존재했다. CNN에서는 필터의 매개변수가 그동안의 ‘가중치’에 해당한다. 편향까지 추가하면 [그림7-5]의 흐름이 된다.

[그림 7-5]


7.2.3 패딩

합성곱 연산을 수행하기 전에 입력 데이터 주변을 특정 값으로 채우기도 한다. 이를 패딩(padding)이라 한다. 패딩은 주로 출력 크기를 조정할 목적으로 사용한다. 패딩을 사용해 입력 데이터의 공간적 크기를 고정한 채로 다음 계층에 전달할 수 있다.

[그림 7-6] 합성곱 연산의 패딩 처리

7.2.4 스트라이드


필터를 적용하는 위치의 간격을 스트라이드(stride)라고 한다. 예를 들어 stride를 2로 하면 필터를 적용하는 window가 두 칸씩 이동한다.

[그림 7-7]

[그림 7-7]에서는 크기가 (7, 7)인 입력 데이터에 stride를 2로 설정한 필터를 적용한다. 이처럼 stride는 필터를 적용하는 간격을 지정한다.

지금까지 공부한 stride를 키우면 출력 크기는 작아지고, padding을 크게 하면 출력 크기는 커지게 된다. 이러한 관계를 수식화하면 출력 값의 height와 width를 계산할 수 있다. 입력 크기를 (H, W), 필터 크기를 (FH, FW), 출력 크기를 (OH, OW), 패딩을 P, 스트라이드를 S라 하면 출력 크기는 [식 7.1]로 계산할 수 있다.

[식 7.1]

7.2.5 3차원 데이터의 합성곱 연산


[그림 7-9]은 3차원 데아터의 합성곱 연산 예이다. 2차원일 때 계산과 비교하면, 채널 방향으로 feature map이 늘어났다. 채널 쪽으로 feature map이 여러 개 있다면 입력 데아터와 필터의 합성곱 연산을 채널마다 수행하고, 그 결과를 더해서 하나의 출력을 얻는다.

[그림 7-9] 3차원 데이터 합성곱 연산의 계산 순서

3차원의 합성곱 연산에서 주의할 점은 입력 데이터의 채널 수와 필터의 채널 수가 같아야 한다.

3차원 합성곱 연산을 직육면체 블록이라고 생각하면 이해하기 쉽다.

[그림 7-10] 합성곱 연산의 처리 흐름

[그림 7-10]처럼 입력 데이터에 FN개의 필터를 적용하는만큼 출력의 형상도 (FN, OH, OW)으로 된다. 이처럼 합성곱 연산에서는 필터의 수도 고려해야 한다. 편향은 채널 하나에 값 하나씩으로 구성된다.

7.2.7 배치 처리


신경망 처리에서는 입력 데이터를 한 덩어리로 묶는 배치 기법을 사용했다. 합성곱 연산도 각 계층에 흐르는 데이터의 차원을 하나 늘리면 배치 처리를 진행할 수 있다. 구체적으로는 데이터를 (데이터 수, 채널 수, 높이, 너비) 순으로 저장한다. 데이터가 N개일 때 [그림 7-10]을 배치 처리한다면 형태가 [그림 7-11]처럼 되는 것이다.

[그림 7-11] 합성곱 연산의 처리 흐름(배치 처리)

7.3 풀링 계층


Pooling은 세로, 가로 방향의 공간을 줄이는 연산이다. 예를 들어 [그림 7-12]와 같이 2x2 영역을 원소 하나로 집약하여 공간 크기를 줄인다.

[그림 7-12] 최대 풀링의 처리 순서

[그림 7-12]는 2x2 최대 풀링(max pooling)을 stride 2로 처리하는 순서이다. max pooling은 최댓값을 구하는 연산으로, window에 해당하는 영역에서 가장 큰 원소 하나를 꺼내는 원리이다. 참고로, pooling의 window크기와 stride는 같은 값으로 설정하는 것이 보통이다.


7.4 합성곱/풀링 계층 구현하기


7.4.2 im2col로 데이터 전개하기


im2col은 입력 데이터를 필터링(가중치 계산)하기 좋게 전개하는 함수이다. [그림 7-13]과 같이 3차원 입력 데이터에 im2col을 적용하면 2차원 행렬로 바뀌게 된다(정확히는 배치 안의 데이터 수까지 포함한 4차원 데이터를 2차원으로 변환한다). 이러한 전개를 필터를 적용하는 모든 영역에서 수행한다.

[그림 7-13] 필터 적용 영역을 앞에서부터 순서대로 1줄로 펼친다.

[그림 7-13]에서는 stride를 크게 잡아 필터의 적용 영역이 겹치지 않도록 했다. 만약 필터 적용 영역이 겹치게 되면 im2col로 전개한 후의 원소 수가 원래 블록의 원소 수보다 많아지게 된다. 그래서 im2col을 사용해 구현하면 메모리를 더 많이 소비한다는 단점이 있다. 하지만 컴퓨터는 큰 행렬을 묶어서 계산하는 데 탁월하므로 문제를 행렬 계산으로 만들면 선형 대수 라이브러리를 활용해 효율을 높일 수 있다.

im2col로 입력 데이터를 전개한 다음에는 합성곱 계층의 필터를 1열로 전개하고, 두 행렬의 곱을 계산하면 된다. 이는 완전연결 계층의 Affine 계층에서 한 것과 거의 동일하다.

[그림 7-14] 합성곱 연산의 필터 처리 상세 과정

[그림 7-14]와 같이 im2col로 출력한 결과는 2차원 행렬이다. CNN은 데이터를 4차원 배열로 저장하므로 2차원인 출력 데이터를 4차원으로 변형(reshape)한다. 이것이 합성곱 계층의 구현 흐름이다.

7.4.3 합성곱 계층 구현하기


im2col을 구현한 코드는 아래와 같다.

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


합성곱 계층 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 # filter 전개
        out = np.dot(col, col_W) + self.b
        
        out = out.resahpe(N, out_h, out_w, -1).transpose(0, 3, 1, 2)
        
        return out


Convolution 클래스는 입력 데이터를 im2col로 전개하고 필터도 reshape을 사용해 2차원 배열로 전개한다. 그리고 이렇게 전개한 두 행렬의 곱을 구한다. 이때 reshape의 두 번째 인수를 -1로 지정했는데, reshape에 -1을 지정하면 다차원 배열의 원소 수가 변환 후에도 똑같이 유지되도록 적절히 묶어주는 기능이 있다. 예를 들어 (10, 3, 5, 5) 형상을 한 다차원 배열 W의 원소 수는 총 750개인데, 이 배열에 reshape(10, -1)을 호출하면 750개의 원소를 형상이 (10, 75)인 배열로 만들어준다.
다음으로 forward 구현의 마지막에서는 출력 데이터를 적절한 형상으로 바꿔준다. 이때 numpy의 transpose 함수를 사용하는데, 이는 다차원 배열의 축 순서를 바꿔주는 함수이다. [그림 7-15]과 같이 지정하여 축의 순서를 변경한다.

[그림 7-15] numpy의 transpose 함수로 축 순서 변경하기

합성곱 계층의 역전파에서는 im2col을 역으로 처리해야 한다. 이는 이 책에서 제공하는 col2im을 사용하면 된다. col2im을 사용한다는 점을 제외하면 합성곱 계층의 역전파는 Affine 계층과 똑같다.

7.4.4 풀링 계층 구현하기


풀링 계층 구현도 합성곱 계층과 마찬가지로 im2col을 사용해 입력 데이터를 전개한다. 단, 풀링의 경우엔 채널 쪽이 독립적이라는 점이 합성곱 계층 때와 다르다. 구체적으로는 [그림 7-16]과 같이 풀링 적용 영역을 채널마다 독립적으로 전개한다.

[그림 7-16] 풀링 계층 구현의 흐름

이상이 풀링 계층의 forward 처리 흐름이다. 풀링 계층 구현은 다음의 세 단계로 진행한다.

1. 입력 데이터를 전개한다.
2. 행별 최댓값을 구한다.
3, 적절한 모양으로 성형한다.

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
        


7.5 CNN 구현하기


합성곱 계층과 풀링 계층을 조합해 손글씨 숫자를 인식하는 CNN을 조립해보도록 한다. [그림 7-17]과 같은 CNN을 구현한다.

[그림 7-17] 단순한 CNN 네트워크 구성


class SimpleConvNet:
    def __init__(self, input_dim=(1, 28, 28), 
                 conv_param={'filter_num':30, 'filter_size':5, 'pad':0, 'stride':1},
                 hidden_size=100, output_size=10, weight_init_std=0.01):
        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]
        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 * \
                            np.random.randn(pool_output_size, hidden_size)
        self.params['b2'] = np.zeros(hidden_size)
        self.params['W3'] = weight_init_std * \
                            np.random.randn(hidden_size, output_size)
        self.params['b3'] = np.zeros(output_size)

        # 계층 생성
        self.layers = 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()

    def predict(self, x):
        for layer in self.layers.values():
            x = layer.forward(x)

        return x

    def loss(self, x, t):
        y = self.predict(x)
        return self.last_layer.forward(y, t)

    def accuracy(self, x, t, batch_size=100):
        if t.ndim != 1 : t = np.argmax(t, axis=1)
        
        acc = 0.0
        
        for i in range(int(x.shape[0] / batch_size)):
            tx = x[i*batch_size:(i+1)*batch_size]
            tt = t[i*batch_size:(i+1)*batch_size]
            y = self.predict(tx)
            y = np.argmax(y, axis=1)
            acc += np.sum(y == tt) 
        
        return acc / x.shape[0]

    def gradient(self, x, t):
        # 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
        


초기화 함수에서는 가중치 매개변수들을 먼저 초기화 한다. 그 다음 순서가 있는 딕셔너리(OrderedDict)인 layers에 계층들을 차례로 추가한다. 마지막 SoftmaxWithLoss 계층은 last_layer라는 별도 변수에 저장하도록 한다.

이렇게 초기화를 마친 후에는 추론을 수행하는 predict 메서드와 손실 함수의 값을 구하는 loss 메서드를 구현한다.
predict 메서드는 초기화 때 layers에 추가한 계층을 맨 앞에서부터 차례로 forward 메서드를 호출하며 그 결과를 다음 계층에 전달한다.
손실 함수를 구하는 loss 메서드는 predict 메서드의 결과를 인수로 마지막 층의 forward 메서드를 호출한다. 즉, 첫 계층부터 마지막 계층까지 forward를 처리한다.

이어서 오차역전파법으로 기울기를 구현한다. layers들을 reverse해 마지막 layer부터 역전파를 진행하도록 한다. 마지막으로 grads라는 딕셔너리 변수에 각 가중치 매개변수의 기울기를 저장하면 SimpleConvNet의 구현이 끝난다.

7.6 CNN 시각화하기


7.6.1 1번째 층의 가중치 시각화하기


  MNIST 데이터셋으로 CNN 학습을 진행할 수 있었다. 그때 1번째 층의 합성곱 계층의 가중치(1번째 계층의 필터)를 이미지로 나타내면 [그림 7-18]과 같다.

[그림 7-18] 학습 전과 후의 1번째 층의 합성곱 계층의 가중치

[그림 7-18]과 같이 학습 전 필터는 무작위로 초기화되고 있어 흑백의 정도에 규칙성이 없다. 한편, 학습을 마친 필터는 규칙성 있는 이미지가 되었다. 흰색에서 검은색으로 점차 변화하는 필터와 덩어리(blob)가 진 필터 등, 규칙을 띄는 필터로 바뀌었다.

규칙성 있는 필터는 edge(색상이 바뀐 경계선)과 blob(국소적으로 덩어리진 영역) 등을 보고 있다. 예를 들어 왼쪽 절반이 흰색이고 오른쪽 절반이 검은색인 필터는 [그림 7-19]와 같이 세로 방향의 edge에 반응하는 필터이다.

[그림 7-19] 가로 edge와 세로 edge에 반응하는 필터

7.6.2 층 깊이에 따른 추출 정보 변화


딥러닝 시각화에 관한 연구에 따르면, 계층이 깊어질수록 추출되는 정보는 더 추상화된다는 것을 알 수 있다. [그림 7-20]은 일반 사물 인식을 수행한 8층의 CNN이다. 이 네트워크 구조는 AlexNet이라 하는데, 합성곱 계층과 풀링 계층을 여러 겹 쌓고, 마지막으로 완전연결 계층을 거쳐 결과를 출력하는 구조이다.

[그림 7-20] CNN의 합성곱 계층에서 추출되는 정보

처음 층은 단순한 edge에 반응하고, 이어서 텍스처에 반응하고, 더 복잡한 사물의 일부에 반응하도록 변화한다. 즉, 층이 깊어지면서 뉴런이 반응하는 대상이 단순한 모양에서 ‘고급’ 정보로 변화해간다. 다시 말하면 사물의 ‘의미’를 이해하도록 변화하는 것이다.


7.7 대표적인 CNN

CNN의 원조인 LeNet과, 딥러닝이 주목받도록 이끌어준 AlexNet에 대해 알아보도록 한다.

7.7.1 LeNet


LeNet은 손글씨 숫자를 인식하는 네트워크로, [그림 7-21]과 같이 합성곱 계층과 풀링 계층을 반복하고, 마지막으로 완전연결 계층을 거치면서 출력한다.

[그림 7-21] LeNet의 구성

LeNet과 ‘현재의 CNN'은 몇 가지 차이가 있다. 첫 번째 차이는 활성화 함수이다. LeNet은 sigmoid 함수를 사용하는 데 반해, 현재는 주로 ReLU를 사용한다. 또, 원래의 LeNet은 Subsampling을 하여 중간 데이터의 크기를 줄이지만 현재는 max pooling이 주류이다.


7.7.2 AlexNet

2012년에 발표된 AlexNet은 딥러닝 열풍을 일으키는 데 큰 역할을 했다. [그림 7-22]를 보면 구성은 기본적으로 LeNet과 크게 다르지 않지만, AlexNet은 Convolution 계층과 Pooling 계층을 거듭하며 마지막으로 완전연결 계층을 거쳐 결과를 출력한다.

[그림 7-22] AlexNet의 구성


  AlexNet에서는 LeNet에서 다음과 같은 변화를 주었다.

  • 활성화 함수로 ReLU를 이용한다.
  • LRN(Local Response Normalization)이라는 국소적 정규화를 실시하는 계층을 이용한다.
  • Drop Out을 사용한다.


네트워크 구성 면에서는 LeNet과 AlexNet에 큰 차이는 없다. 그러나 이를 둘러싼 환경과 컴퓨터 기술이 큰 진보를 이루었기에 대량의 데이터를 누구나 얻을 수 있게 되었고, 병렬 계산에 특화된 GPU가 보급되면서 대량의 연산을 고속으로 수행할 수 있게 되었다. 이는 하드웨어 기술도 딥러닝에 큰 영향을 끼친다는 것을 시사한다.


























728x90