[핸즈온 머신러닝 2판] MNIST를 활용한 이진 분류(Binary Classification)은 어떻게 하는 것일까?
어제보다 나은 사람이 되기

걱정보단 실행을, 그러나 계획적으로

Box World 자세히보기

AI/Hands-On Machine Learning 2판

[핸즈온 머신러닝 2판] MNIST를 활용한 이진 분류(Binary Classification)은 어떻게 하는 것일까?

Box형 2020. 7. 19. 12:51
반응형

  이번 포스팅에서는 머신러닝 공부의 Hello World! 라고 부르는 데이터셋인 MNIST를 사용하여 분류(Classification) 작업을 하는 모델을 만들어보고자 합니다.

 이번 포스팅은 아래 포스팅을 공부하시고 보시면 더욱 효과적입니다.

https://box-world.tistory.com/24

 

[머신러닝] 머신러닝 시스템 디자인 하기 : Precision, Recall, F score

시작하며 머신러닝 시스템을 디자인하면서 적용해볼 수 있는 방법들은 다양하게 존재합니다. 이번 포스팅에서는 여러 방법들 중에 하나의 최선의 방법을 골라 적용할지 판단하는 체계적인 방��

box-world.tistory.com


3.1 MNIST

 MNIST 데이터셋은 고등학생과 미국 인구조사국 직원들이 손으로 쓴 70000개의 작은 숫자 이미지로 구성되어있습니다. 각 이미지에는 어떤 숫자를 나타내는지 Label은 붙어있지 않습니다. sklearn에서는 MNIST 데이터셋 등 일반적으로 알려진 데이터셋을 내려받을 수 있는 함수를 제공합니다.

from sklearn.datasets import fetch_openml

mnist = fetch_openml('mnist_784', version = 1)
mnist.keys()

###결과값###
>>
dict_keys(['data', 'target', 'frame', 'feature_names', 'target_names', 'DESCR', 'details', 'categories', 'url'])

 sklearn이 읽어들인 데이터셋은 비슷한 딕셔너리 구조를 가지고 있습니다.

- DESCR : 데이터셋을 설명함

- data : 데이터가 하나의 행, feature가 하나의 열로 구성된 배열을 가짐

- target : label을 담아놓은 배열

 직접 살펴보겠습니다.

X, y = mnist["data"], mnist["target"]
X.shape

###결과값###
>>
(70000, 784)

y.shape
###결과값###
>>
(70000,)

 70000개의 이미지는 28 * 28 픽셀이기 때문에 784개의 feature를 가지고 있습니다. 각 feature는 0~255사이의 픽셀 강도를 나타냅니다. 하나의 이미지를 확인하기 위해서는 1) 이미지의 feature 벡터를 28 * 28 배열로 크기를 바꾸고, 2) matplotlib의 imshow() 함수를 사용해 그려줍니다.

import matplotlib as mpl
import matplotlib.pyplot as plt

some_digit = X[0]
some_digit_image = some_digit.reshape(28, 28)

plt.imshow(some_digit_image, cmap = "binary")
plt.axis("off")
plt.show()

 위 이미지의 label을 확인해보겠습니다.

y[0]

###결과값###
>>
'5'

 이때 label은 문자열입니다. 그러나 머신러닝 알고리즘에서는 숫자를 사용해야하기 때문에 y를 정수로 변환해줘야 합니다.

import numpy as np

y = y.astype(np.uint8)

 아래에 보이는 전체적인 MNIST 이미지 샘플들을 보면 분류 작업이 매우 어려울 것이란 것을 직감할 수 있습니다.

def plot_digits(instances, images_per_row=10, **options):
    size = 28
    images_per_row = min(len(instances), images_per_row)
    images = [instance.reshape(size,size) for instance in instances]
    n_rows = (len(instances) - 1) // images_per_row + 1
    row_images = []
    n_empty = n_rows * images_per_row - len(instances)
    images.append(np.zeros((size, size * n_empty)))
    for row in range(n_rows):
        rimages = images[row * images_per_row : (row + 1) * images_per_row]
        row_images.append(np.concatenate(rimages, axis=1))
    image = np.concatenate(row_images, axis=0)
    plt.imshow(image, cmap = mpl.cm.binary, **options)
    plt.axis("off")
    
