EduFlow

الوحدة 3 · التصنيف

العتبات وعدم توازن الفئات

20 دقراءة 4 دتمرين Python

الدرس في لمحة

حرّك عتبة القرار لتبادل الـ precision بالـ recall، واخترها من تكاليف العمل باستخدام TunedThresholdClassifierCV، وتعامل مع الفئات النادرة بأوزان الفئات والمقاييس الصحيحة.

  1. 1العتبة مقبض تديره
  2. 2دع التكاليف تختار العتبة
  3. 3حين تكون الفئة الإيجابية نادرة

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

ظلّت predict تتخذ قرارًا نيابةً عنك بصمت: صنّف العميل منقطعًا حين يكون الاحتمال 0.5 على الأقل. هذا الحدّ الفاصل يفترض أن تفويت منقطع وإهدار عرض لهما الكلفة نفسها. وهذا لا يكاد يحدث أبدًا. اختيار العتبة (threshold) عن قصد من أرخص التحسينات وأعلاها قيمة التي يمكنك إجراؤها على أي مصنِّف، ولا يتطلّب إعادة تدريب أي شيء.

العتبة مقبض تديره

الاحتمالات تأتي من النموذج؛ أما العتبة فهي سياستك أنت. حرّكها وراقب الـ precision والـ recall يتجاذبان.

Python
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 إلى 0.8. ترتفع الـ precision بلطف من 0.69 إلى 0.86. وتنخفض الـ recall بحدّة من 0.93 إلى 0.33. العتبة الافتراضية 0.5 مُعلَّمة بخط عمودي متقطع. 1.0 0.5 0 0.3 0.5 0.8 العتبة precision recall
الـ precision والـ recall مع تحرّك العتبة (بيانات اختبار الانقطاع)

عند 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.

Python
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%.

Python
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.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

خفضت عتبة الانقطاع من 0.5 إلى 0.3. ماذا تتوقع؟

السؤال 2 من 4

إنقاذ عميل منقطع يساوي +10 دولارات صافية، والتواصل مع عميل وفيّ يكلّف 20 دولارًا. إذا كانت الاحتمالات معايَرة جيدًا، فعند أيّ احتمال انقطاع يصبح التواصل مجديًا؟

السؤال 3 من 4

في بيانات الأرقام (digits)، 10% من الصور تمثّل الرقم 9. يحقق نموذج accuracy بنسبة 98%. ماذا يجب أن تعرض بجانبها؟

السؤال 4 من 4

لماذا يُعدّ اختيار العتبة التي تعظّم الربح على بيانات الاختبار خطأً؟

تدريب

تمرين Python

اضبط العتبة لبريد إلكتروني رخيص

انتقل فريق التسويق من المكالمات الهاتفية إلى بريد إلكتروني رخيص. الآن كل منقطع نتواصل معه يساوي في المتوسط +16، وكل عميل وفيّ نتواصل معه يكلّف 4.

  1. اكتب profit(y_true, y_pred) التي تُعيد 16 * TP - 4 * FP.
  2. tuned: نموذج TunedThresholdClassifierCV يغلّف pipeline من StandardScaler + LogisticRegression، ويُقيَّم بـ make_scorer(profit) وcv=5، ومدرَّب على بيانات التدريب.
  3. profit_default: ربح الـ pipeline نفسه على بيانات الاختبار مع العتبة الافتراضية 0.5. وprofit_tuned: ربح tuned على بيانات الاختبار.

ملفات البيانات: churn.csv

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"])

# 1. Email campaign economics: each contacted churner is worth +16, each contacted stayer costs -4.
def profit(y_true, y_pred):
    return 0

# 2. Tune the threshold of a StandardScaler + LogisticRegression pipeline for profit (cv=5)
tuned = None

# 3. Test-set profit at the default 0.5 threshold and at the tuned threshold
profit_default = None
profit_tuned = None
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

(لا مخرجات)

الفحوص

  • profit تُعيد 16·TP − 4·FP
  • tuned مدرَّب وعتبته أقل من 0.5
  • profit_default هو ربح الاختبار عند العتبة 0.5
  • profit_tuned هو ربح النموذج المضبوط على الاختبار، وهو يتفوّق على الافتراضي