EduFlow

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

الـ Pipelines وColumnTransformer

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

الدرس في لمحة

ابنِ Pipeline واحدًا يملأ القيم المفقودة ويوحّد المقاييس ويطبّق one-hot encoding على الأعمدة الصحيحة باستخدام ColumnTransformer، فتُعاد المعالجة المسبقة داخل كل طيّة تحقق متقاطع وترافق النموذج أينما ذهب.

  1. 1الـ Pipelines تجمع الخطوات في estimator واحد
  2. 2أعمدة مختلفة، ومعالجة مختلفة
  3. 3أعمدة أكثر، ونموذج أسوأ

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

ظلّ القسم 3 يترك بيانات على الطاولة. فـ country وsegment نصوص، ولا يقبلها أي نموذج في scikit-learn مباشرة. وavg_satisfaction فيه فجوات يرفضها الانحدار اللوجستي. وكنت توحّد المقاييس يدويًا، متتبّعًا X_train_s وX_test_s. كل خطوة من هذه الخطوات يجب أن تُتعلَّم من بيانات التدريب وحدها، وتُطبَّق بالطريقة نفسها على البيانات الجديدة، وتُعاد داخل كل طيّة تحقق متقاطع. وفعل ذلك يدويًا هو الطريقة التي يحدث بها التسرّب والأخطاء. الـ pipeline يفعله نيابةً عنك.

الـ Pipelines تجمع الخطوات في estimator واحد

الـ Pipeline قائمة من الخطوات: أي عدد من المحوّلات (transformers) (كائنات لها fit وtransform، مثل StandardScaler) يليها estimator نهائي واحد. ويتصرّف الـ pipeline كنموذج واحد:

  • يدرّب pipe.fit(X, y) المحوّل الأول، ويحوّل البيانات، ويمرّرها إلى الخطوة التالية، وهكذا، ثم يدرّب النموذج على الناتج.
  • يمرّر pipe.predict(X_new) البيانات X_new عبر المحوّلات المدرَّبة مسبقًا، ثم يتنبأ.

استخدمت make_pipeline، التي تسمّي الخطوات تلقائيًا ("standardscaler" و"logisticregression"). أما Pipeline([("prep", ...), ("model", ...)]) فيتيح لك اختيار الأسماء، وستحتاج إلى ذلك في الدرس القادم حين تضبط المعاملات بأسمائها.

المكسب الحقيقي يظهر في التحقق المتقاطع. تستنسخ cross_validate الـ pipeline بأكمله وتعيد تدريبه في كل طيّة، فتُتعلَّم متوسطات الـ scaler ووسيطات الـ imputer وقائمة فئات الـ encoder من صفوف تدريب تلك الطيّة وحدها.

أعمدة مختلفة، ومعالجة مختلفة

ليس كل عمود بحاجة إلى المعالجة المسبقة نفسها. يوجّه ColumnTransformer كل مجموعة من الأعمدة عبر محوّلها الخاص، ثم يلصق المخرجات جنبًا إلى جنب.

ينقسم جدول الانقطاع الخام إلى ثلاث مجموعات من الأعمدة. تمرّ الأعمدة الرقمية الثمانية عبر StandardScaler. ويمرّ avg_satisfaction عبر SimpleImputer مع مؤشّر، ثم StandardScaler. ويمرّ country وsegment عبر OneHotEncoder. تُضمّ المخرجات في 21 عمودًا وتُغذّى إلى LogisticRegression. الجدول الخام 11 عمودًا 8 numeric StandardScaler avg_satisfaction SimpleImputer + مؤشّر، ثم scaler country, segment OneHotEncoder 21 عمودًا LogisticRegression
ColumnTransformer داخل Pipeline

