매개변수 갱신
* 손실함수의 값을 최소로 하는 매개변수 값을 찾는 것을 최적화(optimize) 라고 한다. 최적화된 매개변수는 해석적으로 수식의 풀이를 통해 한 번에 찾을 수 없고, 컴퓨터를 이용한 반복적인 계산을 통해서만 찾는 것이 가능하다.
확률적 경사 하강법(Stochastic Gradient Descent)
$W \rightarrow W - \eta {\partial L \over \partial W}$
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]
* update 메소드의 매개변수 params, grads는 dictionary type으로 가중치 매개변수와 기울기를 저장하고 있다.
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)
...
* 앞 뒷 부분은 생략하고 SGD를 활용하는 부분만 보자면, params, grads를 epoch마다 구해서 optimizer를 통해 update하게 된다.
SGD의 단점
$f(x, y) = {1 \over 20} x^2 + y^2$
* 이 함수는 아래와 같은 형태를 띠고 있다.
* 그리고 이 함수의 기울기를 그려보면 아래와 같다.
* 그림을 보면 알겠지만, y축 방향의 기울기는 큰 반면 x축 방향의 기울기는 완만하다. y축은 가파른데, x축 방향은 완만한 것이다.
* 최솟값이 되는 장소는 (x, y) = (0, 0) 이지만, 대부분의 기울기는 (0,0)이 아닌 곳을 가리키고 있다.
* 이러한 특징들을 가진 함수의 경우 random한 상태에서 SGD 방식을 통해 최적해에 접근해보면 아래와 같은 움직임을 보이게 된다. 아래의 예시는 (-7, 2)에서 출발한 경우이다.
* 얼핏 봐도 최단거리에 가깝지 않고 매우 비효율적으로 움직였다.
* 이렇게 SGD는 방향에 따라 성질이 달라지는 비등방성(anisotropy) 함수에서는 탐색 경로가 비효율적이다. 기울기가 낮은 방향으로만 이동한다는 단순한 규칙은 이와 같은 비효율성을 야기할 수 있다. 이를 개선하기 위한 다른 optimizer를 살펴보자.
모멘텀(Momentum)
$v \rightarrow \alpha v - \eta \partial L \partial W$
$W \rightarrow W + v$
* 모멘텀은 운동량을 뜻하는 단어로 물리와 관계가 있다.
* 수식에서 v는 velocity를 의미한다.
* 첫 번째 식은 기울기 방향으로 힘을 받아 물체가 가속된다는 물리법칙을 나타낸다. $\alpha v$는 지면 마찰이나 공기 저항을 의미한다.
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]
* 가중치 갱신 과정을 보면 마치 공이 기울기를 따라 굴러다니듯이 움직이는 것을 볼 수 있다.
* 이는 x축 방향의 기울기는 작지만 방향은 변하지 않기 때문에 일정하게 x축 방향으로 가속되고, y축 방향의 기울기는 y=0을 기준으로 방향이 반대이기 때문에 위아래로 힘 번갈아 받아서 상충해 y축 방향의 속도는 안정적이지 않기 때문에 발생하는 것이다.
AdaGrad(Adaptive Gradient)
* 가중치의 갱신 정도를 나타내는 $\eta$ 즉, learning rate은 학습이 진행될수록 서서히 작게 하는 것이 일반적이다. 이 아이디어를 더욱 발전시킨 것이 AdaGrad이다. h 식에서 생소할 수 있는 연산 기호는 행렬의 element별 곱셈을 뜻한다.
* 기존 SGD에서 변수 h가 추가되었다. h는 학습률을 조정하는 parameter로서 가중치의 기울기를 제곱하여 계속적으로 더해주게 된다. 가중치 W를 update하는 과정에서 ${1 \over \sqrt{h}}$를 곱해주게 되는데 이 과정에서 기울기가 큰, 다시말해 크게 갱신되는 원소는 학습률이 더 작아지게 된다. 이를 통해 각 매개변수에 맞춤형 learning rate이 적용된다.
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를 더해주는 것은 값이 0이 되어도 0으로 나누는 사태(오류 발생)가 발생하지 않도록 하기 위해서이다.
* 그림을 보면 AdaGrad를 사용하여 optimal point를 찾아가는 과정이 매우 효율적이다. 처음엔 y축 방향의 기울기가 커서 크게 움직이지만, 큰 움직임에 비례해 갱신 정도 또한 작아지고, 이에 따라 y축 방향 갱신 정도가 빠르게 약해져 지그재그 움직임이 줄어들게 된다.
AdaGrad의 문제점
* AdaGrad의 문제점은 과거의 기울기를 제곱해서 계속 더해가다 보면 ${1 \over \sqrt h}$ 가 매우 작아져 learning rate가 0이 되어버린다는 점이다.
* 이를 개선하기 위한 기법으로 RMSProp 이라는 방법이 있다. RMSprop은 모든 기울기를 균일하게 더하는 것이 아니라 먼 과거의 기울기는 서서히 잊고 새로운 기울기 정보를 크게 반영한다는 특징이 있다. 이를 지수이동평균(Exponential Moving Average)이라고 하고 과거 기울기의 반영 규모를 기하급수적으로 감수 시킨다.
Adam
* Momentum의 공이 구르는 듯한 움직이는 특성과 AdaGrad의 매개변수 별 적응적 갱신 정도 조정이라는 특성을 결합한 방법이 바로 Adam이다.
* Adam은 hyperparameter의 편향 보정이 진행된다는 특징도 있다.
Adam의 hyperparameter
1) $\alpha$ : 학습률
2) $\beta_1$ : 일차 모멘텀용 계수. default는 0.9
3) $\beta_2$ : 이차 모멘텀용 계수. default는 0.999
가중치의 초깃값
가중치 감소 기법
* 가중치 매개변수의 크기를 작게 하여 overfitting을 방지하는 방법이다.
* 가중치의 크기를 작게 만들기 위해서 초깃값 또한 작게 설정해보자. 예를 들어 0.01 * np.random.randn(10, 100) 처럼 정규분포에서 생성되는 값을 0.01배 한 작은 값을 사용할 수 있다.
* 그렇다면 매개변수의 크기가 작을 때 overfitting이 일어나지 않는다면 아예 0으로 초깃값을 설정해버리는 것을 어떨까? 절대 안된다. 정확히는 모든 가중치에 동일한 초깃값을 부여하면 안된다. backpropagation 과정에서 모든 뉴런의 가중치의 값이 똑같이 갱신되기 때문이다. 모든 뉴런의 가중치가 똑같이 갱신된다면 뉴런이 여러 개 있을 이유가 없다.
* 때문에 random한 초깃값을 부여하는 방법을 사용해야 한다.
은닉층의 활성화값 분포
* 활성화값은 activation function의 출력 데이터를 뜻한다.
* 가중치의 초깃값을 어떻게 설정하느냐에 따라 활성화값의 분포 또한 달라진다. 활성화 값의 분포는 매우 중요한데, 활성화 값의 분포에 따라 'Gradient Descent Problem'이 발생하거나, '표현력 제한' 문제가 발생할 수 있기 때문이다. 이에 대해서는 분포를 살펴본 뒤에 다시 설명하도록 하고 초깃값의 분포에 따라 활성화 값의 분포가 어떻게 달라지는지를 확인해보자.
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
* 정규분포를 따르는 1000개의 데이터를 sigmoid를 사용한 네트워크에 흘려보내보자. 이 때 데이터의 표준편차는 1이다.
* layer 별 활성화값의 분포이다. 거의 모든 layer에서 활성화 값이 0 아니면 1에 치우쳐 있는 것을 확인 가능하다. 그런데 이런 분포를 띠면 굉장히 곤란하다. 왜일까? 바로 우리가 사용하는 activation function 때문이다.
* sigmoid함수를 보면 출력값이 0과 1에 가까워질수록 기울기가 0에 가까워진다는 것을 알 수 있다. 기울기가 0에 가까워진다는 것은 0에 가까운 기울기가 backpropagation 과정에서 chain rule에 의해 계속적으로 곱해진다는 것을 뜻하고, 결국 Gradient Vanishing problem이 발생하게 된다.
* 그럼 이번에는 표준편차 0.01을 사용해 활성화값의 분포를 확인해보자.
* 확인해보니 0.5에 활성화 값이 몰려있다. 이 경우도 곤란한데 다수의 뉴런이 거의 같은 값을 출력해 표현력을 제한한다는 문제가 있기 때문이다. 다시 말해 뉴런이 여러 개 존재하는 의미가 없어지는 것이다.
* 결론적으로 은닉층의 활성화값은 특정 값에 몰려있지 않고 고르게 분포되어야 한다.
* 이 관점을 가지고 다음 initializer들을 살펴보자.
Xavier 초깃값
* Xavier initializer는 앞 layer의 뉴런 개수가 n일 때, 가중치의 초깃값의 표준편차를 {1 \over \sqrt n} 로 설정한다. 이렇게 하면 앞층의 뉴런의 개수가 많아질수록 {1 \over \sqrt n}이 작아져 가중치가 좁게 퍼진다.
* python code로 구현하기 위해서는 다음과 같이 초깃값 설정 부분을 작성해주면 된다.
node_num = 100 # 앞 층의 노드 수
w = np.random.randn(node_num, node_num) / np.sqrt(node_num)
* 앞서 표준편차를 임의로 설정했을 때보다 훨씬 고르게 정규분포 모양으로 퍼져있는 것을 볼 수 있다. 다만 layer가 깊어질수록 그 형태가 일그러지는데, 이는 tanh 함수를 activation function으로 사용함으로써 개선할 수 있다. tanh 또한 원점 대칭인 S자 모양의 함수인데, 참고로 activation function은 원점 대칭인 함수가 바람직하다고 알려져 있다.
He initializer
* Xaivier initializer는 acitvation function이 선형인 것을 전제로 만들어진 방법이다. sigmoid 함수와 tanh함수는 좌우대칭이기 때문에 중앙 부분이 선형의 형태를 띤다. 때문에 ReLU에는 Xavier initializer가 적절하지 않다.
* He initializer는 ReLU를 사용할 때 적절한 initializer이다. He initializer는 표준편차를 Xavier initializer의 ${\sqrt{1 \over n}}$ 보의 $\sqrt{2}$ 배 크기의 표준편차인 ${\sqrt{2 \over n}}$ 를 사용한다. 이는 ReLU 사용시 활성화 함수 값이 x < 0 에서는 모두 0이기 때문에 양수의 값만 출력되고 이에 따라 더 넓게 분포시키기 위해 더 큰 표준편차를 사용하기 위해서라고 직감적으로(정확한 설명은 아님) 설명할 수 있다.
* 아래는 ReLU 사용시 각 초깃값의 표준편차를 0.01, Xaivier initializer를 사용했을 때, He initializer를 사용했을 때 3 가지 분포를 시각화 한 자료이다.
1) 0.01 사용 : 0 근처에 값이 몰려있어 gradient vanishing 발생 가능성 높음
2) Xavier : layer가 깊어질 수록 점점 0 근처로 치우치기 때문에 gradient vanishing 발생 가능성 높음
3) He : 분포 균일
MNIST 데이터셋을 통한 비교
* 실제로 표준편차를 0.01로 설정했을 때 학습이 진행되지 않고(손실함수의 값이 작아지지 않고) 있다.
* Xavier와 He 중 He가 더 빠르게 학습되고 있다.
Batch Normalization
* Batch Normalization은 layer 별로 활성화 값을 적절하게 퍼트리도록 강제하는 기법이다. 목표는 활성화값 분포를 고르게 하는 것으로 initializer와 동일한 목표를 가지고 있다.
배치 정규화의 장점
1) 학습 속도 개선
2) 초깃값에 크게 의존하지 않음(어짜피 layer 내에서 활성화값 분포를 조절하기 때문)
3) 오버피팅을 억제(드롭아웃 등의 필요성 감소)
배치정규화 사용 방법
* 배치정규화는 미니배치 단위로 시행된다. 배치에 들어있는 데이터 $B = {x_1, x_2, ... , x_n}$들의 평균 $\mu_B$, 분산 $\sigma^2_B$을 구한 후 다음과 같이 데이터별로 정규화 해준다.
* $\epsilon$은 0으로 나눠지는 것을 방지하기 위한 매우 작은 값이다.
* 이러한 배치 정규화 처리는 활성화 함수의 앞 혹은 뒤(어디든 상관 없음)에 삽입하게 된다.
* 배치 정규화 계층마다 이 정규화된 데이터에 고유한 확대(scale)과 이동(shift)를 수행한다.
$y_i \leftarrow \gamma \hat{x_i} + \beta$
* 이 식에서 $\gamma$가 확대를, $\beta$가 이동을 담당한다. 두 값은 처음에는 \gamma = 1, \beta = 0부터 시작하고, 학습하면서 점점 적합한 값으로 조정해간다.
* 계산 그래프를 통해 나타내면 위와 같다. 역전파에 대한 설명은 다음 블로그를 참고하길 바란다.
배치 정규화의 효과
1) 학습 속도가 빨라진다.
오버피팅 억제 방법
* 오버피팅은 주로 다음의 두 경우에 발생한다.
1) 매개변수가 많고 표현력이 높은 모델
2) 훈련 데이터가 적음
가중치 감소(weight decay)
* 가중치 감소는 큰 가중치에 대해 패널티를 부여하는 방법 이다. 오버피팅은 주로 매개변수의 값이 커서 발생하는 경우가 많기 때문에 가중치의 크기를 제한하는 것은 오버피팅을 억제하는데 도움이 된다.
* weight decay가 이뤄지는 원리는 이렇다. 가중치의 norm을 손실함수에 더해주면 가중치가 커질수록 손실함수의 값이 커지기 때문에 모델이 가중치를 적절히 조절하게 된다. 만약 가중치를 W라고 하면 L2노름에 따른 weight decay는 ${1 \over 2} \lambda W^2$이 되고, 이를 손실함수에 더한다. 여기서 $\lambda$ 는 정규화 세기를 조절하는 하이퍼파라미터이다. ${1 \over 2}$ 를 곱해준 이유는 미분시 계수가 1이 되도록 만들어주기 위해서이다. 이를 통해 overfitting을 방지할 수 있다.
Dropout
* 신경망이 복잡해지면 weight decay로는 overfitting이 잘 잡히지 않을 경우가 있는데 이 때 dropout을 사용할 수 있다.
* dropout은 훈련 epoch마다 random하게 뉴런들을 활성화시키는 방법이다. 주의할 점은 훈련에서만 뉴런을 확률적으로 활성화시키고 테스트시에는 모든 뉴런을 다 사용해야 한다는 점이다.
* dropout을 python code로 구현하면 다음과 같다.
class Dropout:
def __init__(self, dropout_ratio=0.5):
self.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를 사용해 순전파 때 통과시키지 않은 뉴런은 역전파 때도 신호를 차단하고 순전파 때 통과시킨 뉴런은 역전파 때도 신호를 통과시킨다는 점이다.
* mnist 데이터셋에 dropout을 적용하기 전과 후의 accuracy 그래프이다.
적절한 하이퍼파라미터 찾기
Validation Data
* 신경망 모델에서 사용하는 하이퍼파라미터는 뉴런 개수, 배치 크기, 학습률 등등 다양하다. 우리는 이러한 하이퍼파라미터들의 여러 조합을 실험해보며 모델에 가장 적절한 하이퍼파라미터를 찾아야 한다.
* 모델의 성능을 평가할 때는 test data를 사용하게 된다. 그렇다면 하이퍼파라미터를 찾을 때도 test data를 사용하면 될까? 정답은 아니다. 하이퍼파라미터를 test data에 맞추게 되면 하이퍼파라미터가 test data에 overfitting되기 때문이다.
* 우리는 하이퍼파라미터 전용 데이터를 따로 구성해야 하며 이를 validation data라고 한다.
하이퍼파라미터 최적화
* 적절한 하이퍼파라미터를 찾기 위한 단계는 아래와 같이 정리할 수 있다.
0 단계)
- 하이퍼파라미터 값의 범위 설정
- 이 때 범위는 대략적으로 지정하는것이 좋다. 보통 0.001에서 1000 사이와 같이 10의 거듭제곱 단위의 범위를 사용한다. 즉 log scale로 지정한다.
1 단계)
- 설정된 범위에서 하이퍼파라미터의 값을 무작위로 추출
2단계)
- 1단계에서 샘플링한 하이퍼파라미터 값을 사용하여 학습하고, 검증 데이터로 정확도를 평가함.
- 이 때 학습을 위한 에포크를 작게 해서 1회 평가에 걸리는 시간을 단축하고 빠르게 판단해 범위를 좁힐지 다른 범위를 탐색할지 판단하는게 좋다. 딥러닝은 훈련 시간이 길기 때문이다.
3단계)
- 1단계와 2단계를 특정 횟수(ex - 100회) 반복하며, 정확도의 결과를 보고 하이퍼파라미터 범위를 좁힌다.
* 사실 이러한 탐색 과정은 전문가적인 직관이 많이 필요하다. 만약 수학적인 방식으로 reasonable한 탐색을 시행하고 싶다면 베이즈 최적화에 대해 공부하길 바란다. 논문은 <Practical Bayesian Optimization of Machine Learning Algorithm>를 참고하면 된다.
'Deep Learning > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글
딥러닝 등장 이전의 자연어와 단어의 분산 표현 (0) | 2022.02.01 |
---|---|
합성곱 신경망(CNN) (0) | 2022.01.30 |
Backpropagation (0) | 2022.01.27 |
경사하강법(Gradient Descent method) (0) | 2022.01.25 |
수치 미분 (0) | 2022.01.25 |