plt.figure(figsize=(9,9))
example_images = X[:100]
plot_digits(example_images, images_per_row=10)
save_fig("more_digits_plot")
plt.show()


 이제 본격적인 훈련에 앞서 데이터셋을 6:1 비율의 training set과 test set으로 분리하겠습니다.

X_train, X_test, y_train, y_test = X[:60000], X[60000:],y[:60000],y[60000:]

 참고로 데이터셋은 웬만하면 섞는게 좋습니다. 왜냐하면 어떤 알고리즘은 순서에 민감하여 비슷한 데이터가 연속으로 들어오면 성능이 나빠질 수 있기 때문입니다. 우리가 사용할 training set의 경우 이미 섞여 있어 별도의 함수를 사용하지 않았습니다.


2.3. 이진 분류기 훈련

 문제를 단순화해서 이미지가 5이냐 아니냐 두개의 클래스를 분류하는 이진 분류기(Binary classifier)로 문제의 접근을 시작해보겟습니다.

 우선 분류 작업을 위한 target vector를 만들어 보겠습니다.

y_train_5 = (y_train == 5) # 5는 True, 다른 숫자는 모두 False
y_test_5 = (y_test == 5)

  이제 분류 모델을 만들어 훈련시켜보겠습니다. 우리가 첫 번째로 사용할 모델은 sklearn의 SGDClassifier 클래스의 확률적 경사 하강법(Stochastic Gradient Descent)입니다. SGD는 Loss function 계산 시 전체가 아닌 일부 데이터셋을 이용하기 때문에 속도가 빨라서 매우 큰 데이터셋을 다루는데 효과적입니다.

from sklearn.linear_model import SGDClassifier

sgd_clf  = SGDClassifier(random_state = 42)
sgd_clf.fit(X_train, y_train_5)

 이제 훈련된 모델을 이용해 숫자 5의 이미지를 감지해보겠습니다.

sgd_clf.predict([some_digit])

###결과값###
>>
array([ True])

3.3 성능 측정

3.3.1 cross validation을 사용한 accuracy 측정

sklearn이 제공하는 기능보다 cross validation 과정을 더 많이 제어해야 한다면 직접 함수를 정의하면 됩니다.

- StratifiedFold는 클래스 비율이 유지되도록 fold를 만들어 줍니다.

- 매 반복에서 Classifier 객체를 복제하여 training fold로 훈련시키고, test fold로 예측을 만듭니다.

- 그 다음 올바른 예측의 수를 세어 정확한 예측의 비율을 출력 합니다.

from sklearn.model_selection import StratifiedKFold
from sklearn.base import clone
skfolds = StratifiedKFold(n_splits=3,random_state = 42)

for train_index, test_index in skfolds.split(X_train, y_train_5):
  clone_clf = clone(sgd_clf)
  X_train_folds = X_train[train_index]
  y_train_folds = y_train_5[train_index]
  X_test_fold = X_train[test_index]
  y_test_fold = y_train_5[test_index]

  clone_clf.fit(X_train_folds, y_train_folds)
  y_pred = clone_clf.predict(X_test_fold)
  n_correct = sum(y_pred == y_test_fold)
  print(n_correct / len(y_pred))

 cross_val_score() 함수로 fold가 3개인 k-fold cross validation을 이용하여 SGDClassifier를 평가해보겠습니다.

from sklearn.model_selection import cross_val_score

cross_val_score(sgd_clf, X_train, y_train_5, cv = 3, scoring = "accuracy")

###결과값###
>>
array([0.95035, 0.96035, 0.9604 ])

 모든 fold에 대한 accuracy가 95% 이상입니다. 성능이 매우 준수해보이는데 과연 그럴까요? Label에 상관없이 70000개의 모든 데이터를 5가 아니라고 분류하는 더미 분류기를 만들어 accuracy를 확인해보겠습니다.

