EduFlow

Section 5 · Beyond the Basics

Shipping a Model

18 min4 min readPython exercise

Lesson at a glance

Save the champion pipeline with joblib, wrap it in a validated prediction function, serve it as a nightly batch job or an API, and measure whether it changes business outcomes.

  1. 1Save the pipeline, not just the model
  2. 2Guard the inputs
  3. 3Batch job or API?
  4. 4Did it work? Run an experiment
  5. 5Where you are now

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

A model in a notebook helps nobody. To change what Cartwheel does, the champion pipeline has to run on fresh customer data, on a schedule or on request, and its scores have to reach the people who send the win-back offers. This lesson covers the last mile: saving the model, guarding its inputs, choosing between a batch job and an API, and proving it works.

Save the pipeline, not just the model

The thing to ship is the whole fitted pipeline. The scaler’s means and standard deviations are as much a part of the model as the coefficients; without them, production would scale new customers differently and every score would be off.

scikit-learn’s model persistence guide covers several formats (pickle, joblib, skops, ONNX); joblib is the most common when Python both saves and loads the model. Save a small bundle with what someone will need later: the pipeline, the exact feature list, a version label and the library version it was trained with.

Python
import os
import tempfile

import joblib
import pandas as pd
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
champion = make_pipeline(StandardScaler(), LogisticRegression()).fit(churn[features], churn["churned"])

bundle = {"model": champion, "features": features,
          "version": "churn-2025-09-30.1", "sklearn_version": sklearn.__version__}

with tempfile.TemporaryDirectory() as folder:
    path = os.path.join(folder, "churn_model.joblib")
    joblib.dump(bundle, path)
    print("saved", os.path.getsize(path), "bytes")

    loaded = joblib.load(path)
    same = (loaded["model"].predict_proba(churn[features]) == champion.predict_proba(churn[features])).all()
    print("identical predictions after reload:", same, "| trained with scikit-learn", loaded["sklearn_version"])

Notice the final model is fitted on all 398 labelled customers. The test set’s job was to measure the modelling process; once you’ve measured it, retraining on every labelled row gives the deployed model a little more to learn from. Record that you did it, and keep the reported metric from the test-set evaluation.

Guard the inputs

A model will happily score nonsense. A customer with orders = 0, a mobile_share of 7, or a missing column produces a probability that looks as confident as any other. Put a thin layer in front of predict_proba that checks the input and fails loudly:

Python
# assumes `import pandas as pd` and a loaded `bundle` as above
def predict_churn(record: dict, bundle: dict) -> float:
    missing = [f for f in bundle["features"] if f not in record]
    if missing:
        raise ValueError(f"missing fields: {missing}")
    if not 0 <= record["mobile_share"] <= 1:
        raise ValueError("mobile_share must be between 0 and 1")
    row = pd.DataFrame([record])[bundle["features"]]
    return float(bundle["model"].predict_proba(row)[0, 1])

Selecting [bundle["features"]] fixes the column order, which, as section 1 showed, scikit-learn insists on.

Batch job or API?

There are two common ways to put a model to work:

  • Batch scoring: a scheduled job (nightly or weekly) loads the model, scores every active customer from the warehouse and writes the probabilities to a table. Marketing’s tools read the table. Simple, cheap, easy to monitor, and right for churn, where a decision a day later costs nothing.
  • Online API: a web service scores one customer per request, in milliseconds. Needed when the decision happens during a user’s session, such as showing a retention offer on the cancellation page.
A training pipeline produces a versioned model file. The model file feeds either a nightly batch job that writes scores to a table for marketing, or an online API that answers single requests. Both log inputs and predictions to monitoring, which feeds back into retraining. training pipeline + CV model.joblib versioned nightly batch job scores → table online API one request, ms monitoring logs, drift retrain when monitoring says so
From training to decisions

If you do need an API, a small FastAPI service is a common choice in Python. This sketch isn’t runnable in the browser, but it’s complete enough to run locally with uvicorn app:app:

app.py
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field

# joblib uses pickle: load only model files from your own trusted, access-controlled store
bundle = joblib.load("churn_model.joblib")
app = FastAPI()

