Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🏆 Major Project – Churn Prediction  ·  Day 60

Day 60: Project 5: Deploy App & Final Report

⏱ 4 hours📶 Major project · Part 5 of 5 🎓🎯 100% self-study
Day 60 / 60

✅ 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

Customer database (1st of month)score_customers.py + churn_model_v1.joblibrisk_list_this_month.csvStreamlit app for agents · /score API for CRMCalls → outcomes → monitoring → retrain
The production loop

Step 1: the final model and model card

Python – train_final.py
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))
{ "name": "TeleOne churn model", "version": "1.0.0", "algorithm": "logistic regression (C=0.1)", "trained_on": "3000 customers", "features": [ "senior_citizen", "tenure_months", "monthly_charges", "total_charges", "complaints", "data_usage_gb", "gender", "contract", "internet_service", "tech_support", "payment_method" ], "cv_roc_auc": "0.828 ± 0.015", "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": "1.9.1" }
🧠 Why retrain on all data?

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

Python – score_customers.py
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")
Scored 2,126 active customers → call list of 600 saved to risk_list_this_month.csv customer_id contract tenure_months monthly_charges churn_risk risk_band top_reasons suggested_action value_at_risk_₹ CUST12993 Monthly 23 1336.0 0.851 High high monthly charges, contract Monthly, short tenure months Review plan – suggest a better-value pack 13643.0 CUST10745 Monthly 46 1362.0 0.814 High high complaints, high monthly charges, contract Monthly Senior agent callback on open complaints 13304.0 CUST11822 Monthly 1 1139.0 0.848 High short tenure months, contract Monthly, high monthly charges Welcome-back / onboarding help 11590.0 CUST10268 Monthly 29 1200.0 0.795 High contract Monthly, high monthly charges, high complaints Offer yearly plan at 15% off 11448.0 CUST10672 Monthly 13 1158.0 0.804 High short tenure months, contract Monthly, high monthly charges Welcome-back / onboarding help 11172.0 CUST11505 Monthly 10 1096.0 0.834 High short tenure months, contract Monthly, high monthly charges Welcome-back / onboarding help 10969.0 Risk bands in the call list: {'Medium': 354, 'Low': 137, 'High': 109} High + Medium: 463 customers | avg risk 49% | value at risk ₹22.5 lakh/yr
💼 Rank by value at risk

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.

Schedule – run on the 1st of every month (Linux cron)
# 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

Python – app_retention.py
# 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")
HTML
<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>
▶  How the Streamlit app looks with this month’s list (High + Medium filter)

Step 4: the CRM API

Python – api.py (tested with Flask’s test client)
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"])
200 [{'call': True, 'churn_risk': 0.904}, {'call': False, 'churn_risk': 0.033}] 400 rows [0] are missing fields

Step 5: the GitHub repository

Project structure
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

Markdown – reports/final_report.md
# 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

SlideContent
1Title + one-line result: “Reach 2.3× more churners with the same calls”
2The problem in ₹: 29% churn, ₹80 lakh a year
3Who churns: contract × tenure heatmap
4Top 5 drivers (lift chart)
5Approach: data → model → risk list (one diagram)
6Model quality: test AUC, gains chart
7Business impact: model vs random calling
8Demo: the retention app
9Recommendations + A/B pilot plan
10Limitations, monitoring, next steps
Your 2-minute interview pitch
“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 🎓

🐍
Python & pandasDays 1–20: from variables to a full EDA project.
📊
Visual storytellingDays 21–25: Matplotlib, Seaborn, Plotly, dashboards.
📐
Statistics & SQLDays 26–35: tests, A/B experiments, joins, windows.
🤖
Machine learningDays 36–51: regression to neural networks, deployed.
💼
Job-readyDays 52–60: Excel, Power BI, Git, resume, interviews and a major project.
60 days, one complete data-science toolkit
🚀 What next?

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

✔ Retrain and version a production model✔ Generate an actionable, value-ranked risk list✔ Deliver a Streamlit app and a JSON API✔ Write a final report and present a data project

Practice tasks

🏋️ Practice – do it yourself

Task 1: Add a “why” sentence for each customer in the risk list, e.g. “Monthly plan, only 3 months with us, 2 complaints”.
Task 2: Simulate next month: increase every active customer’s tenure by 1 and re-score. How many customers change risk band?
Show solution
Python
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))
19 customers change band; col_0 Low Medium High row_0 Low 1589 0 0 Medium 14 414 0 High 0 5 104
Task 3: Record your 2-minute pitch, publish the repository and share the link on LinkedIn.

Quick quiz

🧠 Quick Quiz – test yourself

1. Why sort the call list by value at risk instead of risk?
Risk × annual bill.
2. The final production model is trained on…
No new tuning.
3. A model card should include…
Documentation for users and auditors.
4. The best first slide of a project presentation is…
Lead with the answer.
5. How do we learn the true save-rate?
Measure, don’t assume.

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! 🎉
➡️ Next

Course complete! Next: build websites with our HTML, CSS and JavaScript day-wise courses.

Frequently Asked Questions

Can I put this project on my resume?
Yes – it is designed to be a portfolio centrepiece. Rename it, add your own extensions (A/B pilot design, a new dataset) and link the live app.
Where do I download all the code and datasets?
Use the “Download Datasets + All Code” button on this course page after logging in – it contains every dataset and every day’s code as ready-to-run files.
← Data Science (60 Days)
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Course complete! Continue withMySQL + DBMS + RDBMS Your progress: 60 / 60 (100%) · Recommended next course Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free