الوحدة 3 · التصنيف
العتبات وعدم توازن الفئات
20 دقراءة 4 دتمرين Python
الدرس في لمحة
حرّك عتبة القرار لتبادل الـ precision بالـ recall، واخترها من تكاليف العمل باستخدام TunedThresholdClassifierCV، وتعامل مع الفئات النادرة بأوزان الفئات والمقاييس الصحيحة.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
ظلّت predict تتخذ قرارًا نيابةً عنك بصمت: صنّف العميل منقطعًا حين يكون الاحتمال 0.5 على الأقل. هذا الحدّ الفاصل يفترض أن تفويت منقطع وإهدار عرض لهما الكلفة نفسها. وهذا لا يكاد يحدث أبدًا. اختيار العتبة (threshold) عن قصد من أرخص التحسينات وأعلاها قيمة التي يمكنك إجراؤها على أي مصنِّف، ولا يتطلّب إعادة تدريب أي شيء.
العتبة مقبض تديره
الاحتمالات تأتي من النموذج؛ أما العتبة فهي سياستك أنت. حرّكها وراقب الـ precision والـ recall يتجاذبان.
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_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)
prob = model.predict_proba(X_test)[:, 1]
for t in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]:
flagged = (prob >= t).astype(int)
print(f"threshold {t:.1f}: flags {flagged.sum():2} precision {precision_score(y_test, flagged):.2f}"
f" recall {recall_score(y_test, flagged):.2f}")عند 0.3 يُعلِّم النموذج 74 عميلًا ويلتقط 93% من المنقطعين، لكن 31% من العلامات خاطئة. وعند 0.8 يُعلِّم 21، و86% منها صحيحة، لكنه لا يلتقط إلا ثلث المنقطعين. لا طرف “أفضل” من الآخر. النقطة الصحيحة تعتمد على المال.
دع التكاليف تختار العتبة
أرقام Cartwheel: عرض الاسترجاع يكلّف 20 دولارًا بين خصم ومعالجة. والتواصل مع منقطع حقيقي يُنقذه في 30% من الحالات، والعميل المُنقذ يساوي 100 دولار من هامش الربح، فكل منقطع نتواصل معه يساوي في المتوسط 0.3 * 100 - 20 = +$10. أما التواصل مع عميل وفيّ فيُهدر الـ 20 دولارًا. والربح هو 10 * TP - 20 * FP.
لو كانت الاحتمالات معايَرة بإتقان، لاستطعت حلّ معادلة نقطة التعادل: تواصَل حين يكون p * 10 > (1 - p) * 20، أي حين p > 2/3. لكن الاحتمالات الحقيقية نادرًا ما تكون معايَرة بهذه الجودة، فالنهج الأكثر موثوقية هو البحث عن العتبة التي تعظّم الربح، باستخدام التحقق المتقاطع على بيانات التدريب. وهذا بالضبط ما يفعله TunedThresholdClassifierCV.
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import make_scorer
from sklearn.model_selection import TunedThresholdClassifierCV, 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"])
def profit(y_true, y_pred):
y_true, y_pred = np.asarray(y_true), np.asarray(y_pred)
tp = ((y_pred == 1) & (y_true == 1)).sum()
fp = ((y_pred == 1) & (y_true == 0)).sum()
return 10 * tp - 20 * fp
base = make_pipeline(StandardScaler(), LogisticRegression())
tuned = TunedThresholdClassifierCV(base, scoring=make_scorer(profit), cv=5).fit(X_train, y_train)
print("chosen threshold:", round(tuned.best_threshold_, 2))
default = base.fit(X_train, y_train)
print("test profit, contact everyone: ", profit(y_test, np.ones(len(y_test))))
print("test profit, threshold 0.5: ", profit(y_test, default.predict(X_test)))
print("test profit, tuned threshold: ", profit(y_test, tuned.predict(X_test)))التواصل مع عملاء الاختبار المئة كلهم يخسر 350 دولارًا. العتبة الافتراضية تربح 170 دولارًا؛ والمضبوطة، وهي نحو 0.58، تربح 180 دولارًا مع التواصل مع عدد أقل من الناس. أما القيمة النظرية 0.67 فكانت ستربح أقل، لأن احتمالات النموذج منحرفة قليلًا: وهذا تذكير بأن تثق بالنتائج المقيسة أكثر من المعادلات الأنيقة. تحوّل make_scorer أي دالة (y_true, y_pred) إلى شيء تستطيع أدوات الضبط في scikit-learn تعظيمه.
يكون الاحتمال معايَرًا (calibrated) حين ينقطع فعلًا نحو 70% من كل العملاء الذين نالوا درجة 0.7. الانحدار اللوجستي يقترب من ذلك غالبًا؛ والأشجار المعزّزة والغابات تكون أبعد غالبًا. وحين تحتاج إلى أن تكون الاحتمالات نفسها صحيحة (لتقدير الإيرادات المتوقعة مثلًا لا للترتيب)، يستطيع CalibratedClassifierCV في scikit-learn إعادة معايرتها. أما لاختيار عتبة، فالضبط المباشر على المقياس التجاري يتجاوز هذا السؤال.
حين تكون الفئة الإيجابية نادرة
المنقطعون 55% من العملاء، فكلتا الفئتين ممثّلة جيدًا. لكن كثيرًا من المسائل الحقيقية ليست كذلك: الاحتيال، والعيوب، والأمراض النادرة. خذ بيانات الأرقام (digits) واسأل “هل هذا 9؟”. 10% فقط من الصور كذلك، فالنموذج الذي يجيب “لا” دائمًا يحقق accuracy بنسبة 90%.
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, precision_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, digit = load_digits(return_X_y=True)
y = (digit == 9).astype(int)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
print("share of 9s:", round(y.mean(), 3))
for weights in [None, "balanced"]:
m = make_pipeline(StandardScaler(), LogisticRegression(class_weight=weights, max_iter=1000))
m.fit(X_train, y_train)
pred, prob = m.predict(X_test), m.predict_proba(X_test)[:, 1]
print(f"class_weight={weights!s:9} accuracy {m.score(X_test, y_test):.3f} "
f"precision {precision_score(y_test, pred):.2f} recall {recall_score(y_test, pred):.2f} "
f"avg precision {average_precision_score(y_test, prob):.3f}")لا تكاد الـ accuracy تتحرك، من 0.98 إلى 0.97، ولا تخبرك بشيء مفيد. أما الـ precision والـ recall فتحكيان القصة الحقيقية. يزن class_weight="balanced" كل فئة بعكس تكرارها أثناء التدريب، فيكلّف الرقم 9 الفائت نحو تسعة أضعاف الإنذار الكاذب. ترتفع الـ recall من 0.82 إلى 0.91، وتنخفض الـ precision من 0.97 إلى 0.82. وتلخّص الـ average precision (المساحة تحت منحنى precision-recall) جودة الترتيب مع تركيز على الفئة النادرة؛ وهي عادةً أغنى بالمعلومات من الـ ROC AUC حين تكون الحالات الإيجابية شحيحة.
ظللت تقارن النماذج والعتبات على بيانات اختبار واحدة من 100 عميل، وتتحفّظ على كل فجوة بأنها “ربما ضجيج”. يستبدل القسم 4 هذا التقسيم الواحد بالتحقق المتقاطع، فتستطيع أخيرًا أن تقول كم يجب أن يكون الفرق كبيرًا قبل أن يكون حقيقيًا.
أهم النقاط
- تطبّق
predictعتبة 0.5 على الاحتمال؛ ويمكنك تطبيق أي عتبة بنفسك بـpredict_proba(X)[:, 1] >= t. - رفع العتبة يُعلِّم حالات أقل، فيرفع الـ precision عادةً ويخفض الـ recall؛ وخفضها يفعل العكس.
- اختر العتبة من كلفة كل خطأ، على بيانات التدريب عبر التحقق المتقاطع، مثلًا باستخدام
TunedThresholdClassifierCVودالة تقييم مخصّصة. - حين تكون الفئة الإيجابية نادرة، تكون الـ accuracy مضلِّلة؛ فاعرض بدلًا منها الـ precision والـ recall والـ average precision.
- يجعل
class_weight="balanced"أخطاء الفئة النادرة تُحسب أكثر أثناء التدريب، فيرفع الـ recall على حساب شيء من الـ precision.