فصل ۳: طبقهبندی
در فصل ۱ گفته شد که رایجترین وظایف یادگیری نظارتشده، رگرسیون - یعنی پیشبینی مقدار - و طبقهبندی - یعنی پیشبینی کلاس - هستند. در فصل ۲ یک مسئلهٔ رگرسیون را بررسی کردیم و با الگوریتمهایی مانند رگرسیون خطی، درخت تصمیم و جنگل تصادفی قیمت مسکن را پیشبینی کردیم. اکنون تمرکز کتاب به سامانههای طبقهبندی منتقل میشود.
مجموعهدادهٔ MNIST
در این فصل از مجموعهدادهٔ MNIST استفاده میشود؛ مجموعهای شامل ۷۰٬۰۰۰ تصویر کوچک از ارقام دستنویس دانشآموزان دبیرستانی و کارکنان ادارهٔ سرشماری ایالات متحده. هر تصویر با رقمی که نشان میدهد برچسبگذاری شده است. این مجموعهداده آنقدر بررسی شده که اغلب آن را «Hello World یادگیری ماشین» مینامند: هرگاه الگوریتم طبقهبندی تازهای معرفی شود، یکی از نخستین پرسشها این است که روی MNIST چگونه عمل میکند و تقریباً هر کسی که یادگیری ماشین را میآموزد دیر یا زود با آن کار میکند.
Scikit-Learn توابع کمکی متعددی برای دریافت مجموعهدادههای مشهور دارد. MNIST یکی از آنهاست و کد زیر آن را از OpenML.org دریافت میکند:
from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784', as_frame=False)
بستهٔ sklearn.datasets عمدتاً سه نوع تابع دارد:
- توابع
fetch_* مانند fetch_openml() برای دریافت مجموعهدادههای واقعی؛
- توابع
load_* برای بارگذاری مجموعهدادههای کوچک آزمایشی که همراه Scikit-Learn ارائه میشوند و نیازی به اینترنت ندارند؛
- توابع
make_* برای تولید مجموعهدادههای مصنوعی که در آزمونها مفیدند.
مجموعهدادههای تولیدشده معمولاً بهصورت یک زوج (X, y) بازگردانده میشوند که دادهٔ ورودی و هدفها هر دو آرایهٔ NumPy هستند. بسیاری از مجموعهدادههای دیگر بهصورت شیء sklearn.utils.Bunch بازمیگردند؛ ساختاری شبیه Dictionary که ورودیهایش از طریق Attribute نیز قابل دسترسیاند. ورودیهای متداول آن عبارتاند از DESCR برای توضیح مجموعهداده، data برای دادهٔ ورودی - معمولاً آرایهٔ دوبعدی NumPy - و target برای برچسبها - معمولاً آرایهٔ یکبعدی NumPy.
تابع fetch_openml() کمی متفاوت است؛ بهطور پیشفرض ورودیها را DataFrame و برچسبها را Series پانداس برمیگرداند، مگر اینکه داده Sparse باشد. چون MNIST از تصویر تشکیل شده و DataFrame برای چنین دادهای ایدهآل نیست، بهتر است as_frame=False تنظیم شود تا آرایههای NumPy دریافت شوند:
>>> X, y = mnist.data, mnist.target
>>> X
array([[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
...,
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]])
>>> X.shape
(70000, 784)
>>> y
array(['5', '0', '4', ..., '4', '5', '6'], dtype=object)
>>> y.shape
(70000,)
۷۰٬۰۰۰ تصویر وجود دارد و هر تصویر ۷۸۴ ویژگی دارد، زیرا اندازهٔ هر تصویر 28 × 28 پیکسل است و هر ویژگی شدت یک پیکسل را نشان میدهد؛ از ۰ برای سفید تا ۲۵۵ برای سیاه. برای دیدن یکی از ارقام کافی است بردار ویژگی یک نمونه را برداریم، آن را به آرایهٔ 28 × 28 تغییر شکل دهیم و با imshow() در Matplotlib نمایش دهیم. با cmap="binary" نقشهٔ خاکستریای میگیریم که در آن ۰ سفید و ۲۵۵ سیاه است:
import matplotlib.pyplot as plt
def plot_digit(image_data):
image = image_data.reshape(28, 28)
plt.imshow(image, cmap="binary")
plt.axis("off")
some_digit = X[0]
plot_digit(some_digit)
plt.show()
شکل ۳-۱. نمونهای از یک تصویر MNIST
این تصویر شبیه ۵ است و برچسب نیز همین را تأیید میکند:
>>> y[0]
'5'
برای درک بهتر دشواری مسئله، شکل بعدی چند تصویر دیگر از MNIST را نشان میدهد.
شکل ۳-۲. تعدادی از ارقام مجموعهدادهٔ MNIST
اما پیش از بررسی عمیقتر داده، باید مجموعهٔ آزمون را کنار بگذارید. MNIST که fetch_openml() برمیگرداند عملاً از پیش به مجموعهٔ آموزشی - ۶۰٬۰۰۰ تصویر نخست - و مجموعهٔ آزمون - ۱۰٬۰۰۰ تصویر پایانی - تقسیم شده است:
X_train, X_test, y_train, y_test = X[:60000], X[60000:], y[:60000], y[60000:]
مجموعهٔ آموزشی از قبل Shuffle شده است. این کار باعث میشود همهٔ Foldهای Cross-Validation ترکیب مشابهی داشته باشند و مثلاً یک Fold فاقد بعضی ارقام نباشد. همچنین برخی الگوریتمها به ترتیب نمونهها حساساند و اگر چندین نمونهٔ مشابه پشت سر هم ببینند عملکرد ضعیفی دارند؛ Shuffle کردن مانع این وضعیت میشود.
آموزش یک طبقهبند دودویی
فعلاً مسئله را ساده میکنیم و فقط میخواهیم یک رقم - مثلاً ۵ - را تشخیص دهیم. این «تشخیصدهندهٔ ۵» نمونهای از طبقهبند دودویی است که فقط دو کلاس «۵» و «غیر ۵» را از هم جدا میکند. ابتدا بردارهای هدف را میسازیم:
y_train_5 = (y_train == '5') # True for all 5s, False for all other digits
y_test_5 = (y_test == '5')
برای شروع از طبقهبند گرادیان کاهشی تصادفی یا SGD، یعنی کلاس SGDClassifier در Scikit-Learn، استفاده میکنیم. این طبقهبند مجموعهدادههای بسیار بزرگ را کارآمد پردازش میکند، زیرا نمونههای آموزشی را مستقل و یکییکی میبیند؛ همین ویژگی آن را برای یادگیری برخط نیز مناسب میکند.
from sklearn.linear_model import SGDClassifier
sgd_clf = SGDClassifier(random_state=42)
sgd_clf.fit(X_train, y_train_5)
اکنون میتوانیم آن را برای تشخیص تصویر رقم ۵ به کار ببریم:
>>> sgd_clf.predict([some_digit])
array([ True])
در این نمونه، طبقهبند درست حدس زده که تصویر یک ۵ است. مرحلهٔ بعد ارزیابی عملکرد مدل است.
معیارهای عملکرد
ارزیابی طبقهبند معمولاً بهمراتب دشوارتر از ارزیابی رگرسور است. معیارهای متعددی وجود دارد و بخش قابلتوجهی از این فصل به آنها اختصاص دارد.
اندازهگیری Accuracy با Cross-Validation
مانند فصل ۲، Cross-Validation راه مناسبی برای ارزیابی مدل است. با cross_val_score() و اعتبارسنجی سهتایی، مجموعهٔ آموزشی به سه Fold تقسیم میشود و مدل سه بار آموزش میبیند؛ هر بار یک Fold متفاوت برای ارزیابی و دو Fold دیگر برای آموزش استفاده میشوند:
>>> 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 ])
Accuracy بیش از ۹۵ درصد در همهٔ Foldها در نگاه اول عالی به نظر میرسد. اما یک طبقهبند ساده را در نظر بگیرید که همیشه پرتکرارترین کلاس را پیشبینی میکند؛ در اینجا یعنی همیشه «غیر ۵»:
from sklearn.dummy import DummyClassifier
dummy_clf = DummyClassifier()
dummy_clf.fit(X_train, y_train_5)
print(any(dummy_clf.predict(X_train))) # prints False: no 5s detected
Accuracy این مدل:
>>> cross_val_score(dummy_clf, X_train, y_train_5, cv=3, scoring="accuracy")
array([0.90965, 0.90965, 0.90965])
بیش از ۹۰ درصد! علت ساده است: فقط حدود ۱۰ درصد تصاویر، رقم ۵ هستند. اگر همیشه بگویید تصویر ۵ نیست، تقریباً ۹۰ درصد اوقات درست خواهید بود. این مثال نشان میدهد چرا Accuracy معمولاً معیار ترجیحی برای طبقهبندها نیست، بهویژه وقتی با مجموعهدادهٔ نامتوازن سروکار داریم؛ یعنی بعضی کلاسها بسیار پرتکرارتر از بقیهاند. معیار بهتر، ماتریس درهمریختگی است.
پیادهسازی دستی Cross-Validation
گاهی به کنترل بیشتری بر فرایند Cross-Validation نیاز دارید. کد زیر تقریباً همان کار cross_val_score() را انجام میدهد و نتیجهٔ مشابهی چاپ میکند:
from sklearn.model_selection import StratifiedKFold
from sklearn.base import clone
skfolds = StratifiedKFold(n_splits=3) # add shuffle=True if the dataset is
# not already shuffled
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)) # prints 0.95035, 0.96035, and 0.9604
کلاس StratifiedKFold نمونهگیری طبقهبندیشده انجام میدهد تا هر Fold نسبت نمایندهای از هر کلاس داشته باشد. در هر تکرار، کد Clone تازهای از طبقهبند میسازد، آن را روی Foldهای آموزشی آموزش میدهد، روی Fold آزمون پیشبینی میکند، تعداد پیشبینیهای درست را میشمارد و نسبت آنها را گزارش میکند.
ماتریس درهمریختگی
ایدهٔ کلی Confusion Matrix این است که برای همهٔ جفتهای کلاس A و B بشماریم چند بار نمونههای 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)
این تابع مانند cross_val_score() Cross-Validation انجام میدهد، اما بهجای امتیاز ارزیابی، پیشبینی هر Fold آزمون را برمیگرداند. در نتیجه برای هر نمونهٔ آموزشی یک پیشبینی «پاک» یا Out-of-Sample دارید؛ یعنی مدلی که آن نمونه را ندیده است دربارهاش پیشبینی کرده است.
>>> 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).
طبقهبند کامل فقط مثبت واقعی و منفی واقعی دارد، پس فقط قطر اصلی ماتریس غیرصفر است:
>>> y_train_perfect_predictions = y_train_5 # pretend we reached perfection
>>> confusion_matrix(y_train_5, y_train_perfect_predictions)
array([[54579, 0],
[ 0, 5421]])
Precision و Recall
ماتریس درهمریختگی اطلاعات فراوانی میدهد، اما گاهی یک معیار خلاصه مناسبتر است. «دقت مثبتها» یا Precision نشان میدهد از میان پیشبینیهای مثبت چه سهمی واقعاً مثبت بودهاند:
معادلهٔ ۳-۱. Precision
precision = TP / (TP + FP)
با یک ترفند میتوان Precision صددرصد ساخت: همهٔ نمونهها را منفی پیشبینی کنید، جز فقط یک نمونهای که بیشترین اطمینان را دربارهٔ مثبتبودنش دارید. اگر همان یک پیشبینی درست باشد، Precision برابر ۱۰۰ درصد میشود؛ اما چنین طبقهبندی تقریباً بیفایده است، چون همهٔ مثبتهای دیگر را نادیده میگیرد.
به همین دلیل Precision معمولاً همراه معیار دیگری به نام Recall استفاده میشود که Sensitivity یا نرخ مثبت واقعی TPR نیز نام دارد. Recall سهم نمونههای واقعاً مثبت را که درست تشخیص داده شدهاند اندازه میگیرد:
معادلهٔ ۳-۲. Recall
recall = TP / (TP + FN)
شکل ۳-۳. ماتریس درهمریختگی مصور با نمونههای منفی واقعی، مثبت کاذب، منفی کاذب و مثبت واقعی
Scikit-Learn توابع آماده برای این معیارها دارد:
>>> from sklearn.metrics import precision_score, recall_score
>>> precision_score(y_train_5, y_train_pred) # == 3530 / (687 + 3530)
0.8370879772350012
>>> recall_score(y_train_5, y_train_pred) # == 3530 / (1891 + 3530)
0.6511713705958311
اکنون تشخیصدهندهٔ ۵ به درخشندگی Accuracy اولیه نیست. هرگاه میگوید تصویر ۵ است فقط ۸۳٫۷ درصد مواقع درست میگوید و تنها ۶۵٫۱ درصد همهٔ ۵ها را پیدا میکند.
امتیاز F1
گاهی مفید است Precision و Recall را در یک معیار واحد به نام امتیاز F1 ترکیب کنیم، بهخصوص وقتی باید دو طبقهبند را با یک عدد مقایسه کنیم. F1 میانگین هارمونیک Precision و Recall است. برخلاف میانگین معمولی که به همهٔ مقادیر وزن برابر میدهد، میانگین هارمونیک به مقدارهای پایین وزن بیشتری میدهد؛ بنابراین F1 فقط وقتی بالا میشود که هم Precision و هم Recall بالا باشند.
معادلهٔ ۳-۳. امتیاز F1
F1 = 2 / (1/precision + 1/recall) = 2 × precision × recall / (precision + recall) = TP / (TP + (FN + FP)/2)
>>> from sklearn.metrics import f1_score
>>> f1_score(y_train_5, y_train_pred)
0.7325171197343846
F1 به طبقهبندهایی امتیاز بیشتری میدهد که Precision و Recall نزدیک به هم دارند، اما این همیشه مطلوب نیست. اگر هدف تشخیص ویدئوهای مناسب کودک باشد، ممکن است ترجیح دهید تعداد زیادی ویدئوی سالم رد شود - Recall پایین - اما تقریباً هر چیزی که پذیرفته میشود واقعاً سالم باشد - Precision بالا. در مقابل، در تشخیص سارق فروشگاهی از تصویر دوربین، شاید Precision فقط ۳۰ درصد قابلقبول باشد اگر Recall به ۹۹ درصد برسد؛ نگهبانان هشدارهای کاذب بیشتری میگیرند، اما تقریباً همهٔ سارقان شناسایی میشوند.
متأسفانه نمیتوان هر دو را همزمان به دلخواه افزایش داد: معمولاً افزایش Precision باعث کاهش Recall و برعکس میشود. این رابطه «موازنهٔ Precision/Recall» نام دارد.
موازنهٔ Precision/Recall
SGDClassifier برای هر نمونه بر اساس تابع تصمیم یک امتیاز محاسبه میکند. اگر امتیاز از آستانه بالاتر باشد، نمونه به کلاس مثبت میرود و در غیر این صورت منفی است. شکل ۳-۴ چند رقم را از کمترین تا بیشترین امتیاز مرتب کرده است. با آستانهٔ میانی، چهار مثبت واقعی و یک مثبت کاذب داریم؛ Precision برابر ۸۰ درصد و Recall برابر ۶۷ درصد است. اگر آستانه را بالا ببریم، مثبت کاذب حذف میشود و Precision میتواند به ۱۰۰ درصد برسد، اما یکی از مثبتهای واقعی به منفی کاذب تبدیل میشود و Recall به ۵۰ درصد افت میکند. کاهش آستانه برعکس، Recall را افزایش و Precision را کاهش میدهد.
شکل ۳-۴. موازنهٔ Precision/Recall؛ با افزایش آستانه معمولاً Precision بیشتر و Recall کمتر میشود.
Scikit-Learn اجازه نمیدهد آستانهٔ predict() را مستقیماً تنظیم کنید، اما با decision_function() امتیاز تصمیم را در اختیار میگذارد:
>>> y_scores = sgd_clf.decision_function([some_digit])
>>> y_scores
array([2164.22030239])
>>> threshold = 0
>>> y_some_digit_pred = (y_scores > threshold)
array([ True])
>>> threshold = 3000
>>> y_some_digit_pred = (y_scores > threshold)
>>> y_some_digit_pred
array([False])
آستانهٔ پیشفرض SGDClassifier صفر است. افزایش آن به ۳۰۰۰ باعث میشود تصویر واقعی ۵ دیگر شناسایی نشود؛ نمونهای روشن از کاهش Recall در اثر افزایش آستانه.
برای انتخاب آستانه ابتدا با cross_val_predict() امتیاز همهٔ نمونههای آموزشی را میگیریم و این بار method="decision_function" را مشخص میکنیم:
y_scores = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3,
method="decision_function")
سپس با precision_recall_curve() Precision و Recall همهٔ آستانههای ممکن محاسبه میشود:
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)
نمودار آنها بر حسب آستانه:
plt.plot(thresholds, precisions[:-1], "b--", label="Precision", linewidth=2)
plt.plot(thresholds, recalls[:-1], "g-", label="Recall", linewidth=2)
plt.vlines(threshold, 0, 1.0, "k", "dotted", label="threshold")
[...] # beautify the figure: add grid, legend, axis, labels, and circles
plt.show()
شکل ۳-۵. Precision و Recall بر حسب آستانهٔ تصمیم
در آستانهٔ ۳۰۰۰، Precision نزدیک ۹۰ درصد و Recall نزدیک ۵۰ درصد است. راه دیگری برای انتخاب موازنه، رسم مستقیم Precision بر حسب Recall است:
plt.plot(recalls, precisions, linewidth=2, label="Precision/Recall curve")
[...] # beautify the figure: add labels, grid, legend, arrow, and text
plt.show()
شکل ۳-۶. Precision در برابر Recall
در نمودار، Precision از حدود Recall برابر ۸۰ درصد به بعد با شیب زیادی افت میکند. احتمالاً بهتر است نقطهای اندکی قبل از این افت انتخاب شود، مثلاً حوالی Recall برابر ۶۰ درصد؛ البته تصمیم واقعی کاملاً به نیاز پروژه بستگی دارد.
فرض کنید هدف Precision حداقل ۹۰ درصد است. میتوان کمترین آستانهای را که این مقدار Precision میدهد با argmax() پیدا کرد:
>>> idx_for_90_precision = (precisions >= 0.90).argmax()
>>> threshold_for_90_precision = thresholds[idx_for_90_precision]
>>> threshold_for_90_precision
3370.0194991439557
پیشبینیهای متناظر و نتیجهٔ ارزیابی:
y_train_pred_90 = (y_scores >= threshold_for_90_precision)
>>> precision_score(y_train_5, y_train_pred_90)
0.9000345901072293
>>> recall_at_90_precision = recall_score(y_train_5, y_train_pred_90)
>>> recall_at_90_precision
0.4799852425751706
طبقهبند به Precision ۹۰ درصد رسیده، اما Recall فقط حدود ۴۸ درصد است. ساخت طبقهبندی با Precision بسیار بالا ساده است اگر آستانه را زیاد کنیم، اما چنین مدلی وقتی Recall بسیار پایین باشد فایدهٔ کمی دارد. بنابراین اگر کسی گفت «به ۹۹ درصد Precision برسیم»، پرسش درست این است: «با چه Recall؟»
منحنی ROC
منحنی مشخصهٔ عملکرد گیرنده یا ROC ابزار رایج دیگری برای طبقهبند دودویی است. بهجای Precision در برابر Recall، نرخ مثبت واقعی TPR - همان Recall - را در برابر نرخ مثبت کاذب FPR رسم میکند. FPR سهم نمونههای منفی است که بهاشتباه مثبت شناخته شدهاند. این مقدار برابر 1 − TNR است؛ TNR نرخ منفی واقعی و همان Specificity است. بنابراین ROC در واقع Sensitivity را در برابر 1 − Specificity نشان میدهد.
from sklearn.metrics import roc_curve
fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)
برای مشخصکردن نقطهٔ متناظر با Precision برابر ۹۰ درصد، چون آستانهها در این خروجی نزولیاند، از <= استفاده میشود:
idx_for_threshold_at_90 = (thresholds <= threshold_for_90_precision).argmax()
tpr_90, fpr_90 = tpr[idx_for_threshold_at_90], fpr[idx_for_threshold_at_90]
plt.plot(fpr, tpr, linewidth=2, label="ROC curve")
plt.plot([0, 1], [0, 1], 'k:', label="Random classifier's ROC curve")
plt.plot([fpr_90], [tpr_90], "ko", label="Threshold for 90% precision")
[...] # beautify the figure: add labels, grid, legend, arrow, and text
plt.show()
شکل ۳-۷. منحنی ROC؛ دایرهٔ سیاه نقطهٔ انتخابشده با Precision حدود ۹۰ درصد و Recall حدود ۴۸ درصد را نشان میدهد.
بار دیگر موازنه وجود دارد: Recall بیشتر معمولاً با مثبتهای کاذب بیشتری همراه است. خط نقطهچین عملکرد یک طبقهبند کاملاً تصادفی را نشان میدهد؛ طبقهبند خوب باید تا حد امکان از این خط دور و به گوشهٔ بالا-چپ نزدیک باشد.
یک روش مقایسهٔ طبقهبندها، اندازهگیری سطح زیر منحنی یا AUC است. طبقهبند کامل ROC AUC برابر ۱ دارد و طبقهبند کاملاً تصادفی به ۰٫۵ میرسد:
>>> from sklearn.metrics import roc_auc_score
>>> roc_auc_score(y_train_5, y_scores)
0.9604938554008616