Day 60: Project 5: Deploy App & Final Report
✅ Today you will learn
- Retrain the final model on all data
- Produce the monthly call list with reasons and actions
- Ship a Streamlit app and a JSON API
- Write the final report and model card
- Present the project in 10 slides and 2 minutes
🏢 In the real world
Budget approved! From next month the retention team will call 600 customers chosen by your model. Today you ship it: a model file, a scoring script that runs on the 1st of every month, an app for agents, an API for the CRM, and a report that explains everything to management.
What we are shipping
Step 1: the final model and model card
import numpy as np
import pandas as pd
def load_churn(path="telecom_churn.csv"):
# "None" means no internet plan – stop pandas from turning it into NaN
df = pd.read_csv(path, keep_default_na=False, na_values=[""])
df["total_charges"] = df["total_charges"].fillna(df["monthly_charges"] * df["tenure_months"])
df["churned"] = (df["churn"] == "Yes").astype(int)
return df
def add_features(df):
df = df.copy()
df["tenure_band"] = pd.cut(df["tenure_months"], [0, 6, 12, 24, 48, 72], labels=["0-6", "7-12", "13-24", "25-48", "49-72"]).astype(str)
df["is_new"] = (df["tenure_months"] <= 6).astype(int)
df["has_complaint"] = (df["complaints"] > 0).astype(int)
df["charge_per_gb"] = (df["monthly_charges"] / df["data_usage_gb"].clip(lower=1)).round(2)
df["avg_monthly_paid"] = (df["total_charges"] / df["tenure_months"]).round(1)
df["monthly_no_support"] = ((df["contract"] == "Monthly") & (df["tech_support"] == "No")).astype(int)
return df
# original columns – the champion model uses these
NUM = ["senior_citizen", "tenure_months", "monthly_charges", "total_charges", "complaints", "data_usage_gb"]
CAT = ["gender", "contract", "internet_service", "tech_support", "payment_method"]
# original + engineered columns (tested on Day 58)
NUM_ALL = NUM + ["is_new", "has_complaint", "charge_per_gb", "avg_monthly_paid", "monthly_no_support"]
CAT_ALL = CAT + ["tenure_band"]
import json
import joblib
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold
df = load_churn()
X, y = df[NUM + CAT], df["churned"]
model = Pipeline([("prep", ColumnTransformer([("num", StandardScaler(), NUM), ("cat", OneHotEncoder(handle_unknown="ignore"), CAT)])),
("clf", LogisticRegression(C=0.1, max_iter=5000))])
cv_auc = cross_val_score(model, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=0), scoring="roc_auc")
model.fit(X, y) # final model uses ALL 3,000 customers
joblib.dump(model, "churn_model_v1.joblib")
card = {"name": "TeleOne churn model", "version": "1.0.0", "algorithm": "logistic regression (C=0.1)",
"trained_on": f"{len(X)} customers", "features": NUM + CAT,
"cv_roc_auc": f"{cv_auc.mean():.3f} ± {cv_auc.std():.3f}", "locked_test_roc_auc_day59": 0.832,
"intended_use": "rank customers for retention calls; not for pricing or credit decisions",
"monitoring": "monthly PSI on inputs, quarterly AUC on new outcomes, retrain if AUC < 0.78",
"sklearn_version": sklearn.__version__}
json.dump(card, open("model_card.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False)
print(json.dumps(card, indent=2, ensure_ascii=False))The locked test set did its job yesterday: it told us how good the method is. For production we want the model to learn from every customer we have, so we refit the same pipeline and settings on all 3,000 rows – no new tuning.
Step 2: the monthly risk list
import joblib
import numpy as np
import pandas as pd
def load_churn(path="telecom_churn.csv"):
df = pd.read_csv(path, keep_default_na=False, na_values=[""])
df["total_charges"] = df["total_charges"].fillna(df["monthly_charges"] * df["tenure_months"])
return df
model = joblib.load("churn_model_v1.joblib")
pre, clf = model[0], model[-1]
names = pre.get_feature_names_out()
ACTIONS = {"contract_Monthly": "Offer yearly plan at 15% off", "tech_support_No": "Add 3 months free tech support",
"complaints": "Senior agent callback on open complaints", "tenure_months": "Welcome-back / onboarding help",
"monthly_charges": "Review plan – suggest a better-value pack", "internet_service_Fiber": "Fiber quality check visit"}
def label(j, Z, mean):
key = names[j].split("__", 1)[1]
if names[j].startswith("num__"):
if key == "senior_citizen":
return key, "senior citizen"
return key, ("high " if Z[j] > mean[j] else "short " if key == "tenure_months" else "low ") + key.replace("_", " ")
return key, key.replace("_", " ", 1).replace("_", " ")
def explain(Z, mean):
contrib = (Z - mean) * clf.coef_[0]
top = [label(j, Z, mean) for j in np.argsort(contrib)[::-1][:3] if contrib[j] > 0]
action = next((ACTIONS[k] for k, _ in top if k in ACTIONS), "Standard loyalty offer")
return ", ".join(t for _, t in top), action
def monthly_risk_list(customers, capacity=600):
X = customers[model.feature_names_in_]
p = model.predict_proba(X)[:, 1]
Z = pre.transform(X); Z = Z.toarray() if hasattr(Z, "toarray") else Z
mean = np.asarray(Z.mean(axis=0)).ravel()
out = customers[["customer_id", "contract", "tenure_months", "monthly_charges"]].copy()
out["churn_risk"] = p.round(3)
out["risk_band"] = pd.cut(p, [0, 0.3, 0.6, 1], labels=["Low", "Medium", "High"])
expl = [explain(Z[i], mean) for i in range(len(out))]
out["top_reasons"] = [e[0] for e in expl]; out["suggested_action"] = [e[1] for e in expl]
out["value_at_risk_₹"] = (out["churn_risk"] * out["monthly_charges"] * 12).round(0)
return out.sort_values("value_at_risk_₹", ascending=False).head(capacity)
active = load_churn().query("churn == 'No'") # this month: score customers who are still with us
risk = monthly_risk_list(active, capacity=600)
risk.to_csv("risk_list_this_month.csv", index=False)
print(f"Scored {len(active):,} active customers → call list of {len(risk)} saved to risk_list_this_month.csv")
print(risk.head(6).to_string(index=False))
print("\nRisk bands in the call list:", risk["risk_band"].value_counts().to_dict())
hm = risk[risk["risk_band"].isin(["High", "Medium"])]
print(f"High + Medium: {len(hm)} customers | avg risk {hm['churn_risk'].mean():.0%} | value at risk ₹{hm['value_at_risk_₹'].sum() / 1e5:.1f} lakh/yr")We sort by churn risk × annual bill, not by risk alone. A ₹1,200/month customer at 60% risk matters more than a ₹199 customer at 70%. This one line aligns the model with the business goal of protecting revenue.
# crontab -e 0 6 1 * * cd /opt/teleone-churn && /usr/bin/python3 score_customers.py >> logs/score.log 2>&1
Step 3: the retention app
# app_retention.py – run: streamlit run app_retention.py
import joblib
import pandas as pd
import streamlit as st
st.set_page_config(page_title="TeleOne Retention", page_icon="📞", layout="wide")
model = joblib.load("churn_model_v1.joblib")
st.title("📞 TeleOne – Retention Assistant")
tab1, tab2 = st.tabs(["This month’s call list", "Check one customer"])
with tab1:
risk = pd.read_csv("risk_list_this_month.csv")
band = st.multiselect("Risk band", ["High", "Medium", "Low"], default=["High", "Medium"])
view = risk[risk["risk_band"].isin(band)]
c1, c2, c3 = st.columns(3)
c1.metric("Customers to call", len(view))
c2.metric("Avg risk", f"{view['churn_risk'].mean():.0%}")
c3.metric("Value at risk", f"₹{view['value_at_risk_₹'].sum() / 1e5:.1f} lakh / yr")
st.dataframe(view, use_container_width=True, hide_index=True)
st.download_button("Download CSV for the dialler", view.to_csv(index=False), "call_list.csv")
with tab2:
a, b = st.columns(2)
row = {"senior_citizen": int(a.checkbox("Senior citizen")),
"tenure_months": a.slider("Tenure (months)", 1, 72, 6),
"monthly_charges": a.number_input("Monthly bill (₹)", 149, 1500, 850),
"complaints": a.slider("Complaints (last 6 months)", 0, 8, 2),
"data_usage_gb": a.number_input("Data usage (GB/month)", 0.0, 300.0, 40.0),
"gender": b.selectbox("Gender", ["Female", "Male"]),
"contract": b.selectbox("Contract", ["Monthly", "One year", "Two year"]),
"internet_service": b.selectbox("Internet", ["Fiber", "DSL", "None"]),
"tech_support": b.selectbox("Tech support", ["No", "Yes", "No internet"]),
"payment_method": b.selectbox("Payment", ["UPI AutoPay", "Credit Card", "Bank Transfer", "Cash"])}
row["total_charges"] = row["monthly_charges"] * row["tenure_months"]
p = model.predict_proba(pd.DataFrame([row]))[0, 1]
st.metric("Churn risk", f"{p:.0%}")
st.progress(float(p))
if p >= 0.6:
st.error("High risk – call today with a retention offer")
elif p >= 0.3:
st.warning("Medium risk – include in this month’s campaign")
else:
st.success("Low risk – no action needed")<div style="font-family:system-ui,sans-serif;padding:12px;background:#fff"> <h3 style="margin:0 0 8px">📞 TeleOne – Retention Assistant</h3> <div style="display:flex;gap:6px;margin-bottom:10px"><span style="padding:5px 10px;border-bottom:2px solid #ff4b4b;font-weight:600">This month’s call list</span><span style="padding:5px 10px;color:#6b7280">Check one customer</span></div> <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:10px"> <div style="background:#f8fafc;border-radius:8px;padding:8px"><small>Customers to call</small><div style="font-size:22px;font-weight:700">463</div></div> <div style="background:#f8fafc;border-radius:8px;padding:8px"><small>Avg risk</small><div style="font-size:22px;font-weight:700">49%</div></div> <div style="background:#f8fafc;border-radius:8px;padding:8px"><small>Value at risk</small><div style="font-size:22px;font-weight:700">₹22.5 lakh / yr</div></div></div> <table style="width:100%;border-collapse:collapse;font-size:12px"><tr style="background:#f1f5f9"><th align="left">customer</th><th>risk</th><th align="left">top reasons</th><th align="left">suggested action</th></tr> <tr><td>CUST12993</td><td align="center"><b style="color:#dc2626">85%</b></td><td>high monthly charges, contract Monthly, short tenure</td><td>Review plan – better-value pack</td></tr> <tr><td>CUST10745</td><td align="center"><b style="color:#dc2626">81%</b></td><td>high complaints, high monthly charges, contract Monthly</td><td>Senior agent callback on complaints</td></tr> <tr><td>CUST10268</td><td align="center"><b style="color:#dc2626">80%</b></td><td>contract Monthly, high monthly charges, high complaints</td><td>Offer yearly plan at 15% off</td></tr></table> <button style="margin-top:10px;background:#ff4b4b;color:#fff;border:0;padding:7px 12px;border-radius:8px">Download CSV for the dialler</button></div>
Step 4: the CRM API
import joblib, pandas as pd
from flask import Flask, request, jsonify
app = Flask(__name__)
model = joblib.load("churn_model_v1.joblib")
FIELDS = list(model.feature_names_in_)
@app.post("/score")
def score():
rows = request.get_json(silent=True)
rows = rows if isinstance(rows, list) else [rows]
bad = [i for i, r in enumerate(rows) if not isinstance(r, dict) or set(FIELDS) - set(r)]
if bad:
return jsonify(error=f"rows {bad} are missing fields", required=FIELDS), 400
p = model.predict_proba(pd.DataFrame(rows)[FIELDS])[:, 1]
return jsonify([{"churn_risk": round(float(x), 3), "call": bool(x >= 0.3)} for x in p])
client = app.test_client()
cust = {"senior_citizen": 0, "tenure_months": 3, "monthly_charges": 950, "total_charges": 2850, "complaints": 2,
"data_usage_gb": 35.0, "gender": "Female", "contract": "Monthly", "internet_service": "Fiber",
"tech_support": "No", "payment_method": "Cash"}
loyal = dict(cust, tenure_months=60, contract="Two year", tech_support="Yes", complaints=0, total_charges=57000)
r = client.post("/score", json=[cust, loyal]); print(r.status_code, r.get_json())
r = client.post("/score", json={"tenure_months": 5}); print(r.status_code, r.get_json()["error"])Step 5: the GitHub repository
teleone-churn/ ├── README.md ← business summary first (template below) ├── PROJECT_CHARTER.md ← Day 56 ├── requirements.txt ├── data/sample_customers.csv ├── notebooks/ │ ├── 01_problem_and_audit.ipynb (Day 56) │ ├── 02_eda_insights.ipynb (Day 57) │ ├── 03_features_models.ipynb (Day 58) │ └── 04_evaluation_impact.ipynb (Day 59) ├── src/ churn.py (load_churn, add_features, make_model) ├── train_final.py score_customers.py api.py app_retention.py ├── models/ churn_model_v1.joblib model_card.json └── reports/ figures/ final_report.md slides.pdf
Step 6: the final report
# TeleOne Churn Prediction – Final Report ## 1. Problem 29% of customers churned, taking ≈ ₹6.6 lakh of monthly revenue (≈ ₹80 lakh a year). The retention team can call ~600 customers a month and needs to know whom to call and why. ## 2. What drives churn (EDA, Day 57) - Monthly contracts churn ~44% vs ~9% for two-year contracts (largest effect). - First 6 months, multiple complaints, no tech support and Fiber plans raise risk. - Gender has no effect; payment method differences were not significant. ## 3. Model (Days 58–59) - Logistic regression on the 11 original columns (engineered features added nothing). - 5-fold CV ROC-AUC ≈ 0.82; locked test ROC-AUC 0.83 – no sign of leakage or overfitting. - Well calibrated; similar performance for women and men. ## 4. Business impact (agreed assumptions) - Calling the top 20% reaches 2.3× more churners than random calling. - Expected net ≈ ₹12 lakh per monthly campaign vs ≈ ₹3 lakh for random calls. ## 5. Recommendations 1. Use the monthly risk list (sorted by value at risk) for retention calls. 2. Bundle free tech support for monthly Fiber customers; push yearly-plan upgrades. 3. Run a 2-month A/B pilot (call vs hold-out) to measure the real save-rate. 4. Monitor input drift monthly; retrain when AUC on new outcomes < 0.78. ## 6. Limitations Simulated data, single snapshot, save-rate and offer acceptance are assumptions.
Step 7: present it
| Slide | Content |
|---|---|
| 1 | Title + one-line result: “Reach 2.3× more churners with the same calls” |
| 2 | The problem in ₹: 29% churn, ₹80 lakh a year |
| 3 | Who churns: contract × tenure heatmap |
| 4 | Top 5 drivers (lift chart) |
| 5 | Approach: data → model → risk list (one diagram) |
| 6 | Model quality: test AUC, gains chart |
| 7 | Business impact: model vs random calling |
| 8 | Demo: the retention app |
| 9 | Recommendations + A/B pilot plan |
| 10 | Limitations, monitoring, next steps |
“TeleOne was losing 29% of its customers – about ₹80 lakh a year – and the retention team could only call 600 people a month. I framed it as a classification problem, audited the data (and caught a silent NaN bug), and found in EDA that monthly contracts, the first six months, complaints and missing tech support drive churn. I compared five models with cross-validation; plain logistic regression won with AUC 0.83 on a locked test set, and my engineered features did not help, so I kept it simple. Using agreed costs, calling the top 20% reaches 2.3× more churners than random calls – about ₹12 lakh net per campaign. I shipped a monthly risk list with reasons and suggested offers, a Streamlit app for agents and a Flask API, and proposed an A/B pilot to measure the real save-rate.”
You did it 🎓
Repeat this project on a public dataset (IBM Telco churn on Kaggle) or your own domain, publish it on GitHub with the README and app, post one insight a week on LinkedIn, and start applying using the Day 54 plan. Keep building – one small project a month keeps your skills sharp.
🏅 Job skills unlocked today
Practice tasks
🏋️ Practice – do it yourself
Show solution
import joblib, pandas as pd
m = joblib.load("churn_model_v1.joblib")
df = pd.read_csv("telecom_churn.csv", keep_default_na=False, na_values=[""]).query("churn == 'No'").copy()
df["total_charges"] = df["total_charges"].fillna(df["monthly_charges"] * df["tenure_months"])
band = lambda p: pd.cut(p, [0, 0.3, 0.6, 1], labels=["Low", "Medium", "High"])
now = band(m.predict_proba(df[m.feature_names_in_])[:, 1])
df["tenure_months"] = (df["tenure_months"] + 1).clip(upper=72); df["total_charges"] += df["monthly_charges"]
later = band(m.predict_proba(df[m.feature_names_in_])[:, 1])
print((now != later).sum(), "customers change band;", pd.crosstab(now, later))Quick quiz
🧠 Quick Quiz – test yourself
Interview corner
💼 Interview Corner
Walk me through your end-to-end project.
Use the 2-minute pitch above: problem in rupees, data audit, key EDA drivers, honest model comparison (simple model won), test AUC 0.83, business impact 2.3× more churners reached, delivered risk list + app + API, and next step A/B pilot.
What would you do differently with more time or data?
Collect several monthly snapshots to model time-to-churn, measure offer uplift with experiments (uplift modelling), add usage and support-ticket text features, compare boosting again on more data, and automate retraining with monitoring alerts.
Summary
- Final model retrained on all data, versioned with a model card.
- Monthly risk list ranked by value at risk with reasons and actions.
- Streamlit app for agents, Flask API for the CRM.
- Report + 10-slide deck + 2-minute pitch = a job-ready portfolio project.
- Congratulations on finishing the 60-day Data Science course! 🎉
Course complete! Next: build websites with our HTML, CSS and JavaScript day-wise courses.