from sklearn.base import BaseEstimator

class Never5Classifier(BaseEstimator):
  def fit(self, X, y=None):
    return self
  def predict(self, X):
    return np.zeros((len(X),1), dtype=bool) #0으로 초기화된 nparray 리턴
never_5_clf = Never5Classifier()
cross_val_score(never_5_clf, X_train, y_train_5, cv = 3, scoring = "accuracy")

###결과값###
>>
array([0.91125, 0.90855, 0.90915])

 모두 5가 아니라고 예측했음에도 정확도가 90%가 넘습니다. 이미지의 10%가 5에 해당하기 때문에 다 아니라고 예측해도 맞출 확률이 90%이기 때문입니다. 여기에서 왜 classification을 다룰 때, accuracy를 성능 측정 지표로 사용하지 않는지 알려줍니다. 특히 클래스가 불균형한 데이터셋일수록 더욱 그렇습니다.


3.3.2 오차 행렬

 Classification의 성능을 평가하는데 더 좋은 방법은 오차 행렬(Confusion matrix)입니다. 기본적인 아이디어는 클래스 A의 샘플이 클래스 B로 분류된 횟수를 세는 것입니다. 예를 들어서 Classifier가 숫자 5를 3으로 잘못 분류한 횟수를 알고 싶다면, 행렬의 5행 3열을 보면 됩니다.

 Confusion matrix를 만드려면 실제 정답값과 비교할 수 있도록 예측값을 만들어야 합니다. 이때 주의할 점은 test set은 건드려선 안된다는 점입니다. 누차 강조하지만 test set는 프로젝트의 가장 마지막에 쓰입니다. 대신 cross_val_predict()를 사용할 수 있습니다. 

 cross_val_predict()는 cross_val_score()처럼 k-fold cross validation을 수행하지만, 점수 대신 각 test fold에서 얻은 예측을 반환합니다. 이를 다시 말하면 모든 training set에 대해 '깨끗한' 예측을 했다고 말할 수 있습니다. 즉 훈련 동안 보지 못했던 데이터에 대해 예측했다는 의미입니다.

from sklearn.model_selection import  cross_val_predict

y_train_pred = cross_val_predict(sgd_clf, X_train, y_train_5, cv = 3)

 이제 실제 정답값과 예측값을 넣고 Confusion matrix를 호출해보겠습니다.

from sklearn.metrics import confusion_matrix

confusion_matrix(y_train_5, y_train_pred) #분류결과표

###결과값###
>>
array([[53892,   687],
       [ 1891,  3530]])

  Confusion matrix는 네 가지 영역으로 구별됩니다. 행은 실제 클래스, 열은 예측한 클래스입니다.

True Positive(3530)는 실제 이미지가 5인데(Positive), 예측도 5로 한것입니다(Positive).

True Negative(53892)는 실제 이미지가 5가 아닌데(Negative), 예측값도 5가 아니라고 한것입니다.(Negative).

False Positive(687)는 실제값은 5인데(Positive), 예측값은 5가 아닌 경우입니다(Negative). 

False Positive(1891)는 실제값은 5가 아닌데(Negative), 예측값은 5라고 한 경우입니다(Positive).

 만약 Classifier가 완벽하다면 True Positive와 True Negative를 제외하고 다음과 같이 나머지는 0일 것입니다.

y_train_perfect_predictions = y_train_5
confusion_matrix(y_train_5, y_train_perfect_predictions)

###결과값###
>>
array([[54579,     0],
       [    0,  5421]])

 Confusion Matrix에 대한 정보를 요약해 표현하는 두가지 중요한 지표는 Recall Precision입니다.

 - Recall : 5라고 예측한 데이터들 중 실제 5인 데이터는 얼마나 들어있느냐 입니다.

- Precision : 실제로 5인 데이터들 중 예측값도 5라고 얼마나 잘 예측했느냐입니다.


3.3.3 Precision과 Recall

sklearn은 Precision과 Recall을 포함하여 Classifier의 지표를 계산하는 여러 함수를 제공합니다.

from sklearn.metrics import precision_score, recall_score

