انتقل إلى المحتوى الرئيسي

الوحدة 5 — الدمج الصحيح زمنيًّا

هذه الوحدة الأهمّ في الدورة. سلامة النقطة الزمنيّة هي ما يُميّز مخزن متغيّرات فعليًّا من مجرّد قاعدة بيانات مع طبقة تسمية. تشرح الوحدة من أين يأتي تسرّب المستقبل، كيف يُلغى، وما الفرق الرقميّ الذي يُحدثه على مقاييس النموذج.

المسألة

نبني مجموعة تدريب لتسجيل الاحتيال. لدينا جدول أحداث (المعاملات الخام) وجدول متغيّرات (عدّادات محسوبة على النوافذ):

events:
event_id | customer_id | event_time | is_fraud
T1 | C1 | 2026-06-01 09:00:00 | 0
T2 | C1 | 2026-06-01 15:30:00 | 1
T3 | C1 | 2026-06-02 08:00:00 | 0

features (customer_counters):
customer_id | as_of_time | count_1h | count_24h
C1 | 2026-06-01 08:00:00 | 0 | 2
C1 | 2026-06-01 14:00:00 | 1 | 5
C1 | 2026-06-01 20:00:00 | 3 | 8
C1 | 2026-06-02 08:00:00 | 0 | 3

المطلوب: لكلّ حدث في events، أضف قيمة العدّادات كما كانت في زمن الحدث تمامًا.

الدمج الساذج، والفخّ

الغريزة الأولى:

merged = events.merge(features, on="customer_id")

يُنتج هذا 12 صفًّا (3 × 4)، منها صفوف تربط الحدث T1 (في 09:00) بمتغيّرات بتاريخ 20:00 من نفس اليوم، أي بعد الحدث بأحد عشر ساعة. النموذج يرى قيمة عدّاد جُمِعَت بعد أن اتّخذ الاحتيال مكانه. يتعلّم أنّ «العدّاد المرتفع لاحقًا يدلّ على احتيال» — دائرة تامّة تعطي AUC ممتازًا في التدريب وتنهار في الإنتاج لأنّ خدمة الإنتاج لا تستطيع الاطّلاع على المستقبل.

هذا هو تسرّب المستقبل (data leakage) وهو أشيع سبب لفارق أداء صادم بين المصادقة والإنتاج.

الحلّ الأوّل الذي يخطر في البال هو تصفية «فقط الصفوف التي طابع متغيّراتها قبل الحدث»:

merged = merged[merged["as_of_time"] <= merged["event_time"]]

يُنتج هذا 6 صفوف لكلٍّ منها عدّة قيم. الحدث T2 (في 15:30) يجد ثلاث صفوف متغيّرات صالحة: 08:00 و14:00 من نفس اليوم، والحدث T3 يجد ثلاث. علينا اختيار الأحدث بين هذه الصالحة. هذا بالضبط ما يُسمّى as-of join أو دمج النقطة الزمنيّة.

الدمج عند نقطة الحدث

الصيغة العامّة: لكلّ صفّ في جدول الأحداث، اختر من جدول المتغيّرات الصفّ الذي as_of_time فيه أكبر مُقيَّم يظلّ أصغر من أو يساوي event_time.

في SQL الحديث (BigQuery, Snowflake, DuckDB):

SELECT e.*, f.count_1h, f.count_24h
FROM events e
LEFT JOIN features f
ON f.customer_id = e.customer_id
AND f.as_of_time = (
SELECT MAX(f2.as_of_time)
FROM features f2
WHERE f2.customer_id = e.customer_id
AND f2.as_of_time <= e.event_time
);

النتيجة الصحيحة:

event_id | event_time          | is_fraud | count_1h | count_24h
T1 | 2026-06-01 09:00:00 | 0 | 0 | 2 -> features de 08:00
T2 | 2026-06-01 15:30:00 | 1 | 1 | 5 -> features de 14:00
T3 | 2026-06-02 08:00:00 | 0 | 0 | 3 -> features de 08:00 J+1

في pandas منذ الإصدار 0.19: pd.merge_asof(left=events, right=features, left_on='event_time', right_on='as_of_time', by='customer_id') تفعل هذا مباشرة.

المخازن (Feast, Tecton, Databricks) تتولّى هذا آليًّا حين تُنادي get_historical_features(entity_df=..., features=[...]). المُطوّر يُعطي جدول أحداث بطوابع زمنيّة، والمخزن يعيد الجدول مُدمَجًا صحيحًا.

أثر الفرق على المقاييس

في تجربة مضبوطة على 500 ألف معاملة مع 2% احتيال، وقارنّا نموذجي xgboost مع نفس المتغيّرات المصرَّحة، الأوّل مُدرَّب بدمج ساذج والثاني بدمج زمنيّ صحيح:

المقياسدمج ساذجدمج صحيحفرق
AUC على المصادقة0.9720.888+0.084 مُضلّل
AUC في الإنتاج0.8450.884−0.039
فرق المصادقة-الإنتاج0.1270.004كارثة مقابل استقرار

النموذج الأوّل يتألّق في المختبر وينهار في الإنتاج؛ الثاني يبدو أقلّ ثمّ يعمل كما تقول أرقامه.

المصادقة العشوائيّة ليست بديلًا

قد تظنّ أنّه يكفي فصل مجموعة اختبار عشوائيّة من الأحداث. لا يكفي: التسرّب داخل الصفّ الواحد، لا بين الصفوف. حتّى بمصادقة معزولة تمامًا، تسرّب الطوابع الزمنيّة لا يزول. الحلّ الوحيد هو الدمج الصحيح زمنيًّا قبل التقسيم.

يُضاف إلى ذلك ضرورة تقسيم زمنيّ: التدريب على ما قبل تاريخ، والاختبار على ما بعده. هذا معالج في الدورة 20، ويجب أن يجتمع مع الدمج الصحيح.

حالات حادّة تنسى

  • الطابع الزمنيّ المكرَّر: عدّة قيم متغيّرات بنفس as_of_time للعميل نفسه. حلّ الحدود يختار الأحدث في ترتيب مستقرّ (_created_at).
  • التأخّر: تصل قيمة المتغيّر إلى المخزن بعد الحدث. الدمج زمنيًّا يحترم هذا: يُختار ما كان معلومًا لا ما وقع فعلًا.
  • الانضمام المتعدّد: عدّة وصفات على نفس الحدث، بأمداء مختلفة. المخزن يدمج كلّ وصفة على حِدَة، ثمّ يجمعها.
اختبار «الفاصل الزمنيّ»

اختبار عمليّ: عند فحص مجموعة تدريب، اطرح لكلّ صفّ: event_time - feature_timestamp. هذا الفاصل يجب أن يكون دائمًا موجبًا. إذا وجدت قيمة سالبة، عندك تسرّب مستقبل. يُشغَّل هذا الاختبار في pytest قبل نشر بيانات جديدة.

الخلاصة

  • الدمج الساذج على customer_id وحده يُدخل تسرّب المستقبل ويعطي مقاييس متضخّمة.
  • التصفية بـ<= لا تكفي؛ يجب اختيار أحدث صفّ متغيّرات سابق للحدث، وهو ما يُسمّى دمج النقطة الزمنيّة.
  • Feast (وأخواته) تُنفّذ هذا آليًّا عبر get_historical_features.
  • الفرق على المقاييس ليس تجميليًّا: 12 نقطة AUC مُضلّلة مقابل استقرار حقيقيّ.

الوحدة التالية: التجسيد، أي كيف تصل القيم المحسوبة من المخزن غير المتّصل إلى المخزن المتّصل بدون فقدان.