Skip to content

Saving and loading a model

Chapter 40 17 code blocks 3849 words

The code in this chapter uses these filesrun it on your own machine

The situation: you closed the laptop

You worked for three hours. You built a pipeline, tuned it, the result is good. Then you closed the laptop.

Tomorrow you open it and everything is gone. You train again.

That isn't even the worst case. The worst case: you saved the model, but saved it wrong, and you find out six months later, when it's already giving wrong answers in production.

This chapter measures those mistakes.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. maydon_m2 area in m², xonalar rooms, qavat floor, metro_km distance to the metro, qurilgan_yil year built, tuman district. The target ijaraga_berildi_30k means rented within 30 days.

python33 lines
import time
import joblib
import numpy as np
import pandas as pd
import sklearn
from pathlib import Path
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.read_csv(DATA / "uy_ijara.csv")
NUMERIC = ["maydon_m2", "xonalar", "qavat", "metro_km", "qurilgan_yil"]
TEXT = ["tuman"]
X = df[NUMERIC + TEXT]
y = df["ijaraga_berildi_30k"]
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

prep = ColumnTransformer([
    ("num", Pipeline([("imp", SimpleImputer(strategy="median")),
                      ("sc", StandardScaler())]), NUMERIC),
    ("text", OneHotEncoder(handle_unknown="ignore"), TEXT),
])
pipe = Pipeline([("prep", prep),
                 ("model", LogisticRegression(max_iter=2000))]).fit(Xtr, ytr)
original_pred = pipe.predict(Xte)

print(f"sklearn version   : {sklearn.__version__}")
print(f"Pipeline accuracy : {accuracy_score(yte, original_pred):.4f}")
print(f"Pipeline steps    : {[name for name, _ in pipe.steps]}")
Output
sklearn version   : 1.7.2
Pipeline accuracy : 0.7900
Pipeline steps    : ['prep', 'model']

Mistake one: saving only the model

The most common mistake is saving pipe.named_steps["model"]. The model feels like "the smart part", and the preparation feels like "just cleaning".

python8 lines
SAVE = ISH / "models"
SAVE.mkdir(parents=True, exist_ok=True)

joblib.dump(pipe, SAVE / "pipeline.joblib")
joblib.dump(pipe.named_steps["model"], SAVE / "model_only.joblib")

for name in ["pipeline.joblib", "model_only.joblib"]:
    print(f"  {name:<22} {(SAVE / name).stat().st_size:>7} bytes")
Output
  pipeline.joblib           3978 bytes
  model_only.joblib          975 bytes
python9 lines
model_only = joblib.load(SAVE / "model_only.joblib")
print("We give the bare model RAW data:")
try:
    model_only.predict(Xte)
    print("  no error")
except Exception as x:
    print(f"  {type(x).__name__}: {str(x)[:95]}")
print()
print("Good news: this error came out LOUD: the text column 'tuman' isn't numeric.")
Output
We give the bare model RAW data:
  ValueError: could not convert string to float: 'Olmazor'

Good news: this error came out LOUD: the text column 'tuman' isn't numeric.

But if the shape is right, the error stays SILENT

The error above saved us. Now let's look at this case: someone thinks "the model needs numbers" and prepares the columns themselves, but differently from the preparation used in training.

The shape is right, no error appears, the model returns an answer. And the answer is wrong.

python14 lines
raw_num = Xte[NUMERIC].fillna(Xte[NUMERIC].median())          # NO scaling
expected = model_only.n_features_in_
extra = np.zeros((len(raw_num), expected - len(NUMERIC)))     # zeros instead of OneHot
fake_input = np.hstack([raw_num.values, extra])

fake_pred = model_only.predict(fake_input)
agree = float((fake_pred == original_pred).mean())
print(f"Columns the model expects   : {expected}")
print(f"Was there an error          : no")
print(f"Agreement with right answer : {agree:.4f}")
print(f"Actual accuracy             : {accuracy_score(yte, fake_pred):.4f}")
print(f"Original pipeline accuracy  : {accuracy_score(yte, original_pred):.4f}")
print()
print("The model ran, answered, nobody saw an error, and the answers are broken.")
Output
Columns the model expects   : 13
Was there an error          : no
Agreement with right answer : 0.4433
Actual accuracy             : 0.4933
Original pipeline accuracy  : 0.7900