ثلاثة محوّلات تقوم بالعمل:

  • OneHotEncoder ينشئ عمود 0/1 لكل فئة: country_Egypt وcountry_Germany وهكذا. اضبط handle_unknown="ignore"؛ فالقيمة الافتراضية ترفع خطأً في أول مرة يصل فيها عميل من دولة لم تتضمّنها بيانات التدريب، وهذا يعني في بيئة الإنتاج خدمة تنبؤ منهارة.
  • SimpleImputer يملأ القيم المفقودة بالمتوسط أو الوسيط أو القيمة الأكثر تكرارًا أو قيمة ثابتة، متعلَّمة من بيانات التدريب. ومع add_indicator=True يضيف أيضًا عمودًا قيمته 1 حيث كانت القيمة مفقودة. نصف عملاء Cartwheel لم يجيبوا عن الاستبيان قط، وقد تكون هذه الحقيقة أهم من الدرجة نفسها.
  • StandardScaler، كما سبق. ويتيح make_pipeline صغير لمجموعة أعمدة واحدة أن تمرّ عبر خطوتين بالترتيب.
Python
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

churn = pd.read_csv("churn.csv")
X = churn.drop(columns=["customer_id", "churned"])
y = churn["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

numeric = ["tenure_days", "orders", "total_spent", "avg_order_value",
           "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
preprocess = ColumnTransformer([
    ("num", StandardScaler(), numeric),
    ("sat", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()),
     ["avg_satisfaction"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["country", "segment"]),
])
pipe = Pipeline([("prep", preprocess), ("model", LogisticRegression())])

pipe.fit(X_train, y_train)
names = pipe.named_steps["prep"].get_feature_names_out()
print(len(names), "model inputs, e.g.", list(names[8:12]))

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
print("all columns  ROC AUC:", cross_val_score(pipe, X_train, y_train, cv=cv, scoring="roc_auc").mean().round(3))
numeric_only = make_pipeline(StandardScaler(), LogisticRegression())
print("numeric only ROC AUC:", cross_val_score(numeric_only, X_train[numeric], y_train, cv=cv, scoring="roc_auc").mean().round(3))

يقبل الـ pipeline الـ DataFrame الخام، بما فيه من نصوص وفجوات، وينتج 21 مُدخلًا للنموذج: ثمانية أرقام موحّدة المقاييس، ودرجة الرضا المملوءة ومؤشّر غيابها، وثمانية أعمدة للدول وثلاثة للشرائح. وتسردها get_feature_names_out مع بادئة تسمّي المحوّل الذي أنتج كلًّا منها، وهكذا تطابق المعاملات بالأعمدة لاحقًا.

أعمدة أكثر، ونموذج أسوأ

ثم تأتي المفاجأة: الـ pipeline الكامل يرتّب العملاء أسوأ من الأعمدة الرقمية الثمانية وحدها، 0.776 مقابل 0.807. أحد عشر عمود one-hot ودرجة رضا نصف قيمها مملوءة تعطي الانحدار اللوجستي معاملات أكثر ليلائمها بـ 298 صفًا فقط، وعلى هذه البيانات تحمل الأعمدة الإضافية ضجيجًا أكثر من الإشارة. صحيح أن نسبة الانقطاع في مصر تبدو أعلى في بيانات التدريب، لكن مع 44 عميلًا مصريًا في التدريب، هذا أضعف من أن يُراهَن عليه.

هذا ليس فشلًا للـ pipeline. بل هو الـ pipeline يؤدي عمله: جعل اختبار الأعمدة الإضافية تغييرًا من خمسة أسطر، وأعطى التحقق المتقاطع إجابة واضحة. يبقى النموذج الرقمي البطل حاليًا. والأعمدة الفئوية تستحق العودة إليها حين يصبح لدى Cartwheel بضعة آلاف من العملاء، أو مع regularization أقوى.

ويعطي الـ pipeline أيضًا كل إعداد عنوانًا: model__C هو C في الخطوة المسمّاة model، وprep__sat__simpleimputer__strategy يصل إلى الـ imputer. يستخدم الدرس القادم هذه العناوين للبحث عن أفضل الإعدادات تلقائيًا.

أهم النقاط

  • يسلسل الـ Pipeline محوّلات (transformers) ونموذجًا نهائيًا في estimator واحد: يدرّب fit كل خطوة بالترتيب، ويمرّر predict البيانات الجديدة عبر الخطوات نفسها.
  • يطبّق ColumnTransformer معالجة مسبقة مختلفة على أعمدة مختلفة ثم يضمّ النتائج جنبًا إلى جنب.
  • يحوّل OneHotEncoder(handle_unknown="ignore") الفئات إلى أعمدة 0/1 ولا ينهار أمام فئة لم يرَها في التدريب.
  • يملأ SimpleImputer(add_indicator=True) الفجوات ويضيف عمودًا يسجّل القيم التي كانت مفقودة.
  • لأن الـ pipeline كله يُعاد تدريبه في كل طيّة، لا تتعلّم المعالجة المسبقة إلا من صفوف التدريب، وهذا يُبقي التحقق المتقاطع أمينًا.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

أثناء cross_validate(pipe, X_train, y_train, cv=5)، من أين يحصل الـ StandardScaler داخل pipe على متوسطاته؟

السؤال 2 من 4

يصل عميل من فرنسا (غير موجودة في التدريب) إلى pipeline يستخدم فيه OneHotEncoder القيمة الافتراضية لـ handle_unknown. ماذا يحدث؟

السؤال 3 من 4

لماذا نستخدم SimpleImputer(strategy="median", add_indicator=True) مع avg_satisfaction؟

السؤال 4 من 4

إضافة country وsegment إلى pipeline الانحدار اللوجستي تخفض الـ ROC AUC بالتحقق المتقاطع من 0.807 إلى 0.776. ما أفضل تفسير؟

تدريب

تمرين Python

pipeline يصمد أمام البيانات الحقيقية

اجعل الـ pipeline المبدئي يتعامل مع الفجوات والمفاجآت.

  1. preprocess: وحّد مقاييس الأعمدة numeric؛ وبالنسبة إلى avg_satisfaction، املأ القيم المفقودة بالوسيط وأضف مؤشّر غياب، ثم وحّد المقياس؛ وطبّق one-hot encoding على segment بحيث تُتجاهل الفئات غير المرئية بدل رفع خطأ.
  2. pipe: خطوتان باسمَي "prep" و"model" (نموذج LogisticRegression)، مدرَّب على بيانات التدريب. يجب أن ينتج 13 مُدخلًا للنموذج.
  3. cv_auc: متوسط الـ ROC AUC من cross_val_score باستخدام cv المعطى.
  4. new_prob: احتمال انقطاع new_customer، الذي يأتي من دولة غير مرئية وشريحة غير مرئية وليست لديه درجة رضا.

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

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

churn = pd.read_csv("churn.csv")
X = churn.drop(columns=["customer_id", "churned"])
y = churn["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
numeric = ["tenure_days", "orders", "total_spent", "avg_order_value",
           "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]

# A customer the training data never prepared you for
new_customer = pd.DataFrame([{
    "country": "France", "segment": "government", "tenure_days": 200, "orders": 2,
    "total_spent": 150.0, "avg_order_value": 75.0, "days_since_last_order": 100,
    "used_coupon": 0, "support_tickets": 0, "avg_satisfaction": np.nan, "mobile_share": 0.5}])

# 1. Preprocess: scale `numeric`; impute (median, with indicator) then scale avg_satisfaction;
#    one-hot encode `segment` so unseen categories don't crash
preprocess = ColumnTransformer([
    ("num", StandardScaler(), numeric),
    ("sat", StandardScaler(), ["avg_satisfaction"]),
    ("cat", OneHotEncoder(), ["segment"]),
])

# 2. Pipeline with steps named "prep" and "model" (LogisticRegression), fitted on the training data
pipe = Pipeline([("prep", preprocess), ("model", LogisticRegression())])

# 3. Mean cross-validated ROC AUC on the training data
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_auc = None

# 4. Churn probability for new_customer
new_prob = None
جارٍ تحميل المحرّر…

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

(لا مخرجات)

الفحوص

  • في pipe خطوتان باسمَي prep وmodel
  • ينتج المعالج المسبق المدرَّب 13 مُدخلًا
  • cv_auc هو متوسط الـ ROC AUC بالتحقق المتقاطع
  • new_prob احتمال صالح للعميل غير المرئي