EduFlow

الوحدة 5 · ما بعد الأساسيات

تعلّم الآلة المسؤول: الانحياز والإنصاف والانجراف

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

الدرس في لمحة

افحص أخطاء النموذج مجموعةً مجموعة، واختر معيار إنصاف يطابق الضرر، وراقب المدخلات والتنبؤات والنتائج كي تلاحظ حين يبتعد العالم عن بيانات التدريب.

  1. 1من أين يأتي الانحياز
  2. 2فحص النموذج مجموعةً مجموعة
  3. 3اختيار معنى “المنصف”
  4. 4بعد الإطلاق: الانجراف

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

بلغ نموذجك البطل ROC AUC على الاختبار قدره 0.81. هذا رقم واحد لـ 100 عميل من ثماني دول وثلاث شرائح. ومن الممكن تمامًا أن يكون النموذج جيدًا في المتوسط وضعيفًا لمجموعة واحدة، وأن تكون تلك المجموعة هي الأقل قدرة على الشكوى. ومن الممكن أيضًا أن يكون نموذج كان جيدًا في أكتوبر مخطئًا بصمت في مارس. يغطي هذا الدرس الأمرين: الإنصاف (fairness) قبل الإطلاق، والانجراف (drift) بعده.

من أين يأتي الانحياز

نادرًا ما يكون الانحياز (bias) في النموذج سطرًا من الكود. إنه يصل مع البيانات ومع طريقة استخدام التنبؤات:

  • الانحياز التاريخي: الـ labels تسجّل قرارات سابقة، لا الحقيقة. والنموذج المدرَّب على من حصل على الموافقة يتعلّم من حصل على الموافقة، بما في ذلك أي تحيّز في تلك الموافقات.
  • التمثيل: المجموعات الصغيرة تعطي النموذج ما يتعلّمه أقل. لدى Cartwheel 17 عميلًا أردنيًا في الجدول كله؛ وأي نموذج سيكون أقل موثوقية لهم.
  • البدائل (proxies): حذف عمود حسّاس لا يحذف معلوماته. فرسوم الشحن وأزمنة التوصيل والعملة قد تحلّ محل الدولة.
  • الاستخدام: الدرجة نفسها تعني أشياء مختلفة بحسب الإجراء. علامة انقطاع تطلق خصمًا تفيد العميل المُعلَّم؛ وعلامة تطلق تجميد الائتمان تضرّه.

فحص النموذج مجموعةً مجموعة

لتفحص بإنصاف تحتاج إلى تنبؤات لعملاء كثيرين لم يتدرّب عليهم النموذج. تعطي cross_val_predict كل صف تنبؤًا خارج الطيّة: فكل عميل يُقيَّم بنموذج تدرّب على الطيّات الأخرى.

Python
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import StratifiedKFold, cross_val_predict
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"]
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
model = make_pipeline(StandardScaler(), LogisticRegression())
prob = cross_val_predict(model, churn[features], churn["churned"], cv=cv, method="predict_proba")[:, 1]
audit = churn.assign(flagged=(prob >= 0.5).astype(int))

def group_report(g):
    return pd.Series({
        "customers": len(g),
        "churn_rate": g["churned"].mean(),
        "flag_rate": g["flagged"].mean(),
        "recall": recall_score(g["churned"], g["flagged"]),
        "precision": precision_score(g["churned"], g["flagged"]),
    })

print(audit.groupby("segment")[["churned", "flagged"]].apply(group_report).round(2))
print(audit.groupby("country")[["churned", "flagged"]].apply(group_report).round(2))

اقرأ عبر الأعمدة:

  • نسبة العملاء المُعلَّمين (flag rate) أو نسبة الاختيار: كم مرة تحصل كل مجموعة على العرض.
  • الـ Recall: من منقطعي كل مجموعة، كم منهم يلتقط النموذج. وهي هنا حصة العملاء المعرّضين للخطر الذين يحصلون على المساعدة.
  • الـ Precision: من العملاء المُعلَّمين في كل مجموعة، كم منهم كان راحلًا فعلًا. والـ precision المنخفضة تعني خصومات تذهب إلى أشخاص كانوا سيبقون.

