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

الوحدة 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 واضحة، وأنت تنام واثقًا أنّ خدمة الليل لن تسقط النموذج بسبب طلب واحد ضخم.