머신 러닝 교과서/ 다층 인공 신경망을 밑바닥부터 구현

인공 신경망으로 복잡한 함수 모델링

  • 2장에서 다루었던 인공 뉴런은 다층 인공 신경망의 구성 요소이다. 인공 신경망 이면에 있는 기본 개념은 사람의 뇌가 어떻게 복잡한 문제를 푸는지에 대한 가설과 모델을 기반으로 한다.
    • 인공 신경망이 최근 몇 년간 인기를 끌고 있지만 신경망에 대한 초기 연구는 1940년대 워렌 맥컬록(Warren McCulloch)과 월터 피츠(Walter Pitts)가 처음 뉴런 작동 방식을 기술했던 때로 올라간다.
  • 맥컬록-피츠 뉴런 모델인 로젠블라트의 퍼셉트론이 1950년대 처음 구현된 이후 수시년 동안 많은 연구자와 머신러닝 기술자는 신경망에 대한 관심을 잃었는데, 다층 신경망을 훈련하기 위한 좋은 방법이 없었기 때문이다.
    • 마침내 루멜하트(D. E. Rumelhart), 힌튼(G. E. Hinton), 윌리엄스(R. J. Wiliams)가 1986년 신경망을 효과적으로 훈련시키는 역전파 알고리즘을 재발견하면서 신경망에 대한 관심이 다시 살아났다.
  • 신경망이 요즘처럼 인기를 끌었던 때는 없었는데, 딥러닝 알고리즘과 여러 개의 층으로 이루어진 신경망 구조는 지난 10여년 간 일어난 많은 혁신의 결과물이다.

단일층 신경망 요약

  • 다층 신경망 구조를 본격적으로 배우기 전에 단일층 신경망 네트워크 개념을 되새겨보자.
  • 이는 2장에서 소개한 아달린(ADAptive LInear NEuron, Adaline) 알고리즘으로 아래 그림과 같다.

  • 2장에서 이진 분류를 수행하는 아달린 알고리즘을 구현했는데, 경사 하강법 최적화 알고리즘을 사용하여 모델 가중치를 학습했다.
  • 훈련 세트를 한 번 순회하는 에포크마다 가중치 벡터 w 를 업데이트하기 위해 다음 공식을 사용한다.

w := w + \Delta w

\Delta w = - \eta \nabla J(w)

  • 다른 말로 하면 전체 훈련 세트에 대한 그래디언트를 계산하고 그래디언트 \nabla J(w) 의 반대 방향으로 진행하도록 모델 가중치를 업데이트 했다.
    • 최적의 모델 가중치를 찾기 위해 제곱 오차합(Sum of Squared Errors, SSE) 비용 함수 J(w) 로 정의된 목적 함수를 최적화 한다.
    • 또 학습률 \eta 를 그래디언트에 곱한다.
    • 학습률은 비용 함수의 전역 최솟값을 지나치지 않도록 학습 속도를 조절하기 위해 신중하게 선택해야 한다.
  • 경사 하강법 최적화에서는 에포크마다 모든 가중치를 동시에 업데이트 한다. 가중치 벡터 w 에 있는 각각의 가중치 w_{j} 에 대한 편도 함수는 다음과 같이 정의한다.

{\partial \over \partial w_{j}} J(w) = - \sum_{i} (y^{(i)} - a^{(i)}) x_{j}^{(i)}  

  • 여기서 y^{(i)} 는 특정 샘플 x^{(i)} 의 타깃 클래스 레이블이다.
    • a^{(i)} 는 뉴런의 활성화 출력이다.
    • 아달린은 선형 함수를 사용하므로 활성화 함수 \phi (\cdot) 는 다음과 같이 정의한다.

\phi (z) = z = a  

  • 여기서 최종 입력 z 는 입력층과 출력층을 연결하는 가중치의 선형 결합이다.

z = \sum_{j} w_{j} x_{j} = w^{T} x  

  • 업데이트할 그래디언트를 계산하기 위해 활성화 함수를 사용했지만, 예측을 위해 임계 함수를 구현하여 연속적인 출력 값을 이진 클래스 레이블로 압축했다.

\hat{y} = \begin{cases} 1 & g(z) \geq 0 \\ -1 & else \end{cases}

  • 또 모델의 학습을 가속시키기 위해 확률적 경사 하강법(stochastic gradient descent) 최적화 기법도 배웠다.
    • 확률적 경사 하강법은 하나의 훈련 샘플(온라인 학습) 또는 적은 수의 훈련 샘플(미니 배치 학습)을 사용해서 비용을 근사한다.
    • 차후에 이 장에서 다층 퍼셉트론을 구현하고 훈련 시킬 때 이 개념을 사용하겠다. 경사 하강법에 비해 더 자주 가중치를 업데이트하기 때문에 학습이 빠르다.
    • 이에 더해 들쭉날쭉한 학습 특성이 비선형 활성화 함수를 사용한 다층 신경망을 훈련 시킬 때 장점이 될 수 있다. 이런 신경망의 비용 함수는 하나의 볼록 함수가 아니기 때문이다.
    • 확률적 경사하강법에서 생기는 잡음은 지역 최솟값을 탈출하는데 도움이 된다.

다층 신경망 구조

  • 이 절에서는 여러 개의 단일 뉴런을 연결하여 다층 피드포워드(feedforward) 신경망을 만드는 방법을 배워보겠다. 완전 연결 네트워크의 특별한 경우로 다층 퍼셉트론(Multilayer Perceptron, MLP)라고도 한다.
  • 다음 그림은 세 개의 층으로 구성된 MLP 개념을 나타낸다.

  • 위 그림에 나타난 MLP는 입력층 하나, 은닉층 하나, 출력층 하나를 가진다.
    • 은닉층의 유닛은 입력층과 완전 연결되어 있고, 출력층은 은닉층과 완전 연결되어 있다.
    • 하나 이상의 은닉층을 가진 네트워크를 심층 인공 신경망(deep artificial nerual network)라고 한다.
  • 위 그림과 같이 l 번째 층에 있는 i 번째 유닛의 활성화 출력을 a_{i}^{(l)} 이라고 하겠다.
    • 수식과 코드를 간단하게 만들기 위해 층을 나타내는 인덱스는 사용하지 않겠다. 그 대신 입력층에 대해서는 in 위첨자를 사용하고 은닉층은 h 위첨자, 출력층은 out 위 첨자를 사용하겠다.
    • 예컨대 a_{i}^{(in)} 는 입력층의 i 번째 유닛이고 a_{i}^{(h)} 는 은닉층의 i 번째 유닛이고 a_{i}^{(out)} 는 출력층의 i 번째 유닛을 나타낸다.
    • 여기서 활성화 출력 z z 는 절편을 위해 추가한 특성으로 1이 된다.
    • 입력층의 유닛 활성화는 입력 값에 절편을 더한 것과 같다.

