CO6 Topic 23 of 25
Model packaging and deployment
Turn a trained model into something others can call. Save it safely, make it portable with ONNX, serve it with FastAPI, and choose between online and batch.
You will be able to
- Save and load a model with joblib, and know pickle's limits
- Convert a pipeline to ONNX
- Build a FastAPI service with /health and /predict
- Compare REST and gRPC
- Choose batch or online prediction
Read
- Reference book 2, Ch. 8, pp. 247–286; Ch. 9, pp. 287–318
- Reference book 4, Ch. 7, pp. 163–196; Ch. 8, pp. 197–228
Intuition
A model in a notebook helps nobody. Packaging freezes it in a file. Serving wraps it in an interface, so that a website, a batch job or another team can use it without knowing anything about scikit-learn.
How it works
| Format | Good | Limits |
|---|---|---|
| pickle / joblib | Easy; saves the whole Python pipeline | Loading runs code, so never load an untrusted file. Tied to library versions |
| ONNX | Portable, fast, works across languages | Conversion must support every step |
Save with the model: library versions, training data version, metrics, and the feature list.
Interfaces:
| REST (FastAPI) | gRPC | |
|---|---|---|
| Format | JSON, readable | Binary, compact |
| Speed | Good | Very low latency |
| Use | Web and general clients | Internal service to service |
Serving patterns: an online endpoint answers one request at a time, with low latency. A batch job scores a file on a schedule, and it's simpler and cheaper when immediacy doesn't matter. A container (Docker) packs the code and libraries, so that it runs the same everywhere.
Run it: package, load and batch score
The web server can't run in this page, but packaging and batch scoring can.
import io
import joblib
import numpy as np
import pandas as pd
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from placement_predict import FEATURES, load_data, preprocessor, train_val_test
df = load_data()
X_train, X_val, X_test, y_train, y_val, y_test = train_val_test(df)
model = make_pipeline(preprocessor(), LogisticRegression(max_iter=1000)).fit(X_train, y_train)
bundle = {"model": model, "features": FEATURES, "sklearn": sklearn.__version__, "val_accuracy": model.score(X_val, y_val)}
file = io.BytesIO()
joblib.dump(bundle, file)
print(f"Saved bundle: {file.tell() / 1024:.1f} KB, sklearn {bundle['sklearn']}")
file.seek(0)
loaded = joblib.load(file)
assert np.allclose(loaded["model"].predict_proba(X_val), model.predict_proba(X_val))
print("Loaded model gives identical predictions")
def batch_predict(bundle, rows):
"""batch_predict.py: score a whole table."""
p = bundle["model"].predict_proba(rows[bundle["features"]])[:, 1]
return rows.assign(placed_probability=p.round(3), prediction=(p >= 0.5).astype(int))
def end_to_end_check(bundle, data, n_batch=20):
sample = data.sample(n_batch, random_state=0)
out = batch_predict(bundle, sample)
assert out["prediction"].isin([0, 1]).all()
assert out["placed_probability"].between(0, 1).all()
print("Pipeline check PASSED")
scored = batch_predict(loaded, X_test.head(5))
print(scored[["Stream", "CGPA", "CodingTestScore", "placed_probability", "prediction"]].to_string(index=False))
end_to_end_check(loaded, X_test)
On your own computer: FastAPI
Install with python -m pip install fastapi uvicorn httpx, and save placement_predict.py beside your program. This version tests the service without opening a port. To run it for real, save it as app.py, and run uvicorn app:app --port 8000, leaving out the test client lines.
import pandas as pd
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from placement_predict import FEATURES, load_data, preprocessor, train_val_test
X_train, _, _, y_train, _, _ = train_val_test(load_data())
model = make_pipeline(preprocessor(), LogisticRegression(max_iter=1000)).fit(X_train, y_train)
class Student(BaseModel):
CollegeTier: str
Stream: str
Specialisation: str = "AI"
Hostel: str = "No"
HistoryOfBacklogs: str = "No"
CGPA: float = Field(ge=0, le=10)
AttendancePercent: float = Field(default=75, ge=0, le=100)
Internships: int = 0
Projects: int = 0
Workshops: float | None = None
Certifications: int = 0
Publications: int = 0
AptitudeTestScore: float | None = None
SoftSkillsRating: float | None = None
CodingTestScore: float | None = None
MockInterviewScore: float | None = None
ExtraCurricular: int = 0
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/predict")
def predict(student: Student):
row = pd.DataFrame([student.model_dump()]).reindex(columns=FEATURES)
p = float(model.predict_proba(row)[0, 1])
return {"placed_probability": round(p, 3), "prediction": int(p >= 0.5)}
client = TestClient(app)
print(client.get("/health").json())
good = {"CollegeTier": "Tier1", "Stream": "CS", "CGPA": 8.5, "AptitudeTestScore": 75, "CodingTestScore": 80}
print(client.post("/predict", json=good).json())
print("CGPA of 12 returns status", client.post("/predict", json={**good, "CGPA": 12}).status_code)
On your own computer: ONNX
Install with python -m pip install skl2onnx onnxruntime. The converter doesn't support our imputer for text columns, with NaN as its missing value, so this version uses the numeric features only. For a full pipeline, fill the gaps in Stream before the pipeline, and convert the rest.
import numpy as np
import onnxruntime as rt
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from placement_predict import NUMERIC, load_data, train_val_test
X_train, X_val, _, y_train, _, _ = train_val_test(load_data())
model = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression(max_iter=1000))
model.fit(X_train[NUMERIC], y_train)
onnx_model = convert_sklearn(model, initial_types=[("input", FloatTensorType([None, len(NUMERIC)]))],
options={"zipmap": False}, target_opset={"": 15, "ai.onnx.ml": 3})
session = rt.InferenceSession(onnx_model.SerializeToString())
rows = X_val[NUMERIC].head(50).to_numpy(dtype=np.float32)
probabilities = session.run(None, {"input": rows})[1][:, 1]
difference = np.abs(probabilities - model.predict_proba(X_val[NUMERIC].head(50))[:, 1]).max()
print(f"ONNX file is {len(onnx_model.SerializeToString()) / 1024:.1f} KB")
print(f"Largest difference from scikit-learn: {difference:.6f}")
Common mistakes
- Loading a pickle from an unknown source. It can run any code on your machine.
- Mismatched library versions. Record them, and pin them in the container.
- Having no
/healthendpoint or input validation. Deployments need both.
Problems
- Should these be batch or online? (a) A nightly dashboard. (b) An advice screen as a student types.
- Why is pickle unsafe for files from the internet?
- Skill 22: write
api_health_check(base_url, test_payloads). - Compare the size of a joblib file and an ONNX file for the same model.
- Write a short Dockerfile for the service.
Flashcards
Say the meaning to yourself, then select the card to check.
- Pickle danger
- Loading can run arbitrary code, so never load untrusted files.
- ONNX
- A portable model format, for use across languages and runtimes.
- FastAPI
- A Python framework for REST APIs, with validation from type hints.
- gRPC
- Binary, low-latency calls between services.