KoreanFoodie's Study

딥러닝 튜토리얼 6-1강, SGD, 모멘텀, AdaGrad, Adam, 가중치 초기값 설정 - 밑바닥부터 시작하는 딥러닝 본문

Deep Learning/밑바닥부터 시작하는 딥러닝 1

딥러닝 튜토리얼 6-1강, SGD, 모멘텀, AdaGrad, Adam, 가중치 초기값 설정 - 밑바닥부터 시작하는 딥러닝

GoldGiver 2020. 1. 28. 08:00

해당 포스팅은 한빛 미디어에서 출판한 '밑바닥부터 시작하는 딥러닝'이라는 교재의 내용을 따라가며 딥러닝 튜토리얼을 진행하고 있습니다. 관련 자료는 여기에서 찾거나 다운로드 받으실 수 있습니다.


매개변수 갱신

신경망 학습의 목적은 손실 함수의 값을 가능한 한 낮추는 매개변수를 찾는 것이다. 이는 매개변수의 최적값을 찾는 문제이며, 이러한 문제를 푸는 것을 최적화(optimization)라고 한다.

우리는 지금까지 매개변수의 기울기(미분)을 이용해 기울어진 방향으로 매개변수 값을 갱신하는 일을 반복해 최적화를 진행했다. 이것이 확률적 경사 하강법(SGD)이라는 방법이다. SGD는 단순하지만, SGD보다 똑똑한 방법도 있다. 지금부터 SGD의 단점을 알아보고, SGD와는 다른 최적화 기법을 알아보도록 하겠다!

 

  • 모험가 이야기

최적화를 해야 하는 우리의 상황을 모험가 이야기에 비유해 보겠다!

색다른 모험가가 있습니다. 광활한 메마른 산맥을 여행하면서 날마다 깊은 골짜기를 찾아 발걸음을 옮깁니다. 그는 전설에 나오는 세상에서 가장 깊고 낮은 골짜기, ‘깊은 곳’을 찾아가려 합니다. 그것이 그의 여행 목적이죠. 게다가 그는 엄격한 ‘제약’ 2개로 자신을 옭아맸습니다. 하나는 지도를 보지 않을 것, 또 하나는 눈가리개를 쓰는 것입니다. 지도도 없고 보이지도 않으니 가장 낮은 골짜기가 광대한 땅 어디에 있는지 알 도리가 없죠. 그런 혹독한 조건에서 이 모험가는 어떻게 ‘깊은 곳’을 찾을 수 있을까요? 어떻게 걸음을 옮겨야 효율적으로 ‘깊은 곳’을 찾아낼 수 있을까요? 최적 매개변수를 탐색하는 우리도 이 모험가와 같은 어둠의 세계를 탐험하게 됩니다. 광대하고 복잡한 지형을 지도도 없이 눈을 가린 채로 ‘깊은 곳’을 찾지 않으면 안 됩니다. 척 봐도 어려운 문제임이 느껴질 거에요. 이 어려운 상황에서 중요한 단서가 되는 것이 땅의 ‘기울기’입니다. 모험가는 주위 경치는 볼 수 없지만 지금 서 있는 땅의 기울기는 알 수 있습니다. 발바닥으로 전해지죠. 그래서 지금 서 있는 장소에서 가장 크게 기울어진 방향으로 가자는 것이 SGD의 전략입니다. 이 일을 반복하면 언젠가 ‘깊은 곳’에 찾아갈 수 있을지도 모르죠. 적어도 용감한 모험가는 그렇게 생각할지도 모릅니다.

이 상황에서 중요한 단서가 되는 것이 땅의 '기울기'이다. 모험가는 주위 경치는 볼 수 없지만 기울기는 알 수 있다. SGD의 전략은 현 위치에서 가장 크게 기울어진 방향으로 가자는 것이며, 이 일을 반복하면 언젠가 '깊은 곳'에 찾아갈 수 있을 거라고 생각하는 것이다!

 

  • 확률적 경사 하강법(SGD)

