{학습 목적}
Chapter 3 에서는 분류(Classification)에 사용되는 성능 평가 지표 중 0과 1로 결정값이 한정되는 이진 분류의 성능 평가 지표에 대해서 집중적으로 설명한다.
이를 학습하는 이유는 레이블 값이 불균형한 이진분류의 경우 정확도 만으로 머신러닝 모델의 예측성능을 평가할 수 없기 때문에 다양한 분류 성능 평가 지표를 학습하는 것이라고 생각한다.
먼저 분류의 성능 평가 지표부터 살펴보자
- 정확도(Accuracy)
- 오차행렬(Confusion Matrix)
- 정밀도(Precision)
- 재현율(Recall)
- F1 스코어
- ROC AUC
분류는 결정 클래스 값 종류에 따라 긍정/부정과 같은 2개의 결괏값만 가지는 이진 분류와 여러 개의 결정 클래스 값을 가지는 멀티 분류로 나눌 수 있다.
위에서 언급한 성능 지표는 이진/멀티 분류에 모두 적용되는 지표지만, 특히 이진 분류에서 더욱 중요하게 강조되는 지표이다.
01. 정확도(Accuracy)
정확도는 실제 데이터에서 예측 데이터가 얼마나 같은지를 판단하는 지표이다.
정확도는 직관적으로 모델 예측 성능을 나타내는 평가 지표이다.
하지만 이진 분류의 경우 데이터의 구성에 따라 ML 모델의 성능을 왜곡할 수 있기 떄문에 정확도 수치 하나만 가지고 성능을 평가하지 않는다. 이를 예제로 살펴보자.
사이킷런의 BaseEstimator 클래스를 상속받아 아무런 학습을 하지 않고, 성별에 따라 생존자를 예측하는 Classifier를 생성한다.
사이킷런은 BaseEstimator를 상속받으면 Customized 형태의 Estimator를 개발자가 생성할 수 있다.
생성할 MyDummyClssifier 클래스는 학습을 수행하는 fit() 메서드는 아무것도 수행하지 않으며 예측을 수행하는 predict() 메서드는 단순히 Sex 피처가 1이면 0, 그렇지 않으면 1로 예측하는 단순한 Classifier이다.
from sklearn.base import BaseEstimator
class MyDummyClassifier(BaseEstimator):
#fit() 메서드는 아무것도 학습하지 않음.
def fit(self, X, y=None):
pass
#predict() 메서드는 단순히 Sex피처가 1이면 0, 그렇지 않으면 1로 예측함.
def predict(self, X):
pred = np.zeros((X.shape[0],1)) # 0으로 구성된 다차원 array 생성
for i in range(X.shape[0]):
if X['Sex'].iloc[i] == 1 : #위치기반 인덱싱
pred[i] = 0
else:
pred[i] = 1
return pred
이제 생성된 MyDummyClassifier를 이용해 앞 장의 타이타닉 생존자 예측을 수행해보자
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
#원본 데이터를 재로딩, 데이터 가공, 학습 데이터/테스트 데이터 분할
titanic_df = pd.read_csv('titanic_train.csv')
y_titanic_df = titanic_df['Survived']
X_titanic_df = titanic_df.drop('Survived', axis=1)
X_titanic_df = transform_features(X_titanic_df)
X_train,X_test, y_train, y_test = train_test_split(X_titanic_df, y_titanic_df,
test_size=0.2, random_state=0)
#위에서 생성한 DummyClassifier를 이용해 학습/예측/평가 수행
myclf =MyDummyClassifier()
myclf.fit(X_train, y_train)
mypredictions = myclf.predict(X_test)
print('Dummy Classifier의 정확도는: {0:.4f}'.format(accuracy_score(y_test, mypredictions)))
[output]
Dummy Classifier의 정확도는: 0.7877
이렇게 단순한 알고리즘으로 예측을 하더라도 정확도 결과는 78.77%로 꽤 높은 수치가 나올 수 있기 때문에 정확도를 평가 지표로 사용할 때는 신중해야한다.
유명한 MNIST 데이터 세트를 변환해 불균형한 데이터 세트로 만든 뒤 정확도 지표 적용 시 어떤 문제가 발생할 수 있는지 살펴보자.
MNIST 데이터 세트는 0~9까지 숫자 이미지의 픽셀 정보를 가지고 있다.
사이킷런은 load_digits() API를 이용해 MNST 정보를 제공한다.
이것을 레이블 값이 7인 것만 True, 나머지 값은 모두 False로 변환해 이진 분류 문제로 바꿔보자 (10% True, 90% False)
이렇게 불균형한 데이터 세트에 모든 데이터를 False, 즉 0으로 예측하는 classfier를 이용해 정확도를 측정하면 약 90%에 가까운 예측 정확도를 나타낸다.
예제 코드로 확인해보자
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.base import BaseEstimator
from sklearn.metrics import accuracy_score
import numpy as np
import pandas as pd
class MyFakeClassifier(BaseEstimator):
def fit(self, X, y):
pass
# 입력값으로 들어오는 X데이터 세트의 크기만큼 모두 0값으로 만들어서 반환
def predict(self, X):
return np.zeros((len(X), 1), dtype=bool)
# 사이킷런의 내장 데이터 세트인 load_digits()를 이용해 MNIST 데이터 로딩
digits = load_digits()
#digits 번호가 7번이면 True, 이를 astype(int)로 1로 변환, 아니면 False이고 0으로 변환
y = (digits.target == 7).astype(int)
X_train, X_test, y_train, y_test = train_test_split(digits.data, y, random_state=11)
다음으로 불균형한 데이터로 생성한 y_test의 데이터 분포도를 확인하고 MyFakeClassifier를 이용해 예측과 평가를 수행해보자
#불균형한 레이블 데이터 분포도 확인
print('레이블 테스트 데이터 세트 크기:', y_test.shape[0])
print('테스트 세트 레이블 0과 1의 분포도')
print(pd.Series(y_test).value_counts())
#Dummy Classifier로 학습/예측/정확도 평가
fakeclf = MyFakeClassifier()
fakeclf.fit(X_train, y_train)
fakepred = fakeclf.predict(X_test)
print('모든 예측을 0으로 하여도 정확도는:{:.3f}'.format(accuracy_score(y_test, fakepred)))
[output]
레이블 테스트 데이터 세트 크기: 450
테스트 세트 레이블 0과 1의 분포도
0 405
1 45
dtype: int64
모든 예측을 0으로 하여도 정확도는:0.900
단순히 predict()의 결과를 np.zeros()로 모두 0 값으로 반환함에도 불구하고 450개의 테스트 데이터 세트에 수행한 예측 정확도는 90%이다.
이처럼 정확도 평가지표는 불균형한 레이블 데이터 세트에서는 성능 수치로 사용돼서는 안된다.
정확도가 가지는 분류 평가 지표로서 이러한 한계점을 극복하기 위해 여러가지 분류 지표와 함께 적용하여 ML 모델 성능을 평가해야한다.
02. 오차행렬
오차행렬은 이진분류의 예측 오류가 얼마인지와 더불어 어떠한 유형의 예측 오류가 발생하고 있는지를 함께 나타내주는 지표이다.
오차행렬은 4분면 행렬에서 실제 레이블 클래스 값과 예측 레이블 클래스 갓이 어떠한 유형을 가지고 매핑되는지를 나타낸다.
TN,FP,FN,TP 값을 다양하게 결합해 분류 모델 예측 성능의 오류가 어떠한 모습으로 발생하는지 알 수 있는 것이다.
TN,FP,FN,TP 기호가 의미하는 것은 앞 문자 True/False는 예측값과 실제값이 '같은가/틀린가'를 의미한다.
뒤 문자 Negative/Positive는 예측 결과 값이 부정(0)/긍정(1)을 의미한다.
사이킷런은 오차 행렬을 구하기 위해 confusion_matrix() API를 제공한다.
정확도 예제에서 다룬 MyFakeClassifier의 예측 성능 지표를 오차 행렬로 표현해보자
MyFakeClassifier의 예측 결과인 fakepred와 실제 결과인 y_test를 confusion_matrix()의 인자로 입력해 오차행렬을 배열 형태로 출력한다.
from sklearn.metrics import confusion_matrix
confusion_matrix(y_test, fakepred)
[output]
array([[405, 0],
[ 45, 0]], dtype=int64)
출력된 오차행렬은 ndarray 형태이다.
앞 절의 MyFakeClassifier는 load_digits()에 서 target==7인지 아닌지에 따라 True/False 이진 분류로 변경한 데이터 세트를 사용해 무조건 Negative로 예측하는 Classifier였고 테스트 데이터 세트의 클래스값 분포는 0이 405건, 1이 45건이다.
따라서 TN은 전체 데이터 450건 중 무조건 Negative 0으로 예측해서 True가 된 결과 405건, FP,TP는 Positive 1로 예측한 건수가 없으므로 0건, FN은 Positive 1인 건수 45건을 Negative로 예측해서 False가 된 결과 45건이다.
TN,FP,FN,TP 값을 조합하여 Classifier의 성능을 측정할 수 있는 주요 지표인 정확도, 정밀도, 재현율 값을 알 수 있다.
정확도는 오차 행렬상에서 다음과 같이 재정의 될 수 있다.
정확도 = 예측 결과와 실제 값이 동일한 건수/전체 데이터 수 = (TN+TP)/(TN + FP + FN + TP)
일반적으로 불균형한 레이블 클래스를 가지는 이진 분류 모델에서는 많은 데이터 중에서 중점적으로 찾아야 하는 적은 결괏값에 Positive를 설정해 1값을 부여하고, 그렇지 않은 경우 Negative로 0 값을 부여하는 경우가 많다.
03. 정밀도와 재현율
정밀도와 재현율은 Positive 데이터 세트의 예측 성능에 좀 더 초점을 맞춘 평가 지표이다.
정밀도와 재현율은 다음과 같은 공식으로 계산된다.
정밀도 = TP / ( FP + TP)
재현율 = TP / ( FN + TP )
정밀도는 예측을 Positive로 한 대상 중에 예측과 실제 값이 Positive로 일치한 데이터의 비율을 뜻한다.
즉, Positive 예측 성능을 더욱 정밀하게 측정하기 위한 평가지표로 양성 예측도라고도 불린다.
재현율은 실제 값이 Positive한 대상 중에 예측과 실제 값이 Positive로 일치한 데이터의 비율을 뜻한다.
민감도 또는 TPR(True Postive Rate)라고도 불린다.
정밀도와 재현율 지표 중에 이진 분류 모델의 업무 특성에 따라 특정 평가 지표가 더 중요한 지표로 간주될 수 있다.
재현율이 중요 지표인 경우는 실제 Positive 양성 데이터를 Negative로 잘못 판단하게 되면 업무상 큰 영향이 발생하는 경우이다.
ex) 암 판단 모델, 보험, 금융 사기 적발 모델
정밀도가 중요 지표인 경우는 실제 Negative 음성인 데이터 예측을 Positive 양성으로 잘못 판단하게 되면 업무상 큰 영향이 발생하는 경우이다.
ex) 스팸메일 여부 판단 모델
즉 , 재현율과 정밀도 모두 TP를 높이는 데 동일하게 초점을 두지만, 재현율은 FN(실제 Positive, 예측 Negative)를 낮추는 데 정밀도는 FP를 낮추는 데 초점을 둔다.
앞의 타이타닉 예제에 오차 행렬 및 정밀도, 재현율을 모두 구해서 예측성능을 평가해보자
사이킷런은 정밀도 계산을 위해 precision_score(), 재현율 계산을 위해 recall_score()를 API로 제공한다.
평가를 간편하게 하기 위해 confusion matrix, accuracy, precision, recall 등의 평가를 한꺼번에 호출하는 get_clf_eval() 함수를 만들어보자
from sklearn.metrics import accuracy_score, precision_score, recall_score, confusion_matrix
def get_clf_eval(y_test, pred):
confusion = confusion_matrix(y_test, pred)
accuracy = accuracy_score(y_test, pred)
precision = precision_score(y_test, pred)
recall = recall_score(y_test, pred)
print('오차 행렬')
print(confusion)
print('정확도: {0:.4f}, 정밀도:{1:.4f}, 재현율:{2:.4f}'.format(accuracy, precision, recall))
이제 로지스틱 회귀 기반으로 타이타닉 생존자를 예측하고 평가를 수행한다.
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
#원본 데이터를 재로딩, 데이터 가공, 학습 데이터/테스트 데이터 분할
titanic_df = pd.read_csv('titanic_train.csv')
y_titanic_df = titanic_df['Survived']
X_titanic_df = titanic_df.drop('Survived', axis=1)
x_titanic_df = transform_features(X_titanic_df)
X_train,X_test,y_train,y_test = train_test_split(X_titanic_df, y_titanic_df,
test_size=0.2, random_state=11)
Ir_clf = LogisticRegression(solver='liblinear')
Ir_clf.fit(X_train,y_train)
pred = Ir_clf.predict(X_test)
get_clf_eval(y_test,pred)
[output]
오차 행렬
[[108 10]
[ 14 47]]
정확도: 0.8659, 정밀도:0.8246, 재현율:0.7705
1. 정밀도/재현율 트레이드 오프
분류하려는 업무 특성상 정밀도 또는 재현율이 특별히 강조돼야할 경우 분류의 결정 임계값(Thershold)을 조정해 정밀도 또는 재현율을 높일 수 있다.
하지만 정밀도와 재현율은 상호보완적인 평가 지표이기 때문에 어느 한 쪽을 강제로 높이면 다른 하나의 수치는 떨어지기 쉽다.
이를 정밀도/재현율의 트레이드오프(Trade-off)라고 부른다.
사이킷런은 개별 데이터별로 예측 확률을 반환하는 메서드인 predict_proba()를 제공한다.
입력 파라미터 - predict() 메서드와 동일하게 보통 테스트 피처 데이터 세트를 입력
반환깂 - 개별 클래스의 예측 확률을 ndarray m x n (m: 입력값의 레코드 수, n: 클래스 값 유형) 형태로 반환
각 열은 개별 클래스의 예측 확률, 이진 분류에서 첫 번쨰 칼럼은 0 Negative의 확률, 두 번째 칼럼은 1 Positive의 확률
타이타닉 예제에서의 predict_proba() 메서드를 수행한 뒤 반환 값을 확인하고 predict() 메서드와 비교해보자
pred_proba = Ir_clf.predict_proba(X_test)
pred = Ir_clf.predict(X_test)
print('pred_proba()결과 shape:{0}'.format(pred_proba.shape))
print('pred_proba array에서 앞 3개만 샘플로 추출 \n:', pred_proba[:3])
#예측 확률 array와 예측 결과값 array를 병합하여(concatenate) 예측 확률과 결괏값을 한눈에 확인
pred_proba_result = np.concatenate([pred_proba, pred.reshape(-1,1)], axis=1)
print('두 개의 class 중에서 더 큰 확률을 클래스 값으로 예측 \n', pred_proba_result[:3])
[output]
pred_proba()결과 shape:(179, 2)
pred_proba array에서 앞 3개만 샘플로 추출
: [[0.44935227 0.55064773]
[0.86335512 0.13664488]
[0.86429645 0.13570355]]
두 개의 class 중에서 더 큰 확률을 클래스 값으로 예측
[[0.44935227 0.55064773 1. ]
[0.86335512 0.13664488 0. ]
[0.86429645 0.13570355 0. ]]
반환 결과인 ndarray는 0과 1에 대한 확률을 나타내므로 첫 번째 칼럼값과 두 번째 칼럼값을 더하면 1이 된다.
또한 predict() 메서드 결과 비교에서도 나타나듯이, 두 개의 칼럼 중에서 더 큰 확률 값으로 predict()가 최종 예측하고 있다.
사이킷런의 predict()는 predict_proba() 메서드가 반환하는 확률 값을 가진 ndarray에서 정해진 임계값을 만족하는 ndarray의 칼럼 위치를 최종 예측 클래스로 결정한다.
이러한 로직을 사이킷런의 Binarizer 클래스를 이용해 코드로 구현하여 정밀도/재현율 트레이드 오프 방식을 이해해보자
먼저 Binarizer 클래스의 사용법부터 간단히 알아보자
from sklearn.preprocessing import Binarizer
X= [[ 1, -1, 2],
[ 2, 0, 0],
[0, 1.1, 1.2]]
# X의 개별 원소들이 threshold 값보다 같거나 작으면 0, 크면 1을 반환
binarizer = Binarizer(threshold=1.1)
print(binarizer.fit_transform(X))
[output]
[[0. 0. 1.]
[1. 0. 0.]
[0. 0. 1.]]
이제 이 Binarizer를 이용해 사이킷런 predict()의 의사(pseudo)코드를 만들어보자
from sklearn.preprocessing import Binarizer
#Binarizer의 threshold 설정값. 분류 결정 임계값임
custom_threshold = 0.5
#predict_proba() 반환값의 두 번째 칼럼, 즉 Positive 클래스 칼럼 하나만 추출해 Binarizer를 적용
pred_proba_1 = pred_proba[:, 1].reshape(-1, 1)
binarizer = Binarizer(threshold=custom_threshold).fit(pred_proba_1)
custom_predict = binarizer.transform(pred_proba_1)
get_clf_eval(y_test, custom_predict)
[output]
오차 행렬
[[108 10]
[ 14 47]]
정확도: 0.8659, 정밀도:0.8246, 재현율:0.7705
이 의사코드는 앞에서 predict()로 계산된 지표 값과 정확히 같다. 즉, predict()가 predict_proba()에 기반함을 알 수 있다.
#Binarizer의 threshold 설정값을 0.5에서 0.4로 낮춤.
custom_threshold = 0.4
pred_proba_1 = pred_proba[:, 1].reshape(-1, 1)
binarizer = Binarizer(threshold=custom_threshold).fit(pred_proba_1)
custom_predict = binarizer.transform(pred_proba_1)
get_clf_eval(y_test, custom_predict)
[output]
오차 행렬
[[97 21]
[11 50]]
정확도: 0.8212, 정밀도:0.7042, 재현율:0.8197
임계값을 낮추니 정밀도는 떨어지고 재현율이 올라간 것을 확인할 수 있다.
그 이유는 임계값은 Positive 예측값을 결정하는 확률의 기준이기 때문에 이를 낮추면 Positive 예측을 할 확률에 대한 범위가 더 넓어진다.
그러므로 양성 예측을 많이하게 되어 실제 양성을 음성으로 예측하는 횟수가 상대적으로 줄어들게된다.
이번에는 임계값을 0.4부터 0.6까지 0.05씩 증가시키며 평가 지표를 조사해보자
이를 위해 get_eval_by_threshold()함수를 만든다.
#테스트를 수행할 모든 임곗값을 리스트 객체로 저장.
thresholds = [0.4, 0.45, 0.5, 0.55, 0.6]
def get_eval_by_threshold(y_test, pred_proba_c1, thresholds):
#thresholds list 객체 내의 값을 차례로 iteration 하면서 Evaluation 수행.
for custom_threshold in thresholds:
binarizer = Binarizer(threshold=custom_threshold).fit(pred_proba_c1)
custom_predict = binarizer.transform(pred_proba_c1)
print('임곗값:', custom_threshold)
get_clf_eval(y_test, custom_predict)
get_eval_by_threshold(y_test, pred_proba[:,1].reshape(-1,1), thresholds)
[output]
임곗값: 0.4
오차 행렬
[[97 21]
[11 50]]
정확도: 0.8212, 정밀도:0.7042, 재현율:0.8197
임곗값: 0.45
오차 행렬
[[105 13]
[ 13 48]]
정확도: 0.8547, 정밀도:0.7869, 재현율:0.7869
임곗값: 0.5
오차 행렬
[[108 10]
[ 14 47]]
정확도: 0.8659, 정밀도:0.8246, 재현율:0.7705
임곗값: 0.55
오차 행렬
[[111 7]
[ 16 45]]
정확도: 0.8715, 정밀도:0.8654, 재현율:0.7377
임곗값: 0.6
오차 행렬
[[113 5]
[ 17 44]]
정확도: 0.8771, 정밀도:0.8980, 재현율:0.7213
임곗값이 0.45일 경우에 디폴트 0.5인 경우와 비교해서 정확도는 동일하고 정밀도는 약간 떨어졌으나 재현율이 오른 것을 확인할 수 있다.
사이킷런은 이와 유사한 precision_recall_curve() API를 제공한다.
입력 파라미터:
- y_true: 실제 클래스값 배열(배열 크기 = [데이터 건수])
- probas_pred: Positive 칼럼의 예측 확률 배열(배열 크기=[데이터 건수])
반환값:
- 정밀도: 임곗값별 정밀도 값을 배열로 반환
- 재현율: 임곗값별 재현율 값을 배열로 반환
이 메서드를 이용해 타이타닉 예측 모델의 임곗값별 정밀도와 재현율을 구해보자
from sklearn.metrics import precision_recall_curve
#레이블 값이 1일때 예측확률을 추출
pred_proba_class1 = Ir_clf.predict_proba(X_test)[:,1]
# 실제값 데이터 세트와 레이블 값이 1일 때의 예측확률을 precision_recall_curve 인자로 입력
precisions, recalls, thresholds = precision_recall_curve(y_test, pred_proba_class1)
print('반환된 분류 결정 임곗값 배열의 shape:', thresholds.shape)
#반환된 임계값 배열 로우가 147건이므로 샘플로 10건만 추출하되, 임곗값을 15 step으로 추출
thr_index = np.arange(0,thresholds.shape[0],15)
print('샘플 추출을 위한 임계값 배열의 index 10개:', thr_index)
print('샘플용 10개의 임곗값:', np.round(thresholds[thr_index],2))
#15 step 단위로 추출된 임계값에 따른 정밀도와 재현율 값
print('샘플 임계값별 정밀도:', np.round(precisions[thr_index],3))
print('샘플 임계값별 재현율:', np.round(recalls[thr_index],3))
[output]
반환된 분류 결정 임곗값 배열의 shape: (147,)
샘플 추출을 위한 임계값 배열의 index 10개: [ 0 15 30 45 60 75 90 105 120 135]
샘플용 10개의 임곗값: [0.12 0.13 0.15 0.17 0.26 0.38 0.49 0.63 0.76 0.9 ]
샘플 임계값별 정밀도: [0.379 0.424 0.455 0.519 0.618 0.676 0.797 0.93 0.964 1. ]
샘플 임계값별 재현율: [1. 0.967 0.902 0.902 0.902 0.82 0.77 0.656 0.443 0.213]
추출된 임곗값 샘플 10개에 해당하는 정밀도 값과 재현율 값을 살펴보면 임곗값이 증가할수록 정밀도 값은 동시에 높아지나 재현율 값은 낮아짐을 알 수 있다.
precision_recall_curve() API는 정밀도와 재현율의 임곗값에 따른 값 변화를 곡선 형태의 그래프로 시각화 하는 데 이용할 수 있다.
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
%matplotlib inline
def precision_recall_curve_plot(y_test, pred_proba_c1):
# thredshold ndarray와 이 threshold에 따른 정밀도, 재현율 ndarray 추출
precision, recalls, thresholds = precision_recall_curve(y_test, pred_proba_c1)
#X축을 thredshold 값으로, Y축은 정밀도, 재현율 값으로 각각 plot 수행. 정밀도는 점선으로 표시
plt.figure(figsize=(8,6))
threshold_boundary = thresholds.shape[0]
plt.plot(thresholds, precision[0:threshold_boundary], linestyle='--', label='precision')
plt.plot(thresholds, recalls[0:threshold_boundary], label ='recall')
#thredshold 값 X축 Scale을 0.1단위로 변경
start, end = plt.xlim()
plt.xticks(np.round(np.arange(start, end, 0.1),2))
#X축, y축, label과 legend, 그리고 grid 설정
plt.xlabel('Threshold value'); plt.ylabel('Precision and Recall value')
plt.legend();plt.grid()
plt.show()
precision_recall_curve_plot(y_test, Ir_clf.predict_proba(X_test)[:,1])
[output]
2. 정밀도와 재현율의 쟁점
[정밀도가 100%가 되는 방법]
확실한 기준이 되는 경우만 Positive로 예측하고 나머지는 모두 Negative로 예측한다.
[재현율이 100%가 되는 방법]
모든 암 환자를 Positive로 예측하면 된다.
이처럼 정밀도와 재현율 성능 수치도 어느 한 쪽만 참조하면 극단적인 수치 조작이 가능하다.
따라서 정밀도 또는 재현율 중 하나만 스코어가 좋고 다른 하나는 스코어가 나쁜 분류는 성능이 좋지 않다고 할 수 있다.
Ref) 파이썬 머신러닝 완벽가이드