Time to put everything together. In this project you'll act as a data scientist for a UK broadband company, taking a real-world style problem from a messy spreadsheet all the way to a model, a business recommendation and a presentation. It's the kind of project employers love to see in a portfolio.
- Level: Intermediate
- Time: 6 to 10 hours
- Needs: Modules 1 to 12
By the end of this project you will have
- Turned a business question into a machine learning problem with a clear success measure
- Explored, cleaned and prepared a messy dataset of 6,000 customers
- Built a leak-proof preparation pipeline for numbers and categories
- Compared and tuned several models with cross-validation
- Chosen a decision threshold based on money, not just accuracy
- Explained what drives churn and segmented the customers at risk
- Presented your findings in a way a non-technical manager can act on
13.1The brief
You've just joined Tyne Fibre, a (fictional) broadband provider with customers across the UK. On your first morning, this email arrives from the Head of Customer Retention:
Subject: Can we get ahead of cancellations?
Hi,
About a quarter of our customers leave us each year, and each one who leaves costs us roughly £420 in lost profit. My team can call customers and offer them a retention deal. Each call and offer costs us about £60, and from past campaigns, around 40% of customers who were planning to leave stay if we reach them.
We can't call everyone. Could you use our customer data to tell us:
- Which customers we should call,
- What's driving people to leave, and
- What kind of offer might work for different groups?
I'd love a short presentation I can take to the leadership team.
Thanks, Sarah
This is a realistic brief. Notice it doesn't mention algorithms at all. Your job is to turn it into a machine learning project, and then turn the results back into business language.
13.2Your project plan
You'll follow the seven-step machine learning workflow from Module 1. Every step uses skills from earlier modules.
| Step | What you'll do | Skills from |
|---|---|---|
| 1. Define the problem | Choose the target, the type of problem and how success is measured | Modules 1, 4, 5 |
| 2. Explore the data | Understand the columns, spot problems and look for patterns | Module 2 |
| 3. Prepare the data | Clean, encode, scale and split without leakage | Modules 2, 5 |
| 4. Compare models | Try several algorithms with cross-validation | Modules 3 to 10 |
| 5. Tune and choose a threshold | Tune the best models and pick the most profitable threshold | Modules 4, 5, 8 |
| 6. Explain the model | Find what drives churn and group the at-risk customers | Modules 7, 11 |
| 7. Present your findings | Build a short, clear presentation for Sarah | All of them |
How to use this page
Each step below tells you what to do and gives hints. Try each step yourself first. A full reference solution is at the end (lesson 13.9), but you'll learn far more if you only open it to check your work or when you're truly stuck.
13.3Step 1: Define the problem
Before touching any data, answer these questions in writing. They'll become the first slide of your presentation.
| Question | Our answer |
|---|---|
| What are we predicting? | Whether each customer will cancel (the churned column) |
| What type of problem is it? | Supervised learning, binary classification (Modules 1 and 4) |
| What will the business do with it? | Call the customers with the highest predicted risk and offer a deal |
| How do we rank customers? | By predicted probability of leaving, so we need predict_proba() |
| How will we compare models? | ROC AUC, because it measures how well a model ranks risky customers above safe ones, whatever threshold we pick (Module 5) |
| How will we choose who to call? | The threshold that makes Tyne Fibre the most money, using Sarah's figures |
Why not accuracy?
Only about 25% of customers leave. A model that says "nobody leaves" would be 75% accurate and completely useless (the accuracy trap from Modules 4 and 5). Worse, accuracy treats a missed leaver and a wasted call as equally bad, when in money terms they're very different.
13.4Step 2: Get and explore the data
Run this script to create the dataset. It builds a file called tyne_fibre_customers.csv with the same data for everyone, so you can compare your results with the reference solution.
# Creates the capstone dataset: tyne_fibre_customers.csv
import numpy as np
import pandas as pd
rng = np.random.default_rng(2026)
n = 6000
regions = ["North East", "Yorkshire", "Scotland", "London", "Midlands"]
contract = rng.choice(["Monthly", "12-month", "24-month"], n, p=[0.45, 0.30, 0.25])
tenure = np.where(contract == "Monthly", rng.integers(1, 40, n), rng.integers(1, 72, n))
speed = rng.normal(np.where(rng.random(n) < 0.2, 35, 70), 12, n).clip(5, 120).round(1)
df = pd.DataFrame({
"customer_id": [f"TF{100000 + i}" for i in range(n)],
"age": rng.integers(18, 85, n),
"region": rng.choice(regions, n),
"contract": contract,
"tenure_months": tenure,
"monthly_bill": rng.normal(38, 11, n).clip(15, 90).round(2),
"tv_bundle": rng.choice(["Yes", "No"], n, p=[0.35, 0.65]),
"payment_method": rng.choice(["Direct Debit", "Card", "Bank transfer"], n, p=[0.6, 0.3, 0.1]),
"avg_speed_mbps": speed,
"outages_90d": rng.poisson(0.8, n),
"support_calls_90d": rng.poisson(1.5, n),
})
z = (-2.9
+ 1.3 * (df["contract"] == "Monthly") - 0.9 * (df["contract"] == "24-month")
+ 1.2 * (df["tenure_months"] < 6) - 0.7 * (df["tenure_months"] > 36)
+ 0.45 * df["support_calls_90d"].clip(0, 6)
+ 0.5 * df["outages_90d"].clip(0, 4)
+ 1.1 * (df["avg_speed_mbps"] < 40)
+ 0.03 * (df["monthly_bill"] - 38)
+ 0.6 * ((df["monthly_bill"] > 55) & (df["contract"] == "Monthly"))
- 0.5 * (df["tv_bundle"] == "Yes")
+ 0.4 * (df["payment_method"] == "Card"))
df["churned"] = np.where(rng.random(n) < 1 / (1 + np.exp(-z)), "Yes", "No")
# Real data is messy: add some problems for you to fix
df.loc[rng.choice(n, 300, replace=False), "avg_speed_mbps"] = np.nan
df.loc[rng.choice(n, 120, replace=False), "payment_method"] = np.nan
df.loc[rng.choice(n, 8, replace=False), "age"] = 999
df = pd.concat([df, df.sample(25, random_state=1)], ignore_index=True) # duplicates
df.to_csv("tyne_fibre_customers.csv", index=False)
print(df.shape, "| churn rate:", f"{(df['churned'] == 'Yes').mean():.1%}")
(6025, 12) | churn rate: 25.3%
The data dictionary
| Column | Type | Meaning |
|---|---|---|
customer_id | ID | Unique customer reference (not a feature!) |
age | Number | Customer's age in years |
region | Category | North East, Yorkshire, Scotland, London or Midlands |
contract | Category | Monthly, 12-month or 24-month |
tenure_months | Number | How many months they've been a customer |
monthly_bill | Number | Their monthly bill in pounds |
tv_bundle | Category | Whether they also have TV (Yes/No) |
payment_method | Category | Direct Debit, Card or Bank transfer |
avg_speed_mbps | Number | Their average download speed |
outages_90d | Number | Service outages in the last 90 days |
support_calls_90d | Number | Calls to customer support in the last 90 days |
churned | Target | Did they leave? (Yes/No) |
Your tasks
- Load the file with pandas and check its shape,
df.info()anddf.describe(). - Find the problems: count missing values with
df.isnull().sum(), look for duplicates withdf.duplicated().sum(), and check the minimum and maximum of every number column. - Work out the overall churn rate.
- Compare the churn rate across groups, for example with
df.groupby("contract")["churned"].value_counts(normalize=True), and draw some charts.
Hint: what problems should I find?
You should find 25 duplicate rows, about 300 missing speeds, about 120 missing payment methods and 8 impossible ages (999). Write down how you plan to handle each one, and why.
What your charts might show
13.5Step 3: Prepare the data
Now fix the problems and get the data ready for models, using everything from Module 2.
| Task | Suggested approach |
|---|---|
| Duplicates | Remove with drop_duplicates() |
| Impossible ages | Turn ages over 100 into missing values |
| Missing numbers | Fill with the median, learned from training data only |
| Missing categories | Fill with the most frequent value |
| Text categories | One-hot encode (none of them have a natural order) |
| Number scaling | Standardise, so logistic regression and similar models work well |
| Target | Turn "Yes"/"No" into 1/0 |
| Split | 80% training, 20% test, with stratify=y. Lock the test set away |
New tool: ColumnTransformer
Number columns and category columns need different treatment. scikit-learn's ColumnTransformer lets you send each group of columns through its own mini-pipeline, then joins the results back together. Put it inside a Pipeline with your model, and all the filling, scaling and encoding is learned from the training data only, even inside cross-validation. No leakage.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
num_cols = ["age", "tenure_months", "monthly_bill", "avg_speed_mbps",
"outages_90d", "support_calls_90d"]
cat_cols = ["region", "contract", "tv_bundle", "payment_method"]
prep = ColumnTransformer([
("num", Pipeline([("fill", SimpleImputer(strategy="median")),
("scale", StandardScaler())]), num_cols),
("cat", Pipeline([("fill", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore"))]), cat_cols),
])
# Later: Pipeline([("prep", prep), ("clf", YourModel())])
Leakage check
Is there any column you wouldn't know before a customer leaves? In this dataset, no. But in real projects, watch out for columns like "cancellation reason" or "final bill date", which only exist because someone left (Module 2).
13.6Step 4: Compare models
Put your preparation pipeline in front of several different models, and score each one with 5-fold cross-validation on the training data (Module 5), using ROC AUC.
Try at least three from the track, for example:
- Logistic regression (Module 4): simple, fast and easy to explain. Your baseline.
- Random forest (Module 8): strong with default settings.
- Gradient boosting (Module 8): often the most accurate once tuned.
Then tune the best two with GridSearchCV. Record every result in a table: it will go in your presentation.
What the reference solution found
Logistic regression scored 0.813, random forest 0.807 and gradient boosting 0.794 before tuning. After tuning, gradient boosting rose to 0.810, but logistic regression still just edged it. When two models are this close, choose the simpler, more explainable one. Remember "no free lunch" from Module 8: fancy isn't always better.
13.7Step 5: Choose who to call
This is where your project becomes genuinely useful. The model gives every customer a probability of leaving. The threshold decides who gets a call. Using Sarah's figures:
| What happens | Money |
|---|---|
| Every customer we call (true or false alarm) | −£60 |
| Each real leaver we call, 40% of whom stay | +£420 × 0.4 = +£168 on average |
| Leavers we don't call | £0 (we lose them, but spend nothing) |
Use the chosen model's real predictions for the 1,200 test customers to find the most profitable threshold.
Try it: find the most profitable threshold
Notice the shape of the curve. Call too few people and you miss leavers you could have saved. Call too many and the cost of calls to happy customers eats the profit: calling everyone would lose £20,760 on these 1,200 customers. The best threshold is about 0.43, earning about £11,400 for every 1,200 customers, a little more than the default of 0.5.
Scale it up
A good presentation turns this into Sarah's numbers. If Tyne Fibre has 60,000 customers, that's 50 times our test set, or roughly £570,000 of value a year from a model built in a few days. Always state the assumptions behind a number like this.
13.8Step 6: Explain the model and segment the risk
What drives churn?
Sarah asked why customers leave. Use permutation importance: shuffle one column at a time in the test data and see how much the model's score drops. A big drop means the model relied on that column. It works for any model, even inside a pipeline.
| Rank | Feature | Drop in ROC AUC when shuffled | Plain-English finding |
|---|---|---|---|
| 1 | Contract type | 0.133 | Monthly customers are by far the most likely to leave |
| 2 | Support calls | 0.045 | Each extra call is a warning sign of frustration |
| 3 | Outages | 0.032 | Service problems push people away |
| 4 | Months with us | 0.030 | New customers are the riskiest |
| 5 | Monthly bill | 0.014 | Higher bills add a little risk |
Remember: importance is not cause
Support calls don't cause churn; they signal a problem (Module 7). The recommendation isn't "stop answering the phone". It's "fix what makes people call".
What kind of offer for whom?
Sarah's third question needs clustering (Module 11). Take only the customers your model flags as at risk, scale a few descriptive features, and run K-Means. The reference solution found three groups:
| Segment | Customers | Months with us | Monthly bill | Support calls | Outages | Suggested offer |
|---|---|---|---|---|---|---|
| Frustrated by faults | 77 | 15 | £31 | 3.1 | 1.6 | Priority engineer visit and a service guarantee, not a discount |
| New and paying a lot | 91 | 7 | £47 | 1.6 | 1.0 | A discount for moving onto a 12- or 24-month contract |
| Loyal but pricey | 78 | 29 | £47 | 2.2 | 1.2 | A loyalty price review or a free TV bundle upgrade |
This is what turns a model into a strategy: not just who to call, but what to say to each group.
13.9The reference solution
Here's a complete solution that covers steps 3 to 6. Your code doesn't need to match it; there are many good ways to do this project. Use it to check your results or get unstuck.
Show the full reference solution
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
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.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score, precision_score, recall_score
from sklearn.inspection import permutation_importance
from sklearn.cluster import KMeans
# ---------- 1. Load and clean ----------
df = pd.read_csv("tyne_fibre_customers.csv")
df = df.drop_duplicates()
df.loc[df["age"] > 100, "age"] = np.nan # impossible ages -> missing
print("Rows:", len(df), "| Churn rate:", f"{(df['churned'] == 'Yes').mean():.1%}")
X = df.drop(columns=["customer_id", "churned"])
y = (df["churned"] == "Yes").astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y)
# ---------- 2. Preparation pipeline ----------
num_cols = ["age", "tenure_months", "monthly_bill", "avg_speed_mbps",
"outages_90d", "support_calls_90d"]
cat_cols = ["region", "contract", "tv_bundle", "payment_method"]
prep = ColumnTransformer([
("num", Pipeline([("fill", SimpleImputer(strategy="median")),
("scale", StandardScaler())]), num_cols),
("cat", Pipeline([("fill", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore"))]), cat_cols),
])
# ---------- 3. Compare models with 5-fold cross-validation ----------
candidates = {
"Logistic regression": LogisticRegression(max_iter=1000),
"Random forest": RandomForestClassifier(n_estimators=300, min_samples_leaf=10,
random_state=42),
"Gradient boosting": HistGradientBoostingClassifier(random_state=42),
}
print("\nCross-validated ROC AUC:")
for name, clf in candidates.items():
pipe = Pipeline([("prep", prep), ("clf", clf)])
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="roc_auc")
print(f" {name:20} {scores.mean():.3f} (+/- {scores.std():.3f})")
# ---------- 4. Tune the two strongest options, keep the best ----------
searches = {
"Logistic regression": GridSearchCV(
Pipeline([("prep", prep), ("clf", LogisticRegression(max_iter=1000))]),
param_grid={"clf__C": [0.01, 0.1, 1, 10]}, cv=5, scoring="roc_auc"),
"Gradient boosting": GridSearchCV(
Pipeline([("prep", prep), ("clf", HistGradientBoostingClassifier(random_state=42))]),
param_grid={"clf__learning_rate": [0.03, 0.1], "clf__max_depth": [3, None],
"clf__max_iter": [100, 300]}, cv=5, scoring="roc_auc"),
}
print("\nAfter tuning:")
for name, search in searches.items():
search.fit(X_train, y_train)
print(f" {name:20} {search.best_score_:.3f} {search.best_params_}")
best_name = max(searches, key=lambda k: searches[k].best_score_)
model = searches[best_name].best_estimator_
print("Chosen model:", best_name)
# ---------- 5. Final test and business threshold ----------
prob = model.predict_proba(X_test)[:, 1]
print("\nTest ROC AUC:", round(roc_auc_score(y_test, prob), 3))
VALUE_OF_CUSTOMER, OFFER_COST, SUCCESS_RATE = 420, 60, 0.40
def net_value(t):
contacted = prob >= t
caught = (contacted & (y_test == 1)).sum()
return caught * SUCCESS_RATE * VALUE_OF_CUSTOMER - contacted.sum() * OFFER_COST
thresholds = np.arange(0.05, 0.95, 0.01)
best_t = thresholds[np.argmax([net_value(t) for t in thresholds])]
pred = (prob >= best_t).astype(int)
print(f"Best threshold: {best_t:.2f} | net value: £{net_value(best_t):,.0f}")
print(f"Precision: {precision_score(y_test, pred):.2f} | Recall: {recall_score(y_test, pred):.2f}")
# ---------- 6. What drives churn? ----------
imp = permutation_importance(model, X_test, y_test, scoring="roc_auc",
n_repeats=5, random_state=42)
top = pd.Series(imp.importances_mean, index=X.columns).sort_values(ascending=False)
print("\nTop drivers of churn:")
print(top.head(5).round(3).to_string())
# ---------- 7. Segment the at-risk customers ----------
at_risk = X_test[pred == 1].copy()
seg_cols = ["tenure_months", "monthly_bill", "support_calls_90d", "outages_90d"]
seg_X = StandardScaler().fit_transform(at_risk[seg_cols].fillna(at_risk[seg_cols].median()))
at_risk["segment"] = KMeans(n_clusters=3, n_init=10, random_state=42).fit_predict(seg_X)
print("\nAt-risk segments:")
print(at_risk.groupby("segment")[seg_cols].mean().round(1)
.assign(customers=at_risk["segment"].value_counts()).to_string())
Rows: 6000 | Churn rate: 25.4%
Cross-validated ROC AUC:
Logistic regression 0.813 (+/- 0.011)
Random forest 0.807 (+/- 0.015)
Gradient boosting 0.794 (+/- 0.016)
After tuning:
Logistic regression 0.813 {'clf__C': 1}
Gradient boosting 0.810 {'clf__learning_rate': 0.03, 'clf__max_depth': 3, 'clf__max_iter': 300}
Chosen model: Logistic regression
Test ROC AUC: 0.813
Best threshold: 0.43 | net value: £11,448
Precision: 0.63 | Recall: 0.51
Top drivers of churn:
contract 0.133
support_calls_90d 0.045
outages_90d 0.032
tenure_months 0.030
monthly_bill 0.014
At-risk segments:
tenure_months monthly_bill support_calls_90d outages_90d customers
segment
0 15.3 30.7 3.1 1.6 77
1 7.0 47.4 1.6 1.0 91
2 29.3 47.4 2.2 1.2 78Stretch goals
Finished early? Try one or more of these to make your project stand out:
- Create new features (Module 2), such as "bill per Mbps" or "new and on a monthly contract", and see if they help.
- Try the gradient boosting model with
class_weight="balanced", or try a KNN or SVM (Modules 6 and 9). - Use PCA (Module 12) to draw the at-risk customers in 2D, coloured by segment.
- Test how sensitive your profit estimate is: what if only 25% of contacted leavers stay, or a call costs £80?
- Build a simple Power BI or Excel dashboard showing each customer's risk score and segment.
13.10Step 7: Present your findings
Sarah isn't a data scientist. Your presentation should be short, visual and focused on decisions. Aim for about eight slides:
| Slide | What to include |
|---|---|
| 1. The question | Sarah's three questions and what success looks like |
| 2. The headline | Your main answer in one sentence, e.g. "Calling the riskiest 20% of customers could be worth about £570k a year" |
| 3. The data | What you used, the problems you fixed, and the overall churn rate |
| 4. What drives churn | 2 or 3 simple charts, like Figure 1, and the top drivers |
| 5. The model | Which models you compared, which won and why, in plain English |
| 6. Who to call | The profit curve and the recommended threshold |
| 7. What to offer | The three segments and a tailored offer for each |
| 8. Next steps and risks | Your assumptions, the model's limits, and a suggestion to test it with a small trial first |
Talk like a consultant, not a textbook
Say "we can find about half of the customers who'll leave, and two thirds of the people we call really are at risk" instead of "recall 0.51, precision 0.63". Put the technical detail in an appendix for anyone who asks.
13.11Submission checklist and marking guide
Your finished project should include:
- A Jupyter notebook (or Python script) that runs from top to bottom without errors
- Clear headings and short notes explaining each step and decision
- At least three charts from your exploration
- A comparison table of at least three models with cross-validated scores
- A recommended threshold backed by a profit calculation
- The top drivers of churn and at least two customer segments with suggested actions
- A short presentation (around 8 slides) or a one-page summary for Sarah
| Area | Weight | What a strong project shows |
|---|---|---|
| Problem framing | 10% | A clear target, problem type and success measure linked to the business |
| Exploration and cleaning | 20% | All data problems found and handled, with reasons; useful charts |
| Modelling | 25% | A leak-free pipeline, a fair comparison with cross-validation, sensible tuning |
| Evaluation and threshold | 20% | The right measures, an honest test score, a threshold chosen for business value |
| Insight and recommendations | 15% | Clear drivers and segments turned into practical actions |
| Communication | 10% | Clean code, plain-English explanations and a clear presentation |
Add it to your portfolio
Upload your notebook to GitHub with a short README explaining the problem, your approach and your results. Add the headline result to your CV and LinkedIn, for example: "Built a churn model and targeting strategy estimated to be worth £570k a year for a simulated UK broadband provider." In interviews, be ready to explain why you chose ROC AUC and how you picked the threshold.
SummaryWhat you've achieved
- You turned a business email into a machine learning problem with a clear target and success measure.
- You found and fixed duplicates, impossible values and missing data.
- You built a leak-proof pipeline with ColumnTransformer for numbers and categories.
- You compared and tuned models fairly with cross-validation, and chose the simpler model when scores were tied.
- You chose a threshold based on profit, not accuracy.
- You explained the drivers of churn and segmented at-risk customers into groups with tailored actions.
- You communicated it all in plain English for decision-makers.
Final check: think like a data scientist
Your machine learning roadmap
- 01Introduction to Machine LearningWhat ML is and how it works
- 02Preparing Data for Machine LearningFeatures, encoding, scaling, train/test split
- 03Linear RegressionPredicting numbers
- 04Logistic RegressionPredicting yes or no
- 05Evaluating ModelsAccuracy, precision, recall, overfitting, cross-validation
- 06K-Nearest NeighboursLearning from similar examples
- 07Decision TreesFlowcharts that learn
- 08Random Forest and Gradient BoostingMany models working together
- 09Support Vector MachinesFinding the best boundary
- 10Naive BayesProbability-based classification
- 11Clustering with K-MeansFinding groups without labels
- 12Dimensionality Reduction with PCASimplifying big datasets
- 13Capstone ProjectYou are here
Congratulations, you've completed the Machine Learning track!
You've gone from "what is machine learning?" to building, tuning, explaining and presenting a complete project. That's a genuine, job-ready skill set.