참고: 핵심 설명과 코드는 🔑로 표시되었으며 굳이 알아둘 필요가 없는 코드는 ✋로 표시되었다.
# 파이썬 ≥3.5 필수
import sys
assert sys.version_info >= (3, 5)
# 사이킷런 ≥0.20 필수
import sklearn
assert sklearn.__version__ >= "0.20"
# 공통 모듈 임포트
import numpy as np
import os
# 노트북 실행 결과를 동일하게 유지하기 위해
np.random.seed(42)
# 깔끔한 그래프 출력을 위해
%matplotlib inline
import matplotlib as mpl
import matplotlib.pyplot as plt
mpl.rc('axes', labelsize=14)
mpl.rc('xtick', labelsize=12)
mpl.rc('ytick', labelsize=12)
# 그림을 저장할 위치
PROJECT_ROOT_DIR = "."
CHAPTER_ID = "classification"
IMAGES_PATH = os.path.join(PROJECT_ROOT_DIR, "images", CHAPTER_ID)
os.makedirs(IMAGES_PATH, exist_ok=True)
def save_fig(fig_id, tight_layout=True, fig_extension="png", resolution=300):
path = os.path.join(IMAGES_PATH, fig_id + "." + fig_extension)
print("그림 저장:", fig_id)
if tight_layout:
plt.tight_layout()
plt.savefig(path, format=fig_extension, dpi=resolution)
# 어레이 데이터를 csv 파일로 저장하기
def save_data(fileName, arrayName, header=''):
np.savetxt(fileName, arrayName, delimiter=',', header=header, comments='')
MNIST 데이터셋을 가져온다.
fetch_openml()
함수의 반환값은 사전 자료형을 살짝 확장한 Bunch
객체이며,
기본적으로 사전 자료형처럼 사용하면 된다.
from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784', version=1, as_frame=False)
사용되는 키(key)들은 다음과 같다.
mnist.keys()
dict_keys(['data', 'target', 'frame', 'categories', 'feature_names', 'target_names', 'DESCR', 'details', 'url'])
"data"
속성에 이미지 데이터가, "target"
속성에 레이블 데이터가 들어 있다.
X, y = mnist["data"], mnist["target"]
샘플 수는 70,000개의 이미지이며, 이미지는 길이가 784인 1차원 어레이로 되어 있다.
X.shape
(70000, 784)
레이블 또한 70,000개 이며, 0부터 9까지의 숫자이다.
y.shape
(70000,)
784는 28x28과 같다. 즉, 각 데이터는 원래 28x28 크기의 2차원 어레이를 1차원 어레이로 변환한 값이다.
28 * 28
784
첫째 손글씨 이미지를 확인하면 5로 보이는 이미지가 보인다.
some_digit = X[0]
# reshape() 함수를 이용하여 28x28 모양의 2차원 어레이로 변환
some_digit_image = some_digit.reshape(28, 28)
# 흑백 이미지로 보여주기
plt.imshow(some_digit_image, cmap=mpl.cm.binary)
plt.axis("off")
save_fig("some_digit_plot")
plt.show()
그림 저장: some_digit_plot
첫째 이미지의 실제 레이블 또한 5이다.
y[0]
'5'
그런데 레이블이 문자형으로 저장되어 있기 때문에 모델 훈련을 위해 정수형으로 변환해야 한다.
아래 코드는 8바이트를 사용하여 양의 정수를 표현하는 uint8
자료형을 사용한다.
y = y.astype(np.uint8)
이제 모든 레이블이 정수로 변했다.
y[0]
5
아래 함수는 28x28 모양의 2차원 어레이로 변환하여 숫자 이미지를 보여주는 함수이다. 즉, 앞서 설명한 과정을 함수로 지정하였다.
def plot_digit(data):
image = data.reshape(28, 28)
plt.imshow(image, cmap = mpl.cm.binary,
interpolation="nearest")
plt.axis("off")
✋ 아래 함수는 여러 개의 숫자 이미지를 한 줄에 10개씩 출력하는 함수이다. 10개의 이미지를 이어붙인 다음에 여러 줄을 다시 이어붙여서 커다란 이미지를 만드는 방식을 사용한다.
# 숫자 그림을 위한 추가 함수
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")
처음 100개의 이미지는 다음과 같다.
plt.figure(figsize=(9,9))
example_images = X[:100]
plot_digits(example_images, images_per_row=10)
save_fig("more_digits_plot")
plt.show()
그림 저장: more_digits_plot
처음 60,000개의 샘플은 훈련 세트로, 이후 10,000개의 샘플은 테스트 세트로 이미 계층별 샘플링 방식으로 구분되어 있다.
X_train, X_test, y_train, y_test = X[:60000], X[60000:], y[:60000], y[60000:]
이미지가 숫자 5를 표현하는지 여부만을 판단하는 이진 분류기의 훈련을 위해 레이블을 0 또는 1로 변경한다.
y_train_5 = (y_train == 5)
y_test_5 = (y_test == 5)
확률적 경사 하강법(SGD) 분류기를 훈련시키기 위해 fit()
메서드를 호출한다.
max_iter=1000
: 훈련 데이터를 최대 1000번 반복해서 학습에 사용함. tol=le-3
: 전체 훈련 세트를 한 번 학습할 때마다 성능 향상이 연속적으로 지정된 값 이상으로 이루어지지 않을 때 학습을 멈추도록 하는 데에 사용됨. 기본적으로 5번 이상 성능 향상이 10의 -3승 이상 이루어지지 않으면 학습을 강제로 멈춤.from sklearn.linear_model import SGDClassifier
sgd_clf = SGDClassifier(max_iter=1000, tol=1e-3, random_state=42)
sgd_clf.fit(X_train, y_train_5)
SGDClassifier(random_state=42)
첫째 샘플의 예측값은 True
, 즉, 숫자 5라고 정확하게 맞춘다.
sgd_clf.predict([some_digit])
array([ True])
분류기 성능을 측정하는 세 가지 방법을 소개한다.
분류기의 정확도 평가는 교차 검증을 이용한다. 아래 코드는 3겹 교차 검증을 실행한다. 결과는 정확도 95% 이상이다.
cv=3
: 폴더 3개 사용scoring="accuracy"
: 정확도 검증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 ])
cross_val_score()
함수가 제공하지 않는 기능을 이용하려면 직접 교차 검증을 구현해야 한다.
아래 코드는 3겹 교차 검증을 위해 계층별 샘플링 부터 세 번의 훈련 및 검증을 직접 진행한다.
앞서 사용한 cross_val_score()
함수와 기본적으로 동일한 기능을 수행한다.
from sklearn.model_selection import StratifiedKFold
from sklearn.base import clone
# 계층별 샘플링 진행: 3개의 폴드 지정
# 주의사항: shuffle=False가 기본값이기 때문에 random_state를 삭제하던지
# shuffle=True로 지정하라는 경고가 발생한다.
# 0.24버전부터는 에러가 발생할 예정이므로 향후 버전을 위해 shuffle=True을 지정한다.
skfolds = StratifiedKFold(n_splits=3, random_state=42, shuffle=True)
# 3개의 폴드를 대상으로 두 개씩 돌아가면 훈련 및 검증 진행
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))
0.9669 0.91625 0.96785
교차 검증 결과 95% 이상의 정확도가 나왔지만 전혀 믿음직스럽지 않다. 이유는 무조건 5가 아니라고 찍는 분류기도 90%의 정확도를 보여주기 때문이다.
False
라고 예측하는 분류기from sklearn.base import BaseEstimator
class Never5Classifier(BaseEstimator):
def fit(self, X, y=None):
pass
def predict(self, X):
return np.zeros((len(X), 1), dtype=bool)
훈련 세트의 90% 정도가 5가 아닌 숫자 이미지이기에 정확도는 기본적으로 90%로 나온다.
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])
성능 평가 점수 등이 책의 내용과 조금 다를 수 있지만, 어떤 문제가 있는 것은 아니다. 결과가 조금씩 달라지는 이유는 아래와 같다.
random_state=42
나 np.random.seed(42)
등
난수 생성기에 시드를 지정하여 일관된 결과를 얻을 수 있지만 항상 보장되지는 않는다. 교차 검증을 이용하여 평가 점수 대신에 예측을 수행할 수 있다.
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
cm = confusion_matrix(y_train_5, y_train_pred)
cm
array([[53892, 687], [ 1891, 3530]])
위 결과는 다음을 보여준다.
TN | FP
---|---
FN | TP
참고: 완벽한 이진 분류기의 오차 행렬은 대각선 이외의 값이 모두 0인 대각 행렬이다.
y_train_perfect_predictions = y_train_5 # 예측값을 실제 레이블로 지정. 즉, 완벽한 분류 결과로 사용
confusion_matrix(y_train_5, y_train_perfect_predictions)
array([[54579, 0], [ 0, 5421]])
from sklearn.metrics import precision_score, recall_score
precision_score(y_train_5, y_train_pred)
0.8370879772350012
수동으로 정밀도 계산:
$$ \text{정밀도} = \frac{\text{TP}}{\text{TP} + \text{FP}} $$cm[1, 1] / (cm[0, 1] + cm[1, 1])
0.8370879772350012
recall_score(y_train_5, y_train_pred)
0.6511713705958311
수동으로 재현율 계산:
$$ \text{재현율} = \frac{\text{TP}}{\text{TP} + \text{FN}} $$cm[1, 1] / (cm[1, 0] + cm[1, 1])
0.6511713705958311
정밀도와 재현율의 조화평균을 F1 점수라 한다.
from sklearn.metrics import f1_score
f1_score(y_train_5, y_train_pred)
0.7325171197343846
수동으로 F1 점수 계산:
$$ F_1 = \frac{2}{\frac{1}{\text{정밀도}} + \frac{1}{\text{재현율}}} = \frac{\text{TP}}{\text{TP} + \frac{\text{FN} + \text{FP}}{2}} $$cm[1, 1] / (cm[1, 1] + (cm[1, 0] + cm[0, 1]) / 2)
0.7325171197343847
SGD 분류기는 decision_function()
을 이용하여 예측에 필요한 점수를 각 샘플에 대해 계산한다.
예를 들어, 첫째 샘플의 점수는 아래와 같다.
y_scores = sgd_clf.decision_function([some_digit])
y_scores
array([2164.22030239])
예측한 점수가 지정된 임계갓보다 크면 양성, 즉 True
로 판정한다.
아래 코드는 임곗값을 0으로 지정한 결과를 보여준다.
threshold = 0
y_some_digit_pred = (y_scores > threshold)
y_some_digit_pred
array([ True])
임곗값을 변경하면 예측값이 달라질 수 있다.
아래 코드는 임곗값을 8000으로 할 때, 첫째 샘플에 대한 예측값이 False
로 변경되는 것을 보여준다.
threshold = 8000
y_some_digit_pred = (y_scores > threshold)
y_some_digit_pred
array([False])
사이킷런에서 임곗값을 임의로 직접 지정할 수는 없다. 하지만 임곗값을 수동으로 변경하면서 정밀도와 재현율의 관계를 확인한 다음에 목적에 따라 가장 적절한 임곗값을 찾을 수 있다.
먼저, 교참 검증을 이용하여 모든 훈련 샘플에 대한 예측 점수를 확인한다.
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
precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)
임곗값을 기준으로 정밀도와 재현율의 변화를 그래프로 그리면 다음과 같다. 빨강 점들과 연결된 빨강 점선은 정밀도가 90%가 되는 지점의 정밀도와 재현율을 보여준다.
주의사항: 정밀도와 재현율을 담은 어레이의 마지막 항목은 각각 1과 0이며, 이는 기술적인 이유때문에 추가되었다. 따라서 임곗값 어레이의 길이와 맞추기 위해 정밀도와 재현율의 어레이에서 마지막 항목은 무시해야 한다.
# 정밀도와 재현율 그래프 그리기. x 축은 임곗값을 가리킴.
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)
plt.xlabel("Threshold", fontsize=16)
plt.grid(True)
plt.axis([-50000, 50000, 0, 1])
# 정밀도 90%가 달성되는 지점에서의 재현율과 임곗값
recall_90_precision = recalls[np.argmax(precisions >= 0.90)]
threshold_90_precision = thresholds[np.argmax(precisions >= 0.90)]
# 빨강 점과 빨강 점선 그리기
plt.figure(figsize=(8, 4))
plot_precision_recall_vs_threshold(precisions, recalls, thresholds)
plt.plot([threshold_90_precision, threshold_90_precision], [0., 0.9], "r:") # 수직 빨강 점선
plt.plot([-50000, threshold_90_precision], [0.9, 0.9], "r:") # 위 수평 빨강 점선
plt.plot([-50000, threshold_90_precision], [recall_90_precision, recall_90_precision], "r:") # 아래 수평 빨강 점선
plt.plot([threshold_90_precision], [0.9], "ro") # 위 빨강 점
plt.plot([threshold_90_precision], [recall_90_precision], "ro") # 아래 빨강 점
save_fig("precision_recall_vs_threshold_plot")
plt.show()
그림 저장: precision_recall_vs_threshold_plot
재현율 그래프와는 달리 정밀도 그래프는 100% 근처에서 상승과 하강이 살짝 이루지도 한다. 이유는 임곗값을 올리면서 양성으로 판명된 샘플의 수는 기본적으로 줄어들지만 거짓 양성 샘플 수는 순간적으로는 변하지 않을 수도 있기 때문이다. 반면에 재현율은 전체 양성 샘플 수를 대상으로 하기에 그래프의 진동이 발생하지 않는다.
SGD 분류기는 0을 임곗값으로 사용하며, 이를 아래와 같이 확인할 수 있다.
참고: all()
메서드는 모든 항목이 참일 때만 참을 반환한다.
(y_train_pred == (y_scores > 0)).all()
True
아래 코드는 재현율과 정밀도 사이의 관계를 직접적으로 보여준다. 빨강 점과 빨강 선은 앞 도표에서처럼 정밀도 90%가 달성되는 지점의 재현율을 보여주는 용도이다.
# 정밀도와 재현율 사이의 관계 그래프 그리기
def plot_precision_vs_recall(precisions, recalls):
plt.plot(recalls, precisions, "b-", linewidth=2)
plt.xlabel("Recall", fontsize=16)
plt.ylabel("Precision", fontsize=16)
plt.axis([0, 1, 0, 1])
plt.grid(True)
plt.figure(figsize=(8, 6))
plot_precision_vs_recall(precisions, recalls)
# 빨강 점과 빨강 점선 그리기
plt.plot([recall_90_precision, recall_90_precision], [0., 0.9], "r:") # 수직 빨강 점선
plt.plot([0.0, recall_90_precision], [0.9, 0.9], "r:") # 수평 빨강 직선
plt.plot([recall_90_precision], [0.9], "ro") # 빨강 점 좌표
save_fig("precision_vs_recall_plot")
plt.show()
그림 저장: precision_vs_recall_plot
아래 코드는 정밀도 90%가 달성되는 지점의 임곗값을 확인한다.
threshold_90_precision = thresholds[np.argmax(precisions >= 0.90)]
threshold_90_precision
3370.0194991439557
실제로 바로 위에서 구해진 임곗값을 사용하면 정밀도가 90%인 모델을 (수동)얻게 된다.
y_train_pred_90 = (y_scores >= threshold_90_precision)
precision_score(y_train_5, y_train_pred_90)
0.9000345901072293
하지만 그렇게 얻어진 모델의 재현율은 47.9% 정도로 떨어지기에 사용 목적에 적절한 모델인지 판단해야 한다.
recall_score(y_train_5, y_train_pred_90)
0.4799852425751706
수신기 조작 특성(ROC)을 그려서 분류기의 성능을 측정할 수 있다. 여기서 사용되는 기준은 재현율과 정밀도의 관계가 아니라 참 양성 비율(true positive rate, TPR)과 거짓 양성 비율(false positive rate, FPR) 사이의 관계이다.
TPR은 재현율의 다른 명칭이며, FPR은 실제로 음성에 속하는 샘플 중에서 양성으로 잘못 판정되는 경우의 비율이다.
TN | FP
---|---
FN | TP
$$
\text{TPR} = \frac{\text{TP}}{\text{FN} + \text{TP}} \qquad
\text{FPR} = \frac{\text{FP}}{\text{TN} + \text{FP}}
$$
앞서 정밀도와 재현율의 관계를 그래프로 묘사한 것과 비슷한 방식으로 TPR와 FPR 사이의 관계를 그래프로 그리면 다음과 같다.
roc_curve()
함수: 임곗값의 변화에 따른 TPR, FPR 계산.
precision_recall_curve()
함수와 유사한 기능 제공.from sklearn.metrics import roc_curve
fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)
# SGD 분류기 모델의 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--') # 대각 점선
plt.axis([0, 1, 0, 1])
plt.xlabel('False Positive Rate (Fall-Out)', fontsize=16)
plt.ylabel('True Positive Rate (Recall)', fontsize=16)
plt.grid(True)
plt.figure(figsize=(8, 6))
plot_roc_curve(fpr, tpr)
# 정밀도가 90%를 넘어설 때의 재현율(TPR, 참 양성 비율)에 해당하는 거짓 양성 비율(FPR)
fpr_90 = fpr[np.argmax(tpr >= recall_90_precision)] # 0.0053 정도
# 빨강 점과 원점 사이의 실선 그리기
plt.plot([fpr_90, fpr_90], [0., recall_90_precision], "r:") # 수직 빨강 점선. 좌표는 (0.0053, 0.48) 정도
plt.plot([0.0, fpr_90], [recall_90_precision, recall_90_precision], "r:") # y축과 빨강 점 사이의 수평 빨강 점선(거의 안보임))
plt.plot([fpr_90], [recall_90_precision], "ro") # 빨강 점
save_fig("roc_curve_plot")
plt.show()
그림 저장: roc_curve_plot
TPR이 증가하면 FPR도 증가한다. 따라서 거짓 양성 비율을 최대한 낮추면서 재현율을 최대한 높일 수 있는 모델이 좋은 모델이다. 이를 계량화 화면, ROC 곡선의 아랫쪽의 면적인 AUC(area under curve)가 1에 가깝게 나오도록 해주는 모델이 좋은 모델이다.
SGD 분류기의 AUC는 0.96 정도이다.
from sklearn.metrics import roc_auc_score
roc_auc_score(y_train_5, y_scores)
0.9604938554008616
랜덤 포레스트 분류기 모델을 이용해보자.
주의사항: RandomForestClassifier
모델은 점수를 예측하는 predict_function()
메서드가 없다.
대신에 확률을 예측하는 predict_proba()
메서드를 교차 검증 예측에 사용할 수 있다.
from sklearn.ensemble import RandomForestClassifier
forest_clf = RandomForestClassifier(n_estimators=100, random_state=42)
y_probas_forest = cross_val_predict(forest_clf, X_train, y_train_5, cv=3,
method="predict_proba")
y_probas_forest
변수는 각 훈련 샘플에 대한 음성 클래스, 양성 클래스에 속할 확률을 포함한 2차원 어레이를
가리킨다.
y_probas_forest
array([[0.11, 0.89], [0.99, 0.01], [0.96, 0.04], ..., [0.02, 0.98], [0.92, 0.08], [0.94, 0.06]])
모든 샘플에 대한 양성 클래스에 속할 확률만 가져와서 ROC 곡선을 그려보자. 즉, 양성 클래스에 속할 확률을 임곗점으로 활용하여 TPR과 FPR의 관계를 그래프로 나타낸다.
y_scores_forest = y_probas_forest[:, 1] # 점수 = 양성 클래스의 확률
fpr_forest, tpr_forest, thresholds_forest = roc_curve(y_train_5,y_scores_forest)
램덤 포레스트 분류기의 ROC 곡선이 보다 1x1 크기의 직사각형에 가까워졌다.
# 램덤 포레스트 분류기의 ROC 곡선 그리기
recall_for_forest = tpr_forest[np.argmax(fpr_forest >= fpr_90)] # 90% FPR에 대응하는 TPR. 약 0.95
plt.figure(figsize=(8, 6))
# 파랑 점 곡선: SGD 분류기의 ROC 곡선
plt.plot(fpr, tpr, "b:", linewidth=2, label="SGD")
# 파랑 실선: 랜덤 포레스트 분류기의 ROC 곡선
plot_roc_curve(fpr_forest, tpr_forest, "Random Forest")
plt.plot([fpr_90, fpr_90], [0., recall_90_precision], "r:") # 짧은 빵강 수직 점선
plt.plot([0.0, fpr_90], [recall_90_precision, recall_90_precision], "r:") # 아랫쪽 빨강 수평 점선 (거의 안보임)
plt.plot([fpr_90], [recall_90_precision], "ro") # 아래쪽 빨강 점. 좌표는 (0.0053, 0.48) 정도
plt.plot([fpr_90, fpr_90], [0., recall_for_forest], "r:") # 윗쪽 빨강 수평 점선 (거의 안보임)
plt.plot([fpr_90], [recall_for_forest], "ro") # 위쪽 빨강 점. 좌표는 (0.0053, 0.95) 정도
plt.grid(True)
plt.legend(loc="lower right", fontsize=16)
save_fig("roc_curve_comparison_plot")
plt.show()
그림 저장: roc_curve_comparison_plot
AUC 값도 SGD 분류기보다 훨씬 좋아졌음을 확인할 수 있다.
roc_auc_score(y_train_5, y_scores_forest)
0.9983436731328145
교차 검증을 이용한 모델의 정밀도는 99%, 재현율은 86.6% 정도로 나쁘지 않은 편이다.
y_train_pred_forest = cross_val_predict(forest_clf, X_train, y_train_5, cv=3)
precision_score(y_train_5, y_train_pred_forest)
0.9905083315756169
recall_score(y_train_5, y_train_pred_forest)
0.8662608374838591
이제부터 두 개 보다 많은 클래스로 분류하는 다중 클래스 분류를 진행한다.
서포트 벡터 머신(SVC, support vector machine)은 기본적으로 이진 분류기이지만 일대일(one-versus-one) 전략을 활용하여 다중 클래스 분류에도 사용된다.
gamma="auto"
: 이 하이퍼파라미터의 역할은 5장에서 자세히 설명한다.
다만, 사이킷런 버전 0.22부터 gamma
의 기본값이 "auto"
에서 "scale"
로 변경되어
여기서는 명시적으로 키워드 인자를 지정한다.주의사항: 서포트 벡터 머신 분류기는 훈련 세트의 크기에 민감하다. 따라서 아래 코드는 훈련 세트의 크기를 1,000으로 줄여서 훈련하는 것을 보여준다. 6만 개의 훈련 세트 전체를 대상으로 하면 시간이 좀 걸릴 것이다.
from sklearn.svm import SVC
svm_clf = SVC(gamma="auto", random_state=42)
svm_clf.fit(X_train[:1000], y_train[:1000]) # 이제부터는 y_train_5 대신에 y_train 사용
SVC(gamma='auto', random_state=42)
첫째 훈련 샘플에 대해 숫자 5로 정확하게 예측한다.
svm_clf.predict([some_digit])
array([5], dtype=uint8)
decision_function()
메서드는 샘플이 각각의 클래스에 대한 점수를 어레이로 반환한다.
예를 들어 첫째 훈련 샘플에 대한 각 클래스별 점수는 다음과 같다.
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]])
5번 인덱스의 값이 가장 크다.
참고: 넘파이의 argmax()
함수는 최댓값이 위치한 인덱스를 반환한다.
np.argmax(some_digit_scores)
5
5번 인덱스에 해당하는 클래스를 알아보기 위해 사용된 클래스를 확인하면 다음과 같다.
svm_clf.classes_
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=uint8)
5번 인덱스에 해당하는 클래스는 (우연히도) 숫자 5이다.
주의사항: 분류 클래스가 여기서는 0, 1, 2, ..., 9 이기에 5번 인덱스, 즉 6번째 클래스는 5이다. 하지만 이것은 우연에 불과하다. 예를 들어, 만약에 클래스가 1, 2, 3, ..., 9, 0 으로 순서로 사용된다면 5번 인덱스에 해당하는 클래스는 6이 된다.
svm_clf.classes_[5]
5
서포트 벡터 머신 분류기는 기본적으로 일대일(OvO) 방식으로 다항 클래스 분률를 진행한다.
하지만 일대다(OvR) 방식을 사용하려면 OneVsRestClassfier
가 SVC 모델을 상속하도록 하면 된다.
주의사항: 서포트 벡터 머신 분류기는 훈련 세트의 크기에 민감하며, 이런 이유로 적은 수의 훈련 세트에 대해 많은 많은 수의 이진 분류를 진행하는 일대일 방식을 기본으로 사용한다. 아래 코드는 훈련 세트의 크기를 1,000으로 줄여서 일대다 방식을 SVC 모델에 적용한다. 만약에 6만개의 훈련 세트에 대해 아래 코드를 실행하면 꽤 오래 걸릴 것이다.
from sklearn.multiclass import OneVsRestClassifier
ovr_clf = OneVsRestClassifier(SVC(gamma="auto", random_state=42))
ovr_clf.fit(X_train[:1000], y_train[:1000]) # 첫 1000개의 훈련 샘플만을 이용한 일대다 방식 진행
OneVsRestClassifier(estimator=SVC(gamma='auto', random_state=42))
첫째 샘플을 5라고 예측한다.
ovr_clf.predict([some_digit])
array([5], dtype=uint8)
아래 코드는 일대다 방식에 사용된 SVC 모델의 수가 10개임을 확인해준다.
estimators_
메서드: 일대다 방식에 사용된 예측기, 여기서는 SVC 모델을 저장한다.len(ovr_clf.estimators_)
10
SGDClassifier
와 RandomForestClassifier
분류기¶두 모델은 다중 클래스 분류를 기본으로 지원하기에 일대일, 일대다 방식을 고민할 필요가 없다. 아래 코드는 SGD 분류기를 이용하여 10개 클래스 전체를 대상으로 분류를 진행한다. (실행에 몇 분 걸린다.)
sgd_clf.fit(X_train, y_train)
SGDClassifier(random_state=42)
주의사항: 책에서와는 달리 첫째 샘플이 5가 아니라고 예측할 수도 있다. 여기서는 3으로 예측한다.
sgd_clf.predict([some_digit])
array([3], dtype=uint8)
실제로 3과 혼동할만 하다.
plot_digit(some_digit)
첫째 샘플에 대한 10개 클래스별 예측 점수는 다음과 같이 3번 인덱스에 위치한 값이 가장 크다.
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 ]])
아래 코드는 3-겹 교차 검증을 이용하여 SGD 모델의 성능을 정확도를 기준으로 측정한다. 이진 분류의 경우와는 달리 여기서는 정확도를 충분히 신뢰할 수 있다. 이유는 각 숫자의 비율이 비슷하기 때문이다.
경고: 사용하는 하드웨어에 따라 다음 두 셀을 실행하는데 30분 또는 그 이상 걸릴 수 있다.
cross_val_score(sgd_clf, X_train, y_train, cv=3, scoring="accuracy")
array([0.87365, 0.85835, 0.8689 ])
성능 결과는 87% 정도의 정확도이다. 임의로 찍는 랜덤 분류기가 10% 정도의 정확도를 가질 것이기에 이진 분류의 경우와 달리 상대적으로 매우 높은 성능의 다중 클래스 분류기라 할 수 있다. 하지만 예를 들어 2장에서 전처리 단계로 사용한 축척 조정(스케일링)만 적용하더라도 성능을 90%정도로 좀 더 끌어올릴 수 있다.
# 표준화 축척 조정 전처리 진행 후 SGD 분류기 훈련
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])
이제 좋은 모델을 하나 찾았다고 가정한 후에 모델의 성능을 향상시키는 방법을 하나 소개한다. (좋은 모델을 구하기 위한 하이퍼파라미터 튜닝 등은 2장에서 소개한 그리드 탐색, 랜덤 탐색 등을 이용한다.)
어떤 방법으로 모델의 성능을 향상시켜야 하는가는 경우에 따라 다르다.
여기서는 오차 행렬을 이용하여 분류 오류의 발생 원인을 찾고, 어떻게 해결할 수 있는지를 살펴본다.
아래 코드는 SGD 분류기를 3겹 교차 검증으로 훈련시킨 후의 오차 행렬을 계산한다.
10개의 클래스를 대상으로 하였기에 10x10
행렬 모양의 2차원 어레이가 생성된다.
앞서 이진 분류기의 오차 행렬보도 복잡해졌지만 이해하는 방식은 동일하다.
예를 들어, 실제로 0번을 담은 사진에 대한 예측은 0번 행을 보면 된다. 그러면 0으로 제대로 예측된 이미지는 5,577개, 1로 잘못 예측된 이미지는 0개, 2로 잘못 예측된 이미지는 22개 등이 된다.
주의사항: 앞서 설명한 대로 언급된 구체적인 수가 아래 코드를 실행할 때마다 조금씩 달라질 수 있지만 그리 큰 차이가 나지는 않을 것이다.
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)
save_fig("confusion_matrix_plot", tight_layout=False)
plt.show()
그림 저장: confusion_matrix_plot
plot_confusion_matrix()
함수 활용¶사이킷런 0.22 버전부터는 sklearn.metrics.plot_confusion_matrix()
함수를 사용할 수 있다.
예를 들어, 지금까지 학습된 SGD 분류기의 오차행렬은 다음과 같이 구한다.
include_values=False
: 오차 행렬에 사용된 값 함께 표시 여부 지정. 여기서는 표시 안함.cmap='gray'
: 흑백 이미지로 표현. 지정하지 않으면 컬러 이미지 보여줌.from sklearn.metrics import plot_confusion_matrix
plot_confusion_matrix(sgd_clf, X_train, y_train, include_values=False, cmap='gray')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay at 0x7f96fa9b6040>
숫자별 오차율을 계산해서 이미지로 표현하면 보다 정확한 이유를 알 수 있다. 이를 위해 각 숫자별로 이미지 개수를 확인한 다음에 숫자별 오차율로 이루어진 행렬을 생성한다. 즉, 오차 행렬을 숫자별 이미지 총 개수로 나눈다.
row_sums = conf_mx.sum(axis=1, keepdims=True) # 숫자별 총 이미지 개수
norm_conf_mx = conf_mx / row_sums # 숫자별 오차율 행렬
오차율 행렬을 이미지로 표현하면 다음과 같다.
np.fill_diagonal(norm_conf_mx, 0) # 대각선을 0으로 채우기
plt.matshow(norm_conf_mx, cmap=plt.cm.gray)
save_fig("confusion_matrix_errors_plot", tight_layout=False)
plt.show()
그림 저장: confusion_matrix_errors_plot
3과 5로 한정해서 두 숫자를 확인해 보자. 아래 이미지는 3과 5로 한정한 다음에 오차 행렬과 비슷한 형식으로 숫자들을 모아 보여준다.
cl_a, cl_b = 3, 5
X_aa = X_train[(y_train == cl_a) & (y_train_pred == cl_a)] # 3을 3으로 예측한 샘플들
X_ab = X_train[(y_train == cl_a) & (y_train_pred == cl_b)] # 3을 5로 예측한 샘플들
X_ba = X_train[(y_train == cl_b) & (y_train_pred == cl_a)] # 5를 3으로 예측한 샘플들
X_bb = X_train[(y_train == cl_b) & (y_train_pred == cl_b)] # 5를 5로 예측한 샘플들
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) # 이미지 하단 오른편
save_fig("error_analysis_digits_plot")
plt.show()
그림 저장: error_analysis_digits_plot
위 그림을 보면 3과 5개 매우 구분하기 어려운 경우도 있지만 대부분은 그렇지 않다. 이유는 SGD 분류기가 사용하는 선형 분류 알고리즘이 제대로 작동하지 않았기 때문이다. 4장에서 살펴 보겠지만 선형 분류기는 단순히 각각의 픽셀에 대한 가중치를 조절하는 형식으로 훈련한다. 따라서 픽셀값이 조금만 달라도 예측값을 혼동하기 쉽다.
이에 대한 한 가지 해결책은 이미지를 중앙에 위치시키고 회전되어 있지 않도록 전처리 하거나, 아니면 조금씩 회전된 이미지를 추가로 생성하여 훈련 세트를 확장한 후에 훈련하면 모델의 성능을 높일 수 있다. 이렇게 데이터를 인위적으로 추가하는 작업을 데이터 증식(data augmentation)이라 한다.
아래 부록 B와 연습문제 2번에서 이미지를 상하좌우 1픽쎌씩 이동하여 총 24만개의 이미지를 추가하는 데이터 증식 기법을 소개한다. 이 방법만을 적용해도 정확도가 97% 이상으로 좋아진다.
✋ 참고: MNIST 데이터셋에 데이터 증식을 적용하는 보다 일반적인 방법은 MNIST 데이터 증식을 참조할 수 있다. 단, 아직 다루지 않은 텐서플로우(Tensorflow)를 이용하기에 관심 있는 경우에만 살펴보기 바란다. 사용법은 사실 어렵지 않다.
주의사항: 이미지를 너무 많이 회전시키지 않아야 한다. 예를 들어, 숫자 6을 90도 회전 시키면 숫자 9가 된다. 숫자를 얼마만큼 회전시켜야 하는가는 실험을 통해 확인해야 한다.
지금까지 분류기와 관련된 기초 개념과 사용법, 성능 평가 방법 등에 대해 자세히 알아보았으며, 그 정도면 충분하다. 마지막으로 다중 클래스 분류 개념을 일반화한 두 개념을 소개한다.
다중 레이블 분류는 하나의 샘플에 대해 한 개의 클래스가 아닌 여러 개의 클래스를 예측하는 분류이다. 예를 들어, 하나의 사진에 지정된 세 사람의 인물이 포함되어 있는지 여부를 예측하고 길이가 3인 어레이로 반환하라고 할 수 있다. 지정된 세 사람이 앨리스, 밥, 찰리이면 다중 레이블 분류기는 아래와 같은 예측값을 반환해야 한다.
[True, False, True]
위 결과에 따르면 앨리스와 찰리는 사진에 있지만 밥은 사진에 없다고 예측된다.
아래 코드는 MNIST 이미지 샘플 각각에 대해 이미지가 가리키는 숫자를 예측하는 것 대신에 이미지가 나타내는 수가 홀수인지 여부와, 7보다 같거나 큰지 여부를 함께 예측한다.
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)
knn_clf.predict([some_digit])
array([[False, True]])
아래 코드는 모든 레이블의 중요도가 동일하다고 가정하면서 위 KNN 모델의 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
아래 코드는 MNIST 데이터셋에 포함된 모든 샘플에 잡음을 추가한다. 단, 훈련 세트와 테스트 세트에 서로 다른 잡음을 이용한다.
# MNIST 훈련 세트의 모든 샘플에 잡음 추가
noise = np.random.randint(0, 100, (len(X_train), 784))
X_train_mod = X_train + noise
# MNIST 테스트 세트의 모든 샘플에 잡음 추가
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
잡음(noise)이 추가된 이미지와 원본 이미지를 비교하면 다음과 같다. 아래 코드는 테스트 세트의 0번 샘플을 이용한다.
주의사항: 책에서는 테스트 세트의 0번 인덱스가 4를 가리키는 이미지로 사용되었다. 무슨 이유인지 모르지만 여기서는 7을 나타내는 이미지가 사용된다.
some_index = 0 # 0번 인덱스
plt.subplot(121); plot_digit(X_test_mod[some_index]) # 잡음 추가된 이미지
plt.subplot(122); plot_digit(y_test_mod[some_index]) # 원본 이미지
save_fig("noisy_digit_example_plot")
plt.show()
그림 저장: noisy_digit_example_plot
KNN 모델을 이용하여 다중 출력 모델을 훈련시킨다.
knn_clf.fit(X_train_mod, y_train_mod)
KNeighborsClassifier()
훈련된 모델이 테스트 세트 첫째 샘플에 대해 예측한 이미지는 다음과 같이 잡음을 많이 제거한 이미지다.
clean_digit = knn_clf.predict([X_test_mod[some_index]])
plot_digit(clean_digit)
save_fig("cleaned_digit_example_plot")
그림 저장: cleaned_digit_example_plot
랜덤 분류기의 성능을 ROC 곡선으로 측정하면 y = x
함수의 그래프로 그려진다.
참고: <그림 3-6>
from sklearn.dummy import DummyClassifier
# 0.24버전부터 strategy의 기본값이 'stratified'에서 'prior'로 바뀌므로 명시적으로 지정합니다.
dmy_clf = DummyClassifier(strategy='prior')
y_probas_dmy = cross_val_predict(dmy_clf, X_train, y_train_5, cv=3, method="predict_proba")
y_scores_dmy = y_probas_dmy[:, 1]
fprr, tprr, thresholdsr = roc_curve(y_train_5, y_scores_dmy)
plot_roc_curve(fprr, tprr)
먼저, 기본 훈련세트를 이용한 KNN 분류기의 성능 평가를 진행한다.
from sklearn.neighbors import KNeighborsClassifier
knn_clf = KNeighborsClassifier(weights='distance', n_neighbors=4)
knn_clf.fit(X_train, y_train)
KNeighborsClassifier(n_neighbors=4, weights='distance')
y_knn_pred = knn_clf.predict(X_test)
accuracy_score()
함수를 이용한 정확도는 97.14% 이다.
from sklearn.metrics import accuracy_score
accuracy_score(y_test, y_knn_pred)
0.9714
상하좌우 각각의 방향으로 1 픽셀씩 이동하는 방식을 이동하여 훈련 데이터를 증식한다.
아래 shift_digit()
함수는 지정된 픽셀 만큼 이미지를 이동시킨다.
shift()
함수: scipy에서 제공. 이미지 픽셀을 지정된 크기만큼 이동시켜 새로운 이미지 생성.dx
, dy
: 각각 x축, y축으로 이동 크기 지정cval
: 이동 후 빈 픽셀에 채울 값. 모든 빈 픽셀에 동일한 값 사용.from scipy.ndimage.interpolation import shift
def shift_digit(digit_array, dx, dy, new=0):
return shift(digit_array.reshape(28, 28), [dy, dx], cval=new).reshape(784)
plot_digit(shift_digit(some_digit, 5, 1, new=100))
6만 개의 훈련 샘플 각각에 대해 상하좌우 1 픽셀씩 이동시킨 훈련 데이터를 총 24만 개 생성한다. 따라서 최종 훈련 세트의 크기는 30만이 된다.
X_train_expanded = [X_train]
y_train_expanded = [y_train]
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
shifted_images = np.apply_along_axis(shift_digit, axis=1, arr=X_train, dx=dx, dy=dy)
X_train_expanded.append(shifted_images)
y_train_expanded.append(y_train)
X_train_expanded = np.concatenate(X_train_expanded)
y_train_expanded = np.concatenate(y_train_expanded)
X_train_expanded.shape, y_train_expanded.shape
((300000, 784), (300000,))
확장된 훈련 세트를 이용하여 다시 학습하면 정확도가 97.63%까지 상승한다.
knn_clf.fit(X_train_expanded, y_train_expanded)
KNeighborsClassifier(n_neighbors=4, weights='distance')
y_knn_expanded_pred = knn_clf.predict(X_test)
accuracy_score(y_test, y_knn_expanded_pred)
0.9763
성능이 향상된 모델은 아래 처럼 매우 어려운 손글씨도 정확하게 예측한다.
ambiguous_digit = X_test[2589]
knn_clf.predict_proba([ambiguous_digit])
array([[0.24579675, 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0.75420325]])
plot_digit(ambiguous_digit)
경고: 사용하는 하드웨어에 따라 다음 셀을 실행하는데 16시간 또는 그 이상 걸릴 수 있습니다.
from sklearn.model_selection import GridSearchCV
param_grid = [{'weights': ["uniform", "distance"], 'n_neighbors': [3, 4, 5]}]
knn_clf = KNeighborsClassifier()
grid_search = GridSearchCV(knn_clf, param_grid, cv=5, verbose=3)
grid_search.fit(X_train, y_train)
Fitting 5 folds for each of 6 candidates, totalling 30 fits [CV 1/5] END .................n_neighbors=3, weights=uniform; total time= 9.5s [CV 2/5] END .................n_neighbors=3, weights=uniform; total time= 9.7s [CV 3/5] END .................n_neighbors=3, weights=uniform; total time= 9.5s [CV 4/5] END .................n_neighbors=3, weights=uniform; total time= 9.6s [CV 5/5] END .................n_neighbors=3, weights=uniform; total time= 9.5s [CV 1/5] END ................n_neighbors=3, weights=distance; total time= 9.1s [CV 2/5] END ................n_neighbors=3, weights=distance; total time= 9.1s [CV 3/5] END ................n_neighbors=3, weights=distance; total time= 9.1s [CV 4/5] END ................n_neighbors=3, weights=distance; total time= 9.2s [CV 5/5] END ................n_neighbors=3, weights=distance; total time= 9.2s [CV 1/5] END .................n_neighbors=4, weights=uniform; total time= 12.5s [CV 2/5] END .................n_neighbors=4, weights=uniform; total time= 12.4s [CV 3/5] END .................n_neighbors=4, weights=uniform; total time= 12.4s [CV 4/5] END .................n_neighbors=4, weights=uniform; total time= 12.6s [CV 5/5] END .................n_neighbors=4, weights=uniform; total time= 12.7s [CV 1/5] END ................n_neighbors=4, weights=distance; total time= 11.9s [CV 2/5] END ................n_neighbors=4, weights=distance; total time= 12.1s [CV 3/5] END ................n_neighbors=4, weights=distance; total time= 11.9s [CV 4/5] END ................n_neighbors=4, weights=distance; total time= 12.5s [CV 5/5] END ................n_neighbors=4, weights=distance; total time= 12.2s [CV 1/5] END .................n_neighbors=5, weights=uniform; total time= 12.2s [CV 2/5] END .................n_neighbors=5, weights=uniform; total time= 12.7s [CV 3/5] END .................n_neighbors=5, weights=uniform; total time= 12.2s [CV 4/5] END .................n_neighbors=5, weights=uniform; total time= 12.5s [CV 5/5] END .................n_neighbors=5, weights=uniform; total time= 12.7s [CV 1/5] END ................n_neighbors=5, weights=distance; total time= 12.0s [CV 2/5] END ................n_neighbors=5, weights=distance; total time= 12.3s [CV 3/5] END ................n_neighbors=5, weights=distance; total time= 12.1s [CV 4/5] END ................n_neighbors=5, weights=distance; total time= 12.1s [CV 5/5] END ................n_neighbors=5, weights=distance; total time= 12.4s
GridSearchCV(cv=5, estimator=KNeighborsClassifier(), param_grid=[{'n_neighbors': [3, 4, 5], 'weights': ['uniform', 'distance']}], verbose=3)
grid_search.best_params_
{'n_neighbors': 4, 'weights': 'distance'}
grid_search.best_score_
0.9716166666666666
from sklearn.metrics import accuracy_score
y_pred = grid_search.predict(X_test)
accuracy_score(y_test, y_pred)
0.9714
from scipy.ndimage.interpolation import shift
def shift_image(image, dx, dy):
image = image.reshape((28, 28))
shifted_image = shift(image, [dy, dx], cval=0, mode="constant")
return shifted_image.reshape([-1])
image = X_train[1000]
shifted_image_down = shift_image(image, 0, 5)
shifted_image_left = shift_image(image, -5, 0)
plt.figure(figsize=(12,3))
plt.subplot(131)
plt.title("Original", fontsize=14)
plt.imshow(image.reshape(28, 28), interpolation="nearest", cmap="Greys")
plt.subplot(132)
plt.title("Shifted down", fontsize=14)
plt.imshow(shifted_image_down.reshape(28, 28), interpolation="nearest", cmap="Greys")
plt.subplot(133)
plt.title("Shifted left", fontsize=14)
plt.imshow(shifted_image_left.reshape(28, 28), interpolation="nearest", cmap="Greys")
plt.show()
X_train_augmented = [image for image in X_train]
y_train_augmented = [label for label in y_train]
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
for image, label in zip(X_train, y_train):
X_train_augmented.append(shift_image(image, dx, dy))
y_train_augmented.append(label)
X_train_augmented = np.array(X_train_augmented)
y_train_augmented = np.array(y_train_augmented)
shuffle_idx = np.random.permutation(len(X_train_augmented))
X_train_augmented = X_train_augmented[shuffle_idx]
y_train_augmented = y_train_augmented[shuffle_idx]
knn_clf = KNeighborsClassifier(**grid_search.best_params_)
knn_clf.fit(X_train_augmented, y_train_augmented)
KNeighborsClassifier(n_neighbors=4, weights='distance')
경고: 사용하는 하드웨어에 따라 다음 셀을 실행하는데 1시간 또는 그 이상 걸릴 수 있습니다.
y_pred = knn_clf.predict(X_test)
accuracy_score(y_test, y_pred)
0.9763
간단히 데이터를 증식해서 0.5% 정확도를 높였습니다. :)
승객의 나이, 성별, 승객 등급, 승선 위치 같은 속성을 기반으로 하여 승객의 생존 여부를 예측하는 것이 목표입니다.
그다음 데이터를 적재합니다:
import os
TITANIC_PATH = os.path.join("datasets", "titanic")
import pandas as pd
def load_titanic_data(filename, titanic_path=TITANIC_PATH):
csv_path = os.path.join(titanic_path, filename)
return pd.read_csv(csv_path)
train_data = load_titanic_data("train.csv")
test_data = load_titanic_data("test.csv")
데이터는 이미 훈련 세트와 테스트 세트로 분리되어 있습니다. 그러나 테스트 데이터는 레이블을 가지고 있지 않습니다: 훈련 데이터를 이용하여 가능한 최고의 모델을 만들고 테스트 데이터에 대한 예측을 캐글(Kaggle)에 업로드하여 최종 점수를 확인하는 것이 목표입니다.
훈련 세트에서 맨 위 몇 개의 열을 살펴 보겠습니다:
train_data.head()
PassengerId | Survived | Pclass | Name | Sex | Age | SibSp | Parch | Ticket | Fare | Cabin | Embarked | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 0 | 3 | Braund, Mr. Owen Harris | male | 22.0 | 1 | 0 | A/5 21171 | 7.2500 | NaN | S |
1 | 2 | 1 | 1 | Cumings, Mrs. John Bradley (Florence Briggs Th... | female | 38.0 | 1 | 0 | PC 17599 | 71.2833 | C85 | C |
2 | 3 | 1 | 3 | Heikkinen, Miss. Laina | female | 26.0 | 0 | 0 | STON/O2. 3101282 | 7.9250 | NaN | S |
3 | 4 | 1 | 1 | Futrelle, Mrs. Jacques Heath (Lily May Peel) | female | 35.0 | 1 | 0 | 113803 | 53.1000 | C123 | S |
4 | 5 | 0 | 3 | Allen, Mr. William Henry | male | 35.0 | 0 | 0 | 373450 | 8.0500 | NaN | S |
속성은 다음과 같은 의미를 가집니다:
누락된 데이터가 얼마나 되는지 알아보겠습니다:
train_data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 891 entries, 0 to 890 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 PassengerId 891 non-null int64 1 Survived 891 non-null int64 2 Pclass 891 non-null int64 3 Name 891 non-null object 4 Sex 891 non-null object 5 Age 714 non-null float64 6 SibSp 891 non-null int64 7 Parch 891 non-null int64 8 Ticket 891 non-null object 9 Fare 891 non-null float64 10 Cabin 204 non-null object 11 Embarked 889 non-null object dtypes: float64(2), int64(5), object(5) memory usage: 83.7+ KB
괜찮네요. Age, Cabin, Embarked 속성의 일부가 null입니다(891개의 non-null 보다 작습니다). 특히 Cabin은 77%가 null입니다. 일단 Cabin은 무시하고 나머지를 활용하겠습니다. Age는 19%가 null이므로 이를 어떻게 처리할지 결정해야 합니다. null을 중간 나이로 바꾸는 것이 괜찮아 보입니다.
Name과 Ticket 속성도 값을 가지고 있지만 머신러닝 모델이 사용할 수 있는 숫자로 변환하는 것이 조금 까다롭습니다. 그래서 지금은 이 두 속성을 무시하겠습니다.
통계치를 살펴 보겠습니다:
train_data.describe()
PassengerId | Survived | Pclass | Age | SibSp | Parch | Fare | |
---|---|---|---|---|---|---|---|
count | 891.000000 | 891.000000 | 891.000000 | 714.000000 | 891.000000 | 891.000000 | 891.000000 |
mean | 446.000000 | 0.383838 | 2.308642 | 29.699118 | 0.523008 | 0.381594 | 32.204208 |
std | 257.353842 | 0.486592 | 0.836071 | 14.526497 | 1.102743 | 0.806057 | 49.693429 |
min | 1.000000 | 0.000000 | 1.000000 | 0.420000 | 0.000000 | 0.000000 | 0.000000 |
25% | 223.500000 | 0.000000 | 2.000000 | 20.125000 | 0.000000 | 0.000000 | 7.910400 |
50% | 446.000000 | 0.000000 | 3.000000 | 28.000000 | 0.000000 | 0.000000 | 14.454200 |
75% | 668.500000 | 1.000000 | 3.000000 | 38.000000 | 1.000000 | 0.000000 | 31.000000 |
max | 891.000000 | 1.000000 | 3.000000 | 80.000000 | 8.000000 | 6.000000 | 512.329200 |
타깃이 0과 1로 이루어졌는지 확인합니다:
train_data["Survived"].value_counts()
0 549 1 342 Name: Survived, dtype: int64
범주형 특성들을 확인해 보겠습니다:
train_data["Pclass"].value_counts()
3 491 1 216 2 184 Name: Pclass, dtype: int64
train_data["Sex"].value_counts()
male 577 female 314 Name: Sex, dtype: int64
train_data["Embarked"].value_counts()
S 644 C 168 Q 77 Name: Embarked, dtype: int64
Embarked 특성은 승객이 탑승한 곳을 알려 줍니다: C=Cherbourg, Q=Queenstown, S=Southampton.
노트: 아래 코드는 Pipeline
, FeatureUnion
와 사용자 정의 DataFrameSelector
클래스를 사용해 각 열을 다르게 전처리합니다. 사이킷런 0.20부터는 이전 장에서처럼 ColumnTransformer
를 사용하는 것이 좋습니다.
전처리 파이프라인을 만들어 보죠. 이전 장에서 만든 DataframeSelector
를 재사용하여 DataFrame
에서 특정 열을 선택하겠습니다:
from sklearn.base import BaseEstimator, TransformerMixin
class DataFrameSelector(BaseEstimator, TransformerMixin):
def __init__(self, attribute_names):
self.attribute_names = attribute_names
def fit(self, X, y=None):
return self
def transform(self, X):
return X[self.attribute_names]
숫자 특성을 위한 파이프라인을 만듭니다:
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
num_pipeline = Pipeline([
("select_numeric", DataFrameSelector(["Age", "SibSp", "Parch", "Fare"])),
("imputer", SimpleImputer(strategy="median")),
])
num_pipeline.fit_transform(train_data)
array([[22. , 1. , 0. , 7.25 ], [38. , 1. , 0. , 71.2833], [26. , 0. , 0. , 7.925 ], ..., [28. , 1. , 2. , 23.45 ], [26. , 0. , 0. , 30. ], [32. , 0. , 0. , 7.75 ]])
문자열로된 범주형 열을 위해 별도의 Imputer 클래스가 필요합니다(일반 SimpleImputer
클래스는 이를 처리하지 못합니다):
# stackoverflow.com/questions/25239958 에서 착안했습니다
class MostFrequentImputer(BaseEstimator, TransformerMixin):
def fit(self, X, y=None):
self.most_frequent_ = pd.Series([X[c].value_counts().index[0] for c in X],
index=X.columns)
return self
def transform(self, X, y=None):
return X.fillna(self.most_frequent_)
from sklearn.preprocessing import OneHotEncoder
이제 범주형 특성을 위한 파이프라인을 만듭니다:
cat_pipeline = Pipeline([
("select_cat", DataFrameSelector(["Pclass", "Sex", "Embarked"])),
("imputer", MostFrequentImputer()),
("cat_encoder", OneHotEncoder(sparse=False)),
])
cat_pipeline.fit_transform(train_data)
array([[0., 0., 1., ..., 0., 0., 1.], [1., 0., 0., ..., 1., 0., 0.], [0., 0., 1., ..., 0., 0., 1.], ..., [0., 0., 1., ..., 0., 0., 1.], [1., 0., 0., ..., 1., 0., 0.], [0., 0., 1., ..., 0., 1., 0.]])
마지막으로 숫자와 범주형 파이프라인을 연결합니다:
from sklearn.pipeline import FeatureUnion
preprocess_pipeline = FeatureUnion(transformer_list=[
("num_pipeline", num_pipeline),
("cat_pipeline", cat_pipeline),
])
좋습니다! 이제 원본 데이터를 받아 머신러닝 모델에 주입할 숫자 입력 특성을 출력하는 전처리 파이프라인을 만들었습니다.
X_train = preprocess_pipeline.fit_transform(train_data)
X_train
array([[22., 1., 0., ..., 0., 0., 1.], [38., 1., 0., ..., 1., 0., 0.], [26., 0., 0., ..., 0., 0., 1.], ..., [28., 1., 2., ..., 0., 0., 1.], [26., 0., 0., ..., 1., 0., 0.], [32., 0., 0., ..., 0., 1., 0.]])
레이블을 가져옵니다:
y_train = train_data["Survived"]
이제 분류기를 훈련시킬 차례입니다. 먼저 SVC
를 사용해 보겠습니다:
from sklearn.svm import SVC
svm_clf = SVC(gamma="auto")
svm_clf.fit(X_train, y_train)
SVC(gamma='auto')
모델이 잘 훈련된 것 같습니다. 이를 사용해서 테스트 세트에 대한 예측을 만듭니다:
X_test = preprocess_pipeline.transform(test_data)
y_pred = svm_clf.predict(X_test)
이 예측 결과를 (캐글에서 기대하는 형태인) CSV 파일로 만들어 업로드하고 평가를 받아볼 수 있습니다. 하지만 그냥 좋을거라 기대하는 것보다 교차 검증으로 모델이 얼마나 좋은지 평가하는 것이 좋습니다.
from sklearn.model_selection import cross_val_score
svm_scores = cross_val_score(svm_clf, X_train, y_train, cv=10)
svm_scores.mean()
0.7329588014981274
RandomForestClassifier
를 적용해 보겠습니다:
from sklearn.ensemble import RandomForestClassifier
forest_clf = RandomForestClassifier(n_estimators=100, random_state=42)
forest_scores = cross_val_score(forest_clf, X_train, y_train, cv=10)
forest_scores.mean()
0.8126466916354558
훨씬 좋네요!
10 폴드 교차 검증에 대한 평균 정확도를 보는 대신 모델에서 얻은 10개의 점수를 1사분위, 3사분위를 명료하게 표현해주는 상자 수염 그림(box-and-whisker) 그래프를 만들어 보겠습니다(이 방식을 제안해 준 Nevin Yilmaz에게 감사합니다). boxplot()
함수는 이상치(플라이어(flier)라고 부릅니다)를 감지하고 수염 부분에 이를 포함시키지 않습니다. 1사분위가 $Q_1$이고 3사분위가 $Q_3$이라면 사분위수 범위는 $IQR = Q_3 - Q_1$가 됩니다(이 값이 박스의 높이가 됩니다). $Q_1 - 1.5 \times IQR$ 보다 낮거나 $Q3 + 1.5 \times IQR$ 보다 높은 점수는 이상치로 간주됩니다.
plt.figure(figsize=(8, 4))
plt.plot([1]*10, svm_scores, ".")
plt.plot([2]*10, forest_scores, ".")
plt.boxplot([svm_scores, forest_scores], labels=("SVM","Random Forest"))
plt.ylabel("Accuracy", fontsize=14)
plt.show()
이 결과를 더 향상시키려면:
train_data["AgeBucket"] = train_data["Age"] // 15 * 15
train_data[["AgeBucket", "Survived"]].groupby(['AgeBucket']).mean()
Survived | |
---|---|
AgeBucket | |
0.0 | 0.576923 |
15.0 | 0.362745 |
30.0 | 0.423256 |
45.0 | 0.404494 |
60.0 | 0.240000 |
75.0 | 1.000000 |
train_data["RelativesOnboard"] = train_data["SibSp"] + train_data["Parch"]
train_data[["RelativesOnboard", "Survived"]].groupby(['RelativesOnboard']).mean()
Survived | |
---|---|
RelativesOnboard | |
0 | 0.303538 |
1 | 0.552795 |
2 | 0.578431 |
3 | 0.724138 |
4 | 0.200000 |
5 | 0.136364 |
6 | 0.333333 |
7 | 0.000000 |
10 | 0.000000 |
먼저 데이터를 다운받습니다:
import os
import tarfile
import urllib.request
DOWNLOAD_ROOT = "http://spamassassin.apache.org/old/publiccorpus/"
HAM_URL = DOWNLOAD_ROOT + "20030228_easy_ham.tar.bz2"
SPAM_URL = DOWNLOAD_ROOT + "20030228_spam.tar.bz2"
SPAM_PATH = os.path.join("datasets", "spam")
def fetch_spam_data(ham_url=HAM_URL, spam_url=SPAM_URL, spam_path=SPAM_PATH):
if not os.path.isdir(spam_path):
os.makedirs(spam_path)
for filename, url in (("ham.tar.bz2", ham_url), ("spam.tar.bz2", spam_url)):
path = os.path.join(spam_path, filename)
if not os.path.isfile(path):
urllib.request.urlretrieve(url, path)
tar_bz2_file = tarfile.open(path)
tar_bz2_file.extractall(path=spam_path)
tar_bz2_file.close()
fetch_spam_data()
다음, 모든 이메일을 읽어 들입니다:
HAM_DIR = os.path.join(SPAM_PATH, "easy_ham")
SPAM_DIR = os.path.join(SPAM_PATH, "spam")
ham_filenames = [name for name in sorted(os.listdir(HAM_DIR)) if len(name) > 20]
spam_filenames = [name for name in sorted(os.listdir(SPAM_DIR)) if len(name) > 20]
len(ham_filenames)
2500
len(spam_filenames)
500
파이썬의 email
모듈을 사용해 이메일을 파싱합니다(헤더, 인코딩 등을 처리합니다):
import email
import email.policy
def load_email(is_spam, filename, spam_path=SPAM_PATH):
directory = "spam" if is_spam else "easy_ham"
with open(os.path.join(spam_path, directory, filename), "rb") as f:
return email.parser.BytesParser(policy=email.policy.default).parse(f)
ham_emails = [load_email(is_spam=False, filename=name) for name in ham_filenames]
spam_emails = [load_email(is_spam=True, filename=name) for name in spam_filenames]
데이터가 어떻게 구성되어 있는지 감을 잡기 위해 햄 메일과 스팸 메일을 하나씩 보겠습니다:
print(ham_emails[1].get_content().strip())
Martin A posted: Tassos Papadopoulos, the Greek sculptor behind the plan, judged that the limestone of Mount Kerdylio, 70 miles east of Salonika and not far from the Mount Athos monastic community, was ideal for the patriotic sculpture. As well as Alexander's granite features, 240 ft high and 170 ft wide, a museum, a restored amphitheatre and car park for admiring crowds are planned --------------------- So is this mountain limestone or granite? If it's limestone, it'll weather pretty fast. ------------------------ Yahoo! Groups Sponsor ---------------------~--> 4 DVDs Free +s&p Join Now http://us.click.yahoo.com/pt6YBB/NXiEAA/mG3HAA/7gSolB/TM ---------------------------------------------------------------------~-> To unsubscribe from this group, send an email to: forteana-unsubscribe@egroups.com Your use of Yahoo! Groups is subject to http://docs.yahoo.com/info/terms/
print(spam_emails[6].get_content().strip())
Help wanted. We are a 14 year old fortune 500 company, that is growing at a tremendous rate. We are looking for individuals who want to work from home. This is an opportunity to make an excellent income. No experience is required. We will train you. So if you are looking to be employed from home with a career that has vast opportunities, then go: http://www.basetel.com/wealthnow We are looking for energetic and self motivated people. If that is you than click on the link and fill out the form, and one of our employement specialist will contact you. To be removed from our link simple go to: http://www.basetel.com/remove.html 4139vOLW7-758DoDY1425FRhM1-764SMFc8513fCsLl40
어떤 이메일은 이미지나 첨부 파일을 가진 멀티파트(multipart)입니다(메일에 포함되어 있을수 있습니다). 어떤 파일들이 있는지 살펴 보겠습니다:
def get_email_structure(email):
if isinstance(email, str):
return email
payload = email.get_payload()
if isinstance(payload, list):
return "multipart({})".format(", ".join([
get_email_structure(sub_email)
for sub_email in payload
]))
else:
return email.get_content_type()
from collections import Counter
def structures_counter(emails):
structures = Counter()
for email in emails:
structure = get_email_structure(email)
structures[structure] += 1
return structures
structures_counter(ham_emails).most_common()
[('text/plain', 2408), ('multipart(text/plain, application/pgp-signature)', 66), ('multipart(text/plain, text/html)', 8), ('multipart(text/plain, text/plain)', 4), ('multipart(text/plain)', 3), ('multipart(text/plain, application/octet-stream)', 2), ('multipart(text/plain, text/enriched)', 1), ('multipart(text/plain, application/ms-tnef, text/plain)', 1), ('multipart(multipart(text/plain, text/plain, text/plain), application/pgp-signature)', 1), ('multipart(text/plain, video/mng)', 1), ('multipart(text/plain, multipart(text/plain))', 1), ('multipart(text/plain, application/x-pkcs7-signature)', 1), ('multipart(text/plain, multipart(text/plain, text/plain), text/rfc822-headers)', 1), ('multipart(text/plain, multipart(text/plain, text/plain), multipart(multipart(text/plain, application/x-pkcs7-signature)))', 1), ('multipart(text/plain, application/x-java-applet)', 1)]
structures_counter(spam_emails).most_common()
[('text/plain', 218), ('text/html', 183), ('multipart(text/plain, text/html)', 45), ('multipart(text/html)', 20), ('multipart(text/plain)', 19), ('multipart(multipart(text/html))', 5), ('multipart(text/plain, image/jpeg)', 3), ('multipart(text/html, application/octet-stream)', 2), ('multipart(text/plain, application/octet-stream)', 1), ('multipart(text/html, text/plain)', 1), ('multipart(multipart(text/html), application/octet-stream, image/jpeg)', 1), ('multipart(multipart(text/plain, text/html), image/gif)', 1), ('multipart/alternative', 1)]
햄 메일은 평범한 텍스트가 많고 스팸은 HTML일 경우가 많습니다. 적은 수의 햄 이메일이 PGP로 서명되어 있지만 스팸 메일에는 없습니다. 요약하면 이메일 구조는 유용한 정보입니다.
이제 이메일 헤더를 살펴보겠습니다:
for header, value in spam_emails[0].items():
print(header,":",value)
Return-Path : <12a1mailbot1@web.de> Delivered-To : zzzz@localhost.spamassassin.taint.org Received : from localhost (localhost [127.0.0.1]) by phobos.labs.spamassassin.taint.org (Postfix) with ESMTP id 136B943C32 for <zzzz@localhost>; Thu, 22 Aug 2002 08:17:21 -0400 (EDT) Received : from mail.webnote.net [193.120.211.219] by localhost with POP3 (fetchmail-5.9.0) for zzzz@localhost (single-drop); Thu, 22 Aug 2002 13:17:21 +0100 (IST) Received : from dd_it7 ([210.97.77.167]) by webnote.net (8.9.3/8.9.3) with ESMTP id NAA04623 for <zzzz@spamassassin.taint.org>; Thu, 22 Aug 2002 13:09:41 +0100 From : 12a1mailbot1@web.de Received : from r-smtp.korea.com - 203.122.2.197 by dd_it7 with Microsoft SMTPSVC(5.5.1775.675.6); Sat, 24 Aug 2002 09:42:10 +0900 To : dcek1a1@netsgo.com Subject : Life Insurance - Why Pay More? Date : Wed, 21 Aug 2002 20:31:57 -1600 MIME-Version : 1.0 Message-ID : <0103c1042001882DD_IT7@dd_it7> Content-Type : text/html; charset="iso-8859-1" Content-Transfer-Encoding : quoted-printable
보낸사람의 이메일 주소와 같이 헤더에는 유용한 정보가 많이 있지만 여기서는 Subject
헤더만 다뤄 보겠습니다:
spam_emails[0]["Subject"]
'Life Insurance - Why Pay More?'
좋습니다. 데이터에를 더 살펴보기 전에 훈련 세트와 테스트 세트로 나누도록 하겠습니다:
import numpy as np
from sklearn.model_selection import train_test_split
X = np.array(ham_emails + spam_emails)
y = np.array([0] * len(ham_emails) + [1] * len(spam_emails))
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
<ipython-input-142-c6d32b215bf4>:4: VisibleDeprecationWarning: Creating an ndarray from ragged nested sequences (which is a list-or-tuple of lists-or-tuples-or ndarrays with different lengths or shapes) is deprecated. If you meant to do this, you must specify 'dtype=object' when creating the ndarray X = np.array(ham_emails + spam_emails)
이제 전처리 함수를 작성하겠습니다. 먼저 HTML을 일반 텍스트로 변환하는 함수가 필요합니다. 이 작업에는 당연히 BeautifulSoup 라이브러리를 사용하는게 좋지만 의존성을 줄이기 위해서 정규식을 사용하여 대강 만들어 보겠습니다(un̨ho͞ly radiańcé destro҉ying all enli̍̈́̂̈́ghtenment의 위험에도 불구하고). 다음 함수는 <head>
섹션을 삭제하고 모든 <a>
태그를 HYPERLINK 문자로 바꿉니다. 그런 다음 모든 HTML 태그를 제거하고 텍스트만 남깁니다. 보기 편하게 여러개의 개행 문자를 하나로 만들고 (>
나
같은) html 엔티티를 복원합니다:
import re
from html import unescape
def html_to_plain_text(html):
text = re.sub('<head.*?>.*?</head>', '', html, flags=re.M | re.S | re.I)
text = re.sub('<a\s.*?>', ' HYPERLINK ', text, flags=re.M | re.S | re.I)
text = re.sub('<.*?>', '', text, flags=re.M | re.S)
text = re.sub(r'(\s*\n)+', '\n', text, flags=re.M | re.S)
return unescape(text)
잘 작동하는지 확인해 보겠습니다. 다음은 HTML 스팸입니다:
html_spam_emails = [email for email in X_train[y_train==1]
if get_email_structure(email) == "text/html"]
sample_html_spam = html_spam_emails[7]
print(sample_html_spam.get_content().strip()[:1000], "...")
<HTML><HEAD><TITLE></TITLE><META http-equiv="Content-Type" content="text/html; charset=windows-1252"><STYLE>A:link {TEX-DECORATION: none}A:active {TEXT-DECORATION: none}A:visited {TEXT-DECORATION: none}A:hover {COLOR: #0033ff; TEXT-DECORATION: underline}</STYLE><META content="MSHTML 6.00.2713.1100" name="GENERATOR"></HEAD> <BODY text="#000000" vLink="#0033ff" link="#0033ff" bgColor="#CCCC99"><TABLE borderColor="#660000" cellSpacing="0" cellPadding="0" border="0" width="100%"><TR><TD bgColor="#CCCC99" valign="top" colspan="2" height="27"> <font size="6" face="Arial, Helvetica, sans-serif" color="#660000"> <b>OTC</b></font></TD></TR><TR><TD height="2" bgcolor="#6a694f"> <font size="5" face="Times New Roman, Times, serif" color="#FFFFFF"> <b> Newsletter</b></font></TD><TD height="2" bgcolor="#6a694f"><div align="right"><font color="#FFFFFF"> <b>Discover Tomorrow's Winners </b></font></div></TD></TR><TR><TD height="25" colspan="2" bgcolor="#CCCC99"><table width="100%" border="0" ...
변환된 텍스트입니다:
print(html_to_plain_text(sample_html_spam.get_content())[:1000], "...")
OTC Newsletter Discover Tomorrow's Winners For Immediate Release Cal-Bay (Stock Symbol: CBYI) Watch for analyst "Strong Buy Recommendations" and several advisory newsletters picking CBYI. CBYI has filed to be traded on the OTCBB, share prices historically INCREASE when companies get listed on this larger trading exchange. CBYI is trading around 25 cents and should skyrocket to $2.66 - $3.25 a share in the near future. Put CBYI on your watch list, acquire a position TODAY. REASONS TO INVEST IN CBYI A profitable company and is on track to beat ALL earnings estimates! One of the FASTEST growing distributors in environmental & safety equipment instruments. Excellent management team, several EXCLUSIVE contracts. IMPRESSIVE client list including the U.S. Air Force, Anheuser-Busch, Chevron Refining and Mitsubishi Heavy Industries, GE-Energy & Environmental Research. RAPIDLY GROWING INDUSTRY Industry revenues exceed $900 million, estimates indicate that there could be as much as $25 billi ...
아주 좋습니다! 이제 포맷에 상관없이 이메일을 입력으로 받아서 일반 텍스트를 출력하는 함수를 만들겠습니다:
def email_to_text(email):
html = None
for part in email.walk():
ctype = part.get_content_type()
if not ctype in ("text/plain", "text/html"):
continue
try:
content = part.get_content()
except: # in case of encoding issues
content = str(part.get_payload())
if ctype == "text/plain":
return content
else:
html = content
if html:
return html_to_plain_text(html)
print(email_to_text(sample_html_spam)[:100], "...")
OTC Newsletter Discover Tomorrow's Winners For Immediate Release Cal-Bay (Stock Symbol: CBYI) Wat ...
어간 추출을 해보죠! 이 작업을 하려면 자연어 처리 툴킷(NLTK)을 설치해야 합니다. 다음 명령으로 간단히 설치할 수 있습니다(먼저 virtualenv 환경을 활성화시켜야 합니다. 별도의 환경이 없다면 어드민 권한이 필요할지 모릅니다. 아니면 --user
옵션을 사용하세요):
$ pip install nltk
try:
import nltk
stemmer = nltk.PorterStemmer()
for word in ("Computations", "Computation", "Computing", "Computed", "Compute", "Compulsive"):
print(word, "=>", stemmer.stem(word))
except ImportError:
print("Error: stemming requires the NLTK module.")
stemmer = None
Computations => comput Computation => comput Computing => comput Computed => comput Compute => comput Compulsive => compuls
인터넷 주소는 "URL" 문자로 바꾸겠습니다. 정규식을 하드 코딩할 수도 있지만 urlextract 라이브러리를 사용하겠습니다. 다음 명령으로 설치합니다(먼저 virtualenv 환경을 활성화시켜야 합니다. 별도의 환경이 없다면 어드민 권한이 필요할지 모릅니다. 아니면 --user
옵션을 사용하세요):
$ pip install urlextract
# 코랩에서 이 노트북을 실행하려면 먼저 pip install urlextract을 실행합니다
try:
import google.colab
!pip install -q -U urlextract
except ImportError:
pass # not running on Colab
try:
import urlextract # 루트 도메인 이름을 다운로드하기 위해 인터넷 연결이 필요할지 모릅니다
url_extractor = urlextract.URLExtract()
print(url_extractor.find_urls("Will it detect github.com and https://youtu.be/7Pq-S557XQU?t=3m32s"))
except ImportError:
print("Error: replacing URLs requires the urlextract module.")
url_extractor = None
Error: replacing URLs requires the urlextract module.
이들을 모두 하나의 변환기로 연결하여 이메일을 단어 카운트로 바꿀 것입니다. 파이썬의 split()
메서드를 사용하면 구둣점과 단어 경계를 기준으로 문장을 단어로 바꿉니다. 이 방법이 많은 언어에 통하지만 전부는 아닙니다. 예를 들어 중국어와 일본어는 일반적으로 단어 사이에 공백을 두지 않습니다. 베트남어는 음절 사이에 공백을 두기도 합니다. 여기서는 데이터셋이 (거의) 영어로 되어 있기 때문에 문제없습니다.
from sklearn.base import BaseEstimator, TransformerMixin
class EmailToWordCounterTransformer(BaseEstimator, TransformerMixin):
def __init__(self, strip_headers=True, lower_case=True, remove_punctuation=True,
replace_urls=True, replace_numbers=True, stemming=True):
self.strip_headers = strip_headers
self.lower_case = lower_case
self.remove_punctuation = remove_punctuation
self.replace_urls = replace_urls
self.replace_numbers = replace_numbers
self.stemming = stemming
def fit(self, X, y=None):
return self
def transform(self, X, y=None):
X_transformed = []
for email in X:
text = email_to_text(email) or ""
if self.lower_case:
text = text.lower()
if self.replace_urls and url_extractor is not None:
urls = list(set(url_extractor.find_urls(text)))
urls.sort(key=lambda url: len(url), reverse=True)
for url in urls:
text = text.replace(url, " URL ")
if self.replace_numbers:
text = re.sub(r'\d+(?:\.\d*)?(?:[eE][+-]?\d+)?', 'NUMBER', text)
if self.remove_punctuation:
text = re.sub(r'\W+', ' ', text, flags=re.M)
word_counts = Counter(text.split())
if self.stemming and stemmer is not None:
stemmed_word_counts = Counter()
for word, count in word_counts.items():
stemmed_word = stemmer.stem(word)
stemmed_word_counts[stemmed_word] += count
word_counts = stemmed_word_counts
X_transformed.append(word_counts)
return np.array(X_transformed)
이 변환기를 몇 개의 이메일에 적용해 보겠습니다:
X_few = X_train[:3]
X_few_wordcounts = EmailToWordCounterTransformer().fit_transform(X_few)
X_few_wordcounts
array([Counter({'chuck': 1, 'murcko': 1, 'wrote': 1, 'stuff': 1, 'yawn': 1, 'r': 1}), Counter({'the': 11, 'of': 9, 'and': 8, 'all': 3, 'christian': 3, 'to': 3, 'by': 3, 'jefferson': 2, 'i': 2, 'have': 2, 'superstit': 2, 'one': 2, 'on': 2, 'been': 2, 'ha': 2, 'half': 2, 'rogueri': 2, 'teach': 2, 'jesu': 2, 'some': 1, 'interest': 1, 'quot': 1, 'http': 1, 'www': 1, 'postfun': 1, 'com': 1, 'pfp': 1, 'worboi': 1, 'html': 1, 'thoma': 1, 'examin': 1, 'known': 1, 'word': 1, 'do': 1, 'not': 1, 'find': 1, 'in': 1, 'our': 1, 'particular': 1, 'redeem': 1, 'featur': 1, 'they': 1, 'are': 1, 'alik': 1, 'found': 1, 'fabl': 1, 'mytholog': 1, 'million': 1, 'innoc': 1, 'men': 1, 'women': 1, 'children': 1, 'sinc': 1, 'introduct': 1, 'burnt': 1, 'tortur': 1, 'fine': 1, 'imprison': 1, 'what': 1, 'effect': 1, 'thi': 1, 'coercion': 1, 'make': 1, 'world': 1, 'fool': 1, 'other': 1, 'hypocrit': 1, 'support': 1, 'error': 1, 'over': 1, 'earth': 1, 'six': 1, 'histor': 1, 'american': 1, 'john': 1, 'e': 1, 'remsburg': 1, 'letter': 1, 'william': 1, 'short': 1, 'again': 1, 'becom': 1, 'most': 1, 'pervert': 1, 'system': 1, 'that': 1, 'ever': 1, 'shone': 1, 'man': 1, 'absurd': 1, 'untruth': 1, 'were': 1, 'perpetr': 1, 'upon': 1, 'a': 1, 'larg': 1, 'band': 1, 'dupe': 1, 'import': 1, 'led': 1, 'paul': 1, 'first': 1, 'great': 1, 'corrupt': 1}), Counter({'number': 5, 'http': 4, 'yahoo': 4, 's': 3, 'group': 3, 'com': 3, 'to': 3, 'in': 2, 'forteana': 2, 'martin': 2, 'an': 2, 'and': 2, 'memri': 2, 'we': 2, 'is': 2, 'unsubscrib': 2, 'y': 1, 'adamson': 1, 'wrote': 1, 'for': 1, 'altern': 1, 'rather': 1, 'more': 1, 'factual': 1, 'base': 1, 'rundown': 1, 'on': 1, 'hamza': 1, 'career': 1, 'includ': 1, 'hi': 1, 'belief': 1, 'that': 1, 'all': 1, 'non': 1, 'muslim': 1, 'yemen': 1, 'should': 1, 'be': 1, 'murder': 1, 'outright': 1, 'org': 1, 'bin': 1, 'articl': 1, 'cgi': 1, 'page': 1, 'archiv': 1, 'area': 1, 'ia': 1, 'id': 1, 'ianumb': 1, 'know': 1, 'how': 1, 'unbias': 1, 'don': 1, 't': 1, 'www': 1, 'guardian': 1, 'co': 1, 'uk': 1, 'elsewher': 1, 'journalist': 1, 'stori': 1, 'html': 1, 'rob': 1, 'sponsor': 1, 'dvd': 1, 'free': 1, 'p': 1, 'join': 1, 'now': 1, 'us': 1, 'click': 1, 'ptnumberybb': 1, 'nxieaa': 1, 'mvfiaa': 1, 'numbergsolb': 1, 'tm': 1, 'from': 1, 'thi': 1, 'send': 1, 'email': 1, 'egroup': 1, 'your': 1, 'use': 1, 'of': 1, 'subject': 1, 'doc': 1, 'info': 1, 'term': 1})], dtype=object)
제대로 작동하는 것 같네요!
이제 단어 카운트를 벡터로 변환해야 합니다. 이를 위해서 또 다른 변환기를 만들겠습니다. 이 변환기는 (자주 나타나는 단어 순으로 정렬된) 어휘 목록을 구축하는 fit()
메서드와 어휘 목록을 사용해 단어를 벡터로 바꾸는 transform()
메서드를 가집니다. 출력은 희소 행렬이 됩니다.
from scipy.sparse import csr_matrix
class WordCounterToVectorTransformer(BaseEstimator, TransformerMixin):
def __init__(self, vocabulary_size=1000):
self.vocabulary_size = vocabulary_size
def fit(self, X, y=None):
total_count = Counter()
for word_count in X:
for word, count in word_count.items():
total_count[word] += min(count, 10)
most_common = total_count.most_common()[:self.vocabulary_size]
self.vocabulary_ = {word: index + 1 for index, (word, count) in enumerate(most_common)}
return self
def transform(self, X, y=None):
rows = []
cols = []
data = []
for row, word_count in enumerate(X):
for word, count in word_count.items():
rows.append(row)
cols.append(self.vocabulary_.get(word, 0))
data.append(count)
return csr_matrix((data, (rows, cols)), shape=(len(X), self.vocabulary_size + 1))
vocab_transformer = WordCounterToVectorTransformer(vocabulary_size=10)
X_few_vectors = vocab_transformer.fit_transform(X_few_wordcounts)
X_few_vectors
<3x11 sparse matrix of type '<class 'numpy.int64'>' with 20 stored elements in Compressed Sparse Row format>
X_few_vectors.toarray()
array([[ 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [112, 11, 9, 8, 3, 1, 0, 1, 3, 0, 1], [ 92, 0, 1, 2, 3, 4, 5, 3, 1, 4, 2]])
이 행렬은 무엇을 의미하나요? 세 번째 행의 첫 번째 열의 65는 세 번째 이메일이 어휘 목록에 없는 단어를 65개 가지고 있다는 뜻입니다. 그 다음의 0은 어휘 목록에 있는 첫 번째 단어가 한 번도 등장하지 않는다는 뜻이고 그 다음의 1은 한 번 나타난다는 뜻입니다. 이 단어들이 무엇인지 확인하려면 어휘 목록을 보면 됩니다. 첫 번째 단어는 "the"이고 두 번째 단어는 "of"입니다.
vocab_transformer.vocabulary_
{'the': 1, 'of': 2, 'and': 3, 'to': 4, 'http': 5, 'number': 6, 'com': 7, 'all': 8, 'yahoo': 9, 'in': 10}
이제 스팸 분류기를 훈련시킬 준비를 마쳤습니다! 전체 데이터셋을 변환시켜보죠:
from sklearn.pipeline import Pipeline
preprocess_pipeline = Pipeline([
("email_to_wordcount", EmailToWordCounterTransformer()),
("wordcount_to_vector", WordCounterToVectorTransformer()),
])
X_train_transformed = preprocess_pipeline.fit_transform(X_train)
Note: to be future-proof, we set solver="lbfgs"
since this will be the default value in Scikit-Learn 0.22.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
log_clf = LogisticRegression(solver="lbfgs", max_iter=1000, random_state=42)
score = cross_val_score(log_clf, X_train_transformed, y_train, cv=3, verbose=3)
score.mean()
[Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
[CV] END ................................ score: (test=0.983) total time= 0.3s
[Parallel(n_jobs=1)]: Done 1 out of 1 | elapsed: 0.3s remaining: 0.0s
[CV] END ................................ score: (test=0.984) total time= 0.3s
[Parallel(n_jobs=1)]: Done 2 out of 2 | elapsed: 0.6s remaining: 0.0s
[CV] END ................................ score: (test=0.993) total time= 0.5s
[Parallel(n_jobs=1)]: Done 3 out of 3 | elapsed: 1.1s finished
0.9862500000000001
98.5%가 넘네요. 첫 번째 시도치고 나쁘지 않습니다! :) 그러나 이 데이터셋은 비교적 쉬운 문제입니다. 더 어려운 데이터셋에 적용해 보면 결과가 그리 높지 않을 것입니다. 여러개의 모델을 시도해 보고 제일 좋은 것을 골라 교차 검증으로 세밀하게 튜닝해 보세요.
하지만 전체 내용을 파악했으므로 여기서 멈추겠습니다. 테스트 세트에서 정밀도/재현율을 출력해 보겠습니다:
from sklearn.metrics import precision_score, recall_score
X_test_transformed = preprocess_pipeline.transform(X_test)
log_clf = LogisticRegression(solver="lbfgs", max_iter=1000, random_state=42)
log_clf.fit(X_train_transformed, y_train)
y_pred = log_clf.predict(X_test_transformed)
print("정밀도: {:.2f}%".format(100 * precision_score(y_test, y_pred)))
print("재현율: {:.2f}%".format(100 * recall_score(y_test, y_pred)))
정밀도: 96.88% 재현율: 97.89%