precision_score(y_train_5, y_train_pred)

###결과값###
>>
0.8370879772350012
recall_score(y_train_5,y_train_pred)

###결과값###
>>
0.6511713705958311

 위 결과를 통해 우리의 모델은 전체 숫자 5에서 83%만 정확하게 5라고 예측했으며, 5라고 예측한 것중 65%만 실제 5였음을 알 수 있습니다.

 여기에 한 단계 더 나아가 Precision과 Recall을 하나의 숫자로 표현한 지표인 F1 score가 있습니다.

from sklearn.metrics import f1_score

f1_score(y_train_5, y_train_pred)

###결과값###
>>
0.7325171197343846

 보통 Precision과 Recall이 비슷하다면 F1 score가 높습니다. 그러나 상황에 따라 Precision과 Recall 중 더 중요한 지표가 있을 수 있습니다.

 예를 들어 어린이에게 안전한 동영상을 걸러내는 Classifier를 만든다 한다면 Recall을 높여 나쁜 동영상이 몇개 노출되는 것보단 좋은 동영상이 제외되더라도(Low Recall) 안전한 것들만 노출시키는 것이 좋습니다(High Precision).

 Precision과 Recall은 반비례 관계(trade-off)이므로 상황에 따라 충분히 고민하고 경중을 따지는게 좋습니다.


3.3.4 Precision/Recall trade-off

 SGDClassifier가 어떻게 Classification하는지 보면서 Precision과 Recall의 trade off 관계를 이해해보겠습니다. 우리의 Classifier는 Decision Function을 사용하여 각 데이터의 점수를 계산합니다. 이 점수가 임곗값(threshold)보다 크면 Positive 클래스를 할당하고, 그렇지 않다면 Negative 클래스를 할당합니다.

 Decision Threshold가 위 그림에서 두개의 숫자 5 사이라고 가정해보겠습니다. 이 기준선의 오른쪽에는 4개의 True Positive와 하나의 False Positive가 있습니다. 그러므로 이 threshold에서 Precision은 80%라 할 수 있습니다. 하지만 실제 숫자 5는 6개이고 이중 Classifier는 4개만 감지했으므로 Recall은 67%입니다.

 Threshold를 높이면(오른쪽 방향으로 옮기면) False Positive였던 6이 True Negative가 되면서 Precision이 100%가 됩니다. 그러나 True Positive하나가 False Negatvie가 되면서 Recall은 50%로 줄어듭니다. 반대로 Threshold를 내리면 Recall이 높아지는대신 Precision이 줄어들 것입니다.

 sklearn에서는 Threshold를 직접 지정할 수는 없지만, 예측에 사용한 점수는 확인할 수 있습니다. decision_function() 메서드를 호출하면 각 데이터의 점수를 알 수 있습니다. 그리고 이 점수를 기반으로 원하는 Threshold를 정하여 예측을 만들 수 있습니다.

y_scores = sgd_clf.decision_function([some_digit])
y_scores

###결과값###
>>
array([2164.22030239])
#threshold가 0일때

threshold = 0
y_some_digit_pred = (y_scores > threshold)
y_some_digit_pred

###결과값###
>>
array([ True])

 이제 threshold를 높여보겠습니다. 보다시피 threshold가 0일 때는 감지되던 실제 5인 데이터가 threshold를 높이게 되면서 놓치게 됩니다.

threshold = 8000
y_some_digit_pred = (y_scores > threshold)
y_some_digit_pred

###결과값###
>>
array([False])

 그렇다면 적절한 threshold는 어떻게 정할 수 있을까요? 1) 우선 cross_val_predict() 메서드를 이용하여 training set 내 모든 데이터의 점수를 구해야 합니다.

y_scores = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3, method = "decision_function")

 이제 위의 결과를 precision_recall_curve() 함수에 넣으면 모든 threshold에 대한 Precision과 Recall의 변화 추이를 알 수 있습니다.

from sklearn.metrics import precision_recall_curve

precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)
def plot_precision_recall_vs_threshold(precisions, recalls, thresholds):
    plt.plot(thresholds, precisions[:-1], "b--", label="Precision", linewidth=2)
    plt.plot(thresholds, recalls[:-1], "g-", label="Recall", linewidth=2)
    plt.legend(loc="center right", fontsize=16) # Not shown in the book
    plt.xlabel("Threshold", fontsize=16)        # Not shown
    plt.grid(True)                              # Not shown
    plt.axis([-50000, 50000, 0, 1])             # Not shown



recall_90_precision = recalls[np.argmax(precisions >= 0.90)]
threshold_90_precision = thresholds[np.argmax(precisions >= 0.90)]


plt.figure(figsize=(8, 4))                                                                  # Not shown
plot_precision_recall_vs_threshold(precisions, recalls, thresholds)
plt.plot([threshold_90_precision, threshold_90_precision], [0., 0.9], "r:")                 # Not shown
plt.plot([-50000, threshold_90_precision], [0.9, 0.9], "r:")                                # Not shown
plt.plot([-50000, threshold_90_precision], [recall_90_precision, recall_90_precision], "r:")# Not shown
plt.plot([threshold_90_precision], [0.9], "ro")                                             # Not shown
plt.plot([threshold_90_precision], [recall_90_precision], "ro")                             # Not shown                   
plt.show()

(아래 그래프에서 Precision 곡선이 중간에 울퉁불퉁한 이유는 threshold를 올리더라도 Precision이 낮아질 때가 있기 때문입니다. 예를 들어 가운데 Threshold에서 오른쪽으로 숫자 하나만큼 이동하면 4/5(80%)에서 3/4(75%)로 줄어들게 됩니다.)

 혹은 Recall에 대한 Precision 곡선(PR)을 그릴 수도 있습니다. 아래 그림을 보시면 Recall이 80%인 부근에서 Precision이 급격하게 줄어들기 시작합니다. 이 부분 직전을 trade off로 선택하는 것이 좋습니다. 예를 들면 60% 부근이 여기에 해당될 수 있겠습니다.

 만약 Precision 90%가 목표라면 최댓값의 첫번째 인덱스를 반환하는 np.argmax()를 이용하여 90%의 Precision을 만드는 가장 낮은 Threshold를 찾을 수 있습니다.

threshold_90_precision = thresholds[np.argmax(precisions >= 0.90)]
#~7816

 training set에 대한 예측을 만듭니다.

y_train_pred_90 = (y_scores >= threshold_90_precision)

 이 예측에 대한 Precsion과 Recall을 확인하면, Precision 90%를 실제로 달성했음을 볼 수 있습니다.

precision_score(y_train_5, y_train_pred_90)
>>
0.9000345901072293

recall_score(y_train_5, y_train_pred_90)
>>
0.4799852425751706

3.3.5 ROC 곡선

 ROC 곡선도 Binary Classification에서 많이 쓰이는 도구 중 하나입니다. ROC 곡선은 False Positive 비율(FPR)에 대한 True Positive 비율(TPR, Recall의 다른 이름)의 곡선입니다. FPR은 1 - True Positive 비율(TPR)입니다. TPR은 특이도(specificity)라고도 합니다. 다시 말해서 ROC 곡선은 Recall에 대한 1 - specificity라고 할 수 있습니다.

 ROC 곡선 사용을 위해서는 우선 roc_curve() 함수를 사용하여 여러 threshold에 대한 TPR과 FPR을 계산해야 합니다.

from sklearn.metrics import roc_curve

fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)

 그리고 matplotlib을 이용하여 TPR에 대한 FPR 곡선을 나타냅니다.

def plot_roc_curve(fpr, tpr, label=None):
    plt.plot(fpr, tpr, linewidth=2, label=label)
    plt.plot([0, 1], [0, 1], 'k--') # dashed diagonal
    plt.axis([0, 1, 0, 1])                                    # Not shown in the book
    plt.xlabel('False Positive Rate (Fall-Out)', fontsize=16) # Not shown
    plt.ylabel('True Positive Rate (Recall)', fontsize=16)    # Not shown
    plt.grid(True)                                            # Not shown

