loading

cs231n 내용 요약 (8) - Learning and evaluation


Lecture summary

Published on November 09, 2022 by JunYoung

AI Deep learning cs231n

30 min READ

들어가며…

이전 게시글에서 다뤘던 내용은 neural network에서 고정으로 사용될 수 있는 내용이었다. 여기서 고정으로 사용된다는 것은 학습 시에 변하지 않는 것을 의미한다. 예를 들어 정규화 방법으로 L1 regularization을 선택할 수도, L2 regularization을 선택할 수도 있지만 task에 따라 선택한 objective function은 불변이라는 점이다. 정규화를 제외하고도 네트워크 구조나 데이터 전처리 등등에 대해서 살펴볼 수 있었다.
이번 글에서 다룰 내용은 학습 시 변할 수 있는 부분, 대표적으로 weight parameter를 학습시키는 방법이나 직접 조정해가며 찾는 hyperparameter searching에 대한 개념이 될 것이다.


Learning

Gradient checkanalytic gradient(실제 미분 가능한 함수 형태에서 도함수를 정의하여 계산하는 것)과 numerical gradient(미분이 불가능한 함수 형태에서 input의 미소 변화 $h$에 대한 output의 변화 $f(x+h) - f(x)$의 비율을 도함수로 사용하는 것)를 서로 비교하는 것이다.

Use the centered formula

[ \begin{aligned} \frac{df(x)}{dx} =& \frac{f(x+h) - f(x)}{h}~\text{(bad)} \newline \frac{df(x)}{dx} =& \frac{f(x+h) - f(x-h)}{2h}~\text{(use instead)} \end{aligned}
] $h$를 아주 작은 수로 가정했을때, 일반적인 도함수 정의를 따라가는 numerical gradient는 위와 같이 계산할 수 있다. 고등학교 수학에서 배울 수 있듯이 도함수의 정의는 다음과 같이 정의되는 것을 알 수 있다. [ f^\prime (x) = \frac{df(x)}{dx} = \lim_{h \rightarrow 0} \frac{f(x+h)-f(x)}{h}
] 그러나 만약 $f^\prime(x)$를 analytic하게 구하기 어려운 함수라면(너무 복잡해서 미분이 어려운 함수), 실제로 함수 형태를 미분해서 analytic한 연산 결과를 내는 것보다 함숫값의 차이를 통해 도함수에 근사하는 연산값을 도출할 수 있다.
따라서 앞서 소개한 식과 같이 $h$를 아주 작은 수로 가정하여 numerical gradient를 구하게 된다. Centered formula를 사용하는 이유는 아래 그림을 참고해보도록 하자.

검은색으로 표시된 $f^\prime(x)$가 실제로 구해야하는 analytic gradient 값이고, 이를 근사하기 위해 미소 단위 $h$에 대해 함숫값의 차이에 대한 비율을 통해 기울기를 계산하고자 한다. 만약 붉은색 선과 같이 $h$만큼 이동한 함숫값과의 차이를 계산하게 되면 함수의 곡률이 큰 경우(tangential line에서 급격하게 벗어나는 경우) 기울기 오차가 발생하게 된다. 따라서 녹색 선과 같이 $f(x)$를 중앙에 두는 형태의 계산을 통해 보다 실제 도함수 값(접선의 기울기)을 따라가고자 하는 방식이 centered formula가 되겠다.

Use relative error for comparison

위에서 설명한 내용은 보다 유사한 numerical gradient를 구하기 위한 방법이었고, 지금부터 설명할 내용은 numerical gradientanalytic gradient를 어떻게 하면 공정하게 비교할 수 있을지에 대한 부분이다.
만약 difference에 대한 절댓값이나, square를 측정한 뒤 이를 특정 threshold보다 크면 오차가 크다고 판단하는 경우를 생각해보자. Difference가 $10^{-4}$가 나왔더라도 두 gradient value가 $1.0$ 정도라면 이 정도의 오차는 충분히 작은 값으로 인식될 수 있지만, 만약 두 gradient value가 $10^{-5}$ 근처의 숫자이거나 더 작은 단위로 표현된다면 해당 오차는 상대적으로 매우 큰 값에 해당된다. 따라서 절대적인 값으로 threshold를 정하는 것보다는 상대적인 오차값, relative error를 정의하는 방식이 바람직하다. [ \frac{\vert f_a^\prime - f_n^\prime \vert}{\max (\vert f_a^\prime \vert,~\vert f_n^\prime \vert)}
]

Gradient의 차이를 difference의 절댓값으로 정의한다면, 이 값을 실제 gradient 값의 최댓값으로 나눠주게 되면 gradient 차이로 나온 값이 실제로 큰 오차에 해당되는지 여부를 측정할 수 있다. 이외에도 네트워크의 깊이에 따른 차이도 존재하는데, 예를 들어 딥러닝 네트워크의 깊이가 깊어질수록 error도 점점 커지게 된다. 따라서 같은 error를 나타내더라도 layer의 갯수가 $10$개인 상황과 $1$개인 상황은 다르다.


Single precition and double precision

이 부분에서 다룰 내용은 사실상 딥러닝과는 큰 연관이 없을 수도 있다. 그냥 단순히 오차에 대한 개념을 정리하려다 보니, 오차를 계산하는 과정에서 부동 소수점과 관련된 내용을 짚고 넘어가는게 좋을 것 같아서 가져오게 되었다.
Single precision과 double precition은 C언어 프로그래밍에서 배우는 부분인데, 흔히 변수의 자료형을 정해줄 때 사용되는 단위인 float이나 double의 차이에 대한 내용이다.
컴퓨터에서는 소수점 단위로 무한히 존재하는 실수를 표현하기 위해 소수점의 위치를 고정하지 않고 그 위치를 나타내는 수를 따로 적음으로써 표현하는 방식인 부동소수점 방법을 사용하게 된다. 고정 소수점 방식보다 더 넓은 범위를 커버할 수는 있지만 근삿값으로 표현되며, 연산 속도가 느리다는 특징이 있다. 또한 고정 소수점과 다르게 정수와 소수 부분이 명확하게 구분되진 않으나 유효 숫자의 갯수는 한정되어있다.
초창기 컴퓨터에서는 각각 서로 다른 방식을 사용했었지만 지금은 거의 모든 컴퓨터들이 호환성을 보장하기 위해 IEEE에서 표준화된 754 형식을 사용중이다.