The model ran, answered, nobody saw an error, and the answers are broken.

The rule: what you save is the whole Pipeline, not the model. The preparation is an inseparable part of the model.

What a loaded pipeline survives, and what it doesn't

I was going to write in this section that "if the column order changes, the prediction breaks silently". I measured it.

python22 lines
loaded = joblib.load(SAVE / "pipeline.joblib")
tests = []

def attempt(name, frame):
    try:
        b = loaded.predict(frame)
        return {"case": name, "result": "worked",
                "agrees with original": f"{float((b == original_pred).mean()):.4f}"}
    except Exception as x:
        return {"case": name, "result": type(x).__name__,
                "agrees with original": "-"}

tests.append(attempt("original", Xte))
tests.append(attempt("columns shuffled", Xte[TEXT + NUMERIC]))
tests.append(attempt("numbers in reverse order", Xte[NUMERIC[::-1] + TEXT]))
tests.append(attempt("one column MISSING", Xte.drop(columns=["metro_km"])))
with_extra = Xte.copy()
with_extra["new_column"] = 1.0
tests.append(attempt("extra column added", with_extra))
renamed = Xte.rename(columns={"maydon_m2": "xonalar", "xonalar": "maydon_m2"})
tests.append(attempt("two column NAMES swapped", renamed))
show(pd.DataFrame(tests))
caseresultagrees with original
originalworked1.0000
columns shuffledworked1.0000
numbers in reverse orderworked1.0000
one column MISSINGValueError-
extra column addedworked1.0000
two column NAMES swappedworked0.4433

"Column order matters" is a myth; the real danger is elsewhere

Look at the table. I shuffled the columns, I reversed the numbers, and the prediction didn't change.

The reason: ColumnTransformer selects columns by name, not by position. So if you pass a DataFrame, the order doesn't matter.

A missing column is safe too: a ValueError comes out loud.

An extra column is safe too: it's ignored.

The real danger is the last row: if column NAMES get swapped, the code runs, no error appears, and the answers are broken. Because ColumnTransformer trusts the name: if the name is right, it has no idea the value inside is wrong.

So the protection has to be in a check, not in the name: the data contract from Chapter 38 (cleaning/contract.py) catches exactly this: area within 15..400, rooms within 1..10. If the names get swapped, the ranges break and the check shouts.

And one more thing: if you pass a numpy array instead of a DataFrame, the names are lost and order matters again. So pass a DataFrame in production.

A model is never saved alone: the manifest

A model file says nothing about itself. The questions you'll need answered six months from now: which version was it trained with? which columns does it expect? what was its result?

create this filemodels/manifest.py

python39 lines
"""Information saved together with the model."""
import json
import platform
from datetime import date
from pathlib import Path


def write_manifest(path, model, columns, metrics, note=""):
    """Writes `manifest.json` next to the model.

    Why it's needed: a `.joblib` file says nothing about itself. If the versions
    don't match, loading can break silently; the manifest shows that.
    """
    import sklearn
    import numpy
    record = {
        "created": str(date.today()),
        "python": platform.python_version(),
        "sklearn": sklearn.__version__,
        "numpy": numpy.__version__,
        "model_type": type(model).__name__,
        "expected_columns": list(columns),
        "metrics": metrics,
        "note": note,
    }
    Path(path).write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
    return record


def check_versions(manifest_path):
    """Does the current environment match the manifest. If not, a list of warnings."""
    import sklearn
    import numpy
    m = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
    warnings = []
    for name, now in [("sklearn", sklearn.__version__), ("numpy", numpy.__version__)]:
        if m.get(name) != now:
            warnings.append(f"{name}: model saved with {m.get(name)}, now {now}")
    return warnings
