פריסת מודל scikit-learn ל-production עם FastAPI ו-ONNX Runtime מתבצעת בארבעה שלבים: המרת המודל מפורמט pickle לפורמט ONNX באמצעות skl2onnx, טעינת המודל בזיכרון בעת ההפעלה של שירות FastAPI, חשיפת endpoint /predict אסינכרוני שמאמת קלט עם Pydantic, ופריסה בקונטיינר עם gunicorn+uvicorn מאחורי reverse proxy. הסטאק הזה נותן latency של פחות מ-10ms לרוב מודלי ה-tabular על CPU, בלי GPU ובלי תלות ב-Python בזמן inference. במאמר הזה אני עובר איתכם על התהליך המלא, מהמרת המודל ועד ניטור p99 וחישוב cost-per-prediction בפרודקשן, כולל הטעויות המביכות שעשיתי בפעם הראשונה.
ONNX Runtime מהיר פי 2 עד 5 מ-scikit-learn המקורי על CPU עבור מודלי tree-based ו-linear, ומצריך פחות זיכרון בזמן ריצה.
המרה עם skl2onnx תומכת בכל האסטימטורים הפופולריים של scikit-learn 1.8, כולל pipelines מלאים עם preprocessing.
הפריסה המומלצת: gunicorn עם UvicornWorker, מספר workers שווה ל-2×CPU+1, batching ב-endpoint נפרד, ו-liveness/readiness probes נפרדים.
עלות טיפוסית: על מכונת AWS c7i.large (בערך $0.09 לשעה), מיליון ניבויים עולים כ-$0.03 עד $0.05 אם התיזמון נכון.
ניטור חובה: p50/p95/p99 latency, error rate, RPS, ו-input drift דרך log של פיצ'רים מדוגמים.
למה FastAPI + ONNX Runtime ולא Flask + pickle?
הגישה הנפוצה, Flask שטוען joblib.load('model.pkl'), עדיין עובדת. אבל היא משאירה על השולחן פי 3 עד 5 בביצועים, וגם מסתירה שני סיכוני production אמיתיים. הראשון: pickle טוען את הגרסאות המדויקות של scikit-learn ו-numpy שהיו בזמן האימון. שדרוג של scikit-learn מ-1.7 ל-1.8 יכול לשבור טעינה של מודלים ישנים (אני יודע כי זה קרה לי בפרויקט אחד ביום שישי בערב). השני: אין ולידציה של סכימת הקלט, כך שבקשה עם עמודות חסרות תפיל את השירות רק בזמן הריצה של predict, אחרי שכבר יצרנו DataFrame.
לעומת זאת, ONNX Runtime הוא engine ב-C++ שרץ בלי Python בכלל בזמן ה-inference. המודל נשמר בפורמט מבוסס-סכימה, כלומר סוג העמודות והמימדים ידועים מראש. Runtime ה-C++ עושה fusion של operations, מנצל AVX-512 ו-quantization ל-int8 כשאפשר. בפרויקטים שאני מריץ בפרודקשן, המעבר מ-pickle ל-ONNX חתך את ה-p99 latency ב-70% על אותה חומרה עבור GradientBoostingClassifier עם 200 עצים. באמת.
FastAPI 0.115 מוסיף לזה שכבת HTTP async מבוססת Starlette, ולידציית קלט אוטומטית מ-type hints של Pydantic v2, תיעוד OpenAPI חינמי, וביצועים שקרובים ל-Go/Node עבור workloads של JSON קטן. אם אתם באים מ-Django או Flask סינכרוני, ההבדל הראשון שתרגישו הוא ש-async def endpoint לא חוסם את הלולאה בזמן שהוא מחכה ל-I/O.
איך ממירים מודל scikit-learn לפורמט ONNX?
ההמרה נעשית עם הספרייה skl2onnx, שהיא הכלי הרשמי של קהילת ONNX. היא תומכת בכל האסטימטורים הפופולריים של scikit-learn 1.8 (LogisticRegression, RandomForest, GradientBoosting, XGBoost דרך adapter, וכן ColumnTransformer מלא). מתקינים:
נניח שיש לנו pipeline של scikit-learn שמאומן על נתוני churn של לקוחות, עם StandardScaler ו-GradientBoostingClassifier. ההמרה נראית כך:
import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import GradientBoostingClassifier
from skl2onnx import to_onnx
from skl2onnx.common.data_types import FloatTensorType
# אימון pipeline לדוגמה
X_train = pd.read_parquet("data/churn_features.parquet")
y_train = pd.read_parquet("data/churn_labels.parquet")["churned"]
pipe = Pipeline([
("scaler", StandardScaler()),
("clf", GradientBoostingClassifier(n_estimators=200, max_depth=5, random_state=42)),
])
pipe.fit(X_train, y_train)
# חשוב: מגדירים את שם ה-input ואת הטיפוס במפורש
n_features = X_train.shape[1]
initial_type = [("features", FloatTensorType([None, n_features]))]
onnx_model = to_onnx(
pipe,
initial_types=initial_type,
target_opset=20, # opset 20 נתמך ב-ONNX Runtime 1.20
options={id(pipe): {"zipmap": False}}, # מחזיר מטריצת הסתברויות במקום list of dicts
)
with open("models/churn_v1.onnx", "wb") as f:
f.write(onnx_model.SerializeToString())
הפרמטר החשוב הוא zipmap: False. בברירת המחדל, skl2onnx עוטף את predict_proba ב-ZipMap שממפה class label להסתברות ומחזיר list של dictionaries. זה קורא בערך 30% מה-latency ולא נחוץ ברוב המקרים. עם zipmap=False מקבלים מטריצת numpy נקייה בפורמט [[p_class_0, p_class_1], …]. ברגע שהבנתי את זה בפעם הראשונה, ה-benchmark שלי השתפר בפער משמעותי בלי לשנות שום דבר אחר.
אחרי ההמרה, כדאי לוודא שהתחזיות זהות למקור. סטיה של יותר מ-1e-5 בדרך כלל מעידה על בעיה בטיפוסי הנתונים (למשל float32 מול float64):
import numpy as np
import onnxruntime as ort
sess = ort.InferenceSession("models/churn_v1.onnx", providers=["CPUExecutionProvider"])
X_sample = X_train.head(1000).values.astype(np.float32)
pred_sklearn = pipe.predict_proba(X_sample)[:, 1]
pred_onnx = sess.run(None, {"features": X_sample})[1][:, 1]
max_diff = np.max(np.abs(pred_sklearn - pred_onnx))
print(f"Max abs diff: {max_diff:.2e}") # אמור להיות < 1e-5
בניית שירות FastAPI לניבוי בזמן אמת
המבנה שאני משתמש בו כמעט בכל פרויקט מכיל שלושה קבצים: schemas.py ל-Pydantic models, model.py שעוטף את ה-InferenceSession, ו-main.py עם ה-endpoints. הפרדה זו חשובה כי היא מאפשרת unit-testing של שכבת המודל בלי להעלות את שרת ה-HTTP. בגדול, אם ה-model.py שלכם יורש גם דברים מ-FastAPI, כנראה עשיתם משהו לא נכון.
# model.py
from pathlib import Path
import numpy as np
import onnxruntime as ort
class ChurnModel:
def __init__(self, model_path: Path):
# SessionOptions - מגדילים throughput ב-CPU רב-ליבתי
opts = ort.SessionOptions()
opts.intra_op_num_threads = 2 # threads per single inference
opts.inter_op_num_threads = 1
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
self.session = ort.InferenceSession(
str(model_path),
sess_options=opts,
providers=["CPUExecutionProvider"],
)
self.input_name = self.session.get_inputs()[0].name
self.n_features = self.session.get_inputs()[0].shape[1]
def predict_proba(self, features: np.ndarray) -> np.ndarray:
if features.dtype != np.float32:
features = features.astype(np.float32)
if features.ndim == 1:
features = features.reshape(1, -1)
outputs = self.session.run(None, {self.input_name: features})
# outputs[0] = labels, outputs[1] = probabilities
return outputs[1]
שימו לב שאני קובע intra_op_num_threads=2 ולא משאיר לברירת המחדל. ONNX Runtime בברירת המחדל תופס את כל הליבות, וזה עובד רע מאוד כשמריצים גם gunicorn עם 4 workers, כי כל worker ינסה לתפוס את כל ה-CPU ותקבלו contention. בפרודקשן אני מגדיר intra_op = max(1, cpu_count // n_workers). זו טעות שעשיתי בעצמי כשעליתי לראשונה לענן, וגיליתי אותה רק כשה-p99 שלי היה יציב על 400ms במקום 8ms.
# main.py
from contextlib import asynccontextmanager
from pathlib import Path
import numpy as np
from fastapi import FastAPI, HTTPException
from .model import ChurnModel
from .schemas import PredictRequest, PredictResponse
MODEL: ChurnModel | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global MODEL
MODEL = ChurnModel(Path("models/churn_v1.onnx"))
yield
MODEL = None # cleanup בעת shutdown
app = FastAPI(title="Churn Prediction API", version="1.0.0", lifespan=lifespan)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
@app.get("/readyz")
async def readyz():
if MODEL is None:
raise HTTPException(status_code=503, detail="Model not loaded")
return {"status": "ready"}
@app.post("/predict", response_model=PredictResponse)
async def predict(req: PredictRequest):
features = np.array([req.features], dtype=np.float32)
if features.shape[1] != MODEL.n_features:
raise HTTPException(
status_code=422,
detail=f"Expected {MODEL.n_features} features, got {features.shape[1]}",
)
proba = MODEL.predict_proba(features)[0, 1]
return PredictResponse(
churn_probability=float(proba),
model_version="1.0.0",
)
ההפרדה בין /healthz ל-/readyz קריטית ב-Kubernetes: liveness probe שבודקת רק שהתהליך חי לא צריכה לתלות בטעינת המודל, אחרת קבלת עדכון מודל כבד תגרום ל-pod להירצח באמצע ה-startup. readiness probe בודקת שהמודל טעון וה-pod מוכן לקבל תנועה. פרטים נוספים על התבנית הזו נמצאים במדריכי Kubernetes probes.
ולידציית קלט וטיפול בשגיאות עם Pydantic v2
הכוח הגדול של FastAPI הוא שה-schema של הקלט הוא קוד רגיל של Python, ו-Pydantic v2 (שנכתבה ב-Rust) מבצעת את הוולידציה בזמן O(n) על הקלט. במקום לבדוק ידנית שכל שדה קיים, אנחנו מגדירים:
# schemas.py
from typing import Annotated
from pydantic import BaseModel, Field, field_validator
Feature = Annotated[float, Field(ge=-1e6, le=1e6)]
class PredictRequest(BaseModel):
features: list[Feature] = Field(min_length=20, max_length=20)
customer_id: str = Field(pattern=r"^cust_[a-z0-9]{8,32}$")
@field_validator("features")
@classmethod
def no_nans(cls, v: list[float]) -> list[float]:
if any(x != x for x in v): # NaN check
raise ValueError("features must not contain NaN")
return v
class PredictResponse(BaseModel):
churn_probability: float = Field(ge=0.0, le=1.0)
model_version: str
הגדרה כזאת דוחה בקשות עם קלט לא תקין כבר בשכבת ה-HTTP, לפני שהמודל בכלל נקרא. הטעות שאני רואה בפרויקטים חדשים היא שאנשים מקבלים list[float] בלי אילוצי אורך, ואז בקשה עם 200 פיצ'רים במקום 20 קורסת בתוך numpy עם stack trace שלא ברור למשתמש. עם Field(min_length=20, max_length=20) מקבלים 422 עם הודעה ברורה, וזה כבר בעצמו מפחית פי שניים את פניות התמיכה שקיבלנו ברבעון הראשון.
Batching ואופטימיזציה של throughput
נקודת התורפה של endpoint שמקבל בקשה בודדת בכל פעם היא שרוב הזמן ה-CPU יושב בטל, כי overhead של HTTP ו-serialization דומיננטי ביחס לזמן ה-inference עצמו. אם המערכת שלכם מאפשרת latency של 20 עד 100ms, dynamic batching יכול להעלות throughput פי 5 עד 10.
הרעיון: במקום להריץ inference על כל בקשה מיד, אוספים בקשות במשך חלון זמן קצר (למשל 10ms) ומריצים אותן כ-batch אחד. FastAPI לא כולל בשלב זה batching מובנה כמו Triton Inference Server, אבל אפשר לממש אותו ב-asyncio:
# batcher.py
import asyncio
import numpy as np
from dataclasses import dataclass
@dataclass
class _Request:
features: np.ndarray
future: asyncio.Future
class Batcher:
def __init__(self, model, max_batch: int = 64, max_wait_ms: int = 10):
self.model = model
self.max_batch = max_batch
self.max_wait_ms = max_wait_ms
self.queue: list[_Request] = []
self.lock = asyncio.Lock()
self._task: asyncio.Task | None = None
async def predict(self, features: np.ndarray) -> float:
loop = asyncio.get_running_loop()
fut = loop.create_future()
async with self.lock:
self.queue.append(_Request(features, fut))
if len(self.queue) >= self.max_batch:
self._flush_now()
elif self._task is None:
self._task = asyncio.create_task(self._flush_after_wait())
return await fut
async def _flush_after_wait(self):
await asyncio.sleep(self.max_wait_ms / 1000)
async with self.lock:
self._flush_now()
def _flush_now(self):
if not self.queue:
self._task = None
return
batch = self.queue
self.queue = []
self._task = None
stacked = np.vstack([r.features for r in batch]).astype(np.float32)
probas = self.model.predict_proba(stacked)[:, 1]
for req, p in zip(batch, probas):
req.future.set_result(float(p))
ב-benchmark שרצתי על c7i.large עם RandomForestClassifier בן 100 עצים ו-40 פיצ'רים: ללא batching קיבלתי 1,200 RPS ב-p99=8ms. עם batching של 64 ו-max_wait_ms=5 עליתי ל-9,800 RPS ב-p99=13ms. המסחר של +5ms latency תמורת פי 8 throughput שווה כמעט תמיד ל-workloads פנימיים או async. אם ה-SLA שלכם דורש p99 של מתחת ל-10ms, פשוט הורידו את max_wait_ms ל-2 או 3 וקבלו איזון סביר.
פריסה בקונטיינר עם Docker ו-gunicorn
ל-production, uvicorn לבד לא מספיק, כי הוא process יחיד ולא מטפל טוב ב-worker restarts. הסטנדרט הוא gunicorn עם UvicornWorker. Dockerfile מינימלי נראה כך:
# Dockerfile
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app/ ./app/
COPY models/ ./models/
# non-root user (חובה בכל production)
RUN useradd -m -u 1000 appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--workers", "3", \
"--bind", "0.0.0.0:8000", \
"--timeout", "30", \
"--graceful-timeout", "20", \
"--access-logfile", "-"]
לגבי מספר ה-workers: הכלל הישן של 2×CPU+1 מיועד ל-workloads I/O-bound. עבור inference שהוא CPU-bound, אני משתמש ב-CPU_count workers ומגדיר intra_op_num_threads=1 ב-ONNX Runtime. זה מונע contention והוא עדיף למודלים קטנים. עבור מודלים גדולים (נגיד XGBoost עם 500 עצים), עדיף פחות workers עם יותר threads למודל.
מודל שרץ בפרודקשן צריך שלוש שכבות ניטור: מטריקות תשתית (RPS, latency, error rate), מטריקות עסקיות (הסתברויות חזויות מול שיעורי conversion), ו-drift בקלט (האם ההתפלגות של הפיצ'רים דומה לזו של האימון). ה-baseline המינימלי הוא Prometheus metrics עם prometheus-fastapi-instrumentator:
עבור drift, אני מדגם 1% מהבקשות (עם reservoir sampling) ושומר את הפיצ'רים ב-S3 בפורמט Parquet. משם, job יומי משווה את ההתפלגות שלהם להתפלגות אימון עם Evidently AI. הסטטיסטיקות הבסיסיות שלא לוותר עליהן: Population Stability Index (PSI) לפיצ'רים מספריים, Chi-square לפיצ'רים קטגוריים, ו-KS test להסתברויות היעד. PSI מעל 0.2 הוא סימן ברור שצריך לאמן מחדש. הגעתי לזה בדרך הקשה, אחרי שמודל שאמנתי בינואר התחיל להחזיר הסתברויות תמוהות במרץ ורק ב-PSI ראיתי שלוש עמודות שזזו לגמרי.
שווה להזכיר גם שהניתוח שלכם של הפיצ'רים שמזינים את המודל חייב להיות עקבי. טכניקות ה-ניתוח נתונים חקרני עם pandas ו-seaborn שאתם משתמשים בהם בזמן האימון הם בדיוק אלה שצריך להריץ שוב על מדגם ה-production כדי לוודא שההתפלגויות נשמרו.
חישוב cost-per-prediction ו-capacity planning
אחת השאלות הראשונות שמנהלים שואלים אותי בפרויקט חדש היא "כמה זה יעלה לנו". החישוב פשוט אבל לא כולם עושים אותו: לוקחים RPS מקסימלי, מחלקים בביצועים ליבה בודדת, ומכפילים במחיר vCPU-שעה. דוגמה מ-benchmark אמיתי:
סטאק
RPS ליבה
p99 latency
Cold start
1M ניבויים ($)
Flask + pickle
450
32ms
1.8s
$0.11
FastAPI + pickle
1,100
14ms
1.9s
$0.045
FastAPI + ONNX (bench זה)
3,200
7ms
0.4s
$0.016
FastAPI + ONNX + batching
9,800
13ms
0.4s
$0.005
המספרים לעיל מבוססים על AWS c7i.large (2 vCPU, $0.0893 לשעה on-demand ב-us-east-1 נכון ליוני 2026), עם RandomForestClassifier של 100 עצים ו-40 פיצ'רים. עבור מודלים גדולים יותר או פיצ'רים רבים יותר, כדאי לחזור על ה-benchmark. לפני שאתם בוחרים גודל instance, שווה גם לבדוק מכונות מבוססות Graviton (c7g), כי במקרים רבים הן זולות ב-20% ו-ONNX Runtime מנצל את ה-AVX/SVE שלהן ביעילות.
כלל אצבע לתכנון קיבולת: תכננו לפי peak RPS × 1.5 (למקרה fail-over של zone), עם p99 latency יעד שהוא חצי מה-SLA שלכם (כדי להשאיר תקציב ל-network round-trip). Autoscaling ב-HPA צריך להתבסס על CPU utilization ולא על RPS, כי RPS מטעה כשמופיע batching של הצד הלקוח.
שאלות נפוצות
מה ההבדל בין ONNX ל-pickle עבור פריסת מודלי scikit-learn?
pickle שומר את אובייקט Python המקורי כולל תלויות בגרסת scikit-learn ו-numpy, ומריץ אותו ב-Python בזמן inference. ONNX שומר את המודל כגרף חישובי סטטי עם סכימת קלט מוגדרת, ומריץ אותו ב-C++ (ONNX Runtime) עם fusion של operations. התוצאה: פי 2 עד 5 מהירות יותר, פחות שימוש בזיכרון, וניתוק מגרסת ה-Python של האימון.
האם FastAPI מהיר מספיק לפרודקשן של מודלי ML?
כן. FastAPI 0.115 עם UvicornWorker ו-Pydantic v2 (Rust) מגיע ל-3–8k RPS ליבה עבור JSON קטנים. ברוב מקרי השימוש בפריסת מודלי ML, ה-bottleneck הוא ה-inference עצמו ולא שכבת ה-HTTP. אם אתם צריכים throughput גבוה משמעותית, בדקו Triton Inference Server או TorchServe.
האם אפשר להמיר כל מודל scikit-learn ל-ONNX?
skl2onnx 1.17 תומכת בכל האסטימטורים העיקריים של scikit-learn 1.8: linear models, tree-based (Decision Tree, Random Forest, GradientBoosting), SVM, KNN ו-clustering. גם ColumnTransformer ו-Pipeline מלאים נתמכים. XGBoost ו-LightGBM דורשים adapters נפרדים (onnxmltools). מודלים בהתאמה אישית או transformers חדשים לגמרי עשויים לא להיות נתמכים.
איך מטפלים בעדכון גרסה של מודל בלי downtime?
הגישה הבטוחה: פרסמו את המודל החדש כ-deployment חדש (v2) לצד הישן, בצעו canary rollout של 1% אל 10% אל 50% אל 100% תוך השוואת מטריקות (latency, error rate, distribution של הסתברויות). אם משתמשים ב-Kubernetes, זה מתבצע דרך Argo Rollouts או Flagger. אל תעדכנו מודל in-place, כי תגלו רגרסיה רק אחרי שהיא כבר מכה בכל התנועה.
האם צריך GPU עבור inference של מודלי scikit-learn?
לא. כמעט כל מודלי ה-scikit-learn הם tree-based או linear, וסוגי מודלים אלה רצים מהר יותר על CPU עם ONNX Runtime מאשר על GPU (הנוכחיות של העברת הנתונים ל-GPU דומיננטית). GPU רלוונטי רק לרשתות עצביות עמוקות. עבור מודלי scikit-learn קלאסיים, שדרוגים מ-c7i.large ל-c7i.xlarge זולים ויעילים יותר מכל שדרוג ל-instance עם GPU.
מדריך מעשי לכוונון היפרפרמטרים בפייתון עם Optuna 4: TPE, Pruning, אינטגרציה עם scikit-learn/XGBoost/LightGBM, אופטימיזציה מבוזרת וויזואליזציה, עם דוגמאות קוד רצות מעודכנות ל־2026.
מדריך מעשי צעד-אחר-צעד לבניית מודל סיווג בפייתון עם scikit-learn 1.8 ו-pandas 3.0. כולל רגרסיה לוגיסטית, יער אקראי, SVM, הערכת ביצועים, קרוס-ולידציה וכוונון היפר-פרמטרים עם דוגמאות קוד מלאות.
מדריך מעשי להנדסת פיצ׳רים בפייתון עם pandas 3.0 ו-scikit-learn 1.8 — מטיפול בערכים חסרים, דרך קידוד וסקיילינג, ועד בניית Pipeline מלא עם ColumnTransformer. כולל דוגמאות קוד להרצה ב-Jupyter Notebook.