부호지수(E)가수(mantissa)
1 bit8 bit23 bit(52 bit)

사용되는 binary 자릿수인 32bit와 64bit에 따라 가수의 범위가 달라지게 된다. 32 bit를 사용한 표현이 single precision, 64 bit를 사용한 표현을 double precision이라 부른다. Error를 줄이기 위해서는 물론 더 많은 비트를 통해 표현 가능한 방식인 double precision을 사용하는 것이 좋다.

Kinks in the objective

gradient에 대한 오차를 계산하는 과정에서 미분이 가능한 함수와 미분이 불가능한 함수를 구분하는 것은 중요하다. 그런 의미에서 ‘Kinks’란 non-differentiable part(미분 불가능한 부분)을 의미하며, 마치 ReLU function이나 SVM loss(hinge loss) 등등 여러 레이어 혹은 objective function에서 미분 불가능한 part가 생길 수 있다.
예를 들어 ReLU function에서 $x = -10^{-6}$에서의 기울기를 계산한다고 생각해보자. ReLU(Rectified Linear Unit) function은 다음과 같이 정의된다.

[ \text{ReLU}(x) = \begin{cases} 0, & \text{if}~~x < 0 \newline x, & \text{otherwise} \end{cases}
]

물론 $x = 0$에서 미분이 불가능하기 때문에 전체 함수의 도함수를 구하는 것은 불가능하지만, 미분이 가능한 각 부분에 대해서 sub-gradient를 계산하면 다음과 같다.

[ \frac{d}{dx} \left( \text{ReLU(x)} \right) = \begin{cases} 0, & \text{if}~~x < 0 \newline 1, & \text{otherwise} \end{cases}
]

그렇기 때문에 ReLU function에서 $x = -10^{-6}$에서의 기울기는 원칙적으로는(analytic) $0$이 되어야한다. 그런데 만약 analytic한 기울기를 구하지 않고 작은 $h$에 대해서 numerical gradient를 게산하는 상황을 생각해보자.

[ \left. \frac{d}{dx} \left( \text{ReLU(x)} \right) \right\rvert_{x = -10^{-6}} \approx \frac{\text{ReLU}(-10^{-6}+h) - \text{ReLU}(-10^{-6})}{h}
]

만약 $h$가 $10^{-6}$보다 작다면 analytic gradient와의 오차가 0이지만, 이보다 커진다면 오차가 $h - 10^{-6}$ 만큼 발생하게 된다. Loss function을 evaluation하는 과정에서 input이 네트워크를 통과하면서 kink를 거쳤는지 거치지 않았는지 판단하는 방법은 $f(x+h)$와 $f(x-h)$를 동시에 보는 방법이다. 앞서 예시로 들은 ReLU function에서 확인하게 되면, $\max(x, y)$와 같은 형태에서 winner(더 큰 값이 어떤 값인지)를 체크하는 과정을 확인해보면 해당 value가 kinks region을 통과했는지 확인할 수 있다는 것이다. $f(x+h)$와 $f(x-h)$를 비교했을 때 값이 달라진다면(만약 $h$가 10^{-6}보다 크다면 ReLU$(\cdot)$의 연산이 $0$에서 바뀌게 된다), kink point를 지나쳤고, 이로 인해 numerical gradient가 정확하지 않을 것이라는 사실을 확인할 수 있다.

Other tips for gradient checking method

그리고 데이터셋을 많이 사용하지 않는 것이 중요하다. 만약 datapoint 수가 많아지게 되면, kinks를 포함하는 loss function이 확률적으로 늘어날 수 밖에 없기 때문이다. 또한 연산 과정에서 많은 샘플에 대한 gradient checking을 하는 과정이 효율적이지 않고 느리다.
그리고 step size인 $h$를 적절히 조절하는 것도 중요하다. 만약 step size가 너무 작다면 computational limitation 때문에 정확도가 많이 떨어질 수 있고, 반대로 너무 크다면 도함수의 정의와 많이 벗어나기 때문에 오차 계산의 정확도가 떨어질 수 있다. 또한 Kinks가 포함된 objective의 경우에는 kinks를 포함하는 영역이 많이 생기지 않게끔 $h$를 잘 조절하는 것이 중요하다.
그리고 또 중요한 것은 gradient check는 일반적으로 parameter의 특정 지점에서(single point)에서 진행된다. 그렇기 때문에 특정 point에서는 gradient check를 했을 때 오차 범위 내에서 계산되더라도, 실제로 신경망 내부의 모든 부분에서 연산이 제대로 진행되었다는 보장은 할 수 없다(필요 조건이지, 충분 조건이 될 수 없다). 또한 random initialization을 하게되면 characteristic point(gradient 오차를 계산해야할 필수적인 부분이라고 생각하면 된다)가 아닌 지점에서 연산이 진행될 수 있고, 결국 gradient가 잘못 계산되었지만 이를 모르고 넘어갈 수도 있다. 예를 들어 support vector machine의 weight를 아주 작은 값으로 초기화한다면, 거의 모든 datapoint에 대해 zero-score를 내보낼 것이고 그렇다면 대부분의 data point에 대해서 gradient는 비슷한 양상(pattern)을 가질 것이다. 이는 gradient가 잘못 구현되었더라도 마찬가지며, 만약 어떤 score가 다른 값들보다 특정 수준 이상으로 커지게 되면 일반화되기 힘들다. 그러므로 안정적인 계산을 위해서는 짧은 burn-in time을 줌으로써 loss가 학습되기 시작한 이후에 gradient check를 하는 것이 바람직하다. 따라서 first iteration(학습 시작)부터 gradient check를 하는 것은 학습되는 parameter의 pathodology 관점에서 edge(가장자리)에 해당되기 때문에 gradient 연산이 부정확할 수도 있다.
앞선 게시글에서 소개했던 것과 마찬가지로 data loss term에 regularization loss term을 더함으로써(explicit regularization) 네트워크의 overfitting과 parameter의 분산을 막았었다. 이때, data loss에 regularization loss를 $\lambda$만큼 weight하여 더하는 형태로 loss를 구성하게 된다. 그러나 중요한 점은 regularization loss가 data loss를 넘어서게 되면, gradient 계산 시에 data에 의한 gradient보다 regularization에 대한 gradient가 더 커지게 된다. 이는 실제로 data loss gradient가 제대로 구현이 되었는지 확인하는 절차상의 방해요소로 작용할 수 있다. 따라서 data loss를 제대로 구현하였는지 확인하는 단계에서는 regularization loss를 빼고 체크하는 것이 좋다. Regularization term에 대한 gradient를 체크할 때에는 data loss contribution을 제외하고 체크하거나, $\lambda$ term을 증가시켜 regularization term을 무시할 수 없을 정도로 크게 만든 상태에서 진행한다.
또한 dropout이나 augmentation과 같은 implicit regularization도 gradient check 단계에서는 모두 배제해야한다. 말하다보니 지금까지 소개한 모든 정규화 방법들을 언급한 것 같은데, 아무튼 deterministic하지 않고 stochastic한 모든 형태의 작업은 연산의 정확도를 저하시키기 때문에 문제가 될 수 있다. 따라서 random seed를 고정한 채로 $f(x+h)$, $f(x-h)$를 계산하여 변동 가능성을 아예 없애거나 해당 정규화 방법들을 모두 제거한 상태에서 연산을 진행하는 것이 바람직하다.
보통 네트워크를 구성하는 parameter의 수는 수백만의 size를 가지게 된다. 모든 dimension에 대해 gradient check를 진행하게 되면 연산이 오래 걸리게 되므로 실용적이지 못하다. 따라서 보통 대용량의 모델에 대한 gradient check를 진행하는 과정은 일부 parameter를 기준으로 삼으며, 나머지 parameter에 대한 gradient는 모두 정확하다고 가정한다. 여기서 조심해야할 부분은 gradient check를 각각의 parameter에 대해 진행해야한다는 점이다. 일부 parameter에 대해 gradient를 체크하는 것이 효율적이지만, random하게 파라미터를 뽑아서 gradient check를 진행하는 것이 아니라 전체 네트워크에 대한 test를 진행하는 것과 같이 고려해서 결정할 사항이라는 것이다.