class Customer(BaseModel):
    tenure_days: int = Field(ge=0)
    orders: int = Field(ge=1)
    total_spent: float = Field(ge=0)
    avg_order_value: float = Field(ge=0)
    days_since_last_order: int = Field(ge=0)
    used_coupon: int = Field(ge=0, le=1)
    support_tickets: int = Field(ge=0)
    mobile_share: float = Field(ge=0, le=1)

@app.post("/predict")
def predict(customer: Customer):
    row = pd.DataFrame([customer.model_dump()])[bundle["features"]]
    prob = float(bundle["model"].predict_proba(row)[0, 1])
    return {"churn_probability": round(prob, 3), "model_version": bundle["version"]}

The Pydantic model does the input validation declaratively: a request with orders: 0 or a missing field gets a 422 error before the model ever sees it. Returning the model version with every answer makes it possible to trace any prediction back to the exact model that made it. Log every request and response; those logs are what the drift checks from the previous lesson run on.

Did it work? Run an experiment

A good ROC AUC means the model ranks customers well. It doesn’t mean the win-back offer changes anyone’s mind. To measure that, hold out a random slice of flagged customers (say 20%) who don’t get the offer. After 90 days, compare churn between the contacted and held-out groups. The difference is the effect of the offer on the customers the model picks, and multiplied by customer value it’s the number the business actually cares about. Without the held-out group, any drop in churn could be the season, a competitor’s stumble or luck.

Where you are now

You started with a question and a table, and you now have a process that holds up: frame the problem, split before you look, beat a majority-class dummy and a rule, choose metrics by the cost of each mistake, compare models with cross-validation inside leak-free pipelines, check who the model serves worse, and ship something you can monitor and test. The champion turned out to be the simplest serious model, and you know exactly why. That habit of asking “compared to what?” is the most valuable thing this course had to teach, and it applies unchanged to the next model you build, whatever it is.

Key takeaways

  • Save the whole fitted pipeline, not only the model, so preprocessing travels with it; store the feature list, a version and the scikit-learn version alongside.
  • joblib and pickle files can run code when loaded, so only load model files you created or trust, and load them with the same scikit-learn version.
  • [object Object]
  • Many business models, churn included, are best shipped as a scheduled batch job; an online API is for decisions that must happen during a request.
  • The real test of a model is a controlled experiment: compare outcomes for flagged customers who got the offer with a randomly held-out group who didn’t.

Further reading

Check your understanding

3 questions

Question 1 of 3

Why save the fitted pipeline rather than only the LogisticRegression step?

Question 2 of 3

A colleague emails you model.joblib from an unknown source and asks you to evaluate it. What’s the risk?

Question 3 of 3

Marketing contacts every customer the model flags, and churn among them drops from 79% to 60%. Can you credit the model and the offer?

Practice

Python exercise

Package the champion

Get the champion ready to ship.

  1. bundle: a dict with "model" (the fitted pipeline), "features" (the feature list), "version" (any string) and "sklearn_version" (sklearn.__version__).
  2. Save bundle with joblib.dump into the in-memory buffer, rewind it, and load it back into reloaded.
  3. Finish predict_churn(record, bundle): raise ValueError if any feature is missing, if orders is below 1, or if mobile_share is outside 0–1. Otherwise return the churn probability as a Python float, with columns in the bundle’s feature order (the record’s keys may come in any order).

Data files: churn.csv

import io

import joblib
import pandas as pd
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
champion = make_pipeline(StandardScaler(), LogisticRegression()).fit(churn[features], churn["churned"])

good = {"tenure_days": 400, "orders": 2, "total_spent": 180.0, "avg_order_value": 90.0,
        "days_since_last_order": 150, "used_coupon": 0, "support_tickets": 1, "mobile_share": 0.5}

# 1. A bundle with the fitted pipeline, the feature list, a version string and sklearn's version
bundle = {"model": champion}

# 2. Save it with joblib to an in-memory buffer, then load it back into `reloaded`
buffer = io.BytesIO()
reloaded = None

# 3. Validate, then predict. Raise ValueError if any feature is missing,
#    or if orders < 1, or if mobile_share is outside 0..1.
def predict_churn(record, bundle):
    row = pd.DataFrame([record])
    return bundle["model"].predict_proba(row)[0, 1]
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • bundle holds model, features, version and sklearn_version
  • reloaded predicts exactly like the original
  • predict_churn returns the right float, whatever the key order
  • A missing feature raises ValueError
  • orders below 1 or mobile_share outside 0–1 raise ValueError