[혼공머신] 3주차 04-2 확률적 경사 하강법
본 게시글은 한빛미디어의 혼자 공부하는 머신러닝+딥러닝을 바탕으로 작성되었습니다.
점진적 학습
우리는 지금까지 한정된 data를 가지고 학습을 했었다. 그런데 세상은 이렇게 단순하지 않다. 우리는 살면서 수많은 data를 생성한다. 기업이나 연구에서도 단순히 일회성 취미로 data 분석을 할 것이 아니라면 항상 갱신을 해줘야 한다. 이때, 시간이 지날수록 새로운 데이터가 생겨나게 된다. 이러한 상황에서 거울 나라의 앨리스의 붉은 여왕이 말한 것과 같이 우리는 기존의 데이터로만 가지고 분석을 한다면 저 멀리 뒤처지게 될 것이다. 그렇기에 우리는 새로운 데이터를 추가해 성능 향상을 위해 노력을 해야 한다. 그러면 데이터를 추가하려면 어떻게 해야 할까?
우리가 한 가지 생각해 볼 수 있는 방법은 지금까지 해온 것과 같이 기존의 data를 기반으로 새로운 data를 추가하여 전체 model을 학습하는 것이다. 그런데, 이렇게 전체 data를 포함해서 처음부터 다시 한다면 시간이 지날수록 data를 저장하는 공간이 늘어날 것이고, 학습에 드는 시간도 엄청나게 길어질 것이다. 더 많은 공간과 시간은 모두 비용이다. 대체로 이러한 비용은 적을수록 좋기에 우리는 이것을 줄이는 방법을 생각해 볼 것이다.
새로운 data를 추가하면서 data를 저장하는 공간과 학습 시간을 기존과 비슷하게 유지하고 싶다면, 결국 기존의 data를 버리는 수밖에 없다. 하지만 기존의 data를 버리면서 굳이 만든 새 model이 기존의 model보다 성능이 낮으면 어떻게 해야 할까? 굳이 시간과 비용을 들여서 새 model을 만들었는데 성능이 더 떨어지면 그건 그것 나름대로 억울하지 않을 수 없다.
따라서 우린 다시 조금 더 새로운 방법을 생각해볼 것이다. 결국 우리가 새로운 걸 하는데, 억울하지 않기 위해서는 훈련한 모델을 버리지 않아야 한다. 그런데 우리는 새로운 데이터에는 적응을 해야한다. 이러한 것을 위해서 여러 천재적인 개발자분들께서는 점진적 학습 기법을 고안해 내셨다. 여기서 우리가 배울 것은 그 중 대표적인 알고리즘인 확률적 경사 하강법(Stochastic Gradient Descent)이다.
경사 하강법
아마 처음 stochastic gradient descent를 들었을 때 아마 이것이 무엇을 하는 것인지 이해하기 어려울 것이다. 이미 확률이라는 것만 들어도 머리가 아픈데, 경사에 하강? 이건 당최 무슨 말인지 모르겠고 알고 싶지도 않을 수도 있다. 하지만 우리는 머신 러닝을 하기로 한 이상 이것을 알아야 한다. 먼저, 확률이라는 것은 넘어가고 경사, 하강부터 보자. 우선, 간단하게 예시를 들어보도록 하자. 예를 들어 아래의 그래프에서 최솟값을 구한다고 해보자. 어떻게 구해야 할까? 그냥 지금처럼 그래프를 다 그려서 눈으로 어디가 최솟값인지 확인해야 할까?
우리는 여기서 경사 하강법이라는 방법을 사용하는 것이다. 아래의 그래프처럼 경사를 내려가면서 최솟값을 찾는 방법이다. 아래의 그래프를 보면 일정한 간격으로 점이 그래프를 내려감을 확인할 수 있다. 그래서 결국에는 최솟값을 찾게 된다. 그런데 이게 뭐 어쨌다는 것인지 묻고 싶을 수도 있다. 조금씩 내려가서 가장 낮은 곳에 도달하는 것은 당연한 거 아닌가?
이제 아래의 또 다른 그래프를 보자. 이것도 위 그래프와 동일하게 일정한 간격으로 점이 그래프를 따라 내려가지만, 조금 더 적은 횟수에, 그리고 최솟값에 도달하지는 못했다는 것을 알 수 있다.
이렇게 했을 때 또 의문이 생길 수 있다. 그래프에서 점이 내려가는 건 우리가 눈으로 봐서 알 수 있는데, 이걸 컴퓨터는 어떻게 이해할까? 컴퓨터도 눈으로 봐서 가장 낮은 것인지 알 수 있을까? 아쉽게도 그건 아니다. 만약 그게 가능했다면 이런 것도 다 필요가 없을 것이다. 그래서 우리는 고등학교 때 배운 미분이라는 것을 해볼 것이다. 아마 어떤 함수를 미분했을 때 0이 나오는 지점에서 우리는 극값을 구할 수 있다고 배웠을 것이다. 우리도 그것을 활용하는 것이다. 아래의 그래프처럼 원래 함수의 도함수를 구해서 0이 되는 부분을 찾으면 되는 것이다.
그런데, 여기서 한 가지 문제가 발생할 수도 있다. 아래의 그래프처럼 단순히 아래로 볼록인 형태가 아니라 복잡한 형태의 그래프라면 어떻게 할까? 방금까지 한 방법대로라면 국소 최솟값(Local Minimum)에 들어가 학습이 될 수밖에 없다. 하지만 우리가 원하는 것은 전역 최솟값(Global Miniumum)이다. 이러한 경우에는 어떻게 해야할까? 그래프를 그리고 첫 번째 최솟값은 무시하라고 할까?
여기에도 또 다른 방법이 하나 있다. 처음에는 크게 내려가고, 나중에는 세밀하게 조절하는 것이다. 이렇게 학습하면 작은 노이즈의 영향을 최소화하고, 최솟값을 구할 때는 조금 더 정확하게 구할 수 있게 된다.
이렇게 지금까지 무려 그래프를 6개나 그려가며 설명을 했다. 경사하강법은 대충 이런 식으로 흘러간다고 생각을 하면 될 것이다. 물론 앞에서 말한 것대로 해도 반드시 최솟값에 도달하게 될 것이라는 보장은 없다. 다만, 아무것도 안 하는 것보다는 성능이 좋으리라는 것은 분명하다. 이제 확률적이라는 것을 이해해 보자.
확률적 경사 하강법
우리는 지금까지 gradient descent를 할 때, training data 전체를 사용해서 그래프를 최소화하도록 점을 움직였다. 그런데 stochastic gradient descent는 training data 중 하나를 확률적으로, 즉 랜덤하게 골라서 그래프를 그리고 점을 움직이는 방식을 사용한다. 아직 손실 함수에 대해서 설명을 안 해서 설명을 하는 데에 지장이 있어 손실 함수를 먼저 설명하려고 했으나, 귀찮기도 하고, 교재에서도 나중에 설명하기에 먼저 설명하지 않기로 했지만 여기서 잠시만 언급하기로 하자. 지금까지 우리가 사용했던 그래프는 갑자기 어디서 나타난 전혀 새로운 것은 아니고, 우리 모델의 비용을 나타내는 함수이다. 일반적으로 생각했을 때 비용은 낮을수록 좋다. 따라서 우리는 계속 그래프의 최솟값을 찾기 위해 계산을 했던 것이다.
이제 다시 stochastic gradient descent를 보면, 아까까지는 training data 전체를 사용해서 함수를 만들었다면, 이제는 training data 중 하나를 선택해 함수를 만드는 것이다. 이러한 경우, 함수는 training data 하나를 선택할 때마다 달라질 것이다. 이럴 때 함수를 살펴보면 아래와 같이 어지럽게 될 것이다. 물론 정확하게 계산해서 한 것은 아니고 정말 대충 만든 것이기에 이해를 해주기를 바라며, 그래프를 보면, 여기서는 training data 중 4개를 뽑아서 4개의 손실 함수를 만들고 gradient descent를 하여 비용을 낮추는 것이다.
그런데, 그래프를 보면 유의해야 할 것이 하나 있다. 확률적으로 하나의 data만을 선택하여 손실 함수를 만들기에 일시적으로 손실 함수의 값이 증가할 수 있다. 그러나 비용을 낮추는 방향으로 가게 될 것이다.
미니배치 경사 하강법
지금까지 gradient descent, stochastic gradient descent를 살펴보았다. 그런데 너무 극단적이라는 느낌이 있다. gradient descent는 모든 training data를 사용하는데, 시간과 비용이 많이 든다. 그래서 1개씩의 training data를 사용하기 위해 stochastic gradient descent를 사용하는데, 이러면 시간과 비용은 감소하나 너무 변동성이 커지고, gradient descent보다 덜 세밀하게 학습이 될 우려가 있다.
이러한 경우는 교차검증(Cross Validation)에서도 아마 본 것 있을 것이다. 물론 없을 수도 있지만, LOOCV는 한 개의 cross validation data만 써서 성능은 좋지만 시간이 많이 든다. 그래서 우리는 K-Fold cross validation이라는 것을 사용한다. cross validation data를 한 개씩만 사용하는 것이 아니라, 여러 개의 세트, $K$개의 fold로 나누어서 cross validation을 시행하는 것이다. 여기서도 이것과 유사한 방법을 사용한다.
Stochastic gradient descent는 전체 training data 중 하나의 sample data만을 뽑아서 변동성이 너무 커지게 된다. 그렇기에 우리는 하나의 세트, 배치(Batch)를 만들어서 전체가 아닌 일부의 data만 있는 여러 개의 batch를 만들어서 학습을 하는 것이다. 이러한 방법을 미니배치 경사 하강법(Minibatch Gradient Descent)이라고 한다.
간단히 다시 정리해보자면, gradient descent는 전체 data를 사용해 손실 함수를 만들고, minibatch gradient descent는 전체 data를 작은 batch으로 만들어서 그 그룹별로 손실 함수를 만들고, stochastic gradient descent는 하나의 data point만 가지고 손실 함수를 만드는 것이다.
이때, 새로운 용어를 소개하자면, 한 번 training data를 사용해서 학습하는 것을 에포크(Epoch)라고 한다. 따라서 아까 위에서 stochastic gradient descent를 설명할 때 사용했던 그래프를 보면 총 4번의 epoch가 있음을 알 수 있을 것이다. 즉 한 번 경사를 내려가는 것이 한 번의 epoch인 것이다. 다만, 각 방법마다 한 epoch에 사용되는 data가 다른 것이다.
손실 함수
이제 즐거운 손실 함수(Loss Function)에 대해서 다룰 시간이다. 손실 함수는 아까 비용에 대한 것이라고 말한 적이 있을 것이다. 이를 조금 더 러프하게 말한다면 얼마나 우리가 만든 알고리즘이 오류가 많은지 말하는 기준이다. 그렇기에 우리는 손실 함수를 줄이기 위한 방법으로 여러 gradient descent 방법을 살펴본 것이다.
이러한 loss function은 어떻게 구할까? 라고 의문을 가지는 순간 인생이 조금 힘들어질 수 있다. 자세한 것은 나중에 시간이 많을 때 살펴보기로 하고, 우리는 여러 천재분들이 만들어 놓으신 것을 활용하는 것으로 하자. Gradient descent를 하느라 까먹었을 수도 있겠지만, 우리는 지난번까지 logistic regression을 활용한 classification을 다루고 있었다. Loss function은 이러한 logistic regression, linear regression과 같이 함수에 따라 정형화되어 있다. 그러면 logistic regression에 대한 loss function을 살펴보자.
우선, logistic regression에선 확률을 구할 때, sigmoid function을 활용한다고 했었고 이를 활용해 비용함수를 구하면 (2)와 같다. 구하는 과정은 나중에 배우기로 하고, 일단 이렇게 생겼다는 것만 확인하자. 사실, 중간에 로그 가능도 함수를 구하는 과정이 있고, loss function은 로그 가능도 함수의 부호를 바꾼 값이긴 하다. 우리는 gradient descent를 통해서 최소화하는 것이 목표이기에 부호를 바꾸어 최소화할 수 있도록 해준 것이다. 우선, 대충 넘어가기로 하자.
\[\hat{y}_i=\frac{1}{1+\exp(-w_i x_i)} \tag{1}\] \[J(\mathbf{w})=-\sum^n_{i=1}[y_i\log(\hat{y}_i)+(1-y_i)\log(1-\hat{y}_i)] \tag{2}\]이제 식을 보았으니, 이제 간단한 binary classification 예시를 통해 간략하게 설명을 해보기로 하자. 아래의 표를 보면, 총 4개의 prediction 중 2개의 prediction만 정답이므로, accuracy는 0.5이다. 그런데 여기서 우리는 잔머리가 발동할 수 있다. 그러면 여기에 (-1)를 곱해서 이걸 최소화하는 loss function으로 사용해도 되지 않을까?
정답 | 예측 | 정답 여부 |
---|---|---|
1 | 1 | O |
0 | 1 | X |
1 | 0 | X |
0 | 0 | O |
아쉽게도 그건 안 된다. 우리는 loss function을 최소화하는 방법으로 loss function을 미분하여 0이 되는 값을 찾는 것이 목표이므로, 미분이 가능해야 한다. 근데 우리는 예전에 외운 것이 있다. 미분 가능한 함수는 연속이라는 것이 기억이 날 것이다. 그런데 방금 살펴본 accuracy를 계산해 보면 0, 0.25, 0.5, 0.75, 1만 나올 수 있다. 즉 연속이 아니다. 따라서 아쉽게도 accuracy는 loss function으로 사용될 수 없다.
그러면 왜 (2)와 같은 복잡한 함수를 loss function으로 사용하게 되었을까? (2)를 조금 더 간단하게 케이스로 나눠보자면 다음과 같다. 많이 달라 보이지만 표현 방식만 다르지 결국 (2)와 동일한 식이다.
\[J(\mathbf{w})=\sum^n_{i=1}[-J_i(\mathbf{w})] \tag{3}\] \[-J_i(\mathbf{w})=\begin{cases}-\log(\hat{y}_i), y_i=1\\-\log(1-\hat{y}_i), y_i=0\end{cases} \tag{4}\]여기서, $J_i(\mathbf{w})$로 적는 것이 조금 더 수식이 깔끔하나, 그래프로 보여줄 때 시각적으로 보기 편하게 이번만 임의로 비용함수를 $-J_i(\mathbf{w})$와 같은 방식으로 작성하였으니, 참고하길 바란다.
우선, $-$ log function의 그래프를 보자. 만약 우리가 $\hat{y_i}$를 구했고, 그것을 이 함수에 대입하자고 하자. 이때, 만약 $y_i=1$인 경우, 추측한 확률 $\hat{y_i}$가 0에 가까울수록 커진다. 즉, 손실이 커지게 되는 것이다. 만약 반대로 $y_i=0$인 경우, (4)를 살펴보면 $-$비용함수 $-J_i(\mathbf{w})$는 $-\log(1-\hat{y}_i)$이므로 $\hat{y_i}$가 0에 가까워질수록 0에 가까워지게 된다. 이 경우에는 손실이 감소하게 되는 것이다. 즉, 정답에 가까울수록 손실이 줄어들고, 정답에서 멀어질수록 손실이 증가하게 된다는 것을 식으로 표현한 것이다. 이렇게 정의된 loss function을 우리는 직관적으로 로지스틱 손실 함수(Logistic Loss Function)라고 하거나 아니면 이진 크로스엔트로피 손실 함수(BCE, Binary Cross Entropy Loss Function)라고 한다.
SGDClassifier
지금까지 수식도 보고 그래프도 보고 하면서 매우 즐거웠으리라 생각된다. 증명이나 계산 과정은 누구나 재미있어 하지 않는가? 아쉽게도 이제 수식을 보는 시간은 끝났다. 이제 파이썬으로 작성해 보도록 하자. 이제 눈 감고도 할 수 있는 data load나 train_test_split()과 같은 건 설명하지 않도록 하겠다. 일일이 나눠서 쓰기도 귀찮기 때문이다. 그리고 data도 그냥 인터넷에서 가져와야겠다. 어차피 github에 있는 거라 생각보다 지워질 것 같지 않았기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
data = pd.read_csv('https://bit.ly/fish_csv_data')
X = data.iloc[:, 1:]
y = data.iloc[:, 0]
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.fit_transform(X_test)
이제 새로운 것을 사용해 보자. scikit-learn에서 제공하는 stochastic gradient descent를 사용해 볼 것이다. 우리는 loss function으로 logistic loss function을 사용할 것이기에 loss를 log_loss로 설정해 준다. 사용해 보니 overfitting, underfitting은 되지 않은 것 같은데, accuracy가 조금 낮게 나온다. Hyperparameter를 보니 max_iter=10이다. 10번밖에 epoch를 실행하지 않은 것이다. 한 번 조금 더 많이 해볼까?
1
2
3
4
5
from sklearn.linear_model import SGDClassifier
sgd = SGDClassifier(loss='log_loss', max_iter=10, random_state=42)
sgd.fit(X_train_scaled, y_train)
print(sgd.score(X_train_scaled, y_train), sgd.score(X_test_scaled, y_test))
0.773109243697479 0.725
100번 정도로 늘렸는데 그렇게 엄청나게 test score는 증가하지 않은 것 같다. 그래도 어쨌든 accuracy가 올라갔으니 좋은 것이 아닐까? 그러면 계속 늘리면 될까? 라고 생각을 할 수 있는데, 여기서도 overfitting이 발생할 우려가 있다. 한 번 그래프를 통해서 살펴보도록 하자.
1
2
3
sgd = SGDClassifier(loss='log_loss', max_iter=100, random_state=42)
sgd.fit(X_train_scaled, y_train)
print(sgd.score(X_train_scaled, y_train), sgd.score(X_test_scaled, y_test))
0.8571428571428571 0.75
아래의 그래프를 보면 test score의 경우 대략 130 epoch까지는 성능이 향상되나 그 이후로는 더 이상 향상되지 않음을 볼 수 있다. 따라서 epoch를 150 정도로 설정하고 다시 학습을 진행해 보기로 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import numpy as np
import matplotlib.pyplot as plt
sgd = SGDClassifier(loss='log_loss', random_state=42)
score_train = []
score_test = []
classes = np.unique(y_train)
for _ in range(0, 300):
sgd.partial_fit(X_train_scaled, y_train, classes=classes)
score_train.append(sgd.score(X_train_scaled, y_train))
score_test.append(sgd.score(X_test_scaled, y_test))
plt.plot(score_train)
plt.plot(score_test)
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.show
학습을 진행하니 성적이 더 좋게 나왔다. 다만, 이때 우리는 hyperparameter 설정이 필요하다. 기본적으로 SGDClassifier는 일정 횟수 이상 성능이 향상되지 않으면 자동으로 학습을 멈추는 조기 종료(Early Stopping) 기능이 있기에 허용 오차인 tol을 false로 설정하여 무조건 반복을 하도록 설정해 준다.
1
2
3
sgd = SGDClassifier(loss='log_loss', max_iter=150, tol=None, random_state=42)
sgd.fit(X_train_scaled, y_train)
print(sgd.score(X_train_scaled, y_train), sgd.score(X_test_scaled, y_test))
0.957983193277311 0.9
이렇게 gradient descent에 대해서 살펴 보았다. 다음에는 가장 기본적이고 자주 보게 될 알고리즘인 트리 알고리즘에 대해서 알아보기로 하자.