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

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

Box World 자세히보기

AI/Hands-On Machine Learning 2판

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

Box형 2020. 7. 20. 14:34
반응형

나머지 인생을 설탕물이나 팔면서 보내고 싶습니까

아니면 세상을 바꿔 놓을 기회를 갖고 싶습니까?

- 스티브 잡스 -

 저번 포스팅에서는 MNIST 데이터셋을 활용하여 이진 분류(Bianry Classification)을 구현해보았습니다. 이번 포스팅에서는 같은 MNIST 데이터셋을 활용하여 둘 이상의 클래스를 구별할 수 있는 다중 분류(Multiclass Classifier)에 대해 공부해보겠습니다.

3.4 Multi-Class Classification

 SGDClassifier, RandomClassifier, Naive Bayers 같은 일부 알고리즘들은 Multi-class Classification이 가능합니다. 그러나 Logistic Regression, SVM과 같은 알고리즘은 Binary Classification만 가능합니다. 그러나 여러 개의 Binary Classifier를 사용하여 Multiclass Classifier 구현이 가능합니다.

 예를 들어 특성 숫자 하나만을 구분하는 10개의 Bianry Classifier(0~9)를 훈련시켜 클래스가 10개인 숫자 이미지 분류 시스템을 만들 수 있습니다. 여기에 이미지 분류 시 Decision score가 가장 높은 것을 클래스로 선택하게 됩니다. 이를 OvR(one vs rest) 혹은 OvA(one vs all)이라고 합니다.

 클래스가 10개인 숫자 이미지 분류 시스템을 구현하는 또 하나의 방법은 0과 1 구별, 0과 2구별, 1과 2 구별 등 모든 숫자의 조합에 대해 Binary Classifier를 학습시키는 것입니다. 이를 OvO(one vs one)이라고 합니다. 즉 클래스가 $N$개라면 Classifier는 $N * (N-1)/2$개가 필요합니다.

 MNIST의 경우 10개의 클래스가 존재하므로 45개의 Classifier를 훈련시켜야 할 것이고, 이미지 하나를 분류하기 위해선 이 45개의 Classifier를 모두 통과시킨 후 가장 높은 점수를 얻은 클래스를 선택하게 될것입니다. 이러한 OvO의 장점은 각 Classifier를 훈련시킬 때 전체 데이터가 아닌 구별을 위한 두가지 클래스에 대한 데이터만 들어간다는 점입니다.

 이러한 OvO의 특징은 training set의 크기에 민감하여 작은 training set을 선호하는 SVM에서 효과적입니다. 그러나 대부분의 Binary Classification 알고리즘에서는 OvR을 선호합니다.


 sklearn으로 Multiclass Classification 구현 시 Binary Classifier를 선택하면 알고리즘에 따라 자동으로 OvR 또는 OvO를 실행합니다. 이제 직접 SVM을 테스트 해보겠습니다.

from sklearn.svm import SVC

svm_clf = SVC()
svm_clf.fit(X_train, y_train)
svm_clf.predict([some_digit])

###결과값###
>>
array([5], dtype=uint8)

 이제 sklearn이 실제로 OvO 방법을 사용하는지 확인해보겠습니다. decision_function()을 사용하면 1개가 아니라, 데이터 당 10개의 점수를 반환합니다. 즉 이 10개의 점수는 각 클래스 마다의 점수라는 것을 의미합니다.

some_digit_scores = svm_clf.decision_function([some_digit])
some_digit_scores

###결과값###
>>
array([[ 2.81585438,  7.09167958,  3.82972099,  0.79365551,  5.8885703 ,
         9.29718395,  1.79862509,  8.10392157, -0.228207  ,  4.83753243]])

 이 10개 중 가장 높은 점수에 해당하는 것은 클래스 5입니다.

np.argmax(some_digit_scores)
>>
5

svm_clf.classes_
>>
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=uint8)

