الوحدة 4 · العمل بالطريقة الصحيحة
هندسة الـ Features للتنبؤ بانقطاع العملاء
20 دقراءة 4 دتمرين Python
الدرس في لمحة
حوّل المعرفة بمجال العمل إلى معدّلات ونِسب وتحويلات لوغاريتمية، وضعها في FunctionTransformer داخل الـ pipeline، واحتفظ فقط بالـ features التي تتجاوز ضجيج التحقق المتقاطع.
- 1أفكار تأتي من مجال العمل
- 2الـ Features داخل الـ pipeline
- 3هل تساعد؟ اسأل التحقق المتقاطع
- 4ختام القسم: البطل
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
النماذج لا ترى إلا الأعمدة التي تعطيها إياها. وقائد فريق الدعم حين ينظر إلى عميل لا يفكّر “4 طلبات و600 يوم من العضوية”؛ بل يفكّر “طلب كل خمسة أشهر أو نحوها”. هذا المعدّل، لا أيٌّ من الرقمين الخامين، هو الفكرة المفيدة. وهندسة الـ features (feature engineering) تحوّل هذا النوع من المعرفة بمجال العمل إلى أعمدة، وفي البيانات الجدولية كثيرًا ما تساوي أكثر من أي قدر من الضبط.
أفكار تأتي من مجال العمل
features الانقطاع الجيدة تقع عادةً في بضع عائلات:
- المعدّلات: الطلبات لكل شهر عضوية، والتذاكر لكل طلب. الأعداد الخام تكبر مع مدة العضوية؛ أما المعدّلات فتقارن العملاء بإنصاف.
- النِّسب مقارنةً بعادات العميل نفسه: الأيام منذ آخر طلب مقسومة على الفجوة المعتادة بين طلباته. ثلاثون يومًا من الصمت مقلقة لمشترٍ أسبوعي، وطبيعية لمن يطلب مرتين في السنة.
- التحويلات:
log1pلأعمدة المال، الذي يضغط الذيول الطويلة نحو اليمين بحيث يُحسب الفرق بين 50 و150 دولارًا أكثر من الفرق بين 3,000 و3,100 دولار. - العلامات (flags): “لم يُجب عن الاستبيان قط”، و"لم يطلب في حياته إلا مرة واحدة".
كلٌّ من هذه يجب أن يكون قابلًا للحساب من معلومات متاحة في تاريخ اللقطة. المعدّل المبني من مدة العضوية والطلبات سليم؛ وأي شيء يختلس النظر إلى نافذة النتيجة هو التسرّب الذي تحدّثنا عنه في الدرس السابق.
الـ Features داخل الـ pipeline
اكتب كود الـ features مرة واحدة، كدالة من DataFrame إلى DataFrame، وغلّفها في FunctionTransformer. داخل الـ pipeline تعمل في التحقق المتقاطع، وفي التدريب النهائي، وفي بيئة الإنتاج، دائمًا بالطريقة نفسها.
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، وإلا استخدم صفوف التحقق بصمت.
هل تساعد؟ اسأل التحقق المتقاطع
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 المهندَسة بتحقق متقاطع أمين. ومعظمها لم يتفوّق على النموذج البسيط. لذا يبقى البطل هو الانحدار اللوجستي بعد توحيد المقاييس على ثمانية أعمدة رقمية، وقد حان الوقت لإنفاق بيانات الاختبار:
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 الجديد مكانه إلا إذا كان مكسبه في التحقق المتقاطع أكبر من التشتّت بين الطيّات.
- بعد الاختيار، أعد تدريب البطل على بيانات التدريب كلها وقيّمه مرة واحدة على بيانات الاختبار.