Packaging a Model
The artefact, the contract, pinned versions and a smoke test
A model that lives in a notebook is not a product. Everything from here is about the distance between the two, and almost none of it is about machine learning.
What actually has to be saved
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
model = Pipeline([('prep', prep),
('clf', LogisticRegression(max_iter=2000))])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
model.fit(X_tr, y_tr)
import joblib
import json
import sklearn
import sys
from sklearn.metrics import roc_auc_score
joblib.dump(model, 'churn_model.joblib')
contract = {
'model_version': '1.0.0',
'trained_at': '2026-08-08',
'trained_rows': int(len(X_tr)),
'features': {'numeric': NUM, 'categorical': CAT},
'categories': {c: sorted(df[c].dropna().unique().tolist())
for c in CAT},
'ranges': {c: [float(df[c].min()), float(df[c].max())] for c in NUM},
'threshold': 0.35,
'test_auc': round(float(roc_auc_score(
y_te, model.predict_proba(X_te)[:, 1])), 4),
'python': sys.version.split()[0],
'sklearn': sklearn.__version__,
}
json.dump(contract, open('churn_contract.json', 'w'), indent=2)
print(json.dumps(contract, indent=2)[:760])
"model_version": "1.0.0",
"trained_at": "2026-08-08",
"trained_rows": 2250,
"features": {
"numeric": [
"tenure_months",
"support_calls",
"monthly_charges"
],
"categorical": [
"contract",
"internet_service",
"payment_method",
"has_dependents"
]
},
"categories": {
"contract": [
"Month-To-Month",
"One Year",
"Two Year"
],
"internet_service": [
"DSL",
"Fibre optic",
"No internet"
],
"payment_method": [
"Bank transfer",
"Credit card",
"Electronic check",
... (13 more lines)
The contract is the part people leave out
The .joblib file knows how to turn a dataframe into a prediction. It does not know that contract has exactly three valid values, that tenure_months was never above 72 in training, that the agreed threshold is 0.35 rather than 0.5, or which version of the model produced a given row in your logs. Six months later, every one of those is a question somebody will ask, and the answer needs to be in a file rather than in your memory.
Pinning the environment
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
model = Pipeline([('prep', prep),
('clf', LogisticRegression(max_iter=2000))])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
model.fit(X_tr, y_tr)
import joblib
import sklearn
loaded = joblib.load('churn_model.joblib')
print('loaded a %s' % type(loaded).__name__)
print('steps: %s' % [name for name, _ in loaded.steps])
print('fitted with sklearn %s' % sklearn.__version__)
same = (loaded.predict_proba(X_te)[:, 1]
== model.predict_proba(X_te)[:, 1]).all()
print('reload predicts identically: %s' % bool(same))
steps: ['prep', 'clf']
fitted with sklearn 1.8.0
reload predicts identically: True
pip freeze > requirements.txt
# Or, better, record only what you asked for and let the tool resolve
# the rest reproducibly:
pip install pip-tools && pip-compile requirements.in
A pickle is not a portable format
joblib uses pickle underneath, which stores references to Python classes. Load it under a different scikit-learn version and you get, in ascending order of unpleasantness: a warning, an exception, or a model that loads cleanly and predicts subtly differently. Pin the version, load in the same environment you trained in, and add a smoke test that checks a handful of known inputs still produce known outputs. That test is the only thing that catches the third case.
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
model = Pipeline([('prep', prep),
('clf', LogisticRegression(max_iter=2000))])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
model.fit(X_tr, y_tr)
import joblib
import json
# The smoke test the warning above asks for: freeze a few predictions
# now, and compare against them on every load, forever.
sample = X_te.head(5)
expected = [round(float(p), 6)
for p in model.predict_proba(sample)[:, 1]]
json.dump({'rows': sample.to_dict('records'), 'expected': expected},
open('smoke_test.json', 'w'), indent=2)
fixture = json.load(open('smoke_test.json'))
reloaded = joblib.load('churn_model.joblib')
got = [round(float(p), 6) for p in
reloaded.predict_proba(pd.DataFrame(fixture['rows']))[:, 1]]
print('expected %s' % fixture['expected'])
print('got %s' % got)
print('smoke test passes: %s' % (got == fixture['expected']))
got [0.530642, 0.16512, 0.322593, 0.205547, 0.568866]
smoke test passes: True