الوحدة 3 · التصنيف
مقاييس التصنيف أبعد من الـ Accuracy
22 دقراءة 4 دتمرين Python
الدرس في لمحة
اقرأ مصفوفة الالتباس، واحسب الـ precision والـ recall والـ F1 والـ ROC AUC باستخدام scikit-learn، واختر المقياس الذي يطابق كلفة كل نوع من الأخطاء على الشركة.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
يحقق كلٌّ من الانحدار اللوجستي وقاعدة الـ 80 يومًا accuracy بقيمة 0.72 على بيانات اختبار الانقطاع. فهل هما جيدان بالقدر نفسه؟ الـ accuracy تقول نعم. وفريق التسويق سيخالف ذلك فور أن يرى من يُعلِّمه كلٌّ منهما. فالـ accuracy تمزج خطأين مختلفين جدًا في رقم واحد، وترمي الترتيب الذي جعل قائمة الاتصالات مفيدة. هذا الدرس يفكّكهما.
أربع نتائج
كل تنبؤ في مسألة نعم/لا يقع في واحدة من أربع خانات. سمِّ المنقطعين إيجابيات (positives) (فهم الفئة التي تحاول العثور عليها).
- إيجابي صحيح (TP): تنبأنا بالانقطاع، وانقطع فعلًا. وصل التسويق إلى الشخص الصحيح.
- إيجابي كاذب (FP): تنبأنا بالانقطاع، وبقي. عرض أُهدر على عميل وفيّ.
- سلبي كاذب (FN): تنبأنا بالبقاء، وانقطع. عميل خسرناه دون محاولة.
- سلبي صحيح (TN): تنبأنا بالبقاء، وبقي. تُرك وشأنه عن صواب.
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
"days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
X_train, X_test, y_train, y_test = train_test_split(
churn[features], churn["churned"], test_size=0.25, random_state=42,
stratify=churn["churned"])
model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
pred = model.predict(X_test)
rule = (X_test["days_since_last_order"] > 80).astype(int)
print("model:\n", confusion_matrix(y_test, pred))
print("rule:\n", confusion_matrix(y_test, rule))يرتّب scikit-learn المصفوفة بحيث تكون الفئات الفعلية صفوفًا والفئات المتنبأ بها أعمدة، وكلاهما بترتيب تصاعدي (0 ثم 1). لذا تُقرأ مصفوفة النموذج [[35, 10], [18, 37]] هكذا:
مصفوفة القاعدة هي [[31, 14], [14, 41]]. التنبؤات الصحيحة نفسها، 72، لكن الأخطاء مختلفة: النموذج يهدر عروضًا أقل (10 مقابل 14) ويفوّت منقطعين أكثر (18 مقابل 14). أيّهما أفضل يعتمد على كلفة كل خطأ، والـ accuracy لا تستطيع أن تخبرك بذلك.
الـ Precision والـ Recall والـ F1
نسبتان تحوّلان المصفوفة إلى أسئلة يطرحها المدير:
- الـ Precision = TP / (TP + FP): من بين العملاء الذين علّمناهم، كم منهم كان راحلًا فعلًا؟ النموذج: 37 / 47 = 0.79. القاعدة: 0.75.
- الـ Recall = TP / (TP + FN): من بين العملاء الذين رحلوا، كم منهم علّمنا؟ النموذج: 37 / 55 = 0.67. القاعدة: 0.75.
بينهما توتّر مدمج. علّم أشخاصًا أكثر فترتفع الـ recall بينما تنخفض الـ precision عادةً؛ وعلّم عددًا أقل فيحدث العكس. أما الـ F1 فهو المتوسط التوافقي للاثنين، 2 * P * R / (P + R). ولا يكون مرتفعًا إلا إذا كانا مرتفعين معًا، فلا يستطيع النموذج التلاعب به بتعظيم أحدهما. استخدمه حين تحتاج إلى رقم واحد ويكون للخطأين وزن متقارب.
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
"days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
X_train, X_test, y_train, y_test = train_test_split(
churn[features], churn["churned"], test_size=0.25, random_state=42,
stratify=churn["churned"])
model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
print(classification_report(y_test, model.predict(X_test), target_names=["stayed", "churned"]))
print("model ROC AUC:", round(roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]), 3))
print("rule ROC AUC:", round(roc_auc_score(y_test, X_test["days_since_last_order"]), 3))تطبع classification_report الـ precision والـ recall والـ F1 لكل فئة، مع الـ support (عدد الحالات الحقيقية). اقرأ صف “churned” للسؤال العملي؛ أما صف “stayed” فهو التحليل نفسه مع تبادل الأدوار.
الـ ROC AUC: تقييم الترتيب
تعتمد الـ precision والـ recall على الحدّ الفاصل 0.5. أما الـ ROC AUC فلا تستخدم حدًّا فاصلًا إطلاقًا. إنها تسأل: إذا اخترت منقطعًا واحدًا وباقيًا واحدًا عشوائيًا، فكم مرة يعطي النموذج المنقطعَ الدرجةَ الأعلى؟ القيمة 0.5 تعني أن الدرجات ليست أفضل من رمي عملة؛ و1.0 تعني أن كل منقطع يتقدّم على كل باقٍ.
تأخذ roc_auc_score درجات، لا labels: مرّر predict_proba(...)[:, 1]. وأي درجة ترتّب العملاء تصلح، فيمكنك تقييم القاعدة بالقيمة الخام لـ days_since_last_order. تحصل القاعدة على 0.77؛ ويحصل الانحدار اللوجستي على 0.81. هذه الفجوة هي الفرق الحقيقي بينهما، وقد أخفاه تعادل الـ accuracy: ترتيب النموذج أفضل بشكل ملموس، ولهذا كانت قائمة العشرة الأعلى خطرًا في درس الانحدار اللوجستي دقيقة إلى هذا الحد.
اختيار المقياس
ابدأ من كلفة كل خطأ، ثم اختر:
| الموقف | راقب | السبب |
|---|---|---|
| تفويت حالة مكلف (فحص السرطان، الاحتيال) | الـ Recall | كل سلبي كاذب هو الخطأ المكلف |
| التصرّف بشأن حالة مكلف (عروض غالية، حظر حسابات) | الـ Precision | كل إيجابي كاذب يهدر مالًا أو ثقة |
| ستتصرّف بشأن أفضل N بحسب الدرجة | الـ ROC AUC، أو نسبة الإصابة في أفضل N | جودة الترتيب هي ما يهم |
| الخطآن مهمان، والمطلوب رقم واحد | الـ F1 | يعاقب إهمال أيٍّ منهما |
تحرّكت الـ precision والـ recall في اتجاهين متعاكسين بين النموذج والقاعدة لأنهما يستخدمان فعليًا حدّين فاصلين مختلفين. ويمكنك أن تحرّك هذا الحدّ بنفسك، عن قصد، وهذا موضوع الدرس القادم.
أهم النقاط
- تعدّ مصفوفة الالتباس (confusion matrix) السلبيات الصحيحة والإيجابيات الكاذبة والسلبيات الكاذبة والإيجابيات الصحيحة؛ وفي scikit-learn تمثّل الصفوفُ الفئاتِ الفعلية والأعمدةُ الفئاتِ المتنبأ بها.
- الـ precision هي نسبة الحالات المُعلَّمة التي هي حقيقية؛ والـ recall هي نسبة الحالات الحقيقية التي عُلِّمت.
- الـ F1 هو المتوسط التوافقي للـ precision والـ recall، ومفيد حين تحتاج إلى رقم واحد يعاقب إهمال أيٍّ منهما.
- يقيس الـ ROC AUC جودة الترتيب عبر كل العتبات: أي احتمال أن ينال مثال إيجابي عشوائي درجة أعلى من مثال سلبي عشوائي.
- تحقّق دائمًا من الفئة الإيجابية: في
load_breast_cancerالورم الخبيث هو 0، فالـ recall للخبيث يحتاج إلىpos_label=0.