الوحدة 4 · العمل بالطريقة الصحيحة
تسرّب البيانات
18 دقراءة 4 داكتشف الخطأ
الدرس في لمحة
تعرّف على الأنواع الثلاثة الشائعة لتسرّب البيانات (تسرّب الهدف، والمعالجة المسبقة قبل التقسيم، والصفوف أو الأزمنة المتداخلة) وامنعها باللقطات الزمنية والـ pipelines والـ splitter الصحيح.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
لكل عالم بيانات متمرّس قصة عن النموذج الذي كان أجمل من أن يُصدَّق. دقة 99% في الـ notebook، وأداء رمي العملة في بيئة الإنتاج. والسبب يكاد يكون دائمًا واحدًا: أثناء التدريب أو التقييم أُتيحت للنموذج معلومات لن تكون لديه في الواقع. هذا هو تسرّب البيانات (data leakage)، وهو خطير لأنه لا يبدو كخطأ برمجي. بل يبدو كنجاح.
تسرّب الهدف: features من المستقبل
جدول الانقطاع في Cartwheel لقطة زمنية (snapshot): كل feature يصف العميل كما كان في 30 سبتمبر 2025، وchurned يسجّل ما حدث في الـ 90 يومًا التالية. هذا الخط الزمني هو العقد الذي يعيش النموذج وفقه. ففي وقت التنبؤ، لا تعرف إلا ما حدث قبل اللقطة.
تخيّل الآن أن محلّلًا أعاد بناء الجدول في يناير وحسب days_since_last_order حتى تاريخ التصدير بدل تاريخ اللقطة. بالنسبة إلى المنقطعين، الذين لم يطلبوا شيئًا في النافذة، تكون القيمة أكبر بـ 92 يومًا. وبالنسبة إلى العملاء الذين بقوا، تكون صغيرة، لأنهم طلبوا مؤخرًا. صار العمود الآن يرمّز الإجابة:
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
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"]
rng = np.random.default_rng(1)
# Recency measured at export time (year end), not at the 30 September snapshot
churn["recency_at_export"] = np.where(churn["churned"] == 1,
churn["days_since_last_order"] + 92,
rng.integers(0, 92, len(churn)))
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
model = make_pipeline(StandardScaler(), LogisticRegression())
for cols in [features, features + ["recency_at_export"]]:
auc = cross_val_score(model, churn[cols], churn["churned"], cv=cv, scoring="roc_auc").mean()
print(f"{len(cols)} features: ROC AUC {auc:.3f}")يقفز الـ ROC AUC من 0.81 إلى 0.999. لا شيء في الكود خاطئ؛ والتحقق المتقاطع مُنفَّذ كما ينبغي. التسرّب يكمن في طريقة بناء البيانات، ولهذا عليك أن تبحث عنه في تاريخ البيانات، لا في النموذج.
تسرّبات الهدف تختبئ خلف أسماء بريئة: account_status وlast_contact_reason وrefund_amount وtotal_orders (المحسوب عند التصدير، بما فيه من طلبات مستقبلية). والاختبار لكل عمود سؤال واحد: هل كنت سأعرف هذه القيمة، تمامًا كما هي مخزّنة، في اللحظة التي أُجري فيها التنبؤ؟
المعالجة المسبقة قبل التقسيم
النوع الثاني من التسرّب يحدث في الكود. فأي خطوة تتعلّم من البيانات (توحيد المقاييس، والملء، والترميز، واختيار الـ features) يجب أن تتعلّم من صفوف التدريب وحدها. نفّذها على البيانات كلها أولًا وستكون صفوف التحقق قد شكّلت النموذج.
مع توحيد المقاييس، يكون الضرر صغيرًا عادةً. أما مع اختيار الـ features فقد يكون هائلًا. إليك مجموعة بيانات من الضجيج الصرف: 200 صف، و2,000 عمود عشوائي، وlabels عشوائية. لا ينبغي لأي نموذج أن يتجاوز 50%.
import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
rng = np.random.default_rng(0)
X = rng.normal(size=(200, 2000))
y = rng.integers(0, 2, size=200)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
X_selected = SelectKBest(f_classif, k=20).fit_transform(X, y) # selection saw every label
leaky = cross_val_score(LogisticRegression(), X_selected, y, cv=cv).mean()
pipe = make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression())
honest = cross_val_score(pipe, X, y, cv=cv).mean()
print(f"select then CV: {leaky:.3f} select inside CV: {honest:.3f}")الاختيار على الصفوف المئتين كلها يجد 20 عمودًا صادف أنها تتوافق مع labels كل صف، بما فيها الصفوف التي ستُستخدم لاحقًا للتحقق. ثم يؤكد التحقق المتقاطع نمطًا لا وجود له إلا لأنه اختير باستخدام تلك الصفوف نفسها: دقة 0.75 على ضجيج. وداخل pipeline، لا يرى الاختيار إلا طيّات التدريب، فتعود النتيجة إلى مستوى الصدفة.
صفوف يعرف بعضها بعضًا
النوع الثالث يأتي من علاقة الصفوف ببعضها. التقسيمات العشوائية تفترض أن الصفوف مستقلة. وحين لا تكون كذلك، تحتوي بيانات الاختبار على نسخ شبه مطابقة لصفوف التدريب.
- الكيانات المتكرّرة. إذا كان في الجدول عدة صفوف لكل عميل أو مريض أو جهاز، فالتقسيم العشوائي يضع العميل نفسه في الجهتين، ويُقيَّم النموذج جزئيًا على أشخاص حفظهم. استخدم
GroupKFold(أوGroupShuffleSplit) مع معرّف العميل كـgroups. - الزمن. إذا كنت تتنبأ بالمستقبل، فالتقسيم العشوائي يتيح للنموذج أن يتعلّم من صفوف تأتي بعد الصفوف التي يُختبر عليها. استخدم
TimeSeriesSplit، أو تاريخًا فاصلًا بسيطًا: درّب على الماضي، وتحقّق على الفترات اللاحقة. - التكرارات. الصفوف المكرّرة حرفيًا الموزّعة بين التدريب والاختبار إجابات مجانية. احذفها قبل التقسيم.
في جدول الانقطاع لدى Cartwheel صف واحد لكل عميل وتاريخ لقطة واحد، فالتقسيم العشوائي الطبقي مناسب. غيّر أيًّا من هذين ولن يعود كذلك.
مع إغلاق منافذ التسرّب، يمكنك الوثوق بالتحقق المتقاطع للحكم على الأفكار الجديدة، وأكثر الأفكار الجديدة إنتاجًا عادةً هي features أفضل. يبني الدرس القادم بعضها من المعرفة بمجال العمل ويختبر ما إذا كانت تستحق مكانها.
أهم النقاط
- تسرّب البيانات (data leakage) هو أي معلومة متاحة أثناء التدريب أو التقييم لن تكون متاحة حين يتنبأ النموذج فعليًا.
- يأتي تسرّب الهدف من features سُجّلت بعد لحظة التنبؤ؛ فاسأل عن كل عمود: هل كنت سأعرف هذه القيمة وقت التنبؤ؟
- تدريب scaler أو imputer أو أداة لاختيار الـ features قبل التقسيم يتيح لصفوف التحقق أن تشكّل النموذج؛ فضع كل خطوة متعلَّمة داخل pipeline.
- حين تنتمي الصفوف إلى العميل نفسه أو يتبع بعضها بعضًا زمنيًا، استخدم
GroupKFoldأوTimeSeriesSplitبدل التقسيم العشوائي. - النتيجة التي تبدو أجمل من أن تُصدَّق، أو feature واحد يهيمن على الـ permutation importance، سبب للبحث عن تسرّب قبل الاحتفال.