python14 lines
import sys
sys.path.insert(0, str(ISH))
from models.manifest import write_manifest, check_versions

record = write_manifest(
    SAVE / "manifest.json", pipe, NUMERIC + TEXT,
    {"accuracy": round(accuracy_score(yte, original_pred), 4),
     "test_size": int(len(yte))},
    note="uy_ijara.csv, target ijaraga_berildi_30k",
)
for k, v in record.items():
    print(f"  {k:<18} {v}")
print()
print(f"Version check: {check_versions(SAVE / 'manifest.json') or 'matches'}")
Output
  created            2026-10-13
  python             3.10.12
  sklearn            1.7.2
  numpy              2.2.6
  model_type         Pipeline
  expected_columns   ['maydon_m2', 'xonalar', 'qavat', 'metro_km', 'qurilgan_yil', 'tuman']
  metrics            {'accuracy': 0.79, 'test_size': 300}
  note               uy_ijara.csv, target ijaraga_berildi_30k

Version check: matches

CHECKING that saving worked

Writing the save code and saying "done" isn't enough. A file sitting on disk doesn't mean it loads correctly.

create this filemodels/round_trip.py

python18 lines
"""Checks that the save -> load -> predict chain isn't broken."""
import joblib
import numpy as np


def round_trip(model, X_sample, path):
    """Saves the model, loads it back, and checks the predictions match EXACTLY.

    This test runs before every deploy. It's cheap, but it catches every
    "I thought I saved it" kind of mistake.
    """
    before = model.predict(X_sample)
    joblib.dump(model, path)
    after = joblib.load(path).predict(X_sample)
    if not np.array_equal(before, after):
        diff = int((before != after).sum())
        raise AssertionError(f"save/load changed the predictions: {diff} rows differ")
    return len(X_sample)
python4 lines
from models.round_trip import round_trip

n = round_trip(pipe, Xte, SAVE / "pipeline.joblib")
print(f"Round trip: predictions identical on {n} rows: OK")
Output
Round trip: predictions identical on 300 rows: OK

Does the test catch a real mistake, though? The most common deploy mistake is the wrong artifact: an old model was left in the folder, and the new one never got written.

python17 lines
old_model = Pipeline([
    ("prep", prep),
    ("model", LogisticRegression(max_iter=2000, C=0.0005)),   # a different setting
]).fit(Xtr, ytr)
joblib.dump(old_model, SAVE / "old_artifact.joblib")

before = pipe.predict(Xte)
after = joblib.load(SAVE / "old_artifact.joblib").predict(Xte)
diff = int((before != after).sum())
print(f"Expected model accuracy : {accuracy_score(yte, before):.4f}")
print(f"Old model in the folder : {accuracy_score(yte, after):.4f}")
print(f"Rows that differ        : {diff} / {len(Xte)}")
print()
if diff:
    print("In this case the round trip raises AssertionError and the deploy stops.")
else:
    print("No difference: with this setting the two models gave the same answers.")
Output
Expected model accuracy : 0.7900
Old model in the folder : 0.7967
Rows that differ        : 12 / 300

In this case the round trip raises AssertionError and the deploy stops.

Wire the test into the deploy chain

Having round_trip written isn't enough: it has to run before every deploy.

In practice this is a CI stage: the model is built -> round trip -> the hash is recorded -> it's sent to the server. If the test fails, the chain stops and the broken model never reaches production.

The manual "I checked it" approach breaks at the first urgent fix.

Security: pickle runs code

joblib.load is code EXECUTION, not just reading a file

joblib and pickle execute the instructions inside the file. Loading a specially crafted .joblib file can run arbitrary code: delete your files, send off your keys, open a way into the server.

So the rules are strict:

  1. Only load models you built yourself. Don't open a .joblib that came from the internet, from Telegram, or from an unknown colleague.
  2. Never joblib.load a file a user uploaded. If your service accepts models, that's a straight remote code execution vulnerability.
  3. Restrict write access to model files. Whoever can replace the model file can run code on your server.
  4. Use ONNX in uncontrolled environments: it's only data, not code (below).
  5. Store the model file's hash and compare it before loading.