Sanity check

일반적으로 optimization 과정을 시작하기 전, 네트워크가 제대로 구성되었는지 확인할 수 있는 방법 중 하나가 바로 sanity check이다. 예를 들어 CIFAR-10 dataset에 softmax classifier를 달아놓은 상태라면 cross-entropy loss를 사용하는데, 만약 임의로 모든 weight를 초기화한 상태라면 각 class로 예측될 확률이 $0.1(10\%)$에 수렴하게 된다. 그렇기 때문에 학습 시작 시 loss가 대략 $-\log(0.1) = 2.302$이 되어야 한다. Softmax classfier가 아닌 Weston Watkins SVM(margin이 $1$인 SVM을 생각하면 됨)에서는 초기에 모든 margin들이 violated된 상태(모든 score가 거의 대부분 $0$에 가까운 값을 내보내는 상황)이므로, 초기 상태에서의 loss는 $9$가 나올 것이고, 만약 이 값이 나오지 않다면 초기화가 잘못되었을 가능성이 있다. 그리고 data loss 말고 regularization loss에 대해서도, regularization strength($\lambda$)를 증가하면 전체 loss가 증가하는 식으로 sanity check가 가능하다.
그리고 sanity check과 더불어 딥러닝 연구를 하면서 필요한 팁 중 하나인데, 바로 전체 데이터셋에 대해 바로 학습을 진행하는 것이 아니라 작은 dataset에 대해서 학습을 진행해보고, 제대로 loss가 감소하는지 확인해보는 것이다. 만약 작은 dataset에 대해서도 network가 overfitting되지 않고 학습에 문제가 생긴다면, 코드 상이나 구현 단계에서 문제가 있을 수 있음을 의미한다.


Learning process

신경망 구조를 설계하고 위와 같은 sanity check가 모두 완료된 상태에서, 학습 과정에서의 monitoring이 필요하다. Setting이 완료된 후에도 학습 과정을 모니터링해야하는 이유는 learning rate나 optimizer 설정, 네트워크 레이어 세부 디테일 조정 등 hyperparameter 최적화가 필요하기 때문이다.

Loss function

가장 먼저 확인할 것은 loss function(objective function)의 결과값이다. 모든 loss는 감소하는 방향으로 최적화를 진행하는데, 이 과정에서 각각의 indivisual batch가 네트워크를 통과하는(forward process) 과정에서 측정이 된다. 만약 learning rate를 적절하게 조절하지 못했다면 학습 속도가 너무 느리거나 loss가 발산하는 문제가 발생하게 된다(아래 그래프 참고).

Learning rate란 gradient descent 과정에서 loss에 대해 연산된 각 layer에서의 local gradient를 parameter에 어느 비율로 적용할 지에 대한 척도가 된다. Learning rate가 너무 크다면 loss가 발산하거나 일정 수준의 loss에서 수렴하게 되고, 그렇다고 해서 너무 작게 설정하면 학습 속도가 크게 저하되며 local minima에 빠질 위험이 있다. 모델이나 학습하고자 하는 데이터셋 및 task에 맞게 learning rate를 적절하게 설정하게 되면, loss가 잘 줄어드는 것을 확인할 수 있다. 우측 그래프는 각 epoch에 대해 모든 batch에 대한 loss를 그린 그래프와 같은데, 각 epoch에 대해 모든 batch에 따른 loss를 보게 되면 감소하는 경향성을 확인할 수 있다. 흔히 wiggle(loss가 지나치게 진동하는 형태)되는 그래프는 batch size를 키우면 noise를 줄일 수 있다.

Train/Validation accuracy

그러나 loss가 줄어든다고 무조건 좋은 것은 아니다. 만약 training dataset에 계속 overfitting되고 있는 상황이라면 training loss가 감소하는 것만으로는 이를 확인할 수 없기 때문이다. 따라서 dataset을 단순히 training dataset으로 모두 사용하는 것보다, 실제로 학습된 네트워크가 inference(테스트) 시에도 generalization이 잘될 수 있는지 확인하기 위해 validation set을 따로 구성하게 된다. 따라서 딥러닝에서 사용되는 데이터셋은 크게 train/validation 그리고 test dataset으로 구분할 수 있다.

  1. Training dataset : 학습 과정에서 쓰이는 학습 데이터로, weight parameter에 gradient descent를 적용한다.
  2. Validation dataset : 학습 과정에서 쓰이는 테스트용 데이터로, weight parameter에 gradient descent를 적용하지 않는다.
  3. Test dataset : 학습이 끝난 후 모델에 들어가는 실생활 데이터(test data), inference라고 불리는 부분이다.