SGD의 수식을 보며 복습을 해 보자.

여기서 W는 갱신할 가중치 매개변수고 dL/dW는 W에 대한 손실 함수의 기울기이다. η(이타)는 학습률을 의미하는데, 실제로는 0.01이나 0.001과 같은 값을 미리 정해서 사용한다. 또, <-는 우변의 값으로 좌변의 값을 갱신한다는 뜻이다. 위 식에서 보듯, SGD는 기울어진 방향으로 일정 거리만 가겠다는 단순한 방법이다. 그러면 이 SGD를 파이썬 클래스로 구현해 보자!

class SGD:
	def __init__(self, lr = 0.01):
    	self.lr = lr
    
    def update(self, params, grads):
    	for key in params.keys():
        	params[key] -= self.lr * grads[key]

초기화 때 받는 변수인 lr은 learning rate(학습률)을 의미한다. 인수인 params와 grads는 딕셔너리 변수다. 각각 가중치 매개변수와 기울기를 저장하고 있다!

SGD 클래스를 사용하면 신경망 매개변수의 진행을 다음과 같이 수행할 수 있다.(다음은 실제로는 동작하지 않는 의사 코드이다)

network = TwoLayerNet(...)
optimizer = SGD()

for i in range(10000):
	...
    x_batch, t_batch = get_mini_batch(...) #미니배치
    grads = network.gradient(x_batch, t_batch)
    params = network.params
    optimizer.update(params, grads)
    ...

optimizer는 '최적화를 행하는 자'라는 뜻의 단어이다. 이 코드에서는 SGD가 그 역할을 한다. 이처럼 최적화를 담당하는 클래스를 분리해 구현하면 기능을 모듈화하기 좋다!

대부분의 딥러닝 프레임워크는 다양한 최적화 기법을 구현해 제공한다. 예를 들어 Lasagne라는 딥러닝 프레임워크는 다양한 최적화 기법을 구현해 updates.py 파일에 저장해 두었다. 사용자가 그 중 쓰고 싶은 기법을 선택할 수 있다!

 

  • SGD의 단점

SGD는 단순하고 구현도 쉽지만, 문제에 따라서는 비효율적일 때가 있다.

위 함수는 위 그림의 왼쪽과 같이 '밥그릇'을 x축 방향으로 늘인 듯한 모습이고, 실제로 이 등고선은 오른쪽과 같이 x축 방향으로 늘인 타원으로 되어 있다.

위 함수의 기울기를 그려보면 다음과 같다.

이 기울기의 y축 방향은 크고 x축 방향은 작다는 것이 특징이다. 말하자면 y축 방향은 가파른데 x축 방향은 완만한 것이다. 또, 여기에서 주의할 점으로는 위 식이 최솟값이 되는 장소는 (x, y) = (0, 0)이지만, 위의 그림이 보여주는 기울기 대부분은 (0, 0) 방향을 가리키지 않는다는 것이다.

이제 위 함수에 SGD를 적용해보자. 탐색을 시작하는 장소(초깃값)는 (x, y) = (-7.0, 2.0)으로 하자. 결과는 다음과 같다.

SGD는 위 그림과 같이 심하게 굽이진 움직임을 보여준다. 상당히 비효율적이다! 즉, SGD의 단점은 비등방성(anisotropy) 함수(방향에 따라 성질, 즉 여기에서는 기울기가 달라지는 함수)에서는 탐색 경로가 비효율적이라는 것이다.

그럼 이제부터 SGD의 이러한 단점을 개선해주는 모멘텀, AdaGrad, Adam이라는 세 방법을 소개하겠다.

 

  • 모멘텀

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

SGD의 수식처럼, W는 갱신할 가중치 매개변수, dL/dW는 W에 대한 손실 함수의 기울기, η는 학습률이다. v라는 변수가 새로 나오는데, 이는 물리에서 말하는 속도(velocity)에 해당한다. 위 식은 기울기 방향으로 힘을 받아 물체가 가속된다는 물리 법칙을 나타낸다.

