Chapters
Python114 chapters

Machine learningChapter 113 of 114

Saving and Using a Model

Get a trained model out of your notebook and into something that runs.

Training is not the deliverable

A trained model lives in memory. Close Python and it is gone. Saving it means you train once and predict for as long as it stays useful.

Python needs scikit-learn, downloaded on first run
import joblib
from sklearn.datasets import load_iris
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

X, y = load_iris(return_X_y=True)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)

joblib.dump(model, "iris.joblib")

loaded = joblib.load("iris.joblib")
print(loaded.predict([[5.1, 3.5, 1.4, 0.2]])[0])
print(loaded.score(X, y) == model.score(X, y))

Output

0
True

joblib is the one to use for scikit-learn. It handles the large numpy arrays inside a model more efficiently than plain pickle, and the interface is the same two functions.

Save the pipeline, not the estimator

This is the mistake that produces a model which works in your notebook and nowhere else:

Python needs scikit-learn, downloaded on first run
import joblib
from sklearn.datasets import load_iris
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

X, y = load_iris(return_X_y=True)

# wrong: the scaler is left behind
scaler = StandardScaler().fit(X)
bare = LogisticRegression(max_iter=1000).fit(scaler.transform(X), y)
joblib.dump(bare, "bare.joblib")

# right: everything the model needs travels with it
whole = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)
joblib.dump(whole, "whole.joblib")

flower = [[5.1, 3.5, 1.4, 0.2]]
print("bare model on raw input: ", joblib.load("bare.joblib").predict(flower)[0])
print("pipeline on raw input:   ", joblib.load("whole.joblib").predict(flower)[0])

Output

bare model on raw input:  1
pipeline on raw input:    0

The bare model got it wrong. It expects scaled input, the caller has no idea, and nothing raises — it just quietly answers incorrectly. The pipeline scales first because the scaler is part of what was saved.

Save what you will need to know

A model file alone does not tell you what it is. Save the surrounding facts with it:

Python needs scikit-learn, downloaded on first run
import joblib
import sklearn
from sklearn.datasets import load_iris
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

data = load_iris()
model = make_pipeline(
    StandardScaler(), LogisticRegression(max_iter=1000)
).fit(data.data, data.target)

bundle = {
    "model": model,
    "features": list(data.feature_names),
    "classes": [str(name) for name in data.target_names],
    "sklearn_version": sklearn.__version__,
    "trained_on_rows": len(data.data),
}
joblib.dump(bundle, "bundle.joblib")

loaded = joblib.load("bundle.joblib")
print(loaded["classes"])
print(loaded["features"][2])
print(loaded["trained_on_rows"])

Output

['setosa', 'versicolor', 'virginica']
petal length (cm)
150

Six months later, the column order is the thing you will wish you had written down. Feeding the features in the wrong order produces confident nonsense.

Predicting for real

Inference code is small, and its job is mostly to check its input:

Python needs pandas, scikit-learn, downloaded on first run
import joblib
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

data = load_iris()

# fit on a DataFrame, because that is what predict() will be given. Training on
# a bare array and predicting with named columns warns about the mismatch.
frame = pd.DataFrame(data.data, columns=data.feature_names)
model = make_pipeline(
    StandardScaler(), LogisticRegression(max_iter=1000)
).fit(frame, data.target)

joblib.dump({"model": model, "features": list(data.feature_names),
             "classes": [str(n) for n in data.target_names]}, "iris.joblib")


def predict(rows):
    bundle = joblib.load("iris.joblib")
    frame = pd.DataFrame(rows)

    missing = set(bundle["features"]) - set(frame.columns)
    if missing:
        raise ValueError(f"missing features: {sorted(missing)}")

    ordered = frame[bundle["features"]]
    chances = bundle["model"].predict_proba(ordered)
    return [
        {"species": bundle["classes"][row.argmax()], "confidence": round(float(row.max()), 2)}
        for row in chances
    ]


print(predict([{
    "sepal length (cm)": 5.1, "sepal width (cm)": 3.5,
    "petal length (cm)": 1.4, "petal width (cm)": 0.2,
}]))

try:
    predict([{"sepal length (cm)": 5.1}])
except ValueError as problem:
    print("ValueError:", problem)

Output

[{'species': 'setosa', 'confidence': 0.98}]
ValueError: missing features: ['petal length (cm)', 'petal width (cm)', 'sepal width (cm)']

Reordering by name rather than trusting the caller's column order removes an entire category of silent failure. Returning the confidence lets whoever calls you decide when to escalate to a person.

Versions matter

Python needs scikit-learn, downloaded on first run
import sklearn

print(isinstance(sklearn.__version__, str))
print(len(sklearn.__version__.split(".")) >= 2)

Output

True
True

A model pickled by one scikit-learn version may warn, misbehave or refuse to load under another. Pin the version alongside the model file, and retrain rather than fight it when you upgrade.

Formats

FormatGood forWatch out for
joblibscikit-learn, same environmentversion-tied, executes code
pickleany Python objectslower on big arrays, executes code
ONNXmoving to another language or runtimeconversion is not always exact
skopssharing untrusted modelsnewer, smaller ecosystem

For most projects joblib plus a pinned requirements.txt is the whole answer.

Test yourself

2 questions

Why save the whole pipeline rather than just the estimator?

Show the answer

The preprocessing has to travel with it, or predictions are silently wrong — A bare model expects preprocessed input, the caller has no idea, and nothing raises.

Why is joblib.load dangerous on a file you did not create?

Show the answer

Loading executes code inside the file — It is exactly as dangerous as running a downloaded script.

Next chapter

Where It Goes Wrong

The mistakes that make a model look far better than it is.