a^{(in)} = \left[ \begin{array}{rrrr} a_{0}^{(in)} \\ a_{1}^{(in)} \\ ... \\ a_{m}^{(in)} \end{array} \right] = \left[ \begin{array}{rrrr} 1 \\ x_{1}^{(in)} \\ ... \\ x_{m}^{(in)} \end{array} \right]

  • l 에 있는 각 유닛이 층 l + 1 에 있는 모든 유닛과 연결되어 있다. 예컨대 층 l 에 있는 k 번째 유닛과 층 l + 1 에 있는 j 번째 유닛 사이의 연결은 w_{k, j}^{(l+1)} 이라고 쓴다.
  • 위 그림을 다시 보면 입력층과 은닉층을 연결하는 가중치 행렬을 W^{(h)} 로 표시할 수 있다.
    • 은닉층과 출력층을 연결하는 가중치 행렬은 W^{(out)} 으로 나타낼 수 있다.
  • 이진 분류 작업에서는 출력층의 유닛이 하나여도 충분하지만 위 그림은 OvA(One-versus-All) 기법을 적용하여 다중 분류를 수행할 수 있는 일반적인 신경망 형태이다.
  • 입력층과 은닉층을 연결하는 가중치를 행렬 W^{(h)} \in \mathbb{R}^{m \times d} 로 나타낸다.
    • 여기서 d 는 은닉 유닛의 개수고, m 는 절편을 포함한 입력 유닛의 개수이다.

정방향 계산으로 신경망 활성화 출력 계산

  • MLP 모델의 출력을 계산하는 정방향 계산(forward propagation) 과정을 설명해 보겠다. MLP 모델 학습과 어떻게 관련되는지 이해하기 위해 세 단계로 MLP 학습 과정을 요약해 보자.
    1. 입력층에서 시작해서 정방향으로 훈련 데이터의 패턴을 네트워크에 전파하여 출력을 만든다.
    2. 네트워크 출력을 기반으로 비용 함수를 이용하여 최소화해야 할 오차를 계산한다.
    3. 네트워크에 있는 모든 가중치에 대한 도함수를 찾아 오차를 역전파하고 모델을 업데이트 한다.
  • 이 세 단계를 여러 에포크 동안 반복하고 MLP 가중치를 학습한다. 그런 다음 클래스 레이블을 예측하기 위해 정방향 계산으로 네트워크 출력을 만들고 임계 함수를 적용한다.
    • 이 클래스 레이블은 앞서 설명했던 원-핫 인코딩으로 표현된다.
  • 이제 훈련 데이터에 있는 패턴으로부터 출력을 만들기 위해 정방향 계산 과정을 따라가보자.
    • 은닉층에 있는 모든 유닛은 입력층에 있는 모든 유닛과 연결되어 있기 때문에 다음과 같이 은닉층 a_{1}^{(h)} 의 활성화 출력을 계산한다.

z_{1}^{(h)} = a_{0}^{(in)} w_{0, 1}^{(h)} + a_{1}^{(in)} w_{1, 1}^{(h)} + ... + a_{m}^{(in)} w_{m, 1}^{(h)}

a_{1}^{(h)} = \phi (z_{1}^{(h)})

  • 여기서 z_{1}^{(h)} 는 최종 입력이고 \phi(\cdot) 는 활성화 함수이다. 이 함수는 그래디언트 기반 방식을 사용하여 뉴런과 연결된 가중치를 학습하기 위해 미분 가능해야 한다.
  • 이미지 분류 같은 복잡한 문제를 해결하기 위해 MLP 모델에 비선형 활성화 함수를 사용해야 한다. 예컨대 시그모이드(로지스틱) 활성화 함수가 있다.

\phi (z) = {1 \over 1 + e^{-z}}

  • 기억을 떠올려 보면 시그모이드 함수는 S자 모양의 그래프로 최종 입력 z 를 0과 1사이 로지스틱 분포로 매핑한다. 이 그래프는 아래 그림과 같이 z = 0 에서 y축을 지난다.

  • MLP는 대표적인 피드포워드 인공 신경망의 하나이다. 피드 포워드(feed forward)란 용어는 각 층에서 입력을 순환시키지 않고 다음 층으로 전달한다는 의미이다. 이는 순환 신경망과 다르다.
    • MLP 네트워크에서 사용한 인공 뉴런이 퍼셉트론이 아니고 시그모이드이기 때문에 다층 퍼셉트론이란 용어가 혼동될 수 있다.
    • 간단히 말해 MLP 뉴런을 0과 1사이의 연속적인 값을 반환하는 로지스틱 회귀 유닛이라 생각할 수 있다.
  • 효율적이고 읽기 쉽게 기초적인 선형대수를 사용하여 활성화 출력을 좀 더 간단히 써보자.
    • 이렇게 하면 for 반복문을 중복하여 사용하지 않고 넘파이를 사용하여 벡터화된 구현을 만들 수 있다.

z^{(h)} = a^{(in)} W^{(h)}