모멘텀은 위 그림과 같이 공이 그릇의 바닥을 구르는 듯한 움직임을 보여준다.

또, 위 식의 α항은 물체가 아무런 힘을 받지 않을 때 서서히 하강시키는 역할을 한다. (α는 0.9 등의 값으로 설정한다) 물리에서의 지면 마찰이나 공기 저항에 해당한다. 다음은 모멘텀의 구현이다(소스 코드는 common/optimizer.py에 있다).

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_list(val)
		
        for key in params.keys():
        	self.v[key] = self.momentum * self.v[key] - self.lr * self.grads[key]
            self.params[key] += self.v[key]

인스턴스 변수 v가 물체의 속도다. v는 초기화 때는 아무 값도 담지 않고, 대신 update()가 처음 호출될 때 매개변수와 같은 구조의 데이터를 딕셔너리 변수로 저장한다. 이제 모멘텀을 이용해서 최적화 문제를 풀어보면, 다음과 같은 그림이 그려진다.

위 그림에서 보듯 모멘텀의 갱신 경로는 공이 그릇 바닥을 구르듯 움직인다. SGD와 비교하면 '지그재그 정도'가 덜하다. 이는 x축의 힘은 아주 작지만 방향은 변하지 않아서 한 방향으로 일정하게 가속하기 때문이다. 거꾸로 y축의 힘은 크지만 위아래로 번갈아 받아서 상충하여 y축 방향의 속도는 안정적이지 않다!

 

  • AdaGrad

신경망 학습에서는 학습률(수식에서는 η) 값이 중요하다. 이 값이 너무 작으면 학습 시간이 너무 길어지고, 반대로 너무 크면 발산하여 학습이 제대로 이뤄지지 않는다.

이 학습률을 정하는 효과적 기술로 학습률 감소(learning rate decay)가 있다. 이는 학습을 진행하면서 학습률을 점차 줄여가는 방식이다. 학습률을 서서히 낮추는 가장 간단한 방법은 매개변수 '전체'의 학습률 값을 일괄적으로 낮추는 것이다. 이를 더욱 발전시킨 것이 AdaGrad이다. AdaGrad는 '각각의' 매개변수에 '맞춤형' 값을 만들어 준다!

AdaGrad는 개별 매개변수에 적응적으로(adaptive) 학습률을 조정하면서 학습을 진행한다. AdaGrad의 갱신 방법은 수식으로는 다음과 같다.

마찬가지로 W는 갱신할 가중치 매개변수, dL/dW는 W에 대한 손실 함수의 기울기, η는 학습률이다. 여기에서는 새로 h라는 변수가 등장한다. h는 위 식에서 보듯 기존 기울기 값을 제곱하여 계속 더해준다(동그라미 기호는 행렬의 원소별 곱셈을 의미한다). 그리고 매개변수를 갱신할 때 1/sqrt(h)를 곱해 학습률을 조정한다. 매개변수의 원소 중에서 많이 움직인(크게 갱신괸) 원소는 학습률이 낮아진다는 뜻인데, 다시 말해 학습률 감소가 매개변수의 원소마다 다르게 적용됨을 뜻한다!

AdaGrad는 과거의 기울기를 제곱하여 계속 더해간다. 그래서 학습을 진행할수록 갱신 강도가 약해진다. 실제로 무한히 계속 학습한다면 어느 순간 갱신량이 0이 되어 전혀 갱신되지 않게 된다. 이 문제를 개선한 기법으로서 RMSProp이라는 방법이 있다. RMSProp은 과거의 모든 기울기를 균일하게 더해가는 것이 아니라, 먼 과거의 기울기는 서서히 잊고 새로운 기울기 정보를 크게 반영한다. 이를 지수이동평균(Exponential Moving Average, EMA)라고 하여, 과거 기울기의 반영 규모를 기하급수적으로 감소시킨다.