svm_clf.classes_[5]
>>
5

 만약 sklearn에서 OvO나 OvR을 강제하고 싶다면 'OneVsRestClassifier'나 'OneVsOneClassifier'를 사용하면 됩니다. 사용 방법은 Binary 혹은 Multiclass Classifier 객체를 만들어 저 둘 중 하나에 인자로 던져주면 됩니다. 우선은 SVM부터 실행해보겠습니다.

from sklearn.multiclass import OneVsRestClassifier

ovr_clf = OneVsRestClassifier(SVC())
ovr_clf.fit(X_train, y_train)
ovr_clf.predict([some_digit])

###결과값###
>>
array([5], dtype=uint8)
len(ovr_clf.estimators_)
>>
10

 SGDClassifier나 RnadomForestClassifier를 훈련시키는 것도 마찬가지입니다.

sgd_clf.fit(X_train, y_train)
sgd_clf.predict([some_digit])

###결과값###
>>
array([3], dtype=uint8)

 decision_function()으로 SGD가 클래스마다 부여한 점수를 확인해보겠습니다. 대부분의 점수가 큰 음수라는 것은 Classifier가 예측 결과에 대한 강한 확신을 보인다는 뜻입니다. 다만 클래스가 4의 점수가 1823이라는 것은 분류기가 의심하고 있다는 뜻입니다.

sgd_clf.decision_function([some_digit])

###결과값###
>>
array([[-31893.03095419, -34419.69069632,  -9530.63950739,
          1823.73154031, -22320.14822878,  -1385.80478895,
        -26188.91070951, -16147.51323997,  -4604.35491274,
        -12050.767298  ]])

 이제 Classifier의 성능을 평가해보겠습니다. 평가에는 일반적으로 'cross validation'을 사용합니다.

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

###결과값###
>>
array([0.87365, 0.85835, 0.8689 ])

 모든 fold가 84% 이상을 얻었습니다. 나쁘지는 않은 성능이지만 높일 여지는 있습니다. Input의 scale을 조정하여 accuracy를 높여보고자 합니다.

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train.astype(np.float64))
cross_val_score(sgd_clf, X_train_scaled, y_train, cv=3, scoring="accuracy")

###결과값###
>>
array([0.8983, 0.891 , 0.9018])

3.5 에러 분석

 보통은 가장 좋은 몇가지 모델을 골라 하이퍼파라미터를 튜닝하겠지만, 지금은 가능성 높은 모델 하나를 찾았다고 가정하고 성능을 향상시킬 방법을 찾아보겠습니다. 한 가지 방법은 만들어진 에러의 종류를 분석하는 것입니다.

 먼저 Confusion Matrix(오차 행렬)을 살펴보겠습니다. 

y_train_pred = cross_val_predict(sgd_clf, X_train_scaled, y_train, cv=3)
conf_mx = confusion_matrix(y_train, y_train_pred)
conf_mx

###결과값###
>>
array([[5577,    0,   22,    5,    8,   43,   36,    6,  225,    1],
       [   0, 6400,   37,   24,    4,   44,    4,    7,  212,   10],
       [  27,   27, 5220,   92,   73,   27,   67,   36,  378,   11],
       [  22,   17,  117, 5227,    2,  203,   27,   40,  403,   73],
       [  12,   14,   41,    9, 5182,   12,   34,   27,  347,  164],
       [  27,   15,   30,  168,   53, 4444,   75,   14,  535,   60],
       [  30,   15,   42,    3,   44,   97, 5552,    3,  131,    1],
       [  21,   10,   51,   30,   49,   12,    3, 5684,  195,  210],
       [  17,   63,   48,   86,    3,  126,   25,   10, 5429,   44],
       [  25,   18,   30,   64,  118,   36,    1,  179,  371, 5107]])

 정상적으로 출력되긴 했지만, 가독성이 조금 떨어집니다. 따라서 이 숫자를 이미지로 표현하여 가독성을 높이도록 하겠습니다.