위와 같이 그래프 상에서 training dataset에 대한 training accuracy와 validation dataset에 대한 validation accuracy로 구분 가능하다. 실제로 네트워크를 학습한 뒤에 inference 단계에서 사용하는 test dataset에는 ground truth가 없는 경우가 많기 때문에 정확도를 직접적으로 측정이 불가능하다. 따라서 training dataset과 validation dataset의 정확도를 서로 비교함으로써, 실제로 training data를 통한 학습에 의해 training dataset을 예측하는 performance를 올린 만큼, validation dataset에도 비슷한 성능이 나오는 것을 확인할 수 있고, 이를 통해 학습된 네트워크의 일반화 성능을 측정할 수 있다.


Parameter updates

지금까지 공부했던 loss function 혹은 objective function, 그리고 backpropagation에 대한 정의와 방법에 대해 배우고 gradient 계산법은 모두 weight parameter를 학습하기 위함이었다. 그렇다면 실제로 parameter를 업데이트하는 여러 방법에 대해서 알아보고자 한다. 지금부터 살펴볼 내용은 pytorch에서 optimizer의 개념에 해당되고, 딥러닝 및 신경망 학습의 근간이 되는 optimization 방법인 gradient descent의 여러 변형에 대해서 살펴볼 것이다.

Vanila update

우선 가장 쉽게 생각할 수 있는 방법은 단순히 각 data point에서의 gradient를 계산한 뒤, gradient의 반대 방향으로 일정 learning rate만큼 parameter를 업데이트하는 것이다. 어떤 함수에서의 gradient는 특정 point에서 해당 함수의 함숫값을 가장 크게 증가시킬 수 있는 방향을 의미하므로, 이에 반대되는 방향은 곧 함숫값을 가장 크게 감소시킬 수 있는 방향을 의미한다.

# Vanilla update
x -= learning_rate * dx

Momentum update

위의 optimization 방법은 convex optimization이라면 항상 global optima에 도달할 수 있지만, 만약 convex optimization이 보장되지 않는다면 문제가 발생한다. 다음과 같은 그림을 보자.

Convex 함수의 정의를 나타낸 그림이다. Convex function을 이해하기 위해서는 convex set 그리고 convex hull에 대한 개념도 필요하지만 간단하게 풀어서 설명하면 convex function이란 정의역 전체에서 아래로 볼록한 구조를 가지는 function이라고 할 수 있다. 따라서 모든 실수 $0 \leq \theta \leq 1$에 대해서,

[ f(\theta x_1 + (1-\theta)x_2) \leq \theta f(x_1) + (1-\theta) f(x_2)
]

를 만족하는 것이 convex function이다. 다차원 함수에서는 함수의 hessian이 positive definite인 경우를 의미한다. Convex 함수가 최적화에 있어 유리하게 가져갈 수 있는 장점 중 하나는 오직 하나의(유일한) global optima만 존재한다는 것이다.
만약 어떤 점 $x^*$에서 함숫값이 최소라고 생각해보자. Inner point에서의 SOSC(second-order sufficient condition)은 $\left. \nabla_x f(x) \right \rvert_{x = x^*} = 0$ 이며 $\left. \nabla_x^2 f(x) \right \rvert_{x = x^*}>0$이다. 만약 $x^*$ 이외에 함숫값이 최소가 되는 점 $\hat{x}$이 있다고 생각해보자. ‘Global optima’가 유일하다는 가정을 뒤집은 것이다. 이러한 증명법을 귀류법이라고 한다. 그럴 경우 convex function의 정의에 의해 다음과 같이 풀어쓸 수 있다.

[ \begin{aligned} \text{For }\forall x& \in \left(0,~1 \right), \newline f(\theta x^* + (1-\theta)\hat{x}) &\leq \theta f(x^*) + (1-\theta) f(\hat{x}) \end{aligned} ]

$f(x^*)$ 그리고 $f(\hat{x})$는 모두 global optima기 때문에 함수의 최솟값인 $m$이라는 값을 가진다고 생각하면, 위의 식은 다음과 같이 정리된다.

[ f(\theta x^* + (1-\theta)\hat{x}) \leq m ]

따라서 $x^*,~\hat{x}$ 경로상의 모든 함숫값들은 $m$보다 작다는 결론이 나오게 되고, 이는 $x^*$와 $\hat{x}$가 global optima point라는 조건에 모순된다.
길게 convex function일 때의 최저점이 유일하다는 증명을 한 이유는 바로 일반적인 loss function은 단순한 convex function으로 표현할 수 없기 때문이다. 그렇다면 loss function으로 최적화를 하는 과정에서 gradient가 $0$인 local optima도 무수히 많이 존재하게 되고, 결국 단순히 gradient descent method로는 수렴이 어려워지게 된다.

그렇기 때문에 gradient 정보만 사용하는 방식보다는, local minimum 근처에서 원래의 관성대로 계속 최적화가 가능하게끔 해주는 방식인 momentum update를 생각해볼 수 있다. 이는중력에 의해 계속 굴러가는 쇠구슬을 생각해보면 이해하기 쉽다.

Gradient의 역방향으로 내려오는 최적화 방식은, 산 꼭대기에서부터 굴러 내려오는 쇠구슬에 비유할 수 있다. 다만 중력이 존재하는 현실 세계와는 다르게 단순히 gradient descent 방식의 최적화는 다음과 같이 gradient가 역전되는 부분에서 산등성이를 넘어가지 못하는 문제가 발생한다.

그러나 만약 내려가는 방향으로의 속도가 유지된다는 가정 하에, local optima point의 깊이가 너무 깊지 않다면 momentum을 주는 형태로 산등성이를 넘어가게끔 해줄 수 있다.

이를 코드로 표현하면 다음과 같다.

v = mu * v - learning_rate * dx
x += v

$v$(velocity)는 현재 속도를 기준으로 gradient 역방향으로 추가된 힘을 받는다. 이미 내려가고 있는 방향으로 또다시 gradient가 붙게 되면 속도가 증가하며, 반대의 경우 속도가 감소하는 형태가 된다. $\mu$는 이전 batch에서의 velocity가 다음 batch의 gradient descent에 끼치는 영향력을 의미한다. 일반적으로 안정적인 학습을 위해 $\mu$로는 큰 값을 사용하며, $0.9$를 주로 사용한다.

