본문 바로가기

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

[밑바닥부터 시작하는 딥러닝1: Chapter 6] 학습 관련 기술들

728x90


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

GitHub - oreilly-japan/deep-learning-from-scratch: 『ゼロから作る Deep Learning』(O'Reilly Japan, 2016)

『ゼロから作る Deep Learning』(O'Reilly Japan, 2016). Contribute to oreilly-japan/deep-learning-from-scratch development by creating an account on GitHub.

github.com



6.1 매개변수 갱신


지금까지 공부한 신경망 학습의 목적은 손실 함수의 값을 가능한 한 낮추는 매개변수를 찾는 것이었다. 이는 곧 매개변수의 최적값을 찾는 문제이며, 이러한 문제를 푸는 것을 최적화(optimization)라 한다. 또한 최적의 매개변수 값을 구하기 위해 확률적 경사 하강법(SGD)의 방법을 사용하였다. 이제 SGD와는 다른 최적화 기법을 알아보도록 한다.

6.1.3 SGD의 단점


SGD는 단순하고 구현도 쉽지만, 문제에 따라서는 비효율적일 때가 있다. SGD의 단점을 알아보기 위해 다음 함수의 최솟값을 구하는 문제를 생각해보자.

[식 6.2]

이 함수는 [그림 6-1]의 왼쪽과 같이 x축 방향으로 늘인 듯한 모습이고, 실제로 그 등고선은 오른쪽과 같이 x축 방향으로 늘인 타원으로 되어 있다.

[그림 6-1]

[식 6.2] 함수의 기울기를 그려보면 [그림 6-2]처럼 된다. 이 기울기는 y축 방향은 크고 x축 방향은 작다는 것이 특징이다.

[그림 6-2]

[그림 6-1]의 함수에 SGD를 적용해보면 결과는 [그림 6-3]처럼 된다. 이때 초깃값 (x, y)의 설정은 (-7.0, 2.0)으로 한다.

[그림 6-3] SGD에 의한 최적화 갱신 경로

[그림 6-3]을 보면 SGD는 상당히 비효율적인 움직임을 보인다는 것을 알 수 있다. SGD의 단점은 비등방성 함수(방향에 성질, 즉 여기에서는 기울기가 달라지는 함수)에서는 탐색 경로가 비효율적이라는 것이다. 또한, SGD가 지그재그로 탐색하는 근본 원인은 기울어진 방향이 본래의 최솟값과 다른 방향을 가리켜서라는 점도 있다.

이제부터 SGD의 이러한 단점을 개선해주는 모멘텀, AdaGrad, Adam이라는 세 방법을 배우도록 한다.


6.1.4 모멘텀

모멘텀(Momentum)은 ‘운동량’을 뜻하는 단어로, 물리와 관계가 있다. 모멘텀 기법은 수식으로는 다음과 같이 쓸 수 있다.

[식 6.3]
[식 6.4]


SGD처럼 여기에서도 W는 갱신할 가중치 매개변수이며, 나머지 변수들은 W에 대한 손실 함수의 기울기, 학습률로 이루어져 있다. v라는 새로운 변수는 물리에서 말하는 속도(velocity)에 해당한다. 모멘텀은 공이 그릇의 바닥을 구르는 듯한 움직임을 보여준다.

또, [식 6.3]의 σv 항은 물체가 아무런 힘을 받지 않을 때 서서히 하강시키는 역할을 한다(σ는 0.9 등의 값으로 설정한다). 물리에서의 공기 저항이나 지면 마찰에 해당한다. 다음은 모멘텀의 구현이다.

class Momentum:
    def __init__(self, lr = 0.01, momentum = 0.9):
        self.lr = lr
        self.momentum = momentum
        self.v = None
    
    def update(self, params, grads):
        if self.v is None:
            self.v = {}
            for key, val in params.items():
                self.v[key] = np.zeros_like(val)
                
        for key in params.keys():
            self.v[key] = self.momentum * self.v[key] - self.lr * grads[key]
            params[key] += self.v[key]


인스턴스 변수 v는 초기화 때는 아무 값도 담지 않고, 대신 update()가 처음 호출될 때 매개변수와 같은 구조의 데이터를 딕셔너리 변수로 저장한다. 모멘텀을 사용해서 [식 6.2]의 최적화에 적용하면 결과는 [그림 6-5]처럼 된다.

[그림 6-5] 모멘텀에 의한 최적화 갱신 경로

SGD와 비교하면 ‘지그재그 정도’가 덜한 것을 알 수 있다. 이는 x축의 힘은 아주 작지만 방향은 변하지 않아서 한 방향으로 일정하게 가속하기 때문이다. 거꾸로 y축의 힘은 크지만 위아래로 번갈아 받아서 상충하여 y축 방향의 속도는 안정적이지 않다. 전체적으로는 SGD보다 x축 방향으로 빠르게 다가가 지그재그 움직임이 줄어든다.


6.1.5 AdaGrad


AdaGrad는 학습률을 서서히 낮추는 학습률 감소(learning rate decay) 기술을 발전시킨 것이다. AdaGrad는 ‘각각의’ 매개변수에 ‘맞춤형’ 값을 만들어준다. 개별 매개변수에 적응적으로 학습률을 조정하면서 학습을 진행한다. 수식으로는 다음과 같다.

[식 6.5]
[식 6.6]

여기에서는 새로 h라는 변수가 등장한다. h는 [식 6.5]에서 보듯 기존 기울기 값을 제곱하여 계속 더해준다. 그리고 매개변수를 갱신할 때 루트의 역을 곱해 학습률을 조정한다. 매개변수의 원소 중에서 많이 움직인(크게 갱신된) 원소는 학습률이 낮아진다는 뜻인데, 다시 말해 학습률 감소가 매개변수의 원소마다 다르게 적용됨을 뜻한다. AdaGrad의 구현은 다음과 같다.

class AdaGrad:
    def __init__(self, lr = 0.01):
        self.lr = lr
        self.h = None
    
    def update(self, params, grads):
        if self.h is None:
            self.h = {}
            for key, val in params.items():
                self.h[key] = np.zeros_like(val)
            
        for key in params.keys():
            self.h[key] += grads[key] * grads[key]
            params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + 1e-7)


여기에서 주의할 것은 분모가 0이 되지 않도록 1e-7이라는 작은 값을 더하는 부분이다. AdaGrad를 사용해서 [식 6.2]의 최적화 문제를 풀면, 결과는 [그림 6-6]처럼 된다.

[그림 6-6] AdaGrad에 의한 최적화 갱신 경로


[그림 6-6]을 보면 최솟값을 향해 효율적으로 움직이는 것을 알 수 있다. y축 방향은 기울기가 커서 처음에는 크게 움직이지만, 그 큰 움직임에 비례해 갱신 정도도 큰 폭으로 작아지도록 조정된다. 그래서 y축 방향으로 갱신 강도가 빠르게 약해지고, 지그재그 움직임이 줄어든다.


6.1.6 Adam


모멘텀과 AdaGrad 두 기법을 융합한 기법이 바로 Adam이다. Adam은 2015년에 제안된 새로운 방법이다. 모멘텀과 AdaGrad를 융합한 것에서 더 나아가 하이퍼파라미터의 ’편향 보정‘이 진행된다는 점도 Adam의 특징이다. 구현은 다음과 같다.

class Adam:

    """Adam (http://arxiv.org/abs/1412.6980v8)"""

    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.iter = 0
        self.m = None
        self.v = None
        
    def update(self, params, grads):
        if self.m is None:
            self.m, self.v = {}, {}
            for key, val in params.items():
                self.m[key] = np.zeros_like(val)
                self.v[key] = np.zeros_like(val)
        
        self.iter += 1
        lr_t  = self.lr * np.sqrt(1.0 - self.beta2**self.iter) / (1.0 - self.beta1**self.iter)         
        
        for key in params.keys():
            #self.m[key] = self.beta1*self.m[key] + (1-self.beta1)*grads[key]
            #self.v[key] = self.beta2*self.v[key] + (1-self.beta2)*(grads[key]**2)
            self.m[key] += (1 - self.beta1) * (grads[key] - self.m[key])
            self.v[key] += (1 - self.beta2) * (grads[key]**2 - self.v[key])
            
            params[key] -= lr_t * self.m[key] / (np.sqrt(self.v[key]) + 1e-7)
            
            #unbias_m += (1 - self.beta1) * (grads[key] - self.m[key]) # correct bias
            #unbisa_b += (1 - self.beta2) * (grads[key]*grads[key] - self.v[key]) # correct bias
            #params[key] += self.lr * unbias_m / (np.sqrt(unbisa_b) + 1e-7)


Adam을 사용하여 [식 6.2]의 최적화 문제를 풀면, 결과는 [그림 6-7]과 같다.

[그림 6-7] Adam에 의한 최적화 갱신 경로

[그림 6-7]과 같이 Adam 갱신 과정도 그릇 바닥을 구르는 듯한 움직임이다.


이제 지금까지 본 매개변수 갱신 기법 4가지의 결과를 비교해보도록 한다.

[그림 6-8] 최적화 기법 비교


[그림 6-8]과 같이 사용한 기법에 따라 갱신 경로가 다르다. SGD, 모멘텀, AdaGrad, Adam 중 모든 문제에서 항상 뛰어난 기법이라는 것은 아직 없다. 각자의 상황을 고려해 여러 가지로 시도해볼줄 알아야 한다.

손글씨 숫자 인식을 대상으로 지금까지 설명한 네 기법을 비교해보면 각 방법의 학습 진도가 얼마나 다른지 [그림 6-9]로 확인할 수 있다.

[그림 6-9] MNIST 데이터셋에 대한 학습 진도 비교


6.2 가중치의 초깃값


신경망 학습에서 가중치의 초깃값을 무엇으로 설정할지는 중요한 문제이다.

가중치 감소(wight decay) 기법은 가중치 매개변수의 값이 작아지도록 학습하는 방법이다. 가중치 값을 작게 하여 오버피팅이 일어나지 않게 하는 것이다. 그렇다고 해서 가중치의 초깃값을 모두 0으로 설정하면 학습이 올바르게 이뤄지지 않게 된다. 오차역전파법에서 모든 가중치의 값이 똑같이 갱신되기 때문이다. 가중치가 고르게 되어버리면, 가중치를 여러 개 갖는 의미가 사라지게 된다. 따라서 초깃값을 무작위로 설정해야 하는 것이다.

6.2.2 은닉층의 활성화값 분포


활성화 함수로 시그모이드 함수를 사용하는 5층 신경망에 무작위로 생성한 입력 데이터를 흘리며 각 층의 활성화값 분포를 가중치의 초깃값에 따라 어떻게 변화하는지 히스토그램으로 확인해보자.

import numpy as np
import matplotlib.pyplot as plt


def sigmoid(x):
    return 1 / (1 + np.exp(-x))
    
input_data = np.random.randn(1000, 100)  # 1000개의 데이터
node_num = 100  # 각 은닉층의 노드(뉴런) 수
hidden_layer_size = 5  # 은닉층이 5개
activations = {}  # 이곳에 활성화 결과를 저장

x = input_data

for i in range(hidden_layer_size):
    if i != 0:
        x = activations[i-1]

    w = np.random.randn(node_num, node_num) * 1
    
    a = np.dot(x, w)
    z = sigmoid(a)
    activations[i] = z

# 히스토그램 그리기
for i, a in activations.items():
    plt.subplot(1, len(activations), i+1)
    plt.title(str(i+1) + "-layer")
    plt.hist(a.flatten(), 30, range=(0,1))
plt.show()


이 코드에서는 가중치의 분포에 주의해야 한다. 표준편차가 1인 정규분포를 이용했을때의 활성화값 데이터는 [그림 6-10]과 같다.

[그림 6-10]

각 층의 활성화값들이 0과 1에 치우쳐 분포되어 있는 것을 볼 수 있다. 여기에서 사용한 시그모이드 함수는 그 출력이 0이나 1에 가까워지면 미분은 0에 다가가게 된다. 그래서 데이터가 0과 1에 치우쳐 분포하게 되면 역전파의 기울기 값이 점점 작아지다가 사라지게 된다. 이것이 기울기 소실(gradient vanishing) 문제이다.

가중치의 표준편차를 0.01로 바꿔 같은 실험을 반복해보면 [그림 6-11]의 결과를 얻을 수 있다.

w = np.random.randn(node_num, node_num) * 0.01
[그림 6-11]


이번에는 0.5 부근에 집중된 것을 볼 수 있다. 활성화값들이 치우쳤다는 것은 다수의 뉴런이 거의 같은 값을 출력하고 있으니 뉴런을 여러 개 둔 의미가 없어진다는 뜻이다. 그래서 활성화값들이 치우치면 표현력을 제한한다는 관점에서 문제가 된다.

이어서 사비에르 글로로트(Xavier Glorot)와 요슈아 벤지오(Yoshua Bengio)의 논문에서 권장하는 가중치 초깃값인, Xavier 초깃값을 사용해보도록 하자. 이 논문은 각 층의 활성화값들을 광범위하게 분포시킬 목적으로 가중치의 적절한 분포를 찾고자 했다. 그리고 앞 계층의 노드가 n개라면 표준편차가 1 / sqrt(n)인 분포를 사용하면 된다는 결론을 내린다.

[그림 6-12] Xavier 초깃값

Xavier 초깃값을 사용하면 앞 층에 노드가 많을수록 대상 노드의 초깃값으로 설정하는 가중치가 좁게 퍼진다. 코드에서는 가중치 초깃값 설정만 고쳐주면 된다.

node_num = 100 # 앞 층의 노드 수
w = np.random.randn(node_num, node_num) * np.sqrt(1.0 / node_num)
[그림 6-13]

Xavier 초깃값을 사용한 결과는 [그림 6-13]처럼 된다. 결과를 보면 층이 깊어지면서 형태가 일그러지지만, 앞에서 본 방식보다는 넓게 분포됨을 알 수 있다.

[그림 6-13]을 보면 오른쪽으로 갈수록 일그러지고 있는것이 보인다. 이 일그러짐은 sigmoid대신 tanh함수를 이용하면 개선할 수 있다. tanh 함수는 원점에서 대칭인 반면, sigmoid 함수는 (0, 0.5)에서 대칭인 함수이다. 활성화 함수용으로는 원점에서 대칭인 함수가 바람직하다.

[그림 6-14] tanh 함수와 Xavier 초깃값을 이용한 결과



6.2.3 ReLU를 사용할 때의 가중치 초깃값


Xavier 초깃값은 활성화 함수가 선형인 것을 전제로 이끈 결과이다. sigmoid와 tanh 함수는 좌우 대칭이라 중앙 부근이 선형인 함수로 볼 수 있다. 반면 ReLU를 이용할 때는 ReLU에 특화된 초깃값인 He 초깃값을 이용하도록 권장된다. He 초깃값은 앞 계층의 노드가 n개일 때, 표준편차가 sqrt(2 / n)인 정규분포를 사용한다. ReLU는 음의 영역이 0이라서 더 넓게 분포시키기 위해 2배의 계수가 필요하다고 해석 가능하다.

그러면 활성화 함수로 ReLU를 이용한 경우의 활성화값 분포를 확인해보자. [그림 6-15]는 표준편차가 0.01인 정규분포(std = 0.01), Xavier 초깃값, He 초깃값일 때의 실험 결과를 차례로 보여준다.

[그림 6-15]

결과를 보면 std = 0.01일 때의 각 층의 활성화값들은 아주 작은 값들임을 알 수 있다. 작은 데이터가 흐른다는 것은 역전파 때 가중치의 기울기 역시 작아진다는 뜻이고, 실제로 학습이 거의 이뤄지지 않는 것을 의미한다.

이어서 Xavier 초깃값 결과를 보면 이쪽은 층이 깊어지면서 치우침이 커지는것을 볼 수 있다. 실제로 층이 깊어지면 활성화값들의 치우침이 커지고, 학습할 때 ‘기울기 소실’문제를 일으킨다.

마지막으로 He 초깃값은 모든 층에서 균일하게 분포되었다. 층이 깊어져도 분포가 균일하게 유지되기에 역전파 때도 적절한 값이 나올 것으로 기대할 수 있다.


6.2.4 MNIST 데이터셋으로 본 가중치 초깃값 비교


‘실제’ 데이터를 가지고 가중치의 초깃값을 주는 방법이 신경망 학습에 어떤 영향을 주는지 보자. 지금까지 살펴본 세 경우(std = 0.01, Xavier, He) 모두를 비교해보면 [그림 6-16]과 같다.

[그림 6-16]

6.3 배치 정규화


앞 절에서 가중치의 초깃값을 적절히 설정하면 각 층의 활성화값 분포가 적당히 퍼지면서 학습이 원활하게 수행됨을 배웠다. 그렇다면 각 층이 활성화를 적당히 퍼뜨리도록 ‘강제‘해보는 배치 정규화(Batch Normalization)를 알아보자.

배치 정규화는 아래와 같은 이유로 사용된다.

  • 학습을 빨리 진행할 수 있다.
  • 초깃값에 크게 의존하지 않는다.
  • 오버피팅을 억제한다


배치 정규화는 [그림 6-17]과 같이 데이터 분포를 정규화하는 ‘배치 정규화(Batch Norm) 계층’을 신경망에 삽입한다.

[그림 6-17]

배치 정규화는 학습 시 미니배치를 단위로 정규화한다. 구체적으로는 데이터 분포가 평균이 0, 분산이 1이 되도록 정규화한다. 수식으로는 다음과 같다.

[식 6.7]

여기에는 미니배치 B = {x_1, x_2, ... x_m} 이라는 m개의 입력 데이터의 집합에 대해 평균 μ_B와 분산 σ^2_B을 구한다. 그리고 입력 데이터를 평균이 0, 분산이 1이 되게 정규화한다. 그리고 [식 6.7]에서 ϵ 기호는 작은 값(ex. 10e-7)으로, 0으로 나누는 사태를 예방하는 역할이다.

[식 6.7]을 통해 미니배치 입력 데이터를 평균 0, 분산 1인 데이터로 변환하는 일을 한다. 이 처리를 활성화 함수의 앞(혹은 뒤)에 삽입함으로서 데이터 분포가 덜 치우치게 할 수 있다.

또, 배치 정규화 계층마다 이 정규화된 데이터에 고유한 확대(scale)와 이동(shift) 변환을 수행한다. 수식으로는 다음과 같다.

[식 6.8]

이 식에서 γ가 확대를, β가 이동을 담당한다. 두 값은 처음에는 1과 0부터 시작하고, 학습하면서 적합한 값으로 조정해간다.

배치 정규화 알고리즘을 계산 그래프로 그리면 [그림 6-18]이 된다.

[그림 6-18] 배치 정규화의 계산 그래프

  배치 정규화 계층을 사용할 때와 사용하지 않을 때의 MNIST 데이터셋의 학습 진도가 어떻게 달라지는지 결과를 [그림 6-19]로 확인 할 수 있다.

[그림 6-19] 배치 정규화의 효과

[그림 6-19]과 같이 배치 정규화가 학습을 빨리 진전시킴을 볼 수 있다. [그림 6-20]은 가중치 초깃값의 표준편차를 다양하게 바꿔가며 학습 경과를 관찰한 그래프이다.

[그림 6-20] 실선이 배치 정규ㅗ하를 사용한 경우, 점선이 사용하지 않은 경우

거의 모든 경우에서 배치 정규화를 사용할 때의 학습 진도가 빠른 것으로 나타난다. 이처럼 배치 정규화를 사용하면 학습이 빨라지며, 가중치 초깃값에 크게 의존하지 않아도 된다.

6.4 바른 학습을 위해


기계학습에서는 오버피팅이 문제가 된다. 오버피팅이란 신경망이 훈련 데이터에만 지나치게 적응되어 그 외의 데이터에는 제대로 대응하지 못하는 상태를 말한다. 따라서 오버피팅을 억제하는 기술이 중요해지는 것이다.

6.4.1 오버피팅


오버피팅은 주로 다음의 두 경우에 일어난다.

  • 매개변수가 많고 표현력이 높은 모델
  • 훈련 데이터가 적음


이번 절에서는 이 두 요건을 일부러 충족하여 오버피팅을 일으켜보도록 한다. 그러기 위해 60,000개인 MNIST 데이터셋의 훈련 데이터 중 300개만 사용하고, 7층 네트워크를 사용해 네트워크 복잡성을 높이도록 한다.

(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True)

# 오버피팅을 재현하기 위해 학습 데이터 수를 줄임
x_train = x_train[:300]
t_train = t_train[:300]


이어서 훈련을 수행하는 코드이다. 에폭마다 모든 훈련 데이터와 모든 시험 데이터 각각에서 정확도를 산출한다.

network = MultiLayerNet(input_size=784, hidden_size_list=[100, 100, 100, 100, 100, 100], output_size=10)
optimizer = SGD(lr=0.01) # 학습률이 0.01인 SGD로 매개변수 갱신

max_epochs = 201
train_size = x_train.shape[0]
batch_size = 100

train_loss_list = []
train_acc_list = []
test_acc_list = []

iter_per_epoch = max(train_size / batch_size, 1)
epoch_cnt = 0

for i in range(1000000000):
    batch_mask = np.random.choice(train_size, batch_size)
    x_batch = x_train[batch_mask]
    t_batch = t_train[batch_mask]

    grads = network.gradient(x_batch, t_batch)
    optimizer.update(network.params, grads)

    if i % iter_per_epoch == 0:
        train_acc = network.accuracy(x_train, t_train)
        test_acc = network.accuracy(x_test, t_test)
        train_acc_list.append(train_acc)
        test_acc_list.append(test_acc)

        print("epoch:" + str(epoch_cnt) + ", train acc:" + str(train_acc) + ", test acc:" + str(test_acc))

        epoch_cnt += 1
        if epoch_cnt >= max_epochs:
            break


train_acc_list와 test_acc_list에ㅔ는 에폭 단위(모든 훈련 데이터를 한 번씩 본 단위)의 정확도를 저장한다. 이 두 리스트를 그래프로 그리면 [그림 6-21]처럼 된다.

[그림 6-21]

훈련 데이터를 사용하여 측정한 정확도는 100 에폭을 지나는 무렵부터 거의 100%이다. 그러나 시험 데이터에 대해서는 큰 차이를 보인다. 이처럼 정확도가 크게 벌어지는 이유는 훈련 데이터에만 적응(fitting)해버린 결과이다.


6.4.2 가중치 감소


오버피팅 억제용으로 예로부터 많이 이용해온 방법 중 가중치 감소(weight decay)라는 것이 있다. 이는 학습 과정에서 큰 가중치에 대해서는 그에 상응하는 큰 페널티를 부과하여 오버피팅을 억제하는 방법이다. 오버피팅은 가중치 매개변수의 값이 커서 발생하는 경우가 많기 때문이다.

가중치 감소는 모든 가중치 각각의 손실 함수에 λ * W^2 / 2을 더한다. 따라서 가중치의 기울기를 구하는 계산에서는 그동안의 오차역전파법에 따른 결과에 정규화 항을 미분한 λW를 더한다. 여기서 λ는 정규화의 세기를 조절하는 하이퍼파라미터이다.

	def loss(self, x, t):
        """손실 함수를 구한다.
        
        Parameters
        ----------
        x : 입력 데이터
        t : 정답 레이블 
        
        Returns
        -------
        손실 함수의 값
        """
        y = self.predict(x)

        weight_decay = 0
        for idx in range(1, self.hidden_layer_num + 2):
            W = self.params['W' + str(idx)]
            weight_decay += 0.5 * self.weight_decay_lambda * np.sum(W ** 2)
           
        return self.last_layer.forward(y, t) + weight_decay


결과는 [그림 6-22]와 같다.

[그림 6-22]

[그림 6-22]와 같이 훈련 데이터에 대한 정확도와 시험 데이터에 대한 정확도에는 여전히 차이가 있지만, 가중치 감소를 이용하지 않은 [그림 6-21]과 비교하면 그 차이가 줄었다. 다시 말해 오버피팅이 억제됐다.

6.4.3 드롭아웃


드롭 아웃(Dropout) 기법은 신경망 모델이 복잡해질 때 사용하는 오버피팅 억제 방식이다. 드롭아웃은 뉴런을 임의로 삭제하면서 학습하는 방법이다. 훈련 때 은닉층의 뉴런을 무작위로 골라 삭제한다. 삭제된 뉴런은 [그림 6-23]와 같이 신호를 전달하지 않게 된다. 훈련 때는 데이터를 흘릴 때마다 삭제할 뉴런을 무작위로 선택하고, 시험 때는 모든 뉴런에 신호를 전달한다. 단, 시험 때는 각 뉴런의 출력에 훈련 때 삭제 안 한 비율을 곱하여 출력한다.

[그림 6-23] 드롭아웃의 개념

드롭아웃의 구현은 다음과 같다. 순전파를 담당하는 forward 메서드에서는 훈련 때(train_flg = True)만 잘 계산해두면 시험 때는 단순히 데이터를 흘리기만 하면 된다. 삭제 안 한 비율은 굳이 곱하지 않아도 된다.

class Dropout:
    def __init__(self, dropout_ratio = 0.5):
        sef.dropout_ratio = dropout_ratio
        self.mask = None
    
    def forward(self, x, train_flg = True):
        if train_flg:
            self.mask = np.random.rand(*x.shape) > self.dropout_ratio
            return x * self.mask
        else:
            return x * (1.0 - self.dropout_ratio)
    
    def backward(self, dout):
        return dout * self.mask


위 코드에서 핵심은 훈련 시에는 순전파 때마다 self.mask에 삭제할 뉴런을 False로 표시한다는 것이다. self.mask는 x와 형상이 같은 배열을 무작위로 생성하고, 그 값이 dropout_ratio보다 큰 원소만 True로 설정한다. 역전파 때의 동작은 ReLU와 같다.

드롭아웃의 효과를 MNIST 데이터셋으로 확인해보면 결과는 [그림 6-24]과 같다. 이 결과는 복잡한 신경망에서 실험하기 위해 7층 네트워크를 써서 진행한 결과이다.

[그림 6-24]

드롭아웃을 적용하니 훈련 데이터와 시험 데이터에 대한 정확도 차이가 줄어든 것이 보인다. 또, 훈련 데이터에 대한 정확도가 100%에 도달하지도 않게 되었다.


6.5 적절한 하이퍼파라미터 값 찾기


신경망에는 하이퍼파라미터(각 층의 뉴런 수, 배치 크기, 매개변수 갱신 시의 학습률, 가중치 감소)가 많이 있다. 이러한 하이퍼파라미터의 값을 적절히 설정하지 않으면 모델의 성능이 크게 떨어질 수 있다. 따라서 하이퍼파라미터의 값을 최대한 효율적으로 탐색하는 방법에 대해 공부하도록 한다.

6.5.1 검증 데이터


지금까지는 데이터셋을 훈련 데이터와 시험 데이터라는 두 가지로 분리해 이용했다. 그러나 앞으로 하이퍼파라미터를 다양한 값으로 설정하고 검증해야 하는데 이때는 시험 데이터를 사용해서는 안된다.
왜냐하면 시험 데이터를 사용하여 하이퍼파라미터를 조정하면 하이퍼파라미터 값이 시험 데이터에 오버피팅되기 때문이다. 그래서 하이퍼파라미터를 조정할 때는 하이퍼파라미터 전용 확인 데이터인 검증 데이터(validation data)가 필요하다.

데이터셋에 따라서 훈련 데이터, 시험 데이터, 검증 데이터로 미리 분리된 경우도 있지만, MNIST 데이터셋의 경우에는 훈련 데이터와 시험 데이터로만 분리 되어져있다. 이런 경우에는 훈련 데이터의 일부를 검증 데이터로 분리하는 방법이 있다. 코드로는 다음과 같다.

(x_train, t_train), (x_test, t_test) = load_mnist()

# 훈련 데이터를 뒤섞는다.
x_train, t_train = shuffle_dataset(x_train, t_train)

# 20%를 검증 데이터로 분할
validation_rate = 0.20
validation_num = int(x_train.shape[0] * validation_rate)

x_val = x_train[:validation_num]
t_val = t_train[:validation_num]
x_train = x_train[validation_num:]
t_train = t_train[validation_num:]


훈련 데이터를 뒤섞는 이유는 데이터가 정렬되어 있을수도 있기 때문이다. 여기에서 사용한 shuffle_dataset 함수는 np.random.shuffle을 이용한 것이다.

6.5.2 하이퍼파라미터 최적화


하이퍼파라미터를 최적화하는 것의 핵심은 ‘최적 값’이 존재하는 범위를 조금씩 줄여나가는 것이다. 하이퍼파라미터의 범위는 ‘대략적으로’ 지정하는 것이 효과적이다. 실제로도 0.001에서 1,000 사이와 같이 ‘10의 거듭제곱’ 단위로 범위를 지정하며, 이를 ‘로그 스케일(log scale)'로 지정한다고 한다.

하이퍼파라미터의 최적화 단계는 아래와 같다.

  • 0단계: 하이퍼파라미터 값의 범위를 설정한다.
  • 1단계: 설정된 범위에서 하이퍼파라미터의 값을 무작위로 추출한다.
  • 2단계: 1단계에서 샘플링한 하이퍼파라미터 값을 사용하여 학습하고, 검증 데이터로 정확도를 평가한다.(단, 에폭은 작게 설정한다).
  • 3단계: 1단계아 2단계를 특정 횟수(100회 등) 반복하며, 그 정확도의 결과를 보고 하이퍼파라미터의 범위를 좁힌다.


이상을 반복하여 하이퍼파라미터의 범위를 좁혀가고, 어느 정도 좁아지면 그 압축한 범위에서 값을 하나 골라 낸다.

이제 학습률과 가중치 감소의 세기를 조절하는 계수를 탐색하는 코드를 알아보도록 한다. 앞에서 말한 대로, 하이퍼파라미터의 검증은 로그 스케일 범위에서 무작위로 추출해 수행한다. 이를 파이썬 코드로는 10 ** np.random.uniform(-3, 3)처럼 작성할 수 있다. 코드는 다음과 같이 쓸 수 있다.

weight_decay = 10 ** np.random.uniform(-8, -4)
lr = 10 ** np.random.uniform(-6, -2)


이렇게 무작위로 추출한 값을 사용하여 학습을 수행한다. 그 후에는 여러 차례 다양한 하이퍼파라미터 값으로 학습을 반복하며 어떤 값이 적절한지를 판단한다. 가중치 감소 계수의 범위를 np.random.uniform(-8, -4), 학습률의 범위를 np.random.uniform(-6, -2)로 하여 실험하면 결과는 [그림 6-25]처럼 된다.

[그림 6-25] 실선은 검증 데이터에 대한 정확도, 점선은 훈련 데이터에 대한 정확도


[그림 6-25]는 검증 데이터의 학습 추이를 정확도가 높은 순서로 나열했다. ch06/hyperparameter_optimization.py 파일을 참고하여 코드를 실행한 결과는 아래와 같다.


결과를 보면 학습률은 0.01, 가중치 감소 계수는 7e-05 정도라는 것을 알 수 있다. 이처럼 잘될 것 같은 값의 범위를 관찰하고 범위를 좁혀간다. 그런 다음 그 축소된 범위로 똑같은 작업을 반복하는 것이다. 이렇게 적절한 값이 위치한 범위를 좁혀가다가 특정 단계에서 최종 하이퍼파라미터 값을 하나 선택한다.









728x90