This isn't a theoretical risk. "Loading a model" is one of the most overlooked attack paths.

python11 lines
import hashlib

def file_hash(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()

digest = file_hash(SAVE / "pipeline.joblib")
print(f"pipeline.joblib SHA-256: {digest[:32]}...")
(SAVE / "pipeline.sha256").write_text(digest, encoding="utf-8")

expected = (SAVE / "pipeline.sha256").read_text(encoding="utf-8").strip()
print(f"Check before loading: {'matches' if file_hash(SAVE / 'pipeline.joblib') == expected else 'DOES NOT MATCH, DO NOT LOAD'}")
Output
pipeline.joblib SHA-256: cd2502ecadf97bf1779c049b0a226fb5...
Check before loading: matches

ONNX: no code, faster, portable

python17 lines
from skl2onnx import to_onnx
from skl2onnx.common.data_types import FloatTensorType, StringTensorType
import onnxruntime as ort

types = [(c, (StringTensorType if c in TEXT else FloatTensorType)([None, 1]))
         for c in NUMERIC + TEXT]
onx = to_onnx(pipe, initial_types=types,
              options={id(pipe.named_steps["model"]): {"zipmap": False}})
(SAVE / "pipeline.onnx").write_bytes(onx.SerializeToString())

session = ort.InferenceSession(str(SAVE / "pipeline.onnx"),
                               providers=["CPUExecutionProvider"])
inputs = {c: (Xte[c].values.reshape(-1, 1).astype(np.float32) if c in NUMERIC
              else Xte[c].values.reshape(-1, 1).astype(object))
          for c in NUMERIC + TEXT}
onnx_pred = session.run(None, inputs)[0].ravel()
print(f"ONNX and sklearn predictions agree: {float((onnx_pred == original_pred).mean()):.4f}")
Output
ONNX and sklearn predictions agree: 1.0000
python19 lines
one = {c: v[:1] for c, v in inputs.items()}
t0 = time.perf_counter()
for _ in range(300):
    session.run(None, one)
onnx_ms = (time.perf_counter() - t0) / 300 * 1000

one_df = Xte.iloc[:1]
t0 = time.perf_counter()
for _ in range(300):
    loaded.predict(one_df)
skl_ms = (time.perf_counter() - t0) / 300 * 1000

show(pd.DataFrame([
    {"format": "joblib (sklearn)", "size (bytes)": (SAVE / "pipeline.joblib").stat().st_size,
     "one prediction (ms)": round(skl_ms, 3), "runs code": "yes"},
    {"format": "ONNX", "size (bytes)": (SAVE / "pipeline.onnx").stat().st_size,
     "one prediction (ms)": round(onnx_ms, 3), "runs code": "no"},
]))
print(f"ONNX speed advantage: {skl_ms / onnx_ms:.0f}x")
Output
ONNX speed advantage: 298x
formatsize (bytes)one prediction (ms)runs code
joblib (sklearn)3 9782.464yes
ONNX1 4280.008no

Where the speed gap comes from: an honest explanation

ONNX came out noticeably faster for a single row, but that doesn't mean "ONNX does the math faster".

Most of the time isn't computation, it's overhead: on every call sklearn validates the DataFrame, ColumnTransformer splits the columns, and every stage runs its own checks. In ONNX that work has been compiled ahead of time into a single graph.

So the gap is biggest for single-row prediction, which is exactly what an API service does (Chapter 41). If you predict thousands of rows at once, the gap is much smaller.

When to use ONNX: a real-time API, mobile/browser, a non-Python environment, places where security matters.

When to use joblib: research, offline batch prediction, complex custom transformers (ONNX often doesn't support them).

What gets saved: the full list

python13 lines
show(pd.DataFrame([
    {"File": "pipeline.joblib", "What for": "the full Pipeline: preparation + model"},
    {"File": "manifest.json", "What for": "versions, columns, metrics, date"},
    {"File": "pipeline.sha256", "What for": "checking the file hasn't been replaced"},
    {"File": "pipeline.onnx", "What for": "a fast, code-free format for serving"},
    {"File": "DATASHEET.md", "What for": "Chapter 38: where the data came from"},
    {"File": "requirements.txt", "What for": "rebuilding the environment"},
]))
print()
print("All in one folder: ish_en/models/")
for p in sorted(SAVE.iterdir()):
    if p.is_file():
        print(f"  {p.name:<22} {p.stat().st_size:>7} bytes")
Output
All in one folder: ish_en/models/
  manifest.json              361 bytes
  manifest.py               1330 bytes
  model_only.joblib          975 bytes
  old_artifact.joblib       3978 bytes
  pipeline.joblib           3978 bytes
  pipeline.onnx             1428 bytes
  pipeline.sha256             64 bytes
  round_trip.py              659 bytes
FileWhat for
pipeline.joblibthe full Pipeline: preparation + model
manifest.jsonversions, columns, metrics, date
pipeline.sha256checking the file hasn't been replaced
pipeline.onnxa fast, code-free format for serving
DATASHEET.mdChapter 38: where the data came from
requirements.txtrebuilding the environment
python9 lines
environment = f"""scikit-learn=={sklearn.__version__}
numpy=={np.__version__}
pandas=={pd.__version__}
joblib=={joblib.__version__}
"""
(SAVE / "requirements.txt").write_text(environment, encoding="utf-8")
print(environment)
print("The versions are pinned EXACTLY (==), because sklearn's internal format changes")
print("between versions, and an old model can break silently on a new version.")
Output
scikit-learn==1.7.2
numpy==2.2.6
pandas==2.3.3
joblib==1.5.3

The versions are pinned EXACTLY (==), because sklearn's internal format changes
between versions, and an old model can break silently on a new version.

Connecting to the protocol

python7 lines
show(pd.DataFrame([
    {"Step": "37. Data collected", "Status": "done"},
    {"Step": "38. Dataset built", "Status": "done"},
    {"Step": "39. Model adapted", "Status": "done"},
    {"Step": "40. Model saved", "Status": "pipeline + manifest + hash + ONNX"},
    {"Step": "41. Serving", "Status": "next chapter: Django API"},
]))
StepStatus
37. Data collecteddone
38. Dataset builtdone
39. Model adapteddone
40. Model savedpipeline + manifest + hash + ONNX
41. Servingnext chapter: Django API

What we learned today

python11 lines
show(pd.DataFrame([
    {"Concept": "Save the whole Pipeline", "In one sentence": "Measured: the bare model silently gives wrong answers"},
    {"Concept": "Column order", "In one sentence": "Measured: it does NOT matter with a DataFrame, columns are picked by name"},
    {"Concept": "Missing column", "In one sentence": "A loud ValueError: safe"},
    {"Concept": "Swapped names", "In one sentence": "Measured: breaks SILENTLY, caught by the contract"},
    {"Concept": "Manifest", "In one sentence": "Version, columns, metrics sit next to the model"},
    {"Concept": "Round trip", "In one sentence": "Proves that saving worked instead of assuming it"},
    {"Concept": "pickle security", "In one sentence": "joblib.load runs code: never open a stranger's file"},
    {"Concept": "ONNX", "In one sentence": "Code-free, portable, much faster for one row"},
    {"Concept": "Pinning versions", "In one sentence": "With ==, because the internal format changes"},
]))
ConceptIn one sentence
Save the whole PipelineMeasured: the bare model silently gives wrong answers
Column orderMeasured: it does NOT matter with a DataFrame, columns are picked by name
Missing columnA loud ValueError: safe
Swapped namesMeasured: breaks SILENTLY, caught by the contract
ManifestVersion, columns, metrics sit next to the model
Round tripProves that saving worked instead of assuming it
pickle securityjoblib.load runs code: never open a stranger's file
ONNXCode-free, portable, much faster for one row
Pinning versionsWith ==, because the internal format changes

In the next chapter we put this saved model into service: a Django API, validation, and returning errors properly.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents