EduFlow

الوحدة 4 · العمل بالطريقة الصحيحة

هندسة الـ Features للتنبؤ بانقطاع العملاء

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

الدرس في لمحة

حوّل المعرفة بمجال العمل إلى معدّلات ونِسب وتحويلات لوغاريتمية، وضعها في FunctionTransformer داخل الـ pipeline، واحتفظ فقط بالـ features التي تتجاوز ضجيج التحقق المتقاطع.

  1. 1أفكار تأتي من مجال العمل
  2. 2الـ Features داخل الـ pipeline
  3. 3هل تساعد؟ اسأل التحقق المتقاطع
  4. 4ختام القسم: البطل

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

النماذج لا ترى إلا الأعمدة التي تعطيها إياها. وقائد فريق الدعم حين ينظر إلى عميل لا يفكّر “4 طلبات و600 يوم من العضوية”؛ بل يفكّر “طلب كل خمسة أشهر أو نحوها”. هذا المعدّل، لا أيٌّ من الرقمين الخامين، هو الفكرة المفيدة. وهندسة الـ features (feature engineering) تحوّل هذا النوع من المعرفة بمجال العمل إلى أعمدة، وفي البيانات الجدولية كثيرًا ما تساوي أكثر من أي قدر من الضبط.

أفكار تأتي من مجال العمل

features الانقطاع الجيدة تقع عادةً في بضع عائلات:

  • المعدّلات: الطلبات لكل شهر عضوية، والتذاكر لكل طلب. الأعداد الخام تكبر مع مدة العضوية؛ أما المعدّلات فتقارن العملاء بإنصاف.
  • النِّسب مقارنةً بعادات العميل نفسه: الأيام منذ آخر طلب مقسومة على الفجوة المعتادة بين طلباته. ثلاثون يومًا من الصمت مقلقة لمشترٍ أسبوعي، وطبيعية لمن يطلب مرتين في السنة.
  • التحويلات: log1p لأعمدة المال، الذي يضغط الذيول الطويلة نحو اليمين بحيث يُحسب الفرق بين 50 و150 دولارًا أكثر من الفرق بين 3,000 و3,100 دولار.
  • العلامات (flags): “لم يُجب عن الاستبيان قط”، و"لم يطلب في حياته إلا مرة واحدة".

كلٌّ من هذه يجب أن يكون قابلًا للحساب من معلومات متاحة في تاريخ اللقطة. المعدّل المبني من مدة العضوية والطلبات سليم؛ وأي شيء يختلس النظر إلى نافذة النتيجة هو التسرّب الذي تحدّثنا عنه في الدرس السابق.

الـ Features داخل الـ pipeline

اكتب كود الـ features مرة واحدة، كدالة من DataFrame إلى DataFrame، وغلّفها في FunctionTransformer. داخل الـ pipeline تعمل في التحقق المتقاطع، وفي التدريب النهائي، وفي بيئة الإنتاج، دائمًا بالطريقة نفسها.

Python
import numpy as np
import pandas as pd
from sklearn.preprocessing import FunctionTransformer

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

def add_features(df):
    df = df.copy()
    months = df["tenure_days"] / 30.4
    df["orders_per_month"] = df["orders"] / months
    df["typical_gap"] = df["tenure_days"] / df["orders"]
    df["overdue"] = df["days_since_last_order"] / df["typical_gap"]
    df["log_spent"] = np.log1p(df["total_spent"])
    return df

engineered = FunctionTransformer(add_features).fit_transform(churn[features])
print(engineered[["orders", "tenure_days", "orders_per_month", "overdue", "log_spent"]].head().round(2))
print("skew of total_spent:", round(churn["total_spent"].skew(), 2),
      "| after log1p:", round(np.log1p(churn["total_spent"]).skew(), 2))