plt.figure(figsize=(8, 6))                         # Not shown
plot_roc_curve(fpr, tpr)
plt.plot([4.837e-3, 4.837e-3], [0., 0.4368], "r:") # Not shown
plt.plot([0.0, 4.837e-3], [0.4368, 0.4368], "r:")  # Not shown
plt.plot([4.837e-3], [0.4368], "ro")               # Not shown

plt.show()

 TPR이 높을수록 Classifier가 만드는 FPR이 늘어납니다. 빨간색 점선은 데이터를 랜덤으로 분류하는 Classifier의 ROC 곡선입니다. 좋은 Classifier일수록 이 빨간색 점선에서 멀리 떨어져 있어야 합니다.

 곡선 아래의 면적(AUC)를 측정하면 Classifier를 비교할 수 있습니다. 빨간 색 점선의 AUC는 0.5입니다. 

from sklearn.metrics import roc_auc_score

roc_auc_score(y_train_5, y_scores)
>>
0.9604938554008616

 그렇다면 어떨 때 ROC 곡선을 사용하고 어떨 때 Precision/Recall(PR) 곡선을 사용해야 할까요? 보통은 Positive 클래스가 드물거나, False Negative보다 False Positive가 더 중요할 때, 그러니까 예를 들어 보통 걸릴 확률이 아주 미비한 암 발병과 같은 것을 검출하는 상황에서는 PR 곡선을 씁니다. 그리고 그렇지 않으면 ROC 곡선을 사용합니다.


 이제 RandomForestClassifier를 훈련시켜 SGDClassifier의 ROC 곡선과 ROC AUC 점수를 비교해보겠습니다. 우선 training set의 점수를 알아야 하는데 RandomForestClassifier에는 작동 방식의 차이로 decision_function() 대신 predict_proba() 메서드가 있습니다. 이는 데이터가 행, 클래스가 열이고 데이터가 주어진 클래스에 속할 확률을 담은 배열을 반환합니다.

from sklearn.ensemble import RandomForestClassifier

forest_clf = RandomForestClassifier(random_state = 42)
y_probas_forest = cross_val_predict(forest_clf, X_train, y_train_5, cv=3, method = "predict_proba")

 roc_curve() 함수 사용을 위해서는 Label과 점수가 필요합니다. 그러나 점수 대신에 클래스 확률을 사용할 수 있기 때문에, Positive 클래스 확률을 점수로 사용해보겠습니다.

y_scores_forest = y_probas_forest[:, 1]
fpr_forest, tpr_forest, thresholds_forest = roc_curve(y_train_5, y_scores_forest)

 이제 ROC 곡선을 그려보고 SGDClassifier와 RandomeForest를 비교해보겠습니다. 아래 그래프 결과를 보면 RandomForest의 결과가 훨씬 좋은 것을 확인할 수 있습니다.

plt.figure(figsize=(8, 6))
plt.plot(fpr, tpr, "b:", linewidth=2, label="SGD")
plot_roc_curve(fpr_forest, tpr_forest, "Random Forest")
plt.plot([4.837e-3, 4.837e-3], [0., 0.4368], "r:")
plt.plot([0.0, 4.837e-3], [0.4368, 0.4368], "r:")
plt.plot([4.837e-3], [0.4368], "ro")
plt.plot([4.837e-3, 4.837e-3], [0., 0.9487], "r:")
plt.plot([4.837e-3], [0.9487], "ro")
plt.grid(True)
plt.legend(loc="lower right", fontsize=16)
plt.show()

 ROC AUC 점수도 훨씬 높을 수 밖에 없습니다.

roc_auc_score(y_train_5, y_scores_forest)
>>
0.9983436731328145

 이번 포스팅에서는 Binary Classifier를 훈련ㅅ키는 것부터 다양한 지표를 이용하여 모델을 평가하는 법을 공부하였습니다. 다음 포스팅에서는 Multi Classifier에 대해 공부해보겠습니다.

 긴 글 읽어주셔서 감사합니다. 행복한 하루 보내시길 바랍니다 :)

반응형