그럼 AdaGrad의 구현을 살펴보자(소스 코드는 common/optimizer.py에 있다).

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)

 여기에서 주의할 것은 마지막 줄에서 1e-7이라는 작은 값을 더하는 부분이다. 이 작은 값은 self.h[key]에 0이 담겨 있다 해도 0으로 나누는 사태를 막아준다. 대부분의 딥러닝 프레임워크에서는 이 값도 인수로 설정할 수 있다!

그럼 AdaGrad를 이용해서 위 식의 최적화 문제를 풀어보자. 결과는 다음과 같다.

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

 

  • Adam

모멘텀은 공이 그릇 바닥을 구르는 듯한 움직임을 보였다. AdaGrad는 매개변수의 원소마다 적응적으로 갱신 정도를 조정했다. 그 두기법을 융합한 아이디어에서 출발한 기법이 바로 Adam이다. 자세한 내용을 이해하기 위해서는 원논문을 읽어보면 된다.

Adam을 사용해서 최적화 문제를 풀면 결과가 다음과 같이 나온다.

Adam의 갱신 과정도 그릇 바닥을 구르듯 움직인다. 모멘텀과 비슷한 패턴이지만, 모멘텀보다 공의 좌우 흔들림이 적다!

Adam은 하이퍼파라미터를 3개 설정한다. 하나는 지금까지의 학습률(논문에서는 α로 등장), 나머지 두 개는 일차 모멘텀용 계수 β1과 이차 모멘텀용 계수 β2이다. 논문에 따르면 기본 설정값은 β1은0.9, β2는0.999이며, 이 값이면 많은 경우에 좋은 결과를 얻을 수 있다.

SGD, 모멘텀, AdaGrad, Adam 총 4가지의 optimizer를 소개했지만, 모든 경우에 항상 뛰어난 기법은 없다. 각자의 장단이 있어 잘 푸는 문제와 서툰 문제가 있다! 요즘에는 Adam을 많이 사용한다.

 


 

가중치의 초깃값

신경망 학습에서 특히 중요한 것이 가중치의 초깃값이다. 가중치의 초깃값을 무엇으로 설정하느냐가 신경망 학습의 성패를 가르는 일이 실제로 자주 있다!

 

  • 초깃값을 0으로 하면?

이제부터 오버피팅을 억제해 범용 성능을 높이는 테크닉인 가중치 감소(weight decay)기법을 소개하겠다. 가중치 감소는 간단히 말해 가중치 매개변수의 값이 작아지도록 학습하는 방법이다. 가중치 값을 작게 하여 오버피팅이 일어나지 않게 하는 것이다. 

가중치를 작게 만들고 싶으면 초깃값도 최대한 작은 값에서 시작하는 것이 정공법이다. 사실 지금까지의 가중치의 초깃값은 0.01 * np.random.randn(10, 100)처럼 정규분포에서 생성되는 값을 0.01배 한 작은 값(표준편차가 0.01인 정규분포)를 사용했다.

그렇다면 가중치의 초깃값을 모두 0으로 설정하면 어떨까? 답부터 얘기하면, 학습이 올바로 이뤄지지 않는다!

초깃값을 모두 0으로 해서는 안 되는 이유는 뭘까? (정확히는 가중치를 균일한 값으로 설정해서는 안 된다.) 그 이유는 바로 오차역전파법에서 모든 가중치의 값이 똑같이 갱신되기 때문이다. 예를 들어 2층 신경망에서 첫 번째와 두 번째 층의 가중치가 0이라고 가정하자. 그럼 순전파 때는 입력층의 가중치가 0이기 때문에 두 번째 층의 뉴런에 모두 같은 값이 전달된다. 두 번째 충의 모든 뉴런에 같은 값이 입력된다는 것은 역전파 때 두 번째 층의 가중치가 모두 똑같이 갱신된다는 말이 된다. 그래서 가중치들은 같은 초깃값에서 시작하고 갱신을 거쳐도 여전히 같은 값을 유지하는 것이다. 이는 가중치를 여러 개 갖는 의미를 사라지게 한다. 이 '가중치가 고르게 되어버리는 상황'을 막으려면 초깃값을 무작위로 설정해야 한다!

 

  • 은닉층의 활성화값 분포