Nesterov momentum

앞선 momentum update 방식은 현재 위치를 기준으로 계산된 gradient를 momentum에 더해주고, 이렇게 구한 velocity로 weight parameter를 업데이트하게 된다.

이와는 다르게 momentum에 대한 이동을 먼저 진행한 후에 gradient step을 진행하는 방식인 nesterov momentum update가 있다. Momentum term에 대해 먼저 이동한 뒤의 gradient를 연산한다는 관점에서 look ahead 방식이라 부른다.

x_ahead = x + mu * v
v = mu * v - learning_rate * dx_ahead
x += v

하지만 이 방식은 기존 velocity에 따라 이동된 위치에서의 gradient를 새롭게 계산해야한다는 점에서causality 문제가 발생한다. 이전 방식과 같이 현재 위치에서의 gradient를 기준으로 하려면 다음과 같이 식을 수정할 수 있다.

v_prev = v # back this up
v = mu * v - learning_rate * dx # velocity update stays the same
x += -mu * v_prev + (1 + mu) * v # position update changes form

Annealing learning rate

최적화가 진행될수록 동일한 learning rate를 적용하는 것은 바람직하지 않을 수도 있다. 초반에는 최저점으로부터 거리가 멀기 때문에 빠른 최적화를 위해 어느 정도 step size를 키워도 상관없지만, optimal solution 근처에서는 loss가 진동하면서 최적화가 안될 수도 있기 때문이다. 따라서 learning rate를 annealing(줄여주는) 방법을 사용하게 된다. 이 내용은 pytorch 코드에서 scheduler에 해당되는 내용이다.

Step decay

Training set 전체에 대해 최적화 과정을 거치는 것을 ‘1 epoch’이라 부르는데, 몇몇 epoch마다 learning rate를 줄여주는 방식을 step decay라 부른다. 예를 들어 step size가 $5$이고 factor가 $0.1$이라면 5 epoch마다 learning rate가 $1/10$로 감소하게 된다.

Exponential decay

[ \alpha = \alpha_0 e^{-kt}
] 위와 같은 식으로 scehduling되는 방식이다. 학습이 시작될 당시의 learning rate를 $\alpha_0$라고 했을 때 hyperparameter value $k$에 따라 $t$번째 epoch에서의 learning rate가 exponentially decaying하는 형태가 된다. 일반적으로 $k$를 직접 hyperparameter로 정하지 않고 감소 비율인 $e^{-k}$를 정하게 된다. 앞선 예시처럼 만약 factor가 $0.1$이라면 1 epoch마다 learning rate가 $1/10$로 감소하게 된다.

$1/t$ decay

[ \alpha = \frac{\alpha_0}{1+kt} ] exponential 그래프와 더불어 대표적인 반비례 그래프(점근선이 $y = 0$인)인 $y = \frac{1}{x}$ 형태를 사용한 scheduling 방식이다.

위에서 소개한 여러 learning rate scheduling 방식 이외에도 overfitting을 방지하기 위한 loss term이나 다양한 스케쥴러가 각 task에 맞춰 사용된다.


Second-order methods

최적화 이론을 공부하게 되면 optimization 방법에 단순히 gradient based method만 있는 것은 아니다. 사실상 gradient의 역방향으로 일정 step 만큼 이동하는 것은 first-order taylor series에 근사하는 방법 중 하나이다. Input에 $x$에 대해 continuous하면서 $n$차 미분이 가능한 함수를 $f \in \mathcal{C}^n$ 이라 표현 가능하다. 이때 임의의 $x$에 대해 $f(x)$는 이미 알고있는 함숫값 $f(a)$를 통해 다음과 같이 근사할 수 있다.

[ f(x) = f(a) + \frac{(x-a)}{1!}f^{(1)}(a) + \frac{(x-a)^2}{2!}f^{(2)}(a) + \cdots + \frac{(x-a)^n}{n!} f^{(n)}(a) + o((x-a)^n)
]

여기서 $o(g(x))$라는 notation은 다음과 같은 의미를 가진다고 생각하면 된다.

[ \lim_{x \rightarrow 0,~x \in \Omega} \frac{\vert\vert f(x) \vert\vert}{\vert g(x) \vert} = 0 ] 이 상황에서 만약 $f(x)$를 1차 미분에 근사하면 다음과 같이 정리할 수 있다.

[ f(x) \approx = f(a) + (x-a)f^\prime (x)
] 그러나 선형으로 근사하는 경우, 해당 함숫값을 중심으로 최저점을 찾을 수 없다는 문제가 있기 때문에 step size($\alpha$)를 설정하는 기준을 명확하게 나타낼 수 없다. 다음과 같은 그림을 보면,

특정 지점에서 gradient를 계산하면 감소하는 방향을 찾을 수는 있으나 linear function에는 최솟값이 정의되지 않기 때문에 step size를 찾을 수 없다. 만약 gradient 대신 hessian(2차 미분)의 값을 안다면 $f(x)$를 quadratic function에 근사시킬 수 있기 때문에 보다 효율적인 최적화가 가능할 수 있다. 이러한 방법을 Newton’s method라고 부르며, 다음과 같은 식을 통해 최적화가 가능하다.

[ x^{(k+1)} = x^{k} - {\Delta f(x)}^{-1} \nabla f(x) ]

식에서 $\Delta$는 2차 gradient($\nabla^2$)을 의미한다. 해당 식이 구해질 수 있는 이유는 테일러 2차 급수까지 approximation된 $f(x)$를 미분했을 때 gradient가 $0$인 값을 찾은 것이다. 하지만 이 방법엔 크게 두 가지의 문제점이 존재한다.
첫번째 이유는 연산이 어렵다는 것이다. 다차원 함수 $f(x),~\mathbb{R^n} \rightarrow \mathbb{R}$에 대한 hessian은 다음과 같다.

[ \nabla f(x) = \begin{bmatrix} \frac{\partial f}{\partial x_1}(x) \newline \frac{\partial f}{\partial x_2}(x) \newline \vdots \newline \frac{\partial f}{\partial x_n}(x) \end{bmatrix} ]

