Project: churn model saved & served with joblib
Time to put everything together. You work for a prepaid telecom operator in Jaipur that loses customers every month. The business wants a weekly list of subscribers likely to churn so the retention team can offer a recharge discount.
1. Frame the problem
| Item | Decision |
|---|---|
| Target | churned = 1 if no recharge in the next 30 days |
| Unit | One row per subscriber per snapshot date |
| Features | Tenure, average recharge (INR), data usage trend, complaints, days since last recharge, circle |
| Metric | PR-AUC, plus recall in the top 10% of scores |
| Split | Train on older snapshots, test on the latest month (time-based) |
2. Build a single pipeline
Keeping preprocessing and the model in one object means exactly the same transformations run in training and in production.
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import average_precision_score
df = pd.read_csv("churn_snapshots.csv", parse_dates=["snapshot_date"])
num = ["tenure_months", "avg_recharge_inr", "data_gb_trend", "complaints_90d", "days_since_recharge"]
cat = ["circle", "plan_type"]
train = df[df.snapshot_date < "2026-06-01"]
test = df[df.snapshot_date >= "2026-06-01"]
pre = ColumnTransformer([
("cat", OneHotEncoder(handle_unknown="ignore", min_frequency=20), cat),
], remainder="passthrough")
pipe = Pipeline([
("pre", pre),
("model", HistGradientBoostingClassifier(learning_rate=0.05, max_iter=400,
class_weight="balanced", random_state=42)),
])
pipe.fit(train[num + cat], train["churned"])
proba = pipe.predict_proba(test[num + cat])[:, 1]
print("PR-AUC:", round(average_precision_score(test["churned"], proba), 3))
Check the business metric too: sort test rows by proba, take the top 10%, and measure what share of actual
churners they contain. That tells the retention team how many calls convert into saved customers.
3. Save the model with joblib
import joblib, sklearn
joblib.dump(
{"pipeline": pipe, "features": num + cat, "threshold": 0.42,
"sklearn_version": sklearn.__version__, "trained_until": "2026-05-31"},
"models/churn_v3.joblib", compress=3,
)
Store metadata with the model. Pickled scikit-learn objects are only reliably loadable with the same library
versions, so pin them in requirements.txt. Never joblib.load a file from an untrusted source, because loading a
pickle can execute arbitrary code.
4. Serve it from Django
Load the model once when the module is imported, not on every request.
# churn/views.py
import json
import joblib
import pandas as pd
from django.http import JsonResponse
from django.views.decorators.http import require_POST
BUNDLE = joblib.load("models/churn_v3.joblib")
@require_POST
def predict_churn(request):
rows = json.loads(request.body)["subscribers"]
X = pd.DataFrame(rows)[BUNDLE["features"]]
proba = BUNDLE["pipeline"].predict_proba(X)[:, 1]
return JsonResponse({"results": [
{"id": r["id"], "churn_prob": round(float(p), 3), "at_risk": bool(p >= BUNDLE["threshold"])}
for r, p in zip(rows, proba)
]})
For a weekly batch list you often do not need an API at all: a management command that scores all subscribers and writes a CSV or a database table is simpler and cheaper.
Key takeaways
- Split by time when the model will predict the future.
- One pipeline object from raw columns to prediction avoids training/serving skew.
- Version your model files and log their inputs and outputs to monitor drift.
- The same recipe works for demand forecasting: swap the target for next week's units sold and use a regressor.
Practice
Rebuild this project as a demand forecast for a sweets shop chain: predict next week's kilograms sold per store and item using lag features (last 1, 2 and 4 weeks) and a festival flag. Save the pipeline with joblib and write a Django management command that loads it and prints forecasts for all stores.