df.copy() مهم: لا ينبغي للدالة أن تعدّل الـ DataFrame الخاص بمن استدعاها. ولأن لكل عميل طلبًا واحدًا على الأقل ومدة عضوية موجبة، فالقسمة آمنة هنا؛ أما في بياناتك أنت، فاحترس من الأصفار.

الدالة عديمة الحالة (stateless): القيم الجديدة لكل صف تعتمد على ذلك الصف وحده. والـ features عديمة الحالة لا يمكن أن تتسرّب بين الطيّات. أما feature مثل “الإنفاق مقارنةً بمتوسط العملاء” فمختلف، لأن المتوسط يُتعلَّم من البيانات؛ فهو يحتاج إلى محوّل حقيقي له fit، وإلا استخدم صفوف التحقق بصمت.

هل تساعد؟ اسأل التحقق المتقاطع

Python
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler
from sklearn.tree import DecisionTreeClassifier

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"])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

def add_features(df):
    df = df.copy()
    df["orders_per_month"] = df["orders"] / (df["tenure_days"] / 30.4)
    df["typical_gap"] = df["tenure_days"] / df["orders"]
    df["overdue"] = df["days_since_last_order"] / df["typical_gap"]
    df["log_spent"] = np.log1p(df["total_spent"])
    return df

models = {"tree": DecisionTreeClassifier(min_samples_leaf=20, random_state=42),
          "logistic": make_pipeline(StandardScaler(), LogisticRegression())}
for name, model in models.items():
    plain = cross_val_score(model, X_train, y_train, cv=cv, scoring="roc_auc")
    richer = cross_val_score(make_pipeline(FunctionTransformer(add_features), model),
                             X_train, y_train, cv=cv, scoring="roc_auc")
    print(f"{name:8} plain {plain.mean():.3f} ± {plain.std():.3f}   "
          f"engineered {richer.mean():.3f} ± {richer.std():.3f}")

تتحسّن الشجرة الواحدة من 0.716 إلى 0.759، وتصبح طيّاتها أكثر ثباتًا. انظر إلى داخلها وسترى السبب: يصبح orders_per_month سؤال الجذر. لا تستطيع الشجرة إلا مقارنة عمود واحد بعتبة، فـ “طلبات قليلة بالنسبة إلى مدة العضوية هذه” كانت ستحتاج إلى سلّم من التقسيمات لمحاكاتها. أما حين تُسلَّم النسبة، فيكفيها تقسيم واحد.

ويتحرك الانحدار اللوجستي من 0.807 إلى 0.811، وهذا ضمن تشتّت ±0.04 بوضوح. أي لا تغيير. فالنموذج الخطي يستطيع أصلًا أن يوازن orders مقابل tenure_days، وحداثة آخر طلب، الإشارة المهيمنة، موجودة منذ البداية. عائلات النماذج المختلفة تحتاج إلى features مختلفة: النِّسب والتفاعلات للأشجار، والتحويلات المقوِّمة للنماذج الخطية.

ختام القسم: البطل

اختبر القسم 4 النماذج المجمّعة والأعمدة الإضافية والضبط والـ features المهندَسة بتحقق متقاطع أمين. ومعظمها لم يتفوّق على النموذج البسيط. لذا يبقى البطل هو الانحدار اللوجستي بعد توحيد المقاييس على ثمانية أعمدة رقمية، وقد حان الوقت لإنفاق بيانات الاختبار:

Python
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_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"])

champion = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
print("test ROC AUC:", round(roc_auc_score(y_test, champion.predict_proba(X_test)[:, 1]), 3))
print("test accuracy:", champion.score(X_test, y_test))

ROC AUC على الاختبار بقيمة 0.81 مقابل 0.81 بالتحقق المتقاطع: لا مفاجأة، وهذا بالضبط ما تبدو عليه عملية خالية من التسرّب. هذا التوافق هو ما اشتراه لك القسم 4. ليس رقمًا أعلى، بل رقمًا تستطيع أن تقف خلفه في اجتماع.

