الوحدة 4 — التوقّع الفرديّ وبالدفعات
في هذه الوحدة نستعمل النموذج المُحمَّل في الوحدة السابقة لإنتاج تنبّؤ فعليّ. سنبني مسارَين: واحد يُعالج مشتركًا واحدًا (زمن الاستجابة ملك)، وآخر يُعالج ألف مشترك دفعة واحدة (الإنتاجيّة ملكة). ونعالج فخًّا صامتًا يقتل نصف مشاريع MLOps: انحراف المعالجة القبليّة بين خطّ التدريب وخطّ الخدمة.
المسار الفرديّ: بسيط لكنّه دقيق
بناءً على مخطّط ChurnRequest من الوحدة 2:
import uuid
import numpy as np
from fastapi import FastAPI
@app.post("/predict", response_model=ChurnResponse)
def predire(req: ChurnRequest) -> ChurnResponse:
modele = app.state.model
# ترميز المتغيّرات الفئويّة بنفس ترتيب التدريب
x = np.array([[
req.tenure_months,
req.monthly_charges,
{"month-to-month": 0, "one-year": 1, "two-year": 2}[req.contract],
{"electronic-check": 0, "mailed-check": 1,
"bank-transfer": 2, "credit-card": 3}[req.payment_method],
int(req.fiber_optic),
int(req.online_security),
]])
proba = float(modele.predict_proba(x)[0][1])
return ChurnResponse(
churn_probability=round(proba, 4),
churn_predicted=proba >= 0.5,
model_version=app.state.model_version,
request_id=str(uuid.uuid4()),
)
نلاحظ أنّ الترميز يحدث يدويًّا في المسار. هذا العمل المُعرّض للخطأ. الحلّ الأنيق أدناه.
Pipeline scikit-learn: علاج انحراف المعالجة
الفخّ الكلاسيكيّ: أثناء التدريب يُدوَّن OneHotEncoder وسط الشيفرة، ثمّ يُحفَظ النموذج دون المُرمِّز. في الخدمة، المطوّر يُعيد الترميز يدويًّا فيرتكب خطأ ترتيب فئات أو خطأ استبعاد. النتيجة: النموذج يستقبل مصفوفة بأعمدة بديلة، ويُعطي احتمالات لا معنى لها. الأداء يتدهور بلا خطأ فنّيّ ظاهر. هذا prétraitement divergent الذي يُذكر في كلّ قائمة أخطاء MLOps المشهورة.
الحلّ الوحيد الآمن: حفظ الأنبوب الكامل (المعالجة + النموذج) ككائن واحد:
# script d'entrainement (offline)
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.pipeline import Pipeline
from lightgbm import LGBMClassifier
import joblib
pretraitement = ColumnTransformer([
("num", StandardScaler(), ["tenure_months", "monthly_charges"]),
("cat", OneHotEncoder(handle_unknown="ignore"),
["contract", "payment_method"]),
], remainder="passthrough")
pipe = Pipeline([
("prep", pretraitement),
("clf", LGBMClassifier(n_estimators=500, random_state=42)),
])
pipe.fit(X_train, y_train)
joblib.dump(pipe, "churn_pipeline.joblib")
في الخدمة، نُحمّل الأنبوب فقط ونمرّر قاموسًا (أو DataFrame) بأسماء الأعمدة، فيتولّى الأنبوب الترميز:
import pandas as pd
@app.post("/predict", response_model=ChurnResponse)
def predire(req: ChurnRequest) -> ChurnResponse:
df = pd.DataFrame([req.model_dump()])
proba = float(app.state.model.predict_proba(df)[0][1])
return ChurnResponse(
churn_probability=round(proba, 4),
churn_predicted=proba >= 0.5,
model_version=app.state.model_version,
request_id=str(uuid.uuid4()),
)
الآن إضافة فئة جديدة (payment_method="mobile-wallet") لا تكسر الخدمة صامتًا: handle_unknown="ignore" يمرّرها كمتّجه صفريّ في الترميز، والنموذج يُعيد تنبّؤًا بأداء منخفض متوقّع، لا تنبّؤًا خاطئًا مقنعًا.
المسار بالدفعات: الإنتاجيّة قبل زمن الاستجابة
خطّ تقرير ليليّ يُسجّل عشرة آلاف مشترك في الساعة. عشرة آلاف طلب HTTP فرديّ = عشرة آلاف رحلة شبكة + عشرة آلاف مرور في الأنبوب. الحلّ: مسار دفعات:
from typing import List
MAX_BATCH_SIZE = 1000
class BatchRequest(BaseModel):
subscribers: List[ChurnRequest] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE)
class BatchItem(BaseModel):
churn_probability: float
churn_predicted: bool
class BatchResponse(BaseModel):
predictions: List[BatchItem]
model_version: str
request_id: str
@app.post("/predict/batch", response_model=BatchResponse)
def predire_lot(req: BatchRequest) -> BatchResponse:
df = pd.DataFrame([s.model_dump() for s in req.subscribers])
probas = app.state.model.predict_proba(df)[:, 1] # مصفوفة كاملة
items = [
BatchItem(churn_probability=round(float(p), 4), churn_predicted=p >= 0.5)
for p in probas
]
return BatchResponse(
predictions=items,
model_version=app.state.model_version,
request_id=str(uuid.uuid4()),
)
النقاط الحاسمة:
max_length=MAX_BATCH_SIZEيمنع طلبًا خبيثًا أو خاطئًا بمليون سجلّ يُنهك الذاكرة. الحدّ يعتمد على الذاكرة المتوفّرة ومتوسّط زمن كلّ سجلّ. البدء بـ1000، ثمّ التعديل بعد قياس.- استدعاء واحد لـ
predict_probaعلى المصفوفة كاملة يستفيد من التمرير المتّجه (vectorization) الداخليّ. حلقةforتستدعي النموذج ألف مرّة أبطأ ×10 إلى ×30. - الاستجابة تُعيد تنبّؤات بنفس ترتيب المدخلات، فلا يحتاج العميل لمعرّف مقابل.
قياس الفارق
على نموذج LightGBM عاديّ وجهاز حديث، الأرقام النموذجيّة:
| الحالة | زمن كلّ سجلّ | الإنتاجيّة |
|---|---|---|
| 1000 طلب فرديّ (شبكة محلّيّة) | 8 ميلّي ثانية | 125 سجلّ/ثانية |
| مسار دفعة بـ1000 سجلّ | 0.4 ميلّي ثانية | 2 500 سجلّ/ثانية |
الفرق ×20. لأنبوب ليليّ، هذا يعني ساعة معالجة بدل يوم كامل.
متى نُبقي مسارَين
في مشغّل الاتّصالات، مساران يتعايشان:
/predict: تطبيق خدمة العملاء الذي يسأل عن مشترك واحد أثناء مكالمة. زمن الاستجابة أقلّ من 50 ميلّي ثانية إلزاميّ./predict/batch: تقرير ليليّ يسجّل قاعدة العملاء كلّها. الإنتاجيّة أهمّ من زمن الاستجابة الفرديّ.
مسار موحّد يُعقّد الشيفرة ولا يُبرَّر إلّا إذا كانت الاستعمالات متطابقة تمامًا.
اختبار سريع
def test_batch_max():
payload = {"subscribers": [{"tenure_months": 24, "monthly_charges": 79.5,
"contract": "month-to-month", "payment_method": "electronic-check",
"fiber_optic": True, "online_security": False}] * 1001}
r = client.post("/predict/batch", json=payload)
assert r.status_code == 422 # مخالفة max_length
الخلاصة
- الأنبوب scikit-learn يحفظ المعالجة والنموذج ككائن واحد، ويقضي على انحراف المعالجة القبليّة بين التدريب والخدمة.
- مسار الدفعات يفرض
max_lengthلمنع استنزاف الذاكرة، ويستدعيpredict_probaمرّة واحدة على المصفوفة كاملة. - التمرير المتّجه يعطي إنتاجيّة أعلى بـ20 ضعفًا من حلقة على السجلّات الفرديّة.
- المسار الفرديّ للاستخدام التفاعليّ الحيّ، ومسار الدفعات للتقارير الجماعيّة؛ التعايش أنظف من التوحيد.
قاعدة عمليّة قبل الانتقال: احسب دائمًا حدّ max_length لمسار الدفعات انطلاقًا من ذاكرة النموذج الفعليّة لا من رقم اعتباطيّ. جرِّب دفعة بحجم 100، ثمّ 500، ثمّ 1000 على بيانات إنتاج وقِس memory_profiler؛ ثبِّت الحدّ عند 70 % من السعة القصوى المرصودة، لا أكثر. أيّ عميل يرسل ما يتجاوز ذلك يحصل على 422 واضحة، وأنت تنام واثقًا أنّ خدمة الليل لن تسقط النموذج بسبب طلب واحد ضخم.