본문 바로가기

문돌이 존버/데이터 분석

핸즈온 머신러닝 2 복습하기(챕터 3: 분류)

반응형

오차 행렬(confusion matrix)

분류기의 성능을 평가하는 더 좋은 방법이다. 쉽게 말해 클래스 A의 샘플이 클래스 B로 분류된 횟수를 세는 것이다. 오차 행렬을 만들려면 실제 타깃과 비교할 수 있도록 먼저 예측값을 만들어야 한다. 테스트 세트로 예측을 만들 수 있지만 테스트 세트는 프로젝트의 맨 마지막에 사용되어야 한다. 대신 cross_val_predict() 함수를 사용하면 된다.

from sklearn.model_selection import cross_val_predict

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

from sklearn.metrics import confusion_matrix
confusion_matrix(y_train_5, y_train_pred)

정밀도와 재현율

  (예측) 5가 아님 (예측) 5
(실제) 5가 아님 TN(true negative) FP(false positive)
(실제) 5 FN(false negative) TP(true positive)
정밀도(precision) = TP / TP+FP
재현율(recall) = TP / TP+FN

재현율은 민감도(sensitivity) 또는 진짜 양성 비율(true positive rate, TPR)이라고도 한다. 

from sklearn.metrics import precision_score, recall_score
precision_score(y_train_5, y_train_pred)
recall_score(y_train_5, y_train_pred)

F1 점수(F1 score)

정밀도와 재현율을 하나의 숫자로 만들면 편리하다. F1 점수는 정밀도와 재현율의 조화 평균(harmonic mean)이다. 

F1 = 2 / (1 / 정밀도) + (1 / 재현율) = TP / TP + ((FN + FP) / 2)

from sklearn.metrics import f1_score
f1_score(y_train_5, y_train_pred)

정밀도/재현율 트레이드오프

SGDClassifier 분류기는 결정 함수(decision function)를 사용하여 각 샘플의 점수를 계산한다. 이 점수가 임곗값보다 크면 샘플을 양성 클래스에 할당하고 그렇지 않으면 음성 클래스에 할당한다. 보통 임곗값이 높아질수록 재현율은 낮아지고 반대로 정밀도는 높아진다. 

사이킷런에서 임곗값을 직접 지정할 수 없지만 예측에 사용한 점수를 확인하여 임곗값을 정해 예측할 수 있다. decision_function() 메서드를 호출하면 각 샘플의 점수가 확인 가능하다.

y_scores = sgd_clf.decision_function([some_digit])
y_scores
>>> array([2412.53175101]) # 점수가 양수면 레이블이 1(True) / 음수면 0(False)
threshold = 0
y_some_digit_pred = (y_scores > threshold)
y_some_digit_pred
>>> array([True])

threshold = 8000
y_some_digit_pred = (y_scores > threshold)
y_some_digit_pred
>>> array([False])

임곗값을 높였더니 재현율이 줄어들어 분류기가 숫자 '5'를 감지하지 못한다. 적절한 임곗값을 정하기 위해 cross_val_predict() 함수를 사용해 훈련 세트에 있는 모든 샘플의 점수를 구해야 한다. 이때 예측 결과가 아닌 결정 점수를 반환받도록 지정해야 한다.

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

이 점수로 precision_recall_curve() 함수를 통해 가능한 모든 임곗값에 대해 정밀도와 재현율을 계산해보자.

from sklearn.metrics import precision_recall_curve

# 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="정밀도")
   plt.plot(thresholds, recall[:-1], "g-", label="재현율")
   
plot_precision_recall_vs_threshold(precisions, recalls, thresholds)
plt.show()
# 정밀도 90% 이상에 해당하는 임곗값 최솟값 구하기
# np.argmax()는 최댓값의 첫 번째 인덱스 반환
threshold_90_precision = thresholds[np.argmax(precisions >= 0.90)] # ~7816

# 훈련 세트에 대한 예측 만들기
# 분류기의 predict() 메서드 대신 위의 임곗값 최소값보다 큰 경우를 레이블 1로 예측
y_train_pred_90 = (y_scores >= threshold_90_precision)

# 예측에 대한 정밀도 & 재현율 확인
precision_score(y_train_5, y_train_pred_90)
recall_score(y_train_5, y_train_pred_90)

ROC 곡선

수신기 조작 특성(ROC, receiver operating chracteristic) 곡선은 이진 분류에서 널리 사용하는 도구로, 거짓 양성 비율(FPR, false postivie rate)에 대한 진짜 양성 비율(TPR, true positive rate, 재현율의 다른 이름)의 곡선이다. FPR은 1에서 음성으로 정확하게 분류한 음성 샘플의 비율인 진짜 음성 비율(TNR, true negative rate)을 뺀 값이다. TNR을 특이도라고도 하며, 따라서 ROC 곡선을 민감도(재현율)에 대한 1-특이도 그래프라고 부른다.