plt.matshow(conf_mx, cmap = plt.cm.gray)
plt.show()

 위 Confusion Matrix는 이미지들이 올바르게 분류되었음을 나타내는 주대각선에 있으므로 만족스러운 결과입니다. 그러나 숫자 5는 상대적으로 어두워보이는데, 이는 다른 숫자들만큼 Classification이 잘 되지 않았기 때문입니다.

 그래프의 에러에 초점을 맞춰 Confusion Matrix의 각 값을 대응되는 클래스의 이미지 개수로 나누어 에러 비율을 비교합니다.

row_sums = conf_mx.sum(axis=1, keepdims=True)
norm_conf_mx = conf_mx / row_sums

 이제 이미지로 표현할 건데 주대각선만 0으로 채우겠습니다. 행은 실제 클래스, 열은 예측한 클래스입니다. 클래스 8의 열이 밝다는 것은 많은 이미지들이 8로 잘못 분류되었음을 의미합니다.

np.fill_diagonal(norm_conf_mx, 0)
plt.matshow(norm_conf_mx, cmap=plt.cm.gray)
plt.show()

  Confusion Matrix 분석 시 Classifier의 성능을 향상시킬 키를 찾을 수도 있습니다. 앞서 말했던 것과 같이 위 그래프를 통해 8로 잘못 분류되는 것을 계산할 필요가 있습니다. 예를 들어 8에 대한 데이터를 더 학습하거나, 8과 다른 숫자의 차이를 구별하여 해결하는 등 그 방법은 다양하게 존재하겠습니다.

 좀 더 깊이 들어가 개개의 에러를 분석해보면 Classifier가 무슨 일을 하고, 왜 잘못되었는지에 대해 통찰은 얻겠지만, 난이도는 훨씬 높고 시간도 오래 걸릴 것입니다. 예를 들어 3과 5의 데이터들을 비교해보겠습니다.

cl_a, cl_b = 3, 5
X_aa = X_train[(y_train == cl_a) & (y_train_pred == cl_a)]
X_ab = X_train[(y_train == cl_a) & (y_train_pred == cl_b)]
X_ba = X_train[(y_train == cl_b) & (y_train_pred == cl_a)]
X_bb = X_train[(y_train == cl_b) & (y_train_pred == cl_b)]

plt.figure(figsize=(8,8))
plt.subplot(221); plot_digits(X_aa[:25], images_per_row=5)
plt.subplot(222); plot_digits(X_ab[:25], images_per_row=5)
plt.subplot(223); plot_digits(X_ba[:25], images_per_row=5)
plt.subplot(224); plot_digits(X_bb[:25], images_per_row=5)
plt.show()

  Classifier가 분류한 일부는 정말 사람도 분간하기 어려울만큼 모양이 애매합니다. 그러나 일부를 제외한 대부분의 잘못 분류된 이미지는 확실히 에러인 것 같고, Classifier가 실수한 이유를 이해하기 어렵습니다.

 사실 그 원인은 선형(Linear) 모델은 SGDClassifier를 사용했기 때문입니다. Linear model은 조금 축약해서 말하자면 픽셀 강도에 대한 가중치의 합을 클래스의 점수로 계산합니다. 따라서 모양이 유사한 3과 5는 몇개의 픽셀만 다를 것이므로 모델이 쉽게 혼동할 수 밖에 없습니다.

 이러한 부분을 보완하기 위해서는 3과 5의 형태의 차이를 발견하여 데이터 전처리를 통해 혼동되지 않도록 해준다면 에러를 줄일 수 있을 것입니다.


