매개변수 갱신
확률적 경사 하강법(SGD)
신경망 학습의 목적은 손실 함수의 값을 가능한 한 낮추는 매개변수를 찾는 것으로 이는 곧 매개변수의 최적값을 찾는 문제이며, 이러한 문제를 푸는 것을 `최적화(optimization)`이라고 한다. 최적의 매개변수 값을 찾기 위한 단서로 매개변수의 기울기(미분)를 이용하는데, 매개변수의 기울기를 구해 기울어진 방향으로 매개변수 값을 갱신하는 일을 반복해서 최적의 값을 향해 다가가는 것이 `확률적 경사 하강법(SGD)`이다. SGD는 다음과 같은 수식으로 표현이 가능하다. 결국 SGD는 기울어진 방향으로 일정 거리만 가겠다는 단순한 방법이다.
SGD를 파이썬 코드로 구현하면 다음과 같다.
class SGD:
def __init__(self, lr=0.01):
self.lr = lr
def update(self, params, grads):
for key in params.key():
params[key] -= self.lr * grads[key]
초기화 때의 인수인 lr은 `학습률(learning rate)`을 의미한다. 학습률은 인스턴스 변수로 유지한다. update(params, grads) 메서드는 SGD 과정에서 반복해서 불린다. 인수인 params와 grads는 딕셔너리 변수이며 각각 가중치 매개변수와 기울기를 저장한다. 이러한 SGD의 단점은 `비등방성(anisotropy)` 함수, 즉 방향에 따라 성질(기울기)이 달라지는 함수에서는 탐색 경로가 지그재그로 비효율적이라는 것이다.
모멘텀
`모멘텀(momentum)`은 운동량을 뜻하는 단어로, 물리와 관계가 있다. 모멘텀 기법을 수식으로 나타내면 다음과 같다. 모멘텀의 갱신 경로는 공이 그릇 바닥을 구르듯 움직여 SGD와 비교했을 때 지그재그 정도가 덜하다.
모멘텀을 파이썬 코드로 구현하면 다음과 같다.
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가 물체의 속도이다. v는 초기화 때는 아무것도 없다가 update()가 처음 호출될 때 매개변수와 같은 구조의 데이터를 딕셔너리 변수로 저장한다.
AdaGrad
신경망 학습에서 학습률 값이 너무 작으면 학습 시간이 너무 길어지고, 너무 크면 발산하여 학습이 제대로 이루어지지 않을 수 있다. 이 학습률을 정하는 효과적인 기술로 `학습률 감소(learning rate decay)`가 있다. 이는 처음에는 크게 학습하다가 학습을 진행하면서 학습률을 점차 줄여가는 방법이다. 학습률을 서서히 낮추는 가장 간단한 방법은 전체의 학습률 값을 일괄적으로 낮추는 것으로, AdaGrad는 이를 발전시켜 각각의 매개변수에 맞춤형 값을 만들어준다. AdaGrad는 개별 매개변수에 적응적으로 학습률을 조정하면서 학습을 진행한다.
AdaGrad에서 새로운 h라는 변수가 등장한다. h는 기존 기울기 값을 제곱하여 계속 더해준다. 또한 매개변수를 갱신할 때 학습률을 조정한다. 매개변수의 원소 중 크게 갱신된 원소는 학습률이 낮아지는 것으로 이는 학습률 감소가 매개변수의 원소마다 다르게 적용됨을 의미한다. 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)
그러나 AdaGrad는 과거의 기울기를 계속 제곱하여 더해나가 학습을 진행할수록 갱신 정도가 약해진다. 이를 무한히 학습하게 되면 어느 순간 갱신량이 0이 되어 전혀 갱신하지 않는 문제가 발생한다. 이를 개선한 기법으로 `RMSProp`이 있다. RMSProp는 과거의 모든 기울기를 균일하게 더해나가는 것이 아니라, 먼 과거의 기울기는 서서히 잊고 새로운 기울기 정보를 크게 반영한다. 이를 `지수이동평균(Exponential Moving Average · EMA)라고 하며 과거 기울기의 반영 규모를 기하급수적으로 감소시킨다.
Adam
Adam은 모멘텀과 AdaGrad를 융합한 듯한 방법이다. Adam은 3가지의 하이퍼파라미터를 설정한다. 하나는 학습률, 나머지 두개는 일차 모멘텀용 계수와 이차 모멘텀용 계수이다. 이들의 기본값은 각각 0.9, 0.999이다. 또한 하이퍼파라미터의 편향 보정이 진행된다는 점도 Adam의 특징이다. 이를 파이썬 코드로 구현하면 다음과 같다.
class Adam:
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] += (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)
가중치의 초깃값
초깃값을 0으로 하면?
`가중치 감소(weight decay)`는 가중치 매개변수의 값이 작아지도록 학습하는 방법으로, 오버피팅을 억제해 범용 성능을 높이는 기법이다. 가중치를 작게 하기 위해선 초깃값도 최대한 작은 값에서 시작하는 것이 좋다. 그러나 초깃값을 0으로 설정한다면, 즉 가중치를 균일한 값으로 설정하면 학습이 올바르게 이루어지지 않는다. 이는 역전파 과정에서 모든 가중치의 값이 똑같이 갱신되기 때문이다.
예를 들어 2층 신경망에서 첫 번째 층과 두 번째 층의 가중치가 모두 0이라고 가정해보면, 순전파 때는 입력층의 가중치가 0이기 때문에 두 번째 층의 뉴런에 모두 같은 값이 전달된다. 이는 역전파 과정에서 두 번째 층의 가중치가 모두 똑같이 갱신된다는 의미이다. 따라서 가중치들은 같은 초깃값에서 시작하고 갱신을 거쳐도 여전히 같은 값을 유지하게 된다. 이를 막기 위해선 초깃값을 무작위로 설정해야 한다.
은닉층의 활성화값 분포
가중치의 초깃값에 따라 은닉층의 활성화값, 즉 활성화 함수의 출력 데이터가 어떻게 변화하는지 확인해보면 다음과 같다.
x = np.random.randn(1000, 100) # 1000개의 데이터
node_num = 100 # 각 은닉층의 뉴런 수 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 # 가중치의 분포가 표준편차가 1인 정규분포
a = np.dot(x, w)
z = sigmoid(a)
activations[i] = z
층은 총 5개이며 각 층의 뉴런은 100개씩 구성되어 있다. 입력 데이터는 1,000개의 데이터를 정규분포로 무작위로 생성하였다. 활성화 함수로는 시그모이드 함수를 이용하였으며 각 층의 활성화 결과를 activations 변수에 저장한다. 가중치는 표준편차가 1인 정규분포를 이용했다. 이 activations 변수에 저장된 각 층의 활성화값을 히스토그램으로 나타냈다.
이를 보면 각 층의 활성화값들이 0과 1에 치우쳐 분포하는 것을 확인할 수 있다. 시그모이드 함수는 출력이 0 또는 1에 가까워지면 미분 값은 0에 가까워지며 이를 통해 역전파의 기울기 값이 점점 작아지다가 사라진다. 이는 결국 `기울기 소실(gradient vanishing)`로 이어지며 층을 깊게 하는 딥러닝에서는 기울기 소실은 심각한 문제가 될 수 있다.
다음으론 가중치의 표준편차를 0.01로 바꿔 같은 실험을 반복한다. 앞의 코드에서 가중치 초깃값 설정만 변화시킨다.
for i in range(hidden_layer_size):
if i != 0:
x = activations[i-1]
w = np.random.randn(node_num, node_num) * 0.01 # 가중치의 분포가 표준편차가 0.01인 정규분포
a = np.dot(x, w)
z = sigmoid(a)
activations[i] = z
이 또한 activations 변수에 저장된 각 층의 활성화값 데이터를 히스토그램으로 나타내면 다음과 같다.
이번에는 각 층의 활성화값이 0.5 부근에 집중되었다. 이는 기울기 소실은 발생하지 않지만 활성화값들이 치우쳤다는 것은 결국 표현력을 제한한다는 관점에서 문제가 된다. 즉, 뉴런이 1개 있으나 100개 있으나 동일한 결과가 나와 뉴런을 여러 개 둔 의미가 없어진다는 것이다. 따라서 각 층의 활성화값은 적당히 고루 분포되어야 한다. 층과 층 사이에 적당하게 다양한 데이터가 흘러야 효율적인 신경망 학습이 이루어지며, 반대로 치우친 데이터가 흐르면 기울기 소실 혹은 표현력 제한 문제에 빠져 학습이 잘 이루어지지 않는다.
이어서 일반적인 딥러닝 프레임워크들이 표준적으로 이용하는 `Xavier 초깃값`을 확인한다. 이는 각 층의 활성화값들을 광범위하게 분포시키기 위한 가중치의 적절한 분포를 찾는다. Xavier 초깃값을 사용하면 앞 층에 노드가 많을수록 대상 노드의 초깃값으로 설정하는 가중치가 좁게 퍼지는 구조이다.
앞서 확인한 실험을 이번엔 Xavier 초깃값을 사용한다.
for i in range(hidden_layer_size):
if i != 0:
x = activations[i-1]
w = np.random.randn(node_num, node_num) / np.sqrt(node_num) # Xavier 초깃값
a = np.dot(x, w)
z = sigmoid(a)
activations[i] = z
이 또한 activation 변수에 저장된 각 층의 활성화값 데이터를 히스토그램으로 나타내면 다음과 같다.
Xavier 초깃값을 사용한 결과 층이 깊어지면서 형태가 다소 일그러지지만 앞서 본 분포보다 훨씬 넓게 분포된 것을 확인할 수 있다. 이는 각 층에 흐르는 데이터가 적당히 퍼져 있으므로, 시그모이드 함수의 표현력도 제한받지 않고 학습이 효율적으로 이루어질 것을 기대할 수 있다.
ReLU를 사용할 때의 가중치 초깃값
Xavier 초깃값은 활성화 함수가 선형인 것을 전제로 한다. sigmoid 함수와 tanh 함수는 좌우 대칭이기 때문에 중앙 부근이 선형인 함수로 볼 수 있기 때문에 Xavier 초깃값이 적절한 선택이 될 수 있다. 그러나 `ReLU`를 활성화 함수로 사용할 때는 이에 특화된 초깃값을 사용하는데, 이를 `He 초깃값`이라고 한다. He 초깃값은 다음과 같다. 이는 ReLU의 음의 영역이 0이라서 더 넓게 분포시키기 위해 2배의 계수가 필요한 것으로 직관적으로 해석할 수 있다. 결론적으로 활성화 함수로 ReLU를 사용할 때는 He 초깃값을, sigmoid나 tanh 등의 S자 모양 곡선일 때는 Xavier 초깃값을 일반적으로 사용한다.
배치 정규화(Batch Normalization)
앞서 가중치의 초깃값을 적절히 설정하면 각 층의 활성화값 분포가 적당히 퍼지면서 학습이 원활하게 수행됨을 확인했다. 여기서 각 층이 활성화를 적당히 퍼뜨리도록 강제하는 방법이 바로 `배치 정규화(Batch Normalization)`이다. 배치 정규화를 수행함으로써 얻을 수 있는 효과는 다음과 같다.
- 학습을 빨리 진행하여 학습 속도를 개선
- 초깃값에 크게 의존 x
- 오버피팅을 억제(드롭아웃 등의 필요성 감소)
배치 정규화의 기본 아이디어는 앞에서 설명했듯이 각 층에서의 활성화값이 적당히 분포되도록 조정하는 것이다. 배치 정규화를 사용하는 신경망의 예시는 다음과 같다. 이러한 배치 정규화의 처리를 활성화 함수의 앞 혹은 뒤에 삽입함으로써 데이터 분포가 덜 치우치게 할 수 있다.
배치 정규화는 학습 시 미니배치를 단위로 데이터 분포가 평균이 0, 분산이 1이 되도록 정규화를 수행한다. 수식은 다음과 같다. 또한 추가적으로 배치 정규화 계층마다 정규화된 데이터에 고유한 `확대(scale)`와 `이동(shift)` 변환을 수행한다.
이 포스팅은 "밑바닥부터 시작하는 딥러닝" 교재를 공부하며 작성한 글입니다.