تتراوح الـ recall بين 0.71 (كندا والإمارات والمملكة المتحدة) و0.82 (مصر والأردن). والـ precision للشركات الصغيرة 0.66 مقابل 0.78 للمستهلكين الأفراد. بعض الفجوات ضجيج: فأرقام الأردن تقوم على 17 عميلًا، فيحرّكها عميل واحد بعدة نقاط. وبعضها يستحق نظرة أدقّ مع نمو البيانات. ليس الغرض من الفحص ختم نجاح/رسوب؛ بل معرفة أين يكون النموذج أضعف، كي تقرّر ما إذا كان ذلك مقبولًا.

اختيار معنى “المنصف”

هناك عدة تعريفات معقولة، وحين تختلف النِّسب الأساسية بين المجموعات (تنقطع مصر بنسبة 67%، وألمانيا بنسبة 45%) لا يمكن أن تتحقق كلها معًا:

  • تساوي نِسب الاختيار (demographic parity): كل مجموعة تُعلَّم بالنسبة نفسها.
  • تساوي الـ recall (equal opportunity): المنقطعون الحقيقيون في كل مجموعة يُلتقطون بالنسبة نفسها.
  • تساوي الـ precision: العلامة تعني الخطر نفسه في كل مجموعة.

اختر بأن تسأل من يتضرّر من أيّ خطأ. في عروض الاسترجاع، الضرر هو منقطع لا يصله العرض أبدًا، فتساوي الـ recall هو الهدف الطبيعي. وفي نموذج يجمّد الحسابات، الضرر هو الإيجابي الكاذب، فستنظر إلى معدّلات الإيجابيات الكاذبة بدلًا من ذلك. دوّن اختيارك؛ فهو قرار تجاري وأخلاقي، لا خيار تقني افتراضي.

بعد الإطلاق: الانجراف

تعلّم النموذج من العملاء كما كانوا في 30 سبتمبر 2025. والعالم يتحرّك. انجراف البيانات (data drift) تغيّر في المدخلات: تطبيق جوّال جديد يحرّك mobile_share، وإضراب شركة شحن يمطّ days_since_last_order. وانجراف المفهوم (concept drift) تغيّر في العلاقة نفسها: برنامج ولاء لدى منافس يجعل حتى العملاء الحديثين يرحلون. وللانقطاع تأخّر في الـ labels: فلا تعرف ما إذا كان التنبؤ صحيحًا إلا بعد 90 يومًا.

حلقة من أربع مراحل. يوميًا: افحص توزيعات المدخلات مقابل بيانات التدريب. يوميًا: تتبّع حصة العملاء المُعلَّمين. بعد 90 يومًا: قارن التنبؤات بالنتائج الحقيقية. حين تفشل الفحوص: حقّق، ثم أعد التدريب والفحص، وهذا يعود إلى النموذج المُطلَق. النموذج المُطلَق فحوص انجراف المدخلات يوميًا، دون labels مزيج التنبؤات نسبة المُعلَّمين لكل مجموعة الأداء الحقيقي بعد 90 يومًا حقّق، وأعد التدريب والفحص
مراقبة نموذج انقطاع مُطلَق

يمكن فحص انجراف المدخلات كل يوم، دون labels، بمقارنة القيم الحديثة لكل feature ببيانات التدريب. واختبار Kolmogorov–Smirnov للعيّنتين من SciPy بداية بسيطة:

Python
import numpy as np
import pandas as pd
from scipy.stats import ks_2samp

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"]
training = churn[features]

# Simulate next quarter: a courier problem delays orders and a new app shifts shopping to mobile
rng = np.random.default_rng(7)
recent = training.sample(150, random_state=1).copy()
recent["days_since_last_order"] += rng.integers(20, 60, len(recent))
recent["mobile_share"] = (recent["mobile_share"] + 0.3).clip(0, 1)

for col in features:
    stat, p = ks_2samp(training[col], recent[col])
    flag = "DRIFT" if p < 0.01 else ""
    print(f"{col:22} KS {stat:.2f}  p={p:.3g} {flag}")