은닉층의 활성화값(활성화 함수의 출력 데이터)의 분포를 관찰하면 중요한 정보를 얻을 수 있다. 가중치의 초깃값에 따라 은닉층 활성화값들이 어떻게 변화하는지 간단한 실험을 해보자. 구체적으로는 활성화 함수로 시그모이드 함수를 사용하는 5층 신경망에 무작위로 생성한 입력 데이터를 흘리며 각 층의 활성화값 분포를 히스토그램으로 그려보겠다! (소스코드는 (ch06/weight_init_activation_histogram.py에 있다)

import numpy as np
import matplotilb.pyplot as plt

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

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

층은 5개가 있고, 각 층의 뉴런은 100기썍이다. 입력 데이터로서 1000개의 데이터를 정규분포로 무작위로 생성하여 이 5층 신경망에 흘린다. 활성화 함수로는 시그모이드 함수를 이용했고, 각 층의 활성화 결과를 activations 변수에 저장한다. 이 코드에서는 가중치의 분포에 주의해야 한다. 이번에는 표준편차가 1인 정규분포를 이용했는데, 이 분포된 정도(표준 편차)를 바꿔가며 활성화값들의 분포가 어떻게 변화하는지 관찰하는 것이 이 실험의 목적이다. 그럼 activations에 저장된 각 층의 활성화값 데이터를 히스토그램으로 그려보자.

# 히스토그램 그리기
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()

이 코드를 실행하면 다음과 같은 히스토그램을 얻는다.

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

이번에는 가중치의 표준편차를 0.01로 바꿔 같은 실험을 반복해 보자. 앞의 코드에서 가중치 초깃값 설정 부분을 다음과 같이 바꾸면 된다.

# w = np.random.randn(node_num, node_num) * 1
w = np.random.randn(node_num, node_num) * 0.01

결과를 보자. 표준편차를 0.01로 한 정규분포의 경우 각 층의 활성화값 분포는 다음과 같다.

이번에는 0.5 부근에 집중되었다. 앞의 예처럼 0과 1로 치우치진 않았으니 기울기 소실 문제는 일어나지 않았지만, 활성화값들이 치우쳐져 있어 뉴런을 여러 개 둔 의미가 사라진다. 그래서 활성화값들이 치우치면 표현력을 제한한다는 관점에서 문제가 된다!

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

Xavier 초깃값을 사용하면 앞 층에 노드가 많을수록 대상 노드의 초깃값으로 설정하는 가중치가 좁게 퍼진다. 이제 Xavier 초깃값을 써서 실험해 보자.

node_num = 100 # 앞 층의 노드 수
w = np.random.randn(node_num, node_num) / np.sqrt(node_num)

Xavier 초깃값을 사용한 결과를 보면, 형태가 일그러지지만, 값들이 적당히 퍼져 있으며, 시그모이드 함수의 표현력도 제한받지 않고 학습이 효율적으로 이루어질 것을 기대할 수 있다.

위 그림에서의 일그러짐은 sigmoid 함수 대신 tanh함수를 사용하면 개선된다. 일반적으로 활성화 함수용으로는 원점에서 대칭인 함수가 바람직하다고 알려져 있다.

 

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

ReLU 활성화 함수에 특화된 초깃값 : He 초깃값

-> He 초깃값은 앞 계층의 노드가 n개 일때, 표준편차가 sqrt(2/n)인 정규분포를 사용한다.

다음은 각각 순서대로 표준편차를 0.01, Xavier초깃값, He 초깃값을 사용했을 때의 활성화값들의 분포이다.

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

이번에는 '실제'데이터를 가지고 가중치의 초깃값을 주는 방법이 신경망 하습에 얼마나 영향을 주는지 보자(소스 코드는 ch06/weight_init_compare.py에 있다).

이처럼, 적절한 가중치를 주는 것은 학습에 매우 중요한 요소이다!

Comments