from sklearn.metrics import roc_curve

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

def plot_roc_curve(fpr, tpr, label=None):
    plt.plot(fpr, tpr, linewidth=2, label=label)
    plt.plot([0, 1], [0, 1], 'k--') # 대각 점선
    
plot_roc_curve(fpr, tpr)
plt.show()

TPR이 높을수록 분류기가 만드는 FPR이 늘어난다. 좋은 분류기는 점선에서 왼쪽 위 모서리 방향으로 최대한 멀리 떨어져 있어야 한다. 곡선 아래의 면적(AUC, area under the curve)을 측정하면 분류기들을 비교할 수 있다. 완벽한 분류기는 AUC가 1이고, 완전한 랜덤 분류기[각주:1]는 0.5이다. 

from sklearn.metrics import roc_auc_score
roc_auc_score(y_train_5, y_scores)

RandomForestClassifier를 훈련시켜 SGDClassifier의 ROC 곡선과 AUC 점수를 비교해보려고 한다. RandomForestClassifier에는 decision_function() 메서드가 없고 predict_proba() 메서드가 있다. 이는 샘플이 행, 클래스가 열이고 샘플이 주어진 클래스에 속할 확률을 담은 배열을 반환한다. 

from sklearn.ensemble import RandomForestClassifier

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

roc_curve 함수는 기본적으로 레이블과 점수를 입력해야 한다. 점수 대신 클래스 확률을 전달할 수 있다.

y_scores_forest = y_probas_forest[:, 1] # 양성 클래스에 대한 확률을 점수로 사용
frp_forest, tpr_forest, thresholds_forest = roc_curve(y_train_5, y_scores_forest)

plt.plot(fpr, tpr, "b:", label="SGD")
plot_roc_curve(fpr_forest, tpr_forest, "랜덤 포레스트")
plt.legend(loc="lower right")
plt.show()

roc_auc_score(y_train_5, y_scores_forest)

다중 분류

SGD, 랜덤 포레스트, 나이브 베이즈 => 여러 개의 클래스를 직접 처리
로지스틱 회귀, 서포트 벡터 머신 => 이진 분류만 가능

이진 분류기를 여러 개 사용해 다중 클래스를 분류하는 기법도 많다. 예를 들어, 특정 숫자 하나만 구분하는 숫자별 이진 분류기 10개를 훈련시켜 클래스가 10개인 숫자 이미지 분류 시스템을 만들 수 있다. 0이냐, 아니냐 / 1이냐, 아니냐 / 2냐, 아니냐... 이를 OvR(one-versus-the-rest) 또는 OvA(one-versus-all)전략이라고 한다. 

또 다른 전략은 0과 1 구별, 0과 2 구별, 1과 2 구별 등 각 숫자의 조합마다 이진 분류기를 훈련시키는 것이다. 이를 OvO(one-versus-one) 전략이라고 한다. 클래스가 N개라면 분류기는 N x (N-1) / 2개[각주:2]가 필요하다. OvO 전략의 주요 장점은 각 분류기의 훈련에 전체 훈련 세트 중 구별할 두 클래스에 해당하는 샘플만 필요하다는 것이다. 

서포트 벡터 머신 같은 일부 알고리즘은 훈련 세트의 크기에 민감해서 작은 훈련 세트에서 많은 분류기를 훈련시키는 것이 나아 OvO를 선호한다. 하지만 대부분 이진 분류 알고리즘은 OvR을 선호한다. 

다중 클래스 분류 작업에 이진 분류 알고리즘을 선택하면 사이킷런은 알고리즘에 따라 자동으로 OvR 또는 OvO를 실행한다. sklearn.svm.SVC 클래스를 사용해 서포트 벡터 머신 분류기를 테스트해보자. SVC는 사이킷런이 내부적으로 OvO 전략을 사용해 45개의 이진 분류기를 훈련시키고 각각의 결정 점수를 얻어 점수가 가장 높은 클래스를 선택한다. 

from sklearn.svm import SVC
svm_clf = SVC(gamma="auto")
svm_clf.fit(X_train, y_train) # y_train_5가 아닌 y_train 사용
svm_clf.predict([some_digit])
>>> array([5], dtype=unit8)

some_digit_scores = svm_clf.decision_function([some_digit])
some_digit_scores
>>> array([[2.xxx, 7.xxxx, 3.xxxx ... ]]) # 각 클래스마다 점수 반환
np.argmax(some_digit_scores)
svm_clf.classes

사이킷런에서 OvO나 OvR을 사용하도록 강제하려면 OneVsOneClassifier나 OneVsRestClassifier를 사용하면 된다. SVC 기반으로 OvR 전략을 사용하는 다중 분류기를 만들어보자.

from sklearn.multiclass import OneVsRestClassifier
ovr_clf = OneVsRestClassifier(SVC())
ovr_clf.fit(X_train, y_train)
ovr_clf.predict([some_digit])
len(ovr_clf.estimators_)
>>> 10