3.6 Multi-Label Classification

 지금까지는 하나의 데이터가 하나의 클래스에만 할당되었습니다. 그러나 Classifier에 따라 하나의 데이터가 여러 개의 클래스를 출력해야 할 때도 있습니다.

 예를 들어 얼굴 인식 분류기를 생각해보겠습니다. 만약 하나의 사진에 여러 사람이 등장한다면 인식된 사람마다 하나의 클래스를 모두 지정하는게 맞을 것입니다. 구체적으로 Classifier가 앨리스, 밥, 찰리 세 얼굴을 인식하도록 훈련되어있을 때, 앨리스와 찰리가 있는 사진을 본다면 [1, 0, 1]을 출력해야 합니다. 이처럼 여러 개의 Binary 클래스를 출력하는 Classifier를 'Multi-Label Classification'이라고 합니다.

 이해를 위해 간단한 예시를 들어보겠습니다. 아래 코드는 각 숫자 이미지에 두 개의 Label이 담긴 y_multilabel 배열을 만듭니다. 첫 번째 레이블은 숫자가 큰 값(7, 8, 9) 중 하나인지 나타내고, 두 번째는 홀수인지 나타냅니다. 그리고 그 다음줄에서 KNeighborsClassifier에 이 multilabel을 넣고 훈련시킵니다.

from sklearn.neighbors import KNeighborsClassifier

y_train_large = (y_train >= 7)
y_train_odd = (y_train % 2 == 1)
y_multilabel = np.c_[y_train_large, y_train_odd]

knn_clf = KNeighborsClassifier()
knn_clf.fit(X_train, y_multilabel)

  예측을 만들면 두개의 Label이 출력 됩니다. 5는 7, 8, 9에 속하지 않고(False) 홀수이므로(True) 아래 결과는 맞다고 할 수 있습니다.

knn_clf.predict([some_digit])

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

 Multi-Label Classifier를 평가하는 방법은 많습니다. 다만 적절한 지표는 상황마다 다릅니다. 예를 들어 다음과 같이 모든 Label에 대한 F1 점수의 평균을 계산할 수 있겠습니다.

y_train_knn_pred = cross_val_predict(knn_clf, X_train, y_multilabel, cv=3)
f1_score(y_multilabel, y_train_knn_pred, average="macro")
>>
0.976410265560605

 위 코드는 모든 레이블의 가중치(Weight)가 같다고 가정한 것입니다. Weight를 주는 가장 간단한 방법은 Label에 클래스의 지지도(support) 즉 각 Label에 속한 데이터의 수를 Weight로 주는 것입니다. 이를 위해서는 average="macro"를 "weighted"로 설정하면 됩니다.


3.7 Multi-output Classification

 마지막으로 알아볼 Classification은 Multi-output Classification입니다. Multi-Label Classification에서 하나의 Label이 Multi-Class가 될 수 있도록 즉 두개 이상의 값을 가질 수 있도록 일반화한 것입니다. 

  이를 위해 이미지에서 Noise를 제거하는 시스템을 만들어보겠습니다. Noise가 많은 이미지를 Input으로 받고, Noise를 제거한 숫자 이미지를 MNIST 이미지처럼 픽셀의 강도를 담은 배열로 출력하겠습니다. 특히 하나의 픽셀이 하나의 Label이기 때문에 Classifier의 출력은 Multi-Label이고, 각 Label은 0~255사이의 값을 가집니다.

 우선 Input으로 사용할 Noise 섞인 이미지를 만들기 위해 MNIST에서 나눈 training set과 test set 각각에 numpy의 randint() 함수를 사용하여 픽셀 강도에 잡음을 추가하겠습니다. 이에 대한 Label은 Noise가 없는 원본 이미지가 되겠습니다.

왼쪽이 Noise가 섞인 이미지(input), 오른쪽이 깨끗한 이미지(Label)

   이제 Classifier를 훈련시켜 위 이미지를 깨끗하게 만들어보겠습니다.

knn_clf.fit(X_train_mode, y_train_mod)
clean_digit = knn_clf.predict([X_test_mod[some_index]])
plot_digit(clean_digit)


 이것으로 Classification에 대한 설명을 마쳤습니다. 다음 포스팅에서는 지금까지는 구체적인 이해없이 지나쳤던 모델의 훈련에 대해 알아보는 시간을 가져보겠습니다.

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

반응형