[ \Delta f(x) = \nabla \left( \nabla f(x) \right) = \begin{bmatrix} \frac{\partial^2 f}{\partial x_1^2}(x) & \frac{\partial^2 f}{\partial x_1 \partial x_2}(x) & \cdots & \frac{\partial^2 f}{\partial x_1 \partial x_n}(x) \newline \frac{\partial^2 f}{\partial x_2 \partial x_1}(x) & \frac{\partial^2 f}{\partial x_2^2}(x) & \ddots & \frac{\partial^2 f}{\partial x_2 \partial x_n}(x) \newline \vdots & \vdots & \ddots & \vdots \newline \frac{\partial^2 f}{\partial x_n \partial x_1}(x) & \frac{\partial^2 f}{\partial x_n \partial x_2}(x) & \cdots & \frac{\partial^2 f}{\partial x_n^2}(x) \end{bmatrix}
]

일반적으로 딥러닝에서 근사하고자 하는 $f(x)$의 경우, input 차원과 output 차원이 이보다 훨씬 커질 수 있기 때문에 문제가 발생한다. 예를 들어 $1024 \times 1024$의 이미지를 처리하는 과정이라 생각하면 input dimension이 이미 million 단위가 되기 때문에 hessian 연산에 필요한 matrix의 크기는 $10^{12}$로 급증하게 된다. 두번째 문제는 hessian으로 근사한 quadratic function의 global optimal point가 원래 함수의 global optimal point와 다른 방향이 될 수 있다는 것이다. 앞서 본 예시에서는 1차 미분에 대해 approximated된 함수에 대해서 단순히 역방향으로 일정 step size만큼 이동하면 optimal point에 가까워지는 것을 보장할 수 있었다.

그러나 2차 미분에 대한 approximation된 함수의 optimal value는 극솟값이 아닌 극댓값이 될 수도 있으며, 만약 초기화가 제대로 진행되지 않은 data point에서 학습이 진행되면 오히려 loss가 발산하는 문제가 발생할 수 있다. 이러한 문제점들 때문에 딥러닝에서는 $n$차원 이상의 taylor approximation에 대한 optimization 방법이 아닌 gradient descent method를 사용하게 되었다.


Various methods of optimization

단순히 mini-batch를 사용한 stochastic gradient descent 방식을 사용하는 것은 일반적인 모든 네트워크의 parameter 학습에서 활용될 수 있는 정통법이지만 비효율적인 경우가 많다. 보통 간단한 task인 image classification이나 regression의 경우 loss function의 구조가 크게 복잡하지 않아 최적화 과정이 복잡하지 않지만 modality가 특수하거나 generative network 학습과 같이 parameter/hyperparameter 최적화가 어려운 경우, 혹은 여러 modality가 함께 최적화가 되는 경우(multimodality) 단순히 SGD 방식을 사용하게 되면 빠른 최적화가 불가능할 뿐만 아니라, loss에 여러 regularization term을 더하는 등 ablation을 활발하게 진행할 수 없게 된다. 길게 설명했지만 정리해서 한 마디로 풀어쓰자면 robust한 최적화가 힘들다는 것이다.


Per-parameter adaptive learning rate methods

따라서 딥러닝 연구의 한 갈래로서 optimizer 연구도 함께 진행되기 시작했다. 기존 방식에서 parameter가 업데이트되는 형태를 보면 output에 대한 gradient 계산 후 해당 방향의 반대쪽으로 learning rate만큼 이동하는 식으로 최적화하게 된다. 그나마 local minimal point에 수렴하는 것을 방지하기 위해 momentum을 도입하지만, 이는 근본적으로 최적화 방법을 바꾸는 것은 아니다. Learning rate을 모든 파라미터에 대해 따로 적용하는 것은 최적의 hyperparameter를 찾아야하기 때문에 어렵기도 하고 효율적인 방법이라고 볼 수 없다. 그렇기에 각 parameter에 대해 어떤 식으로 gradient를 적용하면 좋을지에 대한 방법으로 다음과 같은 optimizer들이 제안되었다.

Adagrad

# Assume the gradient dx and parameter vector x
cache += dx**2
x += - learning_rate * dx / (np.sqrt(cache) + eps)

Input data point인 $x$에 대해 연산된 gradient를 $dx$라고 하자. $x$라고 표현된 data point는 사실상 어떠한 parameter value에 대해 연산된 결과로, parameter vector라고 표현하도록 하겠다. Adagrad는 이름에서 알 수 있듯이 adaptive한 gradient를 적용하자는 의미로, backpropagation 연산 과정에서 cache에 계산된 gradient인 $dx$를 지속해서 더해간다. 이렇게 저장된 cache를 gradient update 단계에서 normalize하게 되고, zero-division을 방지하기 위한 $\epsilon$ 값과 함께 square root value가 더해져서 gradient인 $dx$를 나누게 된다.
이렇게 정규화를 진행하게 되면 학습 초기 단계에 해당된다고 볼 수 있는 higher gradient(높은 gradient)에서는 effective learning rate이 줄어들 수 있고, small gradient에서는 상대적으로 effective learning rate이 증가하게 된다. 하지만 이러한 방식의 gradient를 적용한다면 어느 정도 이후에는 학습이 멈춰버리는 문제가 발생할 수 있다.

RMSprop

cache = decay_rate * cache + (1 - decay_rate) * dx**2
x += - learning_rate * dx / (np.sqrt(cache) + eps)

RMSprop는 decay rate을 통해 이전 gradient의 영향력을 줄여가며 normalize를 하게 된다. 앞선 방법에 비해 learning rate을 monotonic하게 감소시키지 않기 때문에 학습이 초기에 종료되는 문제를 해결할 수 있다.

Adam

m = beta1*m + (1-beta1)*dx
v = beta2*v + (1-beta2)*(dx**2)
x += - learning_rate * m / (np.sqrt(v) + eps)

RMSprop 개념에 momentum을 추가한 것이 Adam에 해당된다. 위의 코드와 같이 동작하는데, m은 momentum과 관련된 지표이고 v는 위에서 본 cache 역할이라고 할 수 있다. Optimizer 중에서 가장 일반적으로 많이 사용되는 Adam의 경우 beta1 = 0.9 그리고 beta2 = 0.999를 사용하는 것이 default이다. 그런데 Adam을 사용하는 것보다 일부 네트워크 학습 시에는 SGD에 nesterov momentum을 적용하는 것이 효과적인 경우도 있다.
Adam update 과정에는 bias correction mechanism도 있는데, bias 문제란 처음 몇 step동안 vector m 그리고 v가 초기화된 상태에서 $0$에 biasing되는 문제를 말한다. 이를 해결하기 위해 bias correction이 적용된 코드는 다음과 같다.