يخطو القسم 5 إلى ما هو أبعد من الجداول الموجَّهة: إيجاد البنية دون labels، ونظرة أولى على الشبكات العصبية، وموقع النماذج اللغوية الكبيرة، وما يتطلّبه وضع البطل أمام عملاء حقيقيين بمسؤولية.

أهم النقاط

  • الـ features الجيدة تعبّر عمّا ينظر إليه الخبير: معدّلات لكل وحدة زمن، ونِسب بين أعمدة مترابطة، وعلامات لحالات ذات معنى.
  • تقسم الأشجار على عمود واحد في كل مرة، فهي الأكثر استفادة من features النِّسب التي تجمع الأعمدة؛ أما النماذج الخطية فتستفيد من تحويلات مثل اللوغاريتم التي تقوّم العلاقات.
  • ضع كود الـ features في FunctionTransformer داخل الـ pipeline، كي يحسب التدريب والتحقق المتقاطع وبيئة الإنتاج الـ features بالطريقة نفسها تمامًا.
  • لا يستحق الـ feature الجديد مكانه إلا إذا كان مكسبه في التحقق المتقاطع أكبر من التشتّت بين الطيّات.
  • بعد الاختيار، أعد تدريب البطل على بيانات التدريب كلها وقيّمه مرة واحدة على بيانات الاختبار.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

إضافة orders_per_month ترفع الـ AUC بالتحقق المتقاطع لشجرة واحدة من 0.72 إلى 0.76، لكنها تُبقي الانحدار اللوجستي على حاله عند 0.81. ما السبب الأرجح؟

السؤال 2 من 4

لماذا نحسب الـ features داخل FunctionTransformer في الـ pipeline بدل إضافة أعمدة إلى الـ DataFrame مسبقًا؟

السؤال 3 من 4

العمود total_spent ملتوٍ بشدة نحو اليمين (قلّة من العملاء أنفقوا الآلاف). أيّ تحويل يساعد النموذج الخطي عادةً؟

السؤال 4 من 4

feature جديد يرفع متوسط الـ AUC بالتحقق المتقاطع من 0.807 إلى 0.811، مع انحرافات معيارية للطيّات في حدود 0.035. ماذا تفعل؟

تدريب

تمرين Python

أعطِ الشجرة معدّلًا تقسم عليه

ساعد شجرة قرار واحدة باثنين من الـ features المهندَسة.

  1. أكمل add_features(df) بحيث تُعيد نسخة من df مضافًا إليها orders_per_month (orders / (tenure_days / 30.4)) وtickets_per_order (support_tickets / orders). لا تعدّل الـ DataFrame المُمرَّر.
  2. pipe: FunctionTransformer(add_features) متبوعًا بـ tree.
  3. plain_auc وengineered_auc: متوسط الـ ROC AUC بالتحقق المتقاطع على بيانات التدريب (باستخدام cv المعطى) لـ tree وحدها ولـ pipe.

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

import numpy as np
import pandas as pd
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer
from sklearn.tree import DecisionTreeClassifier

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"])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
tree = DecisionTreeClassifier(min_samples_leaf=20, random_state=42)

# 1. Return a COPY of df with two new columns:
#    orders_per_month = orders / (tenure_days / 30.4)
#    tickets_per_order = support_tickets / orders
def add_features(df):
    return df

# 2. Pipeline: FunctionTransformer(add_features), then `tree`
pipe = make_pipeline(tree)

# 3. Mean CV ROC AUC on the training data, for the plain tree and for your pipeline
plain_auc = None
engineered_auc = None
جارٍ تحميل المحرّر…

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

(لا مخرجات)

الفحوص

  • تضيف add_features العمودين الجديدين كليهما
  • يحمل العمودان الجديدان القيم الصحيحة
  • X_train نفسها لم تُعدَّل
  • pipe هو FunctionTransformer ← DecisionTreeClassifier
  • حُسبت نتيجتا التحقق المتقاطع، والشجرة المهندَسة تتفوّق على العادية