يضيء العمودان اللذان تغيّرا؛ ولا يضيء الباقي. في بيئة الإنتاج ستشغّل هذا وفق جدول زمني، وتطلق تنبيهًا على الانجراف المستمر لا على يوم سيئ واحد، وتقرنه بنسبة العملاء المُعلَّمين في كل مجموعة، ثم بالـ recall والـ precision لكل مجموعة حين تصل النتائج. وحين ينخفض الأداء الحقيقي، أعد التدريب على بيانات حديثة وأعد إجراء فحص هذا الدرس قبل إعادة الإطلاق.

المراقبة تفترض أن النموذج يعمل في مكان ما. يحزم الدرس الأخير الـ pipeline البطل، ويضعه خلف API، ويغلق الحلقة.

أهم النقاط

  • يدخل الانحياز عبر البيانات (labels تاريخية، ومجموعات ممثّلة تمثيلًا ناقصًا، وfeatures بديلة) وعبر طريقة استخدام التنبؤات؛ والنتيجة الإجمالية الجيدة قد تُخفي نتيجة سيئة لمجموعة واحدة.
  • افحص بمقاييس لكل مجموعة: نسبة الاختيار، والـ recall، والـ precision، محسوبة على تنبؤات خارج الطيّة (out-of-fold) أو على بيانات الاختبار.
  • تعريفات الإنصاف مثل تساوي نِسب الاختيار وتساوي الـ recall وتساوي الـ precision لا يمكن عادةً أن تتحقق كلها معًا؛ فاختر التعريف الذي يطابق من يتضرّر من أيّ خطأ.
  • حذف العمود الحسّاس لا يزيل الانحياز، لأن features أخرى تعمل كبدائل عنه؛ فأبقِه متاحًا للفحص.
  • راقب ثلاثة أشياء بعد الإطلاق: توزيعات المدخلات (data drift)، وتوزيع التنبؤات، والأداء الحقيقي حين تصل النتائج.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

الـ recall لنموذج الانقطاع 0.82 للعملاء المصريين و0.71 للعملاء الكنديين. وعروض الاسترجاع تذهب إلى العملاء المُعلَّمين. من المتضرّر؟

السؤال 2 من 3

يحذف زميلك country من الـ features ‘ليجعل النموذج منصفًا’. لماذا لا يكفي ذلك؟

السؤال 3 من 3

بعد شهرين من الإطلاق، ترتفع حصة العملاء المُعلَّمين كمنقطعين من 47% إلى 60%، لكن لا نتائج انقطاع معروفة بعد. ما أفضل خطوة أولى؟

تدريب

تمرين Python

افحص بحسب الشريحة، ثم تحقّق من الانجراف

  1. flagged: علامة انقطاع 0/1 خارج الطيّة لكل عميل، من cross_val_predict(model, ..., cv=cv, method="predict_proba") وعتبة 0.5.
  2. recall_by_segment: الـ recall لهذه العلامات داخل كل segment (كـ Series فهرسها الشريحة). وlowest_recall_segment: الشريحة التي يُلتقط منقطعوها بأقل نسبة.
  3. drifted: قائمة أسماء الـ features التي يختلف فيها recent عن churn[features] بحسب اختبار KS للعيّنتين مع p < 0.01.

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

import numpy as np
import pandas as pd
from scipy.stats import ks_2samp
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import StratifiedKFold, cross_val_predict
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"]
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
model = make_pipeline(StandardScaler(), LogisticRegression())

# Next quarter's customers (simulated): a premium range lifted basket sizes and a returns policy change caused more tickets
rng = np.random.default_rng(3)
recent = churn[features].sample(150, random_state=2).copy()
recent["avg_order_value"] *= 1.6
recent["support_tickets"] += rng.integers(1, 3, len(recent))

# 1. Out-of-fold churn flags (threshold 0.5) for every customer, using cross_val_predict
flagged = None

# 2. Recall per segment (a Series indexed by segment), and the segment with the lowest recall
recall_by_segment = None
lowest_recall_segment = None

# 3. Columns of `recent` whose distribution differs from churn[features] (KS test p < 0.01), as a list
drifted = []
جارٍ تحميل المحرّر…

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

(لا مخرجات)

الفحوص

  • يحتوي flagged على علامات 0/1 خارج الطيّة للعملاء الـ 398 كلهم
  • يحتوي recall_by_segment على الـ recall لكل شريحة
  • يسمّي lowest_recall_segment الشريحة الأضعف
  • يسرد drifted الأعمدة المنزاحة بالضبط