# t is your iteration counter going from 1 to infinity
m = beta1*m + (1-beta1)*dx
mt = m / (1-beta1**t)
v = beta2*v + (1-beta2)*(dx**2)
vt = v / (1-beta2**t)
x += - learning_rate * mt / (np.sqrt(vt) + eps)


Hyperparameter optimization

Parameter의 경우 학습 과정에서 loss fuction을 최적화하면서 chain rule에 따라 업데이트된다. 그리고 parameter를 효율적으로 최적화하는 것을 연구한 여러 optimizer 중 Adam이 가장 보편적으로 사용된다는 사실을 언급하고 넘어왔다. 그러나 hyper-parameter의 경우 학습하는 과정에서 최적화될 수 없기 때문에 manually 조절해야된다. 딥러닝에서 주로 사용되는, 그리고 정의되는 hyperparameter에는 다음과 같은 종류가 있다.

  1. Initial learning rate
  2. Learning rate policy
  3. Regularization strength(loss penalty strength)

그러나 task마다 hyperparameter의 sensitivity가 모두 다르고, 하나하나 모두 searching하며 계산하기에는 무리가 있기 때문에 효율적인 방법을 통해 찾아가는 것이 중요하다.

Implementation

큰 neural network는 training에 오랜 시간이 걸리기 때문에 hyperparameter 조정을 하는 것 자체가 이미 며칠 혹은 몇 주가 걸릴 수 있는 고된 작업이다. 지금부터 소개할 방법은 코드를 디자인하는 방법이나 network에 따라 모두 달라지기 때문에 모든 경우에 대해 일반적으로 적용될 수 있는 방법은 아니라는 점을 짚고 넘어가고 싶다.
가장 대표적인 방법으로는 worker라는 training method를 가지고, 지속적으로 hyperparameter를 샘플링하고 optimization을 거치는 과정을 사용한다. 이런 방식에서는 worker는 각 epoch마다 validation performance를 체크하게 되며, 가장 최근(last epoch에서의)의 model checkpoint를 저장하거나 가장 좋은 성능을 보인 네트워크(loss, accuracy 등등)을 저장한다. 보통 pytorch를 사용하는 사람들은 torch.save() 메소드를 사용하여 .pth 혹은 .pt 파일로 저장하게 된다.

best_loss = 1e9
for epoch in epochs:
    for iteration in training_dataloader:
    # Training dataset을 통한 네트워크 파라미터 최적화
    # 1 epoch training이 끝나면 validation을 진행한다
    avg_loss = 0.0
    with torch.no_grad():
        for iteration in validation_dataloader:
        # Validation dataset을 통한 학습된 network의 평균 loss 계산
        if avg_loss < best_loss:
            best_loss = avg_loss
            torch.save(model.state_dict(), "best_model.pt")

예시를 들게 되면 위의 코드와 같다. Pytorch를 기준으로 작성하였고, 네트워크를 저장할 때의 기준은 Validation dataset에 대해 loss function의 값이 가장 작을 때가 된다. Task 마다의 차이는 있지만 일반적으로 가장 좋은 성능 metric을 보이는 네트워크 파라미터를 저장하는 경우가 대부분이고, 경우에 따라 가장 최근의 네트워크 파라미터(last epoch)을 저장하는 경우도 있다. $K$-nearest neighborhood 방식을 기억할지 모르겠지만, KNN에서의 hyperparameter에 해당하는 $k$를 결정하기 위해 training dataset과 validation dataset을 바꿔가며 평균값을 측정하였다. 사실 딥러닝에서는 이러한 방식이 불가능하다. 왜냐하면 instance(학습 데이터 자체)를 inference의 근거로 두는 instance based learning과는 다르게 한번 training sample이 네트워크 학습에 관여하게 되면 validation sample(학습에 사용되지 않고, 일반화 성능을 측정하기 위한 데이터셋)의 의미를 잃기 때문이다. 따라서 그 수만 충분하다면 deep learning algorithm에서는 single validation dataset만을 성능 평가에 사용하게 된다.

Hyperparameter ranges

Hyperparameter를 찾는 과정은 parameter 최적화와는 다르게 objective function이 없기 때문에 목적이 되는 value가 없다. 결국 hyperparameter를 찾기 위해서는 임의의 feasible set(최적의 해를 찾기 위한 임의의 집합이라고 보면 된다)를 가정해야하는데, 모든 실수값을 랜덤하게 대입해볼 수 없기 때문에 다음과 같은 방법을 사용한다.

learning rate = 10 ** uniform(-6, 1)

우선 learning rate의 경우, network optimization 과정에서 gradient의 역방향으로 얼마나 각 parameter를 업데이트할 지 결정하는 값으로, loss function을 효율적으로 감소시키기 위한 값을 찾는 것이 주된 목적이 된다.
그럴 때 사용하는 방법이 바로 위와 같이 log scale을 사용한 searching 과정이다. 대부분 최적의 learning rate는 위에서 보는 바와 같이 $10^{-6} \sim 10^1$ 사이에서 찾을 수 있다. 실제로 아무 dataset에 대해서 deep learning code를 돌려보게 되면 log scale로 조절해야 유의미한 차이를 볼 수 있는 것을 확인할 수 있다.

Grid search(정해진 간격만큼 떨어진 value를 테스트해보는 것)의 한 종류라고 볼 수 있는 log search와는 다르게 중요한 hyperparameter는 random search를 하는 것이 더 효과적이라는 연구도 있다(참고 링크). 보통 hyperparameter를 건드릴 때, 특정 hyperparameter가 다른 것보다 훨씬 중요한 경우가 있는데, 주요 hyperparameter를 찾는 과정에서 랜덤으로 찾는 과정이 최적의 값을 찾는 상황에서 더 효율적이라는 것이다.
그런데 무작정 랜덤하게 찾는게 좋다고 해서 정말로 임의의 값을 넣으면 안되고, 본인이 실험하면서 feasibility를 볼 수 있는 영역 내에서 서칭하는 것이 가장 효율적이라고 할 수 있다. 바로 아래에서 바로 설명할 내용이다.

Careful with best values on border

Searching하고 있는 range가 별로일 수 있다. 예를 들어,

learning_rate = 10 ** uniform(-6, 1)

이처럼 특정 hyperparameter를 찾는 범위를 설정했을 때, 각 value에 대해 loss value 그래프가 다음과 같다고 생각해보면

