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.
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:
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:
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:
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
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
| Format | Good for | Watch out for |
|---|---|---|
joblib | scikit-learn, same environment | version-tied, executes code |
pickle | any Python object | slower on big arrays, executes code |
ONNX | moving to another language or runtime | conversion is not always exact |
skops | sharing untrusted models | newer, smaller ecosystem |
For most projects joblib plus a pinned requirements.txt is the whole answer.
Test yourself
2 questionsWhy 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.
Where It Goes Wrong
The mistakes that make a model look far better than it is.