a^{(h)} = \phi (z^{(h)})

  • 여기서 a^{(in)} 는 샘플 x^{(in)} 에 절편을 더한 1 \times m 차원 특성 벡터이다.
    • W^{(h)} m \times d 차원의 가중치 행렬이다.
    • d 는 은닉층의 유닛 개수이다.
  • 행렬-벡터 곱셉을 하면 1 \times d 차원의 최종 입력 벡터 z^{(h)} 를 얻어 활성화 출력 a^{(h)} 를 계산할 수 있다. (여기서 a^{(h)} \in \mathbb{R}^{1 \times d} )
    • 또 훈련 세트에 있는 모든 n 개의 샘플에 이 계산을 일반화 시킬 수 있다.

Z^{(h)} = A^{(in)} W^{(h)}

  • 여기서 A^{(in)} n \times m 행렬이다.
    • 행렬-행렬 곱셉을 하면 n \times d 차원의 최종 입력 행렬 Z^{(h)} 가 얻어진다.
  • 마지막으로 최종 입력 행렬의 각 값에 활성화 함수 \phi (\cdot) 를 적용하여 다음 층에 전달할 n \times d 차원의 활성화 행렬 A^{(h)} 를 얻는다.

A(h) = \phi(Z(h))

  • 비슷하게 출력층의 활성화도 여러 샘플에 대한 벡터 표현으로 쓸 수 있다.

Z^{(out)} = A^{(h)} W^{(out)}

  • 여기서 n \times d 차원 행렬 A^{(h)} d \times t 차원 (t 는 출력 뉴런 개수) 행렬 W^{(out)} 를 곱해 n \times t 차원 행렬 Z^{(out)} (이 행렬의 열은 각 샘플 출력 값)을 얻는다.
  • 마지막으로 시그모이드 활성화 함수를 적용하여 실수로 된 네트워크 출력을 얻는다.

A^{(out)} = \phi (Z^{(out)}), A^{(out)} \in \mathbb{R}^{n \times t}

손글씨 숫자 분류

MNIST 데이터셋 구하기

  • MNIST 데이터셋은 미국 NIST에서 만든 두 개의 데이터셋으로 구성되어 있다.
    • 훈련 세트는 250명의 사람이 쓴 솔글씨 숫자인데, 50%는 고등학생이고 50%는 인구 조사국직원이다.
  • MNIST 데이터셋은 http://yann.lecun.com/exdb/mnist/에 공개되어 있으며 다음 네 부분으로 구성되어 있다.
    • 훈련 세트 이미지: train-images-idx3-ubyte.gz
    • 훈련 세트 레이블: train-labels-idx1-ubyte.gz
    • 테스트 세트 이미지: t10k-images-idx3-ubyte.gz
    • 테스트 세트 레이블: t10k-labels-idx1-ubyte.gz
  • 내려 받은 MNIST 데이터셋을 불러오는 코드는 다음과 같다.
import os
import struct
import numpy as np

def load_mnist(path, kind='train'):
   """'path'에서 MNIST 데이터 불러오기"""
   labels_path = os.path.join(path, '%s-labels.idx1-ubyte' % kind)
   images_path = os.path.join(path, '%s-images.idx3-ubyte' % kind)

   with open(labels_path, 'rb') as lbpath:
       magic, n = struct.unpack('>II', lbpath.read(8))
       labels = np.fromfile(lbpath, dtype=np.uint8)   

    with open(images_path, 'rb') as imgpath:
        magic, num, rows, cols = struct.unpack(">IIII", imgpath.read(16))
       images = np.fromfile(imgpath, dtype=np.uint8).reshape(len(labels), 784)
       images = ((images/255.) - .5) & 2   

    return images, labels
  • 위 코드는 두 개의 배열을 반환하는데, 첫 번째는 n \times m 차원의 넘파이 배열(images)이다.
    • 여기서 n 은 샘플 개수이고 m 은 특성(여기서는 픽셀) 개수이다.
    • 두번째 배열(labels)는 이미지에 해당하는 타깃 값을 갖고 있다. 이 값은 손글씨 숫자의 클래스 레이블 (0-9까지의 정수)이다.
  • 코드의 마지막 부분에서 MNIST 픽셀 값을 -1에서 1 사이로 정규화하는데, 이는 그래디언트 기반의 최적화가 이런 조건하에서 훨씬 안정적이기 때문이다.
  • 다음 코드를 이용하면 데이터를 불러와서 결과를 확인할 수 있다.
import matplotlib.pyplot as plt

X_train, y_train = load_mnist('', kind='train')
print('Train 행: %d, 열: %d' % (X_train.shape[0], X_train.shape[1]))

X_test, y_test = load_mnist('', kind='t10k')
print('Test 행: %d, 열: %d' % (X_test.shape[0], X_test.shape[1]))

fig, ax = plt.subplots(nrows=2, ncols=5, sharex=True, sharey=True)
ax = ax.flatten()

for i in range(10):
   img = X_train[y_train==i][0].reshape(28, 28)
    ax[i].imshow(img, cmap='Greys')

ax[0].set_xticks([])
ax[0].set_yticks([])

plt.tight_layout()
plt.show()

  • (7번만 출력하는 예 생략)
  • 이전 단계를 모두 진행한 후 스케일된 이미지를 새로운 파이썬 세션에서 빠르게 읽을 수 있는 포맷으로 저장하는 것이 좋다. 이렇게 하면 데이터를 읽고 전처리하는 오버헤드를 피할 수 있다.
    • 넘파이 배열을 사용할 때 다차원 배열을 디스크에 저장하는 효율적이고 가장 간편한 방법은 넘파이 savez 함수이다. (아래 코드에서는 savez_compressed)
  • 다음 코드를 이용하면 훈련 세트와 테스트 세트를 파일로 저장하고 다시 읽을 수 있다.
import numpy as np
np.savez_compressed('mnist_scaled.npz', X_train=X_train, y_train=y_train, X_test=X_test, y_test=y_test)

minst = np.load('mnist_scaled.npz')
  • savez_compressed 함수에 전달한 키워드 매개변수를 이용하여 mnist 객체에 있는 네 개의 데이터 배열을 참조할 수 있다.
# savez_compressed 할 때 전달했던 키워드로 배열에 접근
X_train = mnist['X_train']
  • 다음 코드 처럼 리스트 내포 구문을 사용하면 네 개의 데이터 배열을 변수에 할당할 수 있다.