단순히 실험 결과를 토대로 $10^{-6}$이 가장 좋은 performance(가장 작은 loss 값)을 보여주기 때문에 최적의 값을 찾았다고 결론을 내릴 수도 있지만, 실제로는 optimal point가 아닌 경우이다. 경향성이 위와 같이 단순한 monotonic function으로 나오지는 않지만, 본인이 설정한 searching area가 정말로 hyperparameter의 searching space 전반을 커버할 수 있는지 확인해야한다.

Stage search from coarse to fine

만약 당신이 친구와 up-down 게임을 진행한다고 생각해보자. 상대방은 $0$보다 크고 $100$보다 작거나 같은 임의의 자연수를 생각해낸다. 독심술을 가진 사람이 아니라면, 처음부터 $2$나 $99$와 같은 boundary 숫자를 부르는 사람은 없을 것이다(물론 친구가 $1$을 선택할 수도 있긴하다).
이처럼 우리는 최적의 값을 모를 경우, 해당 value가 포함될 수 있는 영역을 찾기 위해 coarse(듬성듬성)한 영역부터 시작해서 fine(빽빽한)한 영역을 찾기 시작한다. 그렇기 때문에 $50$을 먼저 부르고 상대방이 ‘up’을 외쳤다면 $75$를 외치는 과정을 통해 searching area를 $(0,~100)$에서 $(51, 100)$으로 줄이는 과정을 거치게 된다. 결국 hyperparameter를 찾는 과정도 이와 똑같다. 적당한 영역에서 최적의 값을 보이는 value를 찾았다면, 해당 영역 내에서 조금씩 더 좁혀나가면서 searching하는 것이다.

그리고 hyperparameter를 튜닝하는 과정은 굳이 network를 dataset 전체에 대해 학습하지 않아도 되므로 epoch를 작게 주거나 training dataset의 일부만 활용해서 feasibility만 확인하는 것이 좋다.

Bayesian hyperparameter optimization

랜덤하게 찾는 위의 과정과는 다르게 hyperparameter를 제대로 찾는 알고리즘이 지속적으로 연구되었다. 주된 아이디어는 exploration(하이퍼파라미터의 개수, 혹은 찾는 범위에 비례하는 searching space)와 생기는 trade-off에 균형을 맞추는 것이다. Spearmint, SMAC 그리고 Hyperopt와 같이 라이브러리가 많이 생겼지만, 여전히 random search를 정교하게 진행하여 찾는 것보다는 최종 성능이 좋지는 않다고 한다.


Ensemble을 통한 evaluation

신경망 네트워크의 performance를 높이고, 일반화 성능을 높이는 방법으로는 multiple network를 학습시키고, test 상황에서 모든 prediction의 평균을 사용하는 것이다(이를 앙상블 기법이라고 한다). 앙상블 기법에서 사용되는 네트워크의 수가 증가하면 증가할수록 performance는 증가하는 추세를 보인다(물론 계속 증가하는 것은 아닐 수도 있음). 보통 앙상블을 사용할 때는 네트워크가 다 비슷비슷한 상황보다는 서로 variation이 클수록 효과가 더 좋다. 다음은 앙상블을 구성할 때 사용할 수 있는 여러 approach를 소개해보도록 하겠다.

Same model, different initialization

서로 다른 네트워크를 학습할 때 사용할 수 있는 방법은 가장 최적의 hyperparameter(동일 hyperparameter)로 학습하되, parameter initialization만 다르게 하는 것이다. 이 approach의 단점은 initialization에만 네트워크들의 variance를 의존해야한다는 것인데, 사실 초기화 단계가 어찌되었든 최종 network parameter가 모두 유사하게 학습된다면 큰 효과가 없을 수 있다.

Top models discovered during cross-validation

가장 좋은 성능을 보이는 hyperparmeter 집합을 기준으로 $n$개의 모델을 뽑는다. 앞선 방법에 비해 network의 다양성은 보장될 수 있지만, optimal한 모델이 아닌 애매하게 optimal(sub-optimal)한 모델을 사용한다는 문제가 있다. 구현은 훨씬 간단하지만 만약 model이 제대로 된 성능을 보이지 못한다면 distracting factor로 작용할 수 있다는 문제가 있다.

Different checkpoints of a single model

만약 학습이 너무 오래 걸린다면, 단순히 가장 좋은 성능을 보이는 네트워크의 서로 다른 학습 단계에서의 checkpoint를 선택하는 방법을 생각해볼 수 있다. 하지만 이 역시 같은 네트워크가 기준이라는 점에서 다양성을 보장할 수 없기도 하고, 최적화가 제대로 진행되지 않았을 수도 있다.

Running average of parameters during training

학습 과정에서 exponentially decaying하는 네트워크의 parameter를 저장해놓고, 이를 통해 여러 iteration의 네트워크 parameter를 smoothing하는 과정이다. 만약 네트워크의 objective가 bowl형태의 convex이고, 네트워크가 최종 학습 단계에서 최적화 point를 제대로 찾지 못하고 ping-pong(왔다갔다)하고 있을 때 효과적으로 사용될 수 있는 방법이다.

Ensemble은 물론 좋은 머신러닝 기법 중 하나이지만, test data에 대한 inference가 오래 걸린다는 치명적인 문제를 가지게 된다. 이를 해결하기 위한 다른 inspring 중에는 ensemble knowledge를 단일 네트워크에 distillation하는 “Dark knowledge”라는 내용도 있다.


마무리하며..

오늘은 딥러닝 학습 시에 변화해야하는 것들(loss, parameter 그리고 hyperparameter)에 대해서 살펴볼 수 있었다. 사실상 딥러닝 기초 지식 그리고 연구에 필요한 내용은 각 task마다의 디테일한 수학 개념이나 domain knowledge를 제외하고는 거의 전부 설명했다고 볼 수 있다.
지금 글은 네이버 블로그에 과거에 올렸던 cs231n 강좌 복습 글과 cs231n course note를 다시 참고하면서 작성하는 중이다. 사실 당시에는 본인이 굉장히 잘 이해하고 작성했다고 생각했지만 지금 다시 보니까 아는 척하고 쓴 내용도 정말 많은 것 같다. 이 글도 나중에 보게 되면 부끄러울 정도로 무지한 상태로 보일 수도 있을지도 모른다…

A n o t h e r p o s t i n c a t e g o r y