SGD 분류기는 직접 샘플을 다중 클래스로 분류할 수 있어 별도의 OvR, OvO를 적용할 필요가 없다. decision_function() 메서드는 클래스마다 하나의 값(=부여한 점수)을 반환한다.

sgd_clf.decision_function([some_digit])
>>> array([[2.xxx, 7.xxxx, 3.xxxx ... ]]) # 총 10개 점수

SGD 분류기 평가를 위해 cross_val_score() 함수를 통한 교차 검증을 사용한다.

cross_val_score(sgd_clf, X_train, y_train, cv=3, scoring="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")

에러 분석

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([[5578, 0, 22, ... ], # 10x10 오차 행렬
           [0, 6410, ...     ],
           ...               ]])
           
plt.matshow(conf_mx, cmap=plt.cm.gray)
plt.show()

맷플롯립의 matshow() 함수를 사용해서 이미지를 살펴보면, 오차 행렬의 대부분 이미지가 올바르게 분류되었음을 나타내는 주대각선[footnote]배열에서 가장 큰 값은 흰색으로, 가장 작은 값은 검은색으로 정규화되어 그려진다./[footnote]에 있어 좋아 보인다. 숫자 5는 다른 숫자보다 조금 어두워보이는데, 데이터셋에 숫자 5의 이미지가 적거나 분류기가 숫자 5를 다른 숫자만큼 잘 분류하지 못한다는 뜻이다. 

그래프 에러 부분에 초점을 맞춰 에러 비율을 비교한다. 에러 비율은 오차 행렬의 각 값을 대응되는 클래스 이미지 개수로 나눈 것이다. 

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

# 주대각선만 0으로 채우기
np.fill_diagonal(norm_conf_mx, 0) # diagonal=대각선
plt.matshow(norm_conf_mx, cmap=plt.cm.gray)
plt.show()

개개의 에러를 분석해보기 위해 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()

# plot_digits() 함수
def plot_digits(instances, images_per_row=10, **options): 
    # **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.concatentate(row_images, axis=0)
    plt.imshow(image, cmap=mpl.cm.binary, **options)
    plt.axis("off")

다중 레이블 분류

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_ood] 
# np.c_: 1차원 배열을 세로로 만들어 2차원 배열로 합침

knn_clf = KNeighborsClassifier
knn_clf.fit(X_train, y_multilabel)
knn_clf.predict([some_digit])
>>> array([[False, True]])

다중 레이블 분류기를 평가하는 방법은 많다고 한다. 예를 들어, 각 레이블의 F1 점수를 구하고 간단하게 평균 점수를 계산하는 방법이 있다. 다음 코드는 모든 레이블에 대한 F1 점수의 평균을 계산하는 것이다.

y_train_knn_pred = cross_val_predict(knn_clf, X_train, y_multilabel, cv=3)
fl_score(y_multilabel, y_train_knn_pred, average="macro")
# average="macro": 모든 레이블의 가중치가 같다고 가정
# average="weighted": 레이블에 클래스의 지지도(support, 타깃 레이블에 속한 샘플 수)를 가중치로 부여
# average="micro": 모든 클래스의 FP,FN,TP 총합을 이용해 F1 계산

다중 출력 분류

다중 출력 다중 클래스 분류(multioutput-multiclass classification)라고도 하며, 한 레이블이 다중 클래스가 될 수 있도록 일반화한 것이다. 예를 들어, 잡음이 많은 숫자 이미지를 입력으로 받고 깨끗한 숫자 이미지를 MNIST 이미지처럼 픽셀의 강도를 담은 배열로 출력해보자. 분류기의 출력이 다중 레이블(픽셀당 한 레이블)이고 각 레이블은 값을 여러 개 가집니다(0부터 255까지 픽셀 강도). 

MNIST 이미지에서 추출한 훈련 세트와 테스트 세트에 넘파이의 randint() 함수를 사용해 픽셀 강도에 잡음을 추가한다. 타깃 이미지는 원본 이미지가 될 것이다.

noise = np.random.randint(0, 100, (len(X_train), 784))
# (len(X_train), 784): rows&columns
X_train_mod = X_train + noise
noise = np.random.randint(0, 100, (len(X_test), 784))
X_test_mod = X_test + noise
y_train_mod = X_train
y_test_mod = X_test

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

 

  1. 랜덤 분류기는 훈련 데이터의 클래스 비율을 따라 무작위로 예측하는 것을 말한다. 오차 행렬의 실제 클래스가 비슷한 비율의 예측 클래스로 나뉘어 FPR과 TPR 값이 비슷해져 결국 ROC 곡선이 y=x에 가까워진다 [본문으로]
  2. n개 원소에서 k개를 뽑을 수 있는 조합의 수인 이항 계수의 공식은 n! / k!(n-k)! 이다. [본문으로]
728x90
반응형