ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [NLP] 합성곱 신경망 CNN : 합성곱/풀링 계층 구현
    ML/NLP 2020. 5. 12. 20:52

     

     

     

     

     

     

     

     

    이번 포스팅에서는 합성곱 계층과 풀링 계층을 파이썬으로 구현해보겠다.

     

     

     

     

    4차원 배열


    CNN에서 계층 사이를 흐르는 데이터는 4차원이다.

     

    예를 들어 데이터의 형상이 (10, 1, 28, 28)이라면, 이는 높이 28, 너비 28, 채널 1개인 데이터가 10개라는 이야기다.

     

    import numpy as np
    x = np.random.rand(10, 1, 28, 28)
    x.shape
    Out:    (10, 1, 28, 28)

     

    x[0].shape
    Out:    (1, 28, 28)

     

    x[1].shape

     

    Out:    (1, 28, 28)

     

     

    CNN은 4차원 데이터를 다룬다.

     

    그래서 합성곱 연산의 구현은 복잡해질 것 같지만, im2col이라는 편의 함수를 사용해 간단하게 구현이 가능하다.

     

     

     

     

     

     

    im2col로 데이터 전개하기


    im2col입력 데이터를 필터링(가중치 계산)하기 좋게 전개하는 함수이다.

     

    [그림 1]과 같이 3차원 입력 데이터에 im2col을 적용하면 2차원 행렬로 바뀐다.

     

    정확히는 배치 안의 데이터 수까지 포함한 4차원 데이터를 2차원으로 변환한다.

     

    그림 1

     

     

    im2col필터링하기 좋게 입력 데이터를 전개한다.

     

    구체적으로는 [그림 2]와 같이 입력 데이터에서 필터를 적용하는 영역 (3차원 블록)을 한 줄로 늘어놓는다.

     

    이 전개를 필터를 적용하는 모든 영역에서 수행하는 게 im2col이다.

     

    그림 2

     

    [그림 2]에서는 보기 좋게 스트라이드를 크게 잡아 필터의 적용 영역이 겹치지 않도록 했으나

     

    실제로는 영역이 겹치는 경우가 대부분이다.

     

    필터 적용 영역이 겹치게 되면 im2col로 전개한 후의 원소 수가 원래 블록의 원소 수보다 많아진다.

     

    그래서 im2col을 사용해 구현하면 메모리를 더 많이 소비한다는 단점이 있다.

     

    그러나 컴퓨터에서 행렬 계산 라이브러리 (선형 대수 라이브러리) 등은 큰 행렬을 묶어서 계산하는 데 탁월하여

     

    큰 행렬의 곱셈을 빠르게 계산할 수 있다.

     

     

     

    [그림 3]의 과정처럼 

     

    im2col로 입력 데이터를 전개한 다음, 합성곱 계층의 필터(가중치)를 1열로 전개하고, 두행렬의 곱을 계산하면 된다. 

     

    그림 3

     

    [그림 3]과 같이 im2col 방식으로 출력한 결과는 2차원 행렬이다.

     

    CNN은 데이터를 4차원 배열로 저장하므로 2차원인 출력 데이터를 4차원으로 변형(reshape)해야 한다.

     

    여기까지가 합성곱 계층의 구현 흐름이다.

     

     

     

     

     

     

    합성곱 계층 구현하기


    im2col은 미리 만들어진 함수를 사용하겠다.

     

    im2col 함수의 인터페이스는 다음과 같다.

     

    im2col (input_data, filter_h, filter_w, stride=1, pad=0)
    • input_data : (데이터 수, 채널 수, 높이, 너비)의 4차원 배열로 이뤄진 입력 데이터
    • filter_h : 필터의 높이
    • filter_w : 필터의 너비
    • stride : 스트라이드
    • pad : 패딩

     

    이를 실제로 사용해보자.

     

     

    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)
    Out:    (9, 75)  # ( output 블록의 개수(=3*3), filter의 원소수(=5*5*3) )

     

    x2 = np.random.rand(10, 3, 7, 7) # 데이터 10개
    col2 = im2col(x2, 5, 5, stride=1, pad=0)
    print(col2.shape)
    Out:    (90, 75)

     

     

     

     

    이제 이 im2col을 사용해 합성곱 계층을 구현하자.

    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 + (H + 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

    합성곱 계층은 필터(가중치), 편향, 스트라이드, 패딩을 인수로 받아 초기화한다.

     

    필터는 (FN, C, FH, FW)의 4차원 형상이다.

     

     

     

    forward 메서드 쪽에서 보면

     

    입력 데이터를 im2col로 전개하고 필터도 reshape를 사용해 2차원 배열로 전개한다.

     

    그리고 이렇게 전개한 두 행렬의 곱을 구한다.

     

     

     

    필터 전개 부분은 [그림 3]에서 보듯 각 필터 블록을 1줄로 펼쳐 세운다.

     

    이때 reshape의 두 번째 인수를 -1로 지정했는데

     

    reshape에 -1을 지정하면 다차원 배열의 원소 수가 변환 후에도 똑같이 유지되도록 적절히 묶어주는 기능을 사용한 거다.

     

     

     

    [그림 4]와 같은 기능을 가진 넘파이의 transpose 함수를 사용하여

     

    출력 데이터를 적절한 형상으로 바꿔주면 forward 구현이 마무리 된다.

     

     

     

    [그림 4] 넘파이의 transpose 함수로 축 순서 변경 (인덱스로 축의 순서 변경)

     

     

    위와 같이 im2col로 전개한 덕분에 완전연결 계층의 Affine 계층과 거의 똑같이 구현할 수 있었다.

     

     

    다음 과정인 합성곱 계층의 역전파에서는 im2col을 역으로 처리해야 한다.

     

    이는 col2im 함수를 사용하면 된다.

     

    col2im을 사용한다는 점을 제외하면 합성곱 계층의 역전파는 Affine 계층과 똑같다.

     

    여기서는 im2col, col2im, 역전파 구현은 생략하겠다.(궁금하면 직접 찾아보는 걸로)

     

     

     

     

     

     

    풀링 계층 구현하기


    풀링 계층 구현도 합성곱 계층과 마찬가지로 im2col을 사용해 입력 데이터를 전개한다.

     

    풀링의 경우에는 [그림 5]와 같이 풀링 적용 영역을 채널마다 독립적으로 전개한다.

     

    그림 5

    위와 같이 전개한 후, 전개한 행렬에서 행별로 최댓값을 구하고 적절한 형상으로 성형하기만 하면 된다. [그림 6]

     

    그림 6

    여기까지가 풀링 계층의 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 = self.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 + (H - 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)  # axis=0 : 열, axis=1 : 행
            
            # 성형 (3)
            out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
            
            return out

    * 참고 : np.max 에서 axis=0은 열 방향, axis=1은 행 방향을 의미함

     

    풀링 계층의 backward(역전파) 처리 설명은 생략한다.

     

    ReLU 계층을 구현할 때 사용한 max의 역전파를 참고하면 된다.

     

     

     

     

     

     

     

    CNN 구현하기


    합성곱 계층과 풀링 계층 구현을 조합하여 CNN을 조립해보겠다.

     

    [그림 7]과 같은 CNN을 구현한다.

     

    그림 7

     

    [그림 7]의 CNN 네트워크는 "Convolution-ReLU-Pooling-Affine-ReLU-Affine-Softmax" 순으로 흐른다.

     

    이를 SimpleConvNet이라는 이름의 클래스로 구현하겠다.

     

     

    SimpleConvNet의 초기화(__init__)를 살펴보자.

     

    초기화 때는 다음 인수들을 받는다.

     

    • input_dim : 입력 데이터(채널 수, 높이, 너비)의 차원
    • conv_param : 합성곱 계층의 하이퍼파라미터(딕셔너리). 딕셔너리의 키는 다음과 같음.
      • filter_num : 필터 수
      • filter_size : 필터 크기
      • stride : 스트라이드
      • pad : 패딩
    • hidden_size : 은닉층(완전연결)의 뉴런 수
    • output_size : 출력층(완전연결)의 뉴런 수
    • weight_init_std : 초기화 때의 가중치 표준편차

     

     

    SimpleConvNet의 초기화는 코드가 길어서 나눠 설명하겠다.

    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)

    가중치 매개변수를 초기화하는 부분이다. 
    1번째 층의 합성곱 계층과 나머지 두 완전연결 계층의 가중치와 편향을 인스턴스 변수 params 딕셔너리에 저장한다.

     

     

            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()

    CNN을 구성하는 계층들을 생성한다.
    순서가 있는 딕셔너리(OrderedDict)인 layers에 계층들을 차례로 추가한다.
    마지막 SoftmaxWithLoss 계층은 last_layer라는 별도 변수에 저장해둔다.

     

    이상이 SimpleConvNet의 초기화이다.

     

    초기화를 마친 다음,

     

    추론을 수행하는 predict 메서드와 손실 함수의 값을 구하는 loss 메서드를 다음과 같이 구현한다.

     

        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)

    인수 x는 입력 데이터, t는 정답 레이블이다.
    초기화 때 layers에 추가한 계층을 맨 앞에서부터 차례로 forward 메서드를 호출하며 그 결과를 다음 계층에 전달한다.

    손실 함수를 구하는 loss 메서드는 predict 메서드의 결과를 인수로 마지막 층의 forward 메서드를 호출한다.
    즉, 첫 계층부터 마지막 계층까지 forward를 처리한다.

     

    이어 오차역전파법으로 기울기를 구하는 구현이다.

     

        def gradient(self, x, t):
            # 순전파
            self.loss(x, t)
            
            # 역전파
            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'] = self.layers['Conv1'].dW
            grads['b1'] = self.layers['Conv1'].db
            grads['W2'] = self.layers['Affine1'].dW
            grads['b2'] = self.layers['Affine1'].db
            grads['W3'] = self.layers['Affine2'].dW
            grads['b3'] = self.layers['Affine2'].db
            
            return grads

    매개변수의 기울기는 오차역전파법으로 구한다.
    이 과정은 순전파와 역전파를 반복한다.
    마지막으로 grads라는 딕셔너리 변수에 각 가중치 매개변수의 기울기를 저장한다.

     

    이상이 SimpleConvNet의 구현이다.

     

     

     

     

     

    SimpleConvNet으로 MNIST 데이터셋을 학습해보겠다.

     

    구현은 이전 포스팅과 같으므로 설명은 생략하겠다.

     

    # coding: utf-8
    import sys, os
    sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
    import numpy as np
    import matplotlib.pyplot as plt
    from dataset.mnist import load_mnist
    from simple_convnet import SimpleConvNet
    from common.trainer import Trainer
    
    # 데이터 읽기
    (x_train, t_train), (x_test, t_test) = load_mnist(flatten=False)
    
    # 시간이 오래 걸릴 경우 데이터를 줄인다.
    x_train, t_train = x_train[:5000], t_train[:5000]
    x_test, t_test = x_test[:1000], t_test[:1000]
    
    max_epochs = 20
    
    network = SimpleConvNet(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)
                            
    trainer = Trainer(network, x_train, t_train, x_test, t_test,
                      epochs=max_epochs, mini_batch_size=100,
                      optimizer='Adam', optimizer_param={'lr': 0.001},
                      evaluate_sample_num_per_epoch=1000)
    trainer.train()
    
    # 매개변수 보존
    network.save_params("params.pkl")
    print("Saved Network Parameters!")
    
    # 그래프 그리기
    markers = {'train': 'o', 'test': 's'}
    x = np.arange(max_epochs)
    plt.plot(x, trainer.train_acc_list, marker='o', label='train', markevery=2)
    plt.plot(x, trainer.test_acc_list, marker='s', label='test', markevery=2)
    plt.xlabel("epochs")
    plt.ylabel("accuracy")
    plt.ylim(0, 1.0)
    plt.legend(loc='lower right')
    plt.show()
    Out:
    train loss:2.3000952148103644
    === epoch:1, train acc:0.233, test acc:0.241 ===
    train loss:2.296048738593153
    train loss:2.2951272407469587
    train loss:2.2879015644793044
    ...

    =============== Final Test Accuracy ===============
    test acc:0.951
    Saved Network Parameters!

     

     

    합성곱 계층과 풀링 계층은 이미지 인식에 필수적인 모듈이다.

     

    이미지라는 공간적인 형상에 담긴 특징을 CNN이 잘 파악하여 손글씨 숫자 인식에서 높은 정확도를 달성할 수 있다.

     

     

    댓글

dokylee's Tech Blog