The Model Works on Your Laptop and Dies With ModuleNotFoundError on the Serving Box
Friday evening you hand the serving team model.pkl (43 MB) over the file share. Monday they report ModuleNotFoundError: No module named 'sklearn.linear_model._logistic'. The model — the one whose curves you diagnosed in Diagnosing the Generalization Gap — trains, evaluates, and ships with a perfect 0.913 AUC on your machine — and on theirs it does not load at all. This is the default outcome of pickle-based serialization, because a pickle stores a reference to where classes lived, not the classes themselves.
pickle writes Python objects by recursively dumping their state and, for instances, the module path of their class (sklearn.linear_model._logistic — note the leading underscore, private module, renamed in later releases). Loading reconstructs the object by importing that exact path from your environment. Three things can break:
| Environment mismatch | Symptom on load | Fix |
|---|---|---|
| scikit-learn 1.3.2 → 1.4.0 | InconsistentVersionWarning, or a hard error when state fields moved |
pin the exact training version |
| Module/class renamed or moved | ModuleNotFoundError |
pin, or export to a stable format |
| numpy 1.x → 2.x dtype changes | odd NaNs or ValueError on first predict |
pin numpy too — it is part of the model |
import joblib, sklearn, warnings
# training machine: scikit-learn 1.3.2
joblib.dump(best_pipeline, 'model.joblib') # one file: preprocessor + estimator
joblib.dump({'sklearn': sklearn.__version__,
'numpy': np.__version__}, 'versions.json')
# serving machine: must print 1.3.2
import sklearn; assert sklearn.__version__ == '1.3.2', sklearn.__version__
pipe = joblib.load('model.joblib')
assert pipe.predict(X_demo).tolist() == ref_predictions # parity check, not just a load
joblib.dump over pickle.dump for scikit-learn because estimators hold large numpy arrays: joblib writes them efficiently and can memory-map them back (joblib.load('model.joblib', mmap_mode='r')) so a 43 MB RandomForest loads in milliseconds without touching all pages. Both, however, execute arbitrary Python on load — never load a model file from an untrusted source, the same rule as pickle.load on any bytes you did not produce.
The security and version problems point at a format designed to carry tensors instead of Python objects. The main options, with the trade-offs that decide between them:
| Format | Contents | Executes code? | Cross-version portability | Use when |
|---|---|---|---|---|
| pickle / joblib | full Python object graph | yes | poor — pin exact library versions | in-house sklearn, same environment |
| ONNX | ops + initializers, graph | no | good — any ONNX Runtime | heterogeneous runtimes, C#/Java/Go serving |
| safetensors | raw tensors + JSON header | no | excellent — mmap-able, immutable | PyTorch/HF weights |
torch.save |
zip of storages (pickle-based) | yes (unpickling) | fragile across versions | PyTorch-only pipelines |
xgboost .json / .ubj |
booster structure | no | good across xgboost versions | gradient boosting without pickle |
PyTorch sharpened this recently: since 2.6, torch.load defaults to weights_only=True, which refuses anything but tensors and primitives — old checkpoints that pickled an optimizer or custom class now fail with an UnpicklingError unless you consciously pass weights_only=False. That failure is the safe behavior; the unsafe alternative is executing whoever wrote the file. safetensors solves the same problem structurally: a JSON header describing tensor names, shapes, dtypes, and byte offsets, followed by the raw little-endian buffer. No import, no code path, so safetensors.torch.load_file('weights.safetensors') cannot run anything, and the header lets readers mmap tensors without parsing Python.
Whichever format you pick, it must capture the whole inference path, not just the estimator. A common silent failure: the serving team receives the classifier but not the ColumnTransformer, and their SQL query returns features in a different column order — the model happily predicts on shuffled features and nobody notices for a week. The fix is structural: serialize a Pipeline so preprocessing and model are one artifact, and ship a small golden batch with expected outputs for the parity check above. The environment side of that bundle — lockfiles and reproducible images — is where we go next.
Exporting to ONNX So the Inference Box Never Installs scikit-learn
Your training environment is a pinned Docker image with Python 3.11, scikit-learn 1.3.2, and a ColumnTransformer you trust. The inference box is a Java service in an org where adding a Python runtime needs a security review that takes six weeks. ONNX (Open Neural Network Exchange) ends that conversation: export the model to a static graph of operators plus tensors, and the box runs it with onnxruntime — a C/C++ library with wheels for every platform, no scikit-learn, no Python version negotiation.
For scikit-learn the converter is skl2onnx, and it can take the preprocessor with the model, which is exactly what fixes the column-order trap:
from skl2onnx import convert_dataframe_schema, to_onnx
from skl2onnx.common.data_types import FloatTensorType
import onnxruntime as rt, numpy as np
initial_type = [('float_input', FloatTensorType([None, X_tr.shape[1]]))]
onx = to_onnx(best_pipeline, initial_types=initial_type,
target_opset=17, options={id(best_pipeline): {'zipmap': False}})
with open('model.onnx', 'wb') as f:
f.write(onx.SerializeToString())
sess = rt.InferenceSession('model.onnx', providers=['CPUExecutionProvider'])
name = sess.get_inputs()[0].name
proba = sess.run(None, {name: X_test.astype(np.float32).astype(np.float32)})[1]
np.testing.assert_allclose(pipe.predict_proba(X_test), proba, rtol=1e-4, atol=1e-5)
Four details in that snippet decide whether the export is trustworthy:
target_opset=17pins the operator-set version. The opset is ONNX's API level; the runtime must support it, and a mismatch between exporting and running environments fails loudly at load. Pin it explicitly rather than accepting whatever the installed converter emits.zipmap: Falsemakespredict_probareturn a plain(n, k)array instead of a list of dictionaries — dictionaries are convenient in Python and useless in every other language.[None, n_features]is a dynamic batch axis:Nonemeans the graph accepts any batch size at runtime. Without it the model is fixed to the batch shape you exported with, and the first request of size 2 fails.- The parity assertion is not optional. Export can silently alter numeric behavior: float64 → float32 casts, different tree-traversal summation order, a
StandardScalerepsilon applied differently. Comparepredict_probaoutputs on a golden batch with a stated tolerance, and fail the build if they drift — that check is your evidence that the exported artifact means the same thing as the pickle.
What ONNX does not do: it cannot export arbitrary Python. A custom feature built with FunctionTransformer(lambda x: ...) has no ONNX operator and will make the conversion throw (Unable to find a parser for ...). Your options are to reimplement the transform as ordinary pipeline steps the converter understands, write a custom ONNX node (real work — build the op from a reference implementation and test parity), or keep that service in Python and pick a different strategy. The honest decision table:
| Situation | Choice |
|---|---|
| sklearn model + standard preprocessing, non-Python consumer | ONNX |
| Same stack, Python consumer, same pinned image | joblib/pickle — simpler, no parity risk |
| PyTorch/TensorFlow weights only | safetensors (or ONNX for cross-runtime) |
| Custom Python featurization that cannot be expressed as ops | keep Python; serve the pickle in a pinned container |
| Latency-critical C++ / mobile / browser | ONNX Runtime or a compiled backend |
One migration caution: an ONNX file is only as portable as its opset and custom domains. Re-test parity after every converter upgrade — skl2onnx versions track scikit-learn releases, and a converter that understood your pipeline today may parse it differently after an upgrade. Export, assert parity, and store the assertion result alongside the artifact so the receiving team inherits the evidence rather than your confidence.
The artifact half of the problem is now solved. The other half is the environment that reproduces it — which is a dependency-pinning problem, and the next chapter.
One Artifact, Six Dependencies: Locking the Environment That Produces the Model
model.joblib is not a model. It is a model plus a Python version, a scikit-learn build, a numpy ABI, a pandas version your ColumnTransformer feature names came from, and the transform code that built the features. Ship the file alone and you have shipped 20% of the system; the other 80% is reconstructed by memory, which is how a pipeline that scored 0.913 starts returning garbage after someone runs pip install -U scikit-learn.
The distinction that matters is between a declaration and a lock:
requirements.in requirements.lock (generated)
scikit-learn==1.3.2 scikit-learn==1.3.2
pandas numpy==1.26.4
numpy pandas==2.1.4
xgboost==1.7.6 joblib==1.3.2
threadpoolctl==3.2.0
# hashes pinned: sha256:...
The left column is what you use; the right column is what you ran, including the transitive closure, exact versions, and (optionally) hashes. Generate it with uv or pip-tools so the resolution is reproducible, and install from it with hashes so a substituted wheel fails verification:
uv pip compile requirements.in -o requirements.lock --generate-hashes
uv pip sync requirements.lock
python -m pip install --require-hashes -r requirements.lock # the CI form
Why not pip freeze > requirements.txt? It works, and it has two defects: it records whatever was already installed (including unrelated packages from your interactive environment), and it offers no separation between your intent and the resolver's choices — reinstall in six months and a transitive dependency may resolve differently. The .in → .lock split keeps intent editable and output immutable.
Containerize the pair. The artifact and the lock only become reproducible when they sit on a base image that is itself pinned, preferably by digest rather than tag, because python:3.11-slim moves under you:
FROM python:3.11.9-slim@sha256:1f3a8e5c... # digest, not the floating tag
WORKDIR /srv
COPY requirements.lock .
RUN pip install --require-hashes -r requirements.lock
COPY model.joblib golden_batch.parquet parity.json .
USER 10001
CMD ["uvicorn", "serving:app", "--host", "0.0.0.0", "--port", "8080"]
Then the full export bundle, which is the checklist to memorize for packaging questions:
- Model artifact —
model.joblibormodel.onnx, written once, never overwritten in place. - Environment lock —
requirements.lockwith hashes, Python version, base-image digest. - Preprocessing contract — feature list with types and order (the pipeline embeds the transform; the contract documents it for the producing and consuming teams).
- Golden batch + expected outputs — a handful of rows with reference predictions, used to assert numeric parity in CI on every environment rebuild.
- Provenance metadata — training run id, git SHA, data snapshot hash, metrics, format/version of the artifact (a single
meta.jsoncovering all five). This is what makes registry entry possible, and it is the next topic. - Checksum —
sha256sum model.joblib > model.sha256, verified after every transfer so corruption or a swapped file is caught at load, not in a dashboard.
Reproducibility has one honest caveat worth stating in an interview: bitwise-identical retraining usually is not achievable, and you should say so — thread-pool nondeterminism in BLAS kernels, GPU atomics, and random shuffling mean a rerun lands within a small band rather than on the same bytes. What you can promise is that inference is deterministic (same artifact + same inputs → same outputs, verified by the golden batch), that the training configuration is recorded (seed, hyperparameters, data hash), and that the environment rebuilds exactly (lock + digest). Promise those three and you can defend the model's behavior in an audit; promise "reproducible training" without them and you cannot defend either claim.
Packaging now produces a self-describing, checksum-verified, environment-pinned artifact with provenance attached. The moment several such artifacts exist and one must be the production model, you need a system that tracks versions, stages, and lineage — Model Registry and Versioning.
Quiz
1. A model.joblib file trained with scikit-learn 1.3.2 raises ModuleNotFoundError for sklearn.linear_model._logistic on a machine with a newer release. Why?
2. Which statement about safetensors is correct?
3. You export a pipeline to ONNX with target_opset=17 and a fixed batch dimension. What breaks in production?
4. Why is `uv pip compile requirements.in -o requirements.lock --generate-hashes` preferred over `pip freeze > requirements.txt`?
Flashcards
Question
Why does a pickled sklearn model break across versions, and what are the two mitigations?
Click to reveal answer
Answer
The pickle stores class module paths (including private modules like sklearn.linear_model._logistic) and estimator state tagged with _sklearn_version, so loading imports and reconstructs against the serving environment. Mitigations: pin the exact training versions everywhere the artifact runs, and verify with a load-then-predict parity check on a golden batch rather than treating a successful load as success.
Question
joblib or pickle for a sklearn pipeline, and why?
Click to reveal answer
Answer
joblib: it serializes large numpy arrays efficiently and can memory-map them on load (mmap_mode='r'), so big RandomForest/GBDT pipelines load fast. Both formats carry the same two risks — arbitrary code execution on load (never load untrusted files) and environment coupling — so the load-time version assert and parity check apply to either.
Question
What two settings in a skl2onnx export most often bite, and what does each do?
Click to reveal answer
Answer
target_opset: the operator-set version, which must be supported by the target runtime and should be pinned explicitly rather than defaulted. And the input shape: a fixed first dimension fixes the batch size — declaring it None gives a dynamic batch axis. Also set zipmap=False for plain probability arrays in non-Python consumers, and always assert predict_proba parity against the original within a stated tolerance.
Question
What must a deployment bundle contain beyond the model file?
Click to reveal answer
Answer
Six things: the artifact (joblib/onnx), a hashed environment lock (requirements.lock plus base-image digest), the feature contract (names, types, order), a golden batch with expected outputs for parity testing in CI, provenance metadata (run id, git SHA, data snapshot hash, metrics), and a checksum verified after transfer. The file alone is about a fifth of the deployable system.
Question
What can and cannot be promised about reproducibility?
Click to reveal answer
Answer
Can: deterministic inference (same artifact plus same inputs yields the same outputs, verified by golden batch), a recorded training configuration (seed, hyperparameters, data hash), and exact environment rebuilds (lockfile with hashes, image pinned by digest). Cannot (usually): bit-identical retraining — BLAS thread nondeterminism and GPU atomics produce a small band of variation, which you should state openly rather than promise away.
Revision Notes
Key Takeaways
- 1. A pickle stores references to class locations, not code — version pins plus a load-then-predict parity check are mandatory, not defensive overkill
- 2. joblib over pickle for numpy-heavy sklearn pipelines; safetensors over torch.save when weights must load without executing anything
- 3. ONNX buys runtime independence at the cost of an export step: pin the opset, declare dynamic axes, disable zipmap, and assert predict_proba parity with a stated tolerance
- 4. requirements.in (intent) versus requirements.lock (resolved, hashed closure) is the distinction that survives a reinstall six months later
- 5. The deployable unit is the artifact plus environment, feature contract, golden batch, provenance, and checksum — and reproducibility claims should promise deterministic inference, not bit-identical retraining
Interview Tips
- • "Why did my model pickle fail in production?" — name the mechanism (stored module paths and version-tagged state resolved against the serving environment), then give the fix ladder: pin versions, assert the version at load, add a parity check, and mention migrating to ONNX or safetensors for cross-runtime portability
- • "How do you ensure the exported model behaves identically?" — golden batch, np.testing.assert_allclose on predict_proba with an explicit tolerance, in CI on every environment rebuild; mention float32 casting and summation-order differences as the usual drift sources
- • "pickle vs ONNX vs safetensors?" — organize by two axes: does loading execute code, and what runtimes can consume it; then pick by constraint (in-house Python pin vs heterogeneous consumers vs weights-only)
- • "How do you make an ML pipeline reproducible?" — hashed lockfile, image pinned by digest, recorded seeds/hyperparameters/data hash, deterministic inference verified by golden batch — and say honestly that bit-identical retraining is not the promise you make
Cheat Sheet
Model Serialization and Packaging
Why pickles break: class module paths + _sklearn_version state are resolved against the serving environment. Renamed private modules → ModuleNotFoundError; moved state → InconsistentVersionWarning or errors; numpy 1.x→2.x dtype shifts → silent NaNs.
Format choice:
| Format | Code execution? | Portability | Use for |
|---|---|---|---|
| joblib | yes | pin exact versions | sklearn in-house |
| ONNX (opset pinned) | no | cross-runtime | non-Python consumers, latency-critical |
| safetensors | no | mmap, cross-version | PyTorch/HF weights |
| torch.save | yes (pickle under the hood) | fragile; 2.6 defaults weights_only=True | PyTorch-only pipelines |
| xgboost .json | no | good | booster without pickle |
ONNX recipe: to_onnx(pipe, initial_types=[('float_input', FloatTensorType([None, n]))], target_opset=17, options={...: {'zipmap': False}}) + np.testing.assert_allclose(pipe.predict_proba(X), exported, rtol=1e-4, atol=1e-5). Dynamic batch axis = None. Custom FunctionTransformer lambdas have no parser — keep those services in Python.
Environment lock: uv pip compile requirements.in -o requirements.lock --generate-hashes; install with --require-hashes. Base image pinned by digest, not tag.
Bundle checklist: artifact · hashed lock + image digest · feature contract · golden batch + expected outputs · provenance (run id, git SHA, data hash, metrics) · sha256 checksum.
Honesty line: deterministic inference yes; bit-identical retraining no (BLAS/GPU nondeterminism) — promise a reproducible environment and recorded configuration instead.