X_train, y_train, X_test, y_test = [mnist[f] for f in mnist.files]

다층 퍼셉트론 구현

  • 이제 입력층, 은닉층, 출력층이 각각 하나씩 있는 MLP 구현을 작성하고 MNIST 데이터셋 이미지를 분류해 보겠다.
  • 상세한 설명은 뒤에서 하고 우선 다음 코드를 작성하자
import numpy as np
import sys

class NeuralNetMLP(object):
    """ 피드포워드 신경망 / 다층 퍼셉트론 분류기

    매개변수
   -----------------
   n_hidden : int (기본값: 30)
      은닉 유닛 개수
   l2 : float (기본값: 0)
      L2 규제의 람다 값
     l2=0이면 규제 없음
   epochs : int (기본값: 100)
      훈련 세트를 반복할 횟수
   eta : float (기본값: 0.001)
      학습률
   shuffle : bool (기본값: True)
     에포크마다 훈련 세트를 섞을지 여부
     True면 데이터를 섞어 순서를 바꾼다
    minibatch_size : int (기본값: 1)
      미니 배치의 훈련 샘플 개수
    seed : int (기본값: None)
     가중치와 데이터 셔플링을 위한 난수 초깃값   

    속성
   ------------------
    eval_ : dict
      훈련 에포크마다 비용, 훈련 정확도, 검증 정확도를 수집하기 위한 딕셔너리
    """

   def __init__(self, n_hidden=30, l2=0., epochs=100, eta=0.001, shuffle=True, minibatch_size=1, seed=None):
       self.random = np.random.RandomState(seed)
        self.n_hidden = n_hidden
        self.l2 = l2
       self.epochs = epochs
        self.eta = eta
       self.shuffle = shuffle
       self.minibatch_size = minibatch_size   

    def _onehot(self, y, n_classes):
       """ 레이블을 원-핫 방식으로 인코딩

        매개변수
       ---------------
       y : 배열, 크기 = [n_samples]
         타깃 값       

        반환값
       ----------------
       onehot : 배열, 크기 = (n_samples, n_labels)
        """

        onehot = np.zeros((n_classes, y.shape[0]))

        for idx, val in enumerate(y.astype(int)):
           onehot[val, idx] = 1.       

       return onehot.T   

   def _sigmoid(self, z):
       """ 로지스킥 함수(시그모이드)를 계산 """
       return 1. / (1. + np.exp(-np.clip(z, -250, 250)))   

    def _forward(self, X):
       """ 정방향 계산을 수행"""

       # 단계 1: 은닉층의 최종 입력
        # [n_samples, n_features] dot [n_features, n_hidden] -> [n_samples, n_hidden]
        z_h = np.dot(X, self.w_h) + self.b_h

       # 단계 2: 은닉층의 활성화 출력
        a_h = self._sigmoid(z_h)

       # 단계 3: 출력층의 최종 입력
        # [n_samples, n_hidden] dot [n_hidden, n_classlabels] -> [n_samples, n_classlabels]
        z_out = np.dot(a_h, self.w_out) + self.b_out

       # 단계 4: 출력층의 활성화 출력
        a_out = self._sigmoid(z_out)

       return z_h, a_h, z_out, a_out   

   def _compute_cost(self, y_enc, output):
        """ 비용 함수를 계산

        매개변수
        -------------------
       y_enc : 배열, 크기 = (n_samples, n_labels)
         원-핫 인코딩된 클래스 레이블
       output : 배열, 크기 = [n_samples, n_output_units]
         출력층의 활성화 출력 (정방향 계산)       

        반환값
       ---------------
        cost : float
         규제가 포함된 비용
        """

        L2_term = (self.l2 * (np.sum(self.w_h ** 2.) + np.sum(self.w_out ** 2.)))
       term1 = -y_enc * (np.log(output))
       term2 = (1. - y_enc) * np.log(1. - output)
        cost = np.sum(term1 - term2) + L2_term

       return cost   

   def predict(self, X):
        """ 클래스 레이블을 예측

        매개변수
       ------------
       X : 배열, 크기 = [n_samples, n_features]
         원본 특성의 입력층       

        반환값
       ------------
       y_pred : 배열, 크기 = [n_samples]
          예측된 클래스 레이블
        """

       z_h, a_h, z_out, a_out = self._forward(X)
        y_pred = np.argmax(z_out, axis=1)

       return y_pred   

   def fit(self, X_train, y_train, X_valid, y_valid):
        """ 훈련 데이터에서 가중치를 학습합니다.

        매개변수
       --------------
       X_train : 배열, 크기 = [n_samples, n_features]
          원본 특성의 입력층
       y_train : 배열, 크기 = [n_samples]
          타깃 클래스 레이블
       X_valid : 배열, 크기 = [n_samples, n_features]
          훈련하는 동안 검증에 사용할 샘플 특성
        y_vaild : 배열, 크기 = [n_samples]
          훈련하는 동안 검증에 사용할 샘플 레이블

        반환값
       ------------
        self

        """

       n_output = np.unique(y_train).shape[0] # 클래스 레이블 개수
        n_features = X_train.shape[1]

       ##########################
        # 가중치 초기화
        ##########################

       # 입력층 -> 은닉층 사이의 가중치
       self.b_h = np.zeros(self.n_hidden)
        self.w_h = self.random.normal(loc=0.0, scale=0.1, size=(n_features, self.n_hidden))

       # 은닉층 -> 출력층 사이의 가중치
       self.b_out = np.zeros(n_output)
        self.w_out = self.random.normal(loc=0.0, scale=0.1, size=(self.n_hidden, n_output))
       epoch_strlen = len(str(self.epochs)) # 출력 포맷을 위해
        self.eval_ = {'cost':[], 'train_acc':[], 'valid_acc':[]}
        y_train_enc = self._onehot(y_train, n_output)

        # 훈련 에포크를 반복한다.
        for i in range(self.epochs):

            # 미니 배치로 반복한다.
            indices = np.arange(X_train.shape[0])

            if self.shuffle:
               self.random.shuffle(indices)           

            for start_idx in range(0, indices.shape[0] - self.minibatch_size + 1, self.minibatch_size):
                batch_idx = indices[start_idx: start_idx + self.minibatch_size]

               # 정방향 계산
                z_h, a_h, z_out, a_out = self._forward(X_train[batch_idx])

               ########################
                # 역전파
                ########################

               # [n_samples, n_classlabels]
                sigma_out = a_out - y_train_enc[batch_idx]

                # [n_samples, n_hidden]
                sigmoid_derivative_h = a_h * (1. - a_h)

                # [n_samples, n_classlabels] dot [n_classlabels, n_hidden] -> [n_samples, n_hidden]
                sigma_h = (np.dot(sigma_out, self.w_out.T) * sigmoid_derivative_h)

                # [n_features, n_samples] dot [n_samples, n_hidden] -> [n_features, n_hidden]
                grad_w_h = np.dot(X_train[batch_idx].T, sigma_h)
                grad_b_h = np.sum(sigma_h, axis=0)

               # [n_hidden, n_samples] dot [n_samples, n_classlabels] -> [n_hidden, n_classlabels]
               grad_w_out = np.dot(a_h.T, sigma_out)
                grad_b_out = np.sum(sigma_out, axis=0)

                # 규제와 가중치 업데이트
               delta_w_h = (grad_w_h + self.l2 * self.w_h)
               delta_b_h = grad_b_h # 편향은 규제하지 않는다.
                self.w_h -= self.eta * delta_w_h
                self.b_h -= self.eta * delta_b_h
                delta_w_out = (grad_w_out + self.l2 * self.w_out)
               delta_b_out = grad_b_out # 편향은 규제하지 않는다.
               self.w_out -= self.eta * delta_w_out
               self.b_out -= self.eta * delta_b_out           

            ####################
            # 평가
            ####################

           # 훈련하는 동안 에포크마다 평가한다.
           z_h, a_h, z_out, a_out = self._forward(X_train)
            cost = self._compute_cost(y_enc = y_train_enc, output=a_out)
           y_train_pred = self.predict(X_train)
            y_valid_pred = self.predict(X_valid)

            train_acc = ((np.sum(y_train == y_train_pred)).astype(np.float) / X_train.shape[0])
            valid_acc = ((np.sum(y_valid == y_valid_pred)).astype(np.float) / X_valid.shape[0])

            sys.stderr.write('\r%0*d/%d | 비용: %.2f | 훈련/검증 정확도: %.2f%%/%.2f%% ' % (epoch_strlen, i+1, self.epochs, cost, train_acc*100, valid_acc*100))
            sys.stderr.flush()

           self.eval_['cost'].append(cost)
           self.eval_['train_acc'].append(train_acc)
           self.eval_['valid_acc'].append(valid_acc)       

       return self
  • 위의 코드는 다음과 같이 사용할 수 있다.
nn = NeuralNetMLP(n_hidden=100, l2=0.01, epochs=200, eta=0.0005, minibatch_size=100, shuffle=True, seed=1)
  • 각 매개변수는 다음과 같다.
    • l2: 과대적합을 중리기 위한 L2 규제의 \lambda 파라미터
    • epochs: 훈련 세트를 반복할 횟수
    • eta: 학습률 \eta
    • shuffle: 알고리즘이 순환 고리에 갇히지 않도록 에포크를 싲가하기 전에 훈련 세트를 섞을지 여부
    • seed: 셔플과 가중치 초기화를 위한 난수 초깃값
    • minibatch_size: 확률적 경사 하강법에서 에포크마다 훈련 세트를 나눈 미니 배치에 들어갈 훈련 샘플 개수. 전체 훈련 세트에서 그래디언트를 계산하지 않고 학습 속도를 높이기 위해 미니 배치마다 계산한다.
  • 그 다음 이 MLP를 뒤섞어 놓은 55,000개의 MNIST 훈련 세트와 검증 용도인 5,000개의 샘플로 훈련시킨다.
    • 이 신경망을 훈련하는데 표준적인 컴퓨터 사양에서 약 5분정도 소요된다.
  • fit 메서드는 훈련 이미지, 훈련 레이블, 검증 이미지, 검증 레이블에 해당하는 네 개의 매개변수를 받고록 구현되어 있다.
    • 신경망 훈련에서는 훈련 정확도와 검증 정확도를 비교하는 것이 아주 중요하다. 신경망 모델이 주어진 구조와 하이퍼파라미터에서 잘 동작하는지 판단하는데 도움이 된다.
  • 일반적으로 (심층) 신경망을 훈련하는 것은 지금까지 배운 다른 모델에 비해 비교적 비용이 많이 든다.
    • 따라서 어떤 조건이 되면 일찍 중지하고 다른 하이퍼파라미터 설정을 시험하는 것이 좋다.
    • 또 훈련 데이터에 점차 과대적합되는 경향을 발견했다면 역시 훈련을 일찍 멈추는 것이 좋다.
  • 다음 코드를 이용하여 훈련을 할 수 있다.
nn.fit(X_train=X_train[:55000], y_train=y_train[:55000], X_valid=X_train[55000:], y_valid=y_train[55000:])
# 200/200 | 비용: 5065.78 | 훈련/검증 정확도: 99.28%/97.98%
  • NeuralNetMPL 구현에서 eval_ 속성을 정의하여 에포크마다 비용, 훈련 정확도, 검증 정확도를 수집했다. 이 결과를 그래프로 그려보자.

import matplotlib.pyplot as plt

plt.plot(range(nn.epochs), nn.eval_['cost'])
plt.ylabel('Cost')
plt.xlabel('Epochs')
plt.show()
  • 그림에서 볼 수 있듯 비용은 100번의 에포크 동안 많이 감소하고  그 이후는 천천히 수렴한다.
    • 하지만 175번째와 200번째 에포크 사이에 약간 경사가 있어서 에포크를 추가하여 훈련하면 비용은 더 감소할 것이다.
  • 훈련 정확도와 검증 정확도를 살펴보자

plt.plot(range(nn.epochs), nn.eval_['train_acc'], label='training')
plt.plot(range(nn.epochs), nn.eval_['valid_acc'], label='validation', linestyle='--')
plt.ylabel('Accuracy')
plt.xlabel('Epochs')
plt.legend()
plt.show()
  • 위 그래프는 네트워크 훈련 에포크가 늘어날수록 훈련 정확도와 검증 정확도 사이 간격이 증가한다는 것을 보여준다.
    • 약 50번째 에포크에서 훈련 정확도와 검증 정확도 값이 동일하고 그 이후에 네트워크는 훈련 세트에 과대적합되기 시작한다.
  • 과대적합 영향을 줄이는 한 가지 방법은 규제 강도를 높이는 것이다. 예컨대 l2를 0.1로 설정한다.
    • 신경망에서 과대적합을 해결하기 위해 사용하는 다른 방법은 15장에서 다룰 드롭아웃(dropout)이다.
  • 마지막으로 테스트 세트에서 예측 정확도를 계산하여 모델 일반화 성능을 평가해 보자.
y_test_pred = nn.predict(X_test)
acc = (np.sum(y_test == y_test_pred).astype(np.float) / X_test.shape[0])
print('테스트 정확도: %.2f%%' % (acc * 100))
# 테스트 정확도: 97.54%
  • 훈련 세트에 조금 과대적합되었지만 하나의 은닉층을 가진 간단한 이 신경망은 비교적 테스트 세트에서 좋은 성능을 달성했다.
    • 이 모델을 더 세밀하게 튜닝하면 은닉 유닛 개수나 규제 매개변수의 값, 학습률을 바꿀 수 있다. 최근 수년간 개발된 여러 다양한 기법이 있지만 이는 이 책의 범위를 넘어선다.
  • 끝으로 이 MLP 구현이 샘플 이미지를 어떻게 판단하는지 알아보자
miscl_img = X_test[y_test != y_test_pred][:25]
correct_lab = y_test[y_test != y_test_pred][:25]
miscl_lab = y_test_pred[y_test != y_test_pred][:25]

fit, ax = plt.subplots(nrows=5, ncols=5, sharex=True, sharey=True)
ax = ax.flatten()

for i in range(25):
   img = miscl_img[i].reshape(28, 28)
   ax[i].imshow(img, cmap='Greys', interpolation='nearest')
    ax[i].set_title('%d) t: %d p: %d' % (i+1, correct_lab[i], miscl_lab[i]))

ax[0].set_xticks([])
ax[0].set_yticks([])

plt.tight_layout()
plt.show()

  • 5×5 격자 그래프를 볼 수 있다. 각 그래프 제목에 나타난 첫 번째 숫자는 그래프 번호이고 두 번째 숫자는 클래스 레이블, 세 번째 숫자는 예측된 클래스 레이블을 나타낸다.

인경 신경망 훈련

로지스틱 비용 함수 계산

  • _compute_cost 메서드에 구현한 로지스틱 비용 함수는 3장에서 설명한 비용 함수와 같다.

J(w) = - \sum_{i=1}^{n} y^{[i]} \log (a^{[i]}) + (1 - y^{[i]}) \log (1 - a^{[i]})

  • 여기서 a^{[i]} 는 데이터셋 n 번째 샘플의 시그모이드 활성화 출력이다. 다음과 같이 정방향 계산을 통해서 구한다.

a^{[i]} = \phi (z^{[i]})

  • 여기서 위첨자 [i] 는 층이 아니라 훈련 샘플의 인덱스이다.
  • 과대적합의 정도를 줄여 주는 규제 항을 추가해보자. 앞서 배운대로 L2 규제 항은 다음과 같이 정의 된다.

L2 = \lambda \| w \|_{2}^{2} = \lambda \sum_{j = 1}^{m} w_{j}^{2}

  • L2 규제 항을 로지스틱 비용 함수에 추가하면 다음 식을 얻는다.

J(w) = - [\sum_{i=1}^{n} y^{[i]} \log (a^{[i]}) + (1 - y^{[i]}) \log (1 - a^{[i]})] + {\lambda \over 2} \|w\|_{2}^{2}

  • t 개의 원소를 가진 출력 벡터를 반환하는 다중 분류 MLP를 구현했다. 원-핫 인코딩 표현의 t \times 1 차원의 타깃 벡터와 비교해야 한다.
    • 예컨대 특정 샘플에 대한 세 번째 층의 활성화 출력과 타깃 클래스(여기서는 클래스 2)는 다음과 같다.

a^{(out)} = \left[ \begin{array}{rrrr} 0.1 \\ 0.9 \\ ... \\ 0.3 \end{array} \right], y = \left[ \begin{array}{rrrr} 0 \\ 1 \\ ... \\ 0 \end{array} \right]

  • 따라서 네트워크에 있는 t 개의 활성화 유닛 전체에 대해 로지스틱 비용 함수를 일반화 해야 한다. 결국 비용 함수는 다음과 같다. (규제 항 제외)

J(w) = - \sum_{i=1}^{n} \sum_{j=1}^{t} y_{j}^{[i]} \log (a_{j}^{[i]}) + (1 - y_{j}^{[i]}) \log (1 - a_{j}^{[i]})

  • 다음 일반화된 규제항은 l 층의 모든 가중치(절편 제외) 합을 더해 첫 번째 항에 추가한 것 뿐이다.

J(w) = - [\sum_{i=1}^{n} \sum_{j=1}^{t} y_{j}^{[i]} \log (a_{j}^{[i]}) + (1 - y_{j}^{[i]}) \log (1 - a_{j}^{[i]})] + {\lambda \over 2} \sum_{l=1}^{L-1} \sum_{i=1}^{u_{l}} \sum_{j=1}^{u_{l+1}} (w_{j, i}^{(l)})^{2}

  • 여기서 u_{l} l 층에 있는 유닛 개수를 나타낸다.
  • 결국 다음 식이 페널티 항을 나타낸다.

{\lambda \over 2} \sum_{l=1}^{L-1} \sum_{i=1}^{u_{l}} \sum_{j=1}^{u_{l+1}} (w_{j, i}^{(l)})^{2}

  • 비용 함수 J(W) 를 최소화하는 것이 목적이므로 네트워크의 모든 가중치에 대해 파라미터 W 의 편도 함수를 계산해야 한다.

{\partial \over \partial w_{j, i}^{(l)}} J(W)

  • 다음 절에서 비용 함수를 최소화하기 위한 편도 함수를 계산해 주는 역전파 알고리즘을 이야기해 보겠다.
  • W 는 여러 행렬로 구성되어 있다. 하나의 은닉층을 가진 다층 퍼셉트론에서는 W^{(h)} 가 입력층과 은닉층을 연결하는 가중치 행렬이고, W^{(out)} 이 은닉층과 출력층을 연결하는 가중치 행렬이다.
  • 3차원 텐서 W 를 이해하기 쉬운 그림으로 보면 다음과 같다.

  • 위 그림은 간단한 예로 MLP의 은닉 유닛, 출력 유닛, 입력 특성의 개수가 같지 않다면 W^{(h)} W^{(out)} 은 같은 행과 열을 갖고 있지 않다.

역전파 알고리즘 이해

  • 역전파 알고리즘이 재발견되어 널리 알려진지 30년이 넘었지만 효과적인 인공 신경망 훈련을 위해 가장 많이 사용되는 알고리즘 중 하나로 남아 있다.
  • 이 절에서는 간단하고 직관적으로 요약해 보겠다. 수학적으로 상세히 알아보기 전에 이 알고리즘의 큰 그림을 그려보겠다.
    • 핵심적으로 말해서 역전파 알고리즘은 다층 신경망에서 복잡한 비용 함수의 편미분을 효율적으로 계산하기 위한 방법으로 생각할 수 있다. 이 편미분을 사용하여 다층 인공 신경망의 가중치 파라미터를 학습한다.
    • 신경망은 전형적으로 고차원 특성 공간에서 비롯된 대규모 가중치를 다루어야 하기 때문에 학습하기 어렵다. 아달린이나 로지스틱 회귀처럼 단일층 신경망의 비용 함수와 달리 일반적인 신경망의 비용 함수 곡면은 볼록 함수가 아니거나 파라미터에 대해 매끄럽지 않다. 고차원 비용 함수의 곡면에는 전역 최솟값을 찾기 위해 넘어야 할 굴곡(지역 최솟값)이 많다.
  • 수학 시간에 배운 연쇄 법칙을 기억해 보자. 연쇄 법칙은 f(g(x)) 처럼 복잡하고 중첩된 함수의 도함수를 계산하는 방법이다. 예컨대 다음과 같다.

{d \over dx} [f(g(x))] = {df \over dg} \cdot {dg \over dx}

  • 비슷하게 임의의 긴 합성 함수에 연쇄 법칙을 사용할 수 있다. 예컨대 다섯 개의 다른 함수 f(x), g(x), h(x), u(x), v(x) 가 있다고 가정하자. F 는 함성함수로 F(x) = f(g(h(u(v(x))))) 이다.
    • 연쇄 법칙을 적용하면 다음과 같이 이 함수의 도함수를 계산할 수 있다.

{dF \over dx} = {d \over dx} F(x) = {d \over dx} f(g(h(u(v(x))))) = {df \over dg} \cdot {dg \over dh} \cdot {dh \over du} \cdot {du \over dv} \cdot {dv \over dx}

  • 컴퓨터 대수학(computer algebra)에서는 이런 문제를 효율적으로 풀기 위한 여러 기법을 개발했다. 이를 자동 미분(automatic differenctiation)
    • 자동 미분은 정방향과 역방향 두 가지 모드가 있다. 역전파는 역방향 자동 미분의 특별한 경우이다.
    • 핵심은 정방향 모드로 연쇄 법칙을 적용하면 계산 비용이 많이 들 수 있다는 것이다. 각 층마다 큰 행렬(야코비 행렬)을 곱한 후 마지막에 벡터를 곱해 출력을 얻기 때문이다.
    • 역방향 모드는 오른쪽에서 왼쪽으로 진행한다. 행렬과 벡터를 곱하여 또 다른 벡터를 얻은 후 다음 행렬을 곱하는 식이다.
    • 행렬-벡터 곱셉은 행렬-행렬 곱셈보다 훨씬 계산 비용이 적게 든다. 신경망을 훈련할 때 역전파 알고리즘이 가장 인기 있는 알고리즘이 된 이유이다.

역전파 알고리즘으로 신경망 훈련

  • 이전 절에서 마지막 층의 활성화 출력과 타깃 클래스 레이블 사이 차이인 비용을 계산하는 방법을 보았다. 이제 수학적 측면에서 역전파 알고리즘이 MLP 가중치를 업데이트하는 방법을 알아보자.
    • 이 부분은 fit 메서드의 ‘# 역전파’ 주석 아래에 구현되어 있다.
  • 먼저 출력층 활성화를 얻기 위해 정방향 계산을 수행해야 한다. 공식은 다음과 같다.
    • 은닉층의 최종 입력
      • Z^{(h)} = A^{(in)} W^{(h)}
    • 은닉층의 활성화 출력
      • A^{(h)} = \phi (Z^{(h)})
    • 출력층의 최종 입력
      • Z^{(out)} = A^{(h)} W^{(out)}
    • 출력층의 활성화
      • A^{(out)} = \phi (Z^{(out)})
  • 간단히 표현해서 아래 그림에 나타난 것처럼 입력 특성을 연결된 네트워크를 통해 앞으로 전파하는 것이다.

  • 역전파에서는 오차를 오른쪽에서 왼쪽으로 전파시킨다. 먼저 출력층의 오차 벡터를 계산한다.

\delta^{(out)} = a^{(out)} - y

  • 여기서 y 는 정답 클래스 레이블 벡터이다 (NeutralNetMLP 코드에서 출력층의 오차 벡터에 대응하는 변수는 sigma_out이다)
  • 다음으로 은닉층의 오차 항을 구해보자

\delta^{(h)} = \delta^{(out)} (W^{(out)})^{T} \odot {\partial \phi (z^{(h)}) \over \partial z^{(h)}}

  • \odot 는 원소별 곱셈을 의미한다.
  • 여기서 {\partial \phi (z^{(h)}) \over \partial z^{(h)}} 는 시그모이드 활성화 함수의 도함수이다.
    • NeuralNetMLP의 fit 메서드에서 sigmoid_derivative_h = a_h * (1. – a_h)로 계산한다.

{\partial \phi (z) \over \partial z} = (a^{(h)} \odot (1 - a^{(h)}))

  • 이제 은닉층의 오차행렬 \delta^{(h)} (sigma_h)는 다음과 같이 계산한다.

\delta^{(h)} = \delta^{(out)} (W^{(out)})^{T} \odot (a^{(h)} \odot (1 - a^{(h)}))

  • \delta^{(h)} 항을 어떻게 계산하는지 이해하기 위해 좀 더 자세히 살펴보자.
    • 앞 공식에서 h \times t 차원의 행렬 W^{(out)} 의 전치행렬 (W^{(out)})^{T} 를 사용했다. 여기서 h 는 은닉 유닛 개수고 t 는 출력 클래스 레이블 개수이다.
    • n \times t 차원 행렬 \delta^{(out)} t \times h 차원 행렬 (W^{(out)})^{T} 의 행렬 곱셉은 n \times h 차원의 행렬을 만든다.
    • 그 다음 동일 차원의 시그모이드 도함수를 원소별 곱셈하여 n \times h 차원 행렬 \delta^{(h)} 를 구한다.
    • 결국 \delta 를 구한 후에는 비용 함수의 도함수를 다음과 같이 쓸 수 있다.

{\partial \over \partial w_{i, j}^{(out)}} J(W) = a_{j}^{(h)} \delta_{i}^{(out)}

{\partial \over \partial w_{i, j}^{(h)}} J(W) = a_{j}^{(in)} \delta_{i}^{(h)}

  • 그 다음 각 층에 있는 모든 노드의 편도 함수와 다음 층의 노드 오차를 모아야 한다. 미니 배치에 있는 모든 샘플에 대해 \Delta_{i, j}^{(l)} 를 계산해야 한다는 것을 기억하자.
    • NeuralNetMLP 코드에 있는 것처럼 벡터화된 구현을 만든는 것이 좋다.

\Delta^{(h)} = (A^{(in)})^{T} \delta^{(h)}

\Delta^{(out)} = (A^{(h)})^{T} \delta^{(out)}

  • 편도 함수를 누적한 후 규제항을 추가한다.
    • 절편은 제외한다.

\Delta^{(l)} := \Delta^{(l)} + \lambda^{(l)} W

  • 앞의 두 수학 공식에 대응되는 NeuralNetMLP 코드의 변수는 delta_w_h, delta_b_h, delta_w_out, delta_b_out 이다.
  • 마지막으로 그래디언트를 계산하고 각 l 층에 대한 그래디언트의 반대 방향으로 가중치를 업데이트 한다.

W^{(l)} := W^{(l)} - \eta \Delta^{(l)}

  • 코드 구현은 다음과 같다.
self.w_h -= self.eta * delta_w_h
self.b_h -= self.eta * delta_b_h
self.w_out -= self.eta * delta_w_out
self.b_out -= self.eta * delta_b_out
  • 역전파 알고리즘 전체 과정을 그림으로 정리하면 다음과 같다.

신경망의 수렴

  • 손글씨 숫자 분류를 위해 신경망을 훈련시킬 때 기본 경사 하강법을 사용하지 않고 미니 배치 방식을 사용했는지 궁금할 것이다.
    • 온라인 학습에서는 한 번에 하나의 훈련 샘플(k = 1 )에 대해 그래디언트를 계산하여 가중치를 업데이트했다. 확률적이지만 매우 정확한 솔루션을 만들고 기본 경사 하강법보다 훨씬 빠르게 수렴한다.
    • 미니 배치 학습은 확률적 경사 하강법의 특별한 경우이다. n 개의 훈련 샘플 중 k 개의 부분 집합에서 그래디엍르르 계산한다. 1 < k < n
    • 미니 배치 학습은 벡터화된 구현을 만들어 계산 효율성을 높일 수 있다는 것이 온라인 학습보다 장점이다. 기본 경사 하강법보다 훨씬 빠르게 가중치가 업데이트 된다.
    • 직관적으로 보았을 때 미니 배치 학습을 대통령 선거의 투표율을 예측하기 위해 (실제 선거와 동일하게) 전체 인구가 아니라 일부 표본 집단에 설문하는 것으로 생각할 수 있다.
  • 다층 신경망은 아달린, 로지스틱 회귀, 서포트 벡터 머신 같은 알고리즘보다 훨씬 훈련하기 어렵다. 다층 신경망은 일반적으로 최적화해야 할 가중치가 수백 개, 수천 개, 심지어 수백만 개가 있다.
    • 게다가 아래 그림과 같이 손실 함수의 표면은 거칠어서 최적화 알고리즘이 쉽게 지역 최솟값에 갇힐 수 있다.

  • 신경망은 매우 많은 차원을 갖고 있어서 비용 함수의 곡면을 시각적으로 나타낼 수 없다.
    • 여기서는 하나의 가중치에 대한 비용 함수의 곡선을 x축에 타나냈다.
  • 이 그림은 알고리즘이 지역 최솟값에 갇혀서는 안된다는 것을 설명한다. 학습률을 크게 하면 지역 최솟값을 손쉽게 탈출 할 수 있지만, 거꾸로 전역 최솟값을 지나칠 수 있는 가능성도 높아진다.
    • 랜덤하게 가중치를 초기화하기 때문에 일반적으로 최적화 문제의 해는 잘못된 지점에서 출발하는 셈이다.
[ssba]

The author

지성을 추구하는 사람/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.