지금까지는 데이터 Set이 이미 충분히 모였다고 가정한 후에, 실습을 진행하였다. 그러나 현실에서는, 아직 데이터가 충분히 준비되지 않은 상황이 발생할 수도 있다. 만약 데이터가 충분히 준비될 때까지 기다릴 시간이 없다면, 어떻게 모델을 훈련해야 할까? 가장 원초적인 접근법으로, 아래와 같은 방법을 생각해볼 수 있다.
하지만, 위 두 가지 방법 모두 좋은 방법 같지는 않다. 그 이유는 전자의 경우, 자칫 데이터의 양이 너무 방대해질 위험성이 있고, 후자의 경우 모델의 예측 일관성이 낮아질 수 있기 때문이다. 당연히, 모델을 처음부터 다시 학습시켜야 하는 것도 큰 문제이다. 그러므로 위 문제를 해결하기 위해선, 새로운 데이터가 지속적으로 주어지는 환경에서 모델을 효과적으로 업데이트하는 방법이 필요할 것이다.
바로 이러한 학습 방식을 점진적 학습이라 부르며, 대표적인 알고리즘으로 Stochastic Gradient Descent(확률적 경사 하강법, SGD)가 있다.
확률적 경사 하강법에서 확률적이라는 말의 의미는 'Randomly'로 해석할 수 있으며, 경사 하강은 말 그대로 '경사를 따라 내려간다'는 의미이다. 그렇다면 무엇을 랜덤하게 선택하며, 어디에서 내려온다는 것일까?
여기서 랜덤하게 선택되는 대상은 훈련 Set의 Sample이다. SGD는 훈련 Set에서 랜덤 Sample을 선택하는 과정을 반복하면서, 경사를 조금씩 내려간다. 이 때, 주의해야 할 것은 경사를 내려오는 폭이 너무 넓으면, 최적점을 지나쳐 버릴 수도 있다는 것이다.
만약 훈련 Set의 모든 Sample을 다 선택(사용)한 이후에도, 충분히 하강하지 못했다면, 다시 훈련 Set에 모든 Sample을 채우고 랜덤 선택을 이어나간다. 이 때, 훈련 Set을 모두 사용하는 것을 Epoch(에포크)라 부른다. 참고로 SGD 모델을 훈련할 때, 일반적으로 수십, 수백번의 Epoch를 반복 수행한다.
※ Batch Gradient Descent와 Mini-batch Gradient Descent
SGD에서는 한번에 하나의 Sample만 선택하여 경사를 하강하지만, 한번에 여러 개의 Sample을 선택하여 경사를 하강하는 방법도 있다. Batch Gradient Descent(배치 경사 하강법)는 한번에 전체 Sample을 선택하는 방법으로 정확성이 높지만, 계산 비용이 높다는 단점이 있다.
반면, Mini-batch Gradient Descent(미니 배치 경사 하강법)는 한 번에 여러 개의 Sample(Mini-batch)을 선택하는 방법으로써, SGD와 BGD의 장점을 결합한 형태를 갖는다.
확실히 SGD 알고리즘을 사용하면, 훈련 Set이 업데이트 되더라도, 다시 경사의 꼭대기에서부터 시작할 필요가 없을 것이다. 그런데 도대체 여기서 말하는 "경사"는 무엇을 말하는 것일까?
결론부터 이야기하자면, 지금까지 "경사"라고 표현한 것은 사실 Loss Function(손실 함수)을 가리킨 것이었다. Loss Function은 머신 러닝 모델의 예측이 실제 값과 얼마만큼 벗어나 있는가를 판단하는 지표이다. 즉, 손실 함수의 값이 낮을수록 모델의 예측이 실제 값과 더 가까워진다는 것이다.
따라서, 손실 함수의 값을 최소화하는 것이 중요한데, 사실 최소 값을 정확히 알아내는 방법은 매우 어렵거나 불가능하다. 그저 "조금씩" 내려가보다가 어느 정도 낮은 값에 도달했다고 판단될 때, 그 값을 최적 값으로 인정하는 것뿐이다.
그렇다면, 손실 함수는 어떻게 정의되는 것일까? 이 과정을 이해하기 위해 이진 분류 모델의 손실 함수를 직접 만들어보기로 하자. 예를 들어, 어떤 이진 분류 모델이 아래와 같은 예측을 수행했다고 해보자.
손실 함수가 예측 값과 실제 값의 차이를 의미한다는 점에서, 모델의 정확도를 손실 함수로 사용하는 방법을 떠올릴 수 있다. 그러나, 모델의 정확도를 그대로 손실 함수에 사용할 수는 없다. 그 이유는 정확도의 값이 불연속적이기 때문이다.
위와 같이 4개의 Sample이 주어진 상황에서 모델이 가질 수 있는 정확도는 오직 0, 0.25, 0.5, 0.75, 1밖에 없다. 당연히 경사(손실 함수)가 불연속적이라면, 조금씩만 내려와야 하는 경사 하강법을 사용할 수 없을 것이다. 따라서, 모델의 정확도는 손실 함수로 부적합하다.
조금 더 수학적으로 접근해보면, Gradient(기울기)라는 말에서도 알 수 있다시피, SGD는 손실 함수의 최저점을 찾기 위해 기울기를 이용한다. 즉, 손실함수는 반드시 미분 가능해야 하며, 미분 가능은 곧 함수의 연속성을 시사한다. 따라서, 불연속적인 값은 손실 함수로 사용할 수 없다.
모델의 예측과 관련한 연속적인 값으로, 저번 시간에 배운 예측 확률을 떠올려보자. 예측 확률은 0과 1 사이의 어떠한 값이라도 가질 수 있는 연속적인 값이므로, 손실 함수로 사용해도 괜찮을 것 같다.
위 모델이 Sample을 1일 것이라고 예측한 확률을 각각 0.9, 0.3, 0.2, 0.8이라고 해보자. 이 때, 예측 확률은 1에 가까운 값일수록 좋은 값인 반면, 손실은 작을수록 좋은 값이므로, (-) 부호를 붙여주어야 한다. 즉, 예측에 대한 손실은 아래와 같이 계산될 것이다.
하지만, 손실이 -1과 0사이의 값이다보니 손실의 크고 작음이 명확하지 않고, 음수로 표기되다보니 보기 불편한 문제가 있다. 이 때, 예측 확률 X를 -log(X)로 변환하면, 손실의 크고 작음이 명확해짐과 동시에, 손실을 양수로 계산할 수 있게 된다.
이렇게 정의되는 손실 함수를 Logistic Loss Function 또는 Binary Cross-Entropy Function이라고 부른다. 비록 위 손실 함수는 이진 분류를 통해 유도되었지만, 다중 분류의 손실 함수도 이와 유사한 형태를 가지고 있다. 참고로, 다중 분류에서 사용하는 손실 함수는 Cross-Entropy Function이라고 부른다.
참고로, 위 과정은 단지 손실 함수의 개념을 이해하기 위해 수행한 것일뿐, 실제로 손실 함수를 직접 정의할 일은 거의 없다. 이미 많은 문제에 대한 손실 함수가 라이브러리에 정의되어 있기 때문에, 우리는 그저 사용할 손실 함수를 지정하기만 하면 된다.
① Pandas 라이브러리를 통해 실습에서 사용할 데이터를 다운로드 받는다.
import pandas as pd
fish = pd.read_csv('https://bit.ly/fish_csv_data')
② 'Species' 열은 타깃 데이터로, 나머지 열은 입력 데이터로 사용한다.
fish_input = fish[['Weight', 'Length', 'Diagonal', 'Height', 'Width']].to_numpy()
fish_target = fish['Species'].to_numpy()
③ 훈련 Set과 테스트 Set을 구분한다.
from sklearn.model_selection import train_test_split
train_input, test_input, train_target, test_target = train_test_split(fish_input, fish_target, random_state=42)
④ 훈련 Set과 테스트 Set의 특성을 정규화(데이터 전처리)한다.
from sklearn.preprocessing import StandardScaler
ss = StandardScaler()
ss.fit(train_input)
train_scaled = ss.transform(train_input)
test_scaled = ss.transform(test_input)
이로써, SDG 모델 학습에 사용할 데이터 준비가 완료되었다.
사이킷런에서 확률적 경사 하강법을 제공하는 클래스는 SGDClassifier이다. SGDClassifier는 객체를 생성할 때, 매개변수로 loss와 max_iter를 입력받는다. 여기서 loss는 사용할 손실 함수를 지정하며, max_iter는 수행할 Epoch 횟수를 지정한다. 일단 loss 값은 'log_loss'로 지정하여 로지스틱 손실 함수를 사용하기로 하고, max_iter는 10으로 지정하여 모델의 성능을 평가해보기로 한다.
※ 다중 분류에서의 Logistic Loss Function
Logistic Loss Function은 이진 분류에서 사용되는 손실 함수이지만, 다중 분류에서도 사용이 가능하다. 다만, 다중 분류에서 Logistic Loss Function을 사용하면, 각 클래스마다 이진 분류 모델이 따로 만들어진다. 즉, 모든 클래스에 대해 한번씩 현재 클래스를 양성 클래스로, 나머지는 음성 클래스로 두는 방식인 것이다. 이러한 방식을 OvR(One versus Rest)이라고 부른다.
from sklearn.linear_model import SGDClassifier
sc = SGDClassifier(loss='log_loss', max_iter=10, random_state=42)
sc.fit(train_scaled, train_target)
print(sc.score(train_scaled, train_target))
print(sc.score(test_scaled, test_target))
(이번에도 수렴하지 않았다는 경고가 발생했지만, 중요한 내용이 아니므로 무시하기로 한다.) 생각보다 낮은 점수가 출력된 것을 보니, 아직 모델이 충분히 훈련되지 못한 것 같다. SGDClassifier의 객체는 점진적 학습이 가능하므로, 추가 훈련을 수행하여 모델의 성능을 개선할 수 있다. 추가 훈련에는 partial_fit()
메서드가 사용되며, partial_fit()
메서드는 전달받은 데이터만으로 한번의 추가 Epoch를 수행한다.
sc.partial_fit(train_scaled, train_target)
print(sc.score(train_scaled, train_target))
print(sc.score(test_scaled, test_target))
Epoch를 한번 더 수행하고나니, 정확도가 향상되었다. 하지만, 여전히 점수가 낮은 것으로 보아, 아직 충분히 경사를 하강하지 못한(손실이 최소화되지 않은) 것 같다. 즉, Epoch 횟수를 조금 더 늘려야 하는 상태인 것이다. 그런데 Epoch 횟수가 많아질수록 모델의 성능도 좋아지는 거라면, 무조건 Epoch를 많이 반복하면 되는 것 아닐까?
당연한 이야기일 수도 있지만, Epoch의 횟수는 무조건 많다고 좋은 것이 아니다. 이는 SGD 모델이 Epoch 횟수에 따라 과대 적합 또는 과소 적합될 수 있기 때문이다.
Epoch 횟수가 너무 적을 경우, 모델이 훈련 Set을 충분히 학습하지 못하게 된다. 또한 경사를 충분히 내려오지 못하기 때문에, 모델이 최적의 성능을 발휘하지도 못한다. 반대로 Epoch 횟수가 너무 많을 경우, 훈련 Set을 너무 잘 학습하게 되면서, 훈련 Set에 특화된 모델이 만들어질 것이다. Epoch 횟수와 모델의 성능 간의 관계를 그래프로 나타내면 아래와 같다.
모델이 과대 적합되기 시작하면, 테스트 Set에서의 성능이 서서히 감소한다. 따라서, 과대 적합이 시작되기 전에 훈련을 멈춰야 하는데, 이를 조기 종료라고 한다. 당연히 조기 종료를 하기 위해선, 몇번째 Epoch에서 과대 적합이 시작되는지를 알아야 할 것이다. 그러므로 지금부터, 과대 적합되기 전 최적의 Epoch 값을 찾는 방법에 대해 알아보기로 하겠다.
① 이번에는 fit()
메서드 없이 partial_fit()
메서드만을 이용해 모델을 훈련시켜보자.
partial_fit()
메서드만으로 모델을 훈련시킬 때에는, 반드시 전체 클래스에 대한 정보를 제공해야 한다.import numpy as np
sc = SGDClassifier(loss='log_loss', random_state=42)
train_score = []
test_score = []
classes = np.unique(train_target)
for _ in range(300):
sc.partial_fit(train_scaled, train_target, classes=classes)
train_score.append(sc.score(train_scaled, train_target))
test_score.append(sc.score(test_scaled, test_target))
※ partial_fit() 메서드에 전체 클래스 정보를 전달해야 하는 이유
partial_fit()
메서드에 클래스 정보를 전달하지 않으면,partial_fit()
이 최초 호출되었을 때 초기 Set에 포함되어 있던 클래스만 모델이 학습하게 된다. 따라서, 나중에 초기 Set에 포함되어 있지 않은 클래스가 들어오면, 이를 처리하지 못하면서 에러가 발생한다. 따라서,partial_fit()
메서드만으로 모델을 학습시키는 경우, 반드시 전체 클래스에 대한 정보를 제공해야 한다.
② 300번의 Epoch 동안 기록된 훈련 Set과 테스트 Set에서의 점수를 그래프로 그려보자.
import matplotlib.pyplot as plt
plt.plot(train_score)
plt.plot(test_score)
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.show()
③ 약 100번째 Epoch부터 과대 적합의 양상이 나타나고 있다. 따라서, SDGClassifier의 max_iter 값을 100으로 지정하면, 모델이 최대 성능을 발휘하게 될 것이다.
sc = SGDClassifier(loss='log_loss', max_iter=100, tol=None, random_state=42)
sc.fit(train_scaled, train_target)
print(sc.score(train_scaled, train_target))
print(sc.score(test_scaled, test_target))
예상대로, 모델의 성능이 크게 향상되었음을 확인할 수 있다. 그런데 아직, 이 모델의 정체에 대해서는 설명하지 않았다. 사실 확률적 경사 하강법은 손실을 최소화하는 최적화 알고리즘일뿐이므로, 그 자체가 분류 모델이 될 수는 없다. 그렇다면, 이 모델의 정체는 무엇인가?
지금까지 만든 모델의 정체는 바로, 지난 시간에 배운 Logistic 회귀 모델이다. 눈치를 챘을 수도 있겠지만, SGDClassifier의 손실 함수로 Logistic Loss Function을 사용하면, Logistic 회귀 모델이 만들어진다.
그렇다고 해서, 이번 모델이 저번 Logistic 모델과 완전히 동일하다는 말은 아니다. 이번 모델은 저번 회귀 모델과 달리, SGD를 사용함으로써 점진적 학습(실시간 학습)이 가능해졌다는 차이가 있다. 덕분에 Logistic 회귀의 기본적인 성질을 유지하면서도, 더 유연한 모델 학습이 가능해진 것이다.