Build a Retention Decision Matrix
Model churn risk and customer value to target retention actions.
Introduction
30 Second Summary
A customer who looks likely to leave may contribute very little future revenue. Another customer can show the same warning signs while putting far more value at stake.
In this project, you will build a retention decision system in Google Colab from two years of retail transactions. It predicts 90-day inactivity before placing each customer into an action group based on a combined view of churn risk with customer value.
What You'll Build
You run your notebook to see every customer land in a retention group whose typical behavior supports a clear action with a business rationale.
By the end of this project, you'll have:
- A leakage-aware churn model that predicts 90-day inactivity from earlier behavior. You can show holdout ROC-AUC plus PR-AUC scores as evidence of ranking performance.
- An interpretable baseline where logistic regression coefficients become percentage changes in churn odds. Two recommendations connect the strongest positive-risk associations to specific retention treatments.
- A four-cell decision matrix that combines a 12-month customer-value proxy with a high-risk cutoff. Its exported customer scores support a portfolio-ready executive summary.
- An optional Secret Mission: benchmark histogram gradient boosting against the logistic baseline. You will compare its strongest permutation-importance features with the baseline's strongest coefficients.
Are there any prerequisites?
An internet-connected browser is enough to run the project. A Google account lets you save the Colab notebook.
You do not need a local Python installation.
Before We Start
Before the hands-on work begins, define which retail customers deserve retention attention. Combining predicted 90-day inactivity risk with a 12-month customer-value proxy improves retention impact while avoiding unnecessary spend on low-value customers.
Set Up the Colab Workspace
You already know the customer decision this project needs to support. Now you need a consistent place to run the analysis without configuring a local data-science environment on Windows.
Google Colab provides a browser-hosted notebook with free compute access. This step locks the package versions before the retail data enters the workflow.
In this step, get ready to:
- Create a Colab notebook that saves to Google Drive.
- Install the three pinned data-analysis packages.
- Verify the package versions before preparing the project script cell.
Create and name the notebook
A Colab notebook keeps your code in one browser tab. Google Drive keeps the notebook available after the temporary runtime ends.
- Open Google Colab in your browser.
- Sign in to your Google account if Colab prompts you.
- Click File in the top menu.
- Select New notebook.
- Select the notebook title at the top of the page.
- Replace the current title with retention_decision_matrix.ipynb.
- Press Enter to apply the new title.
- Wait for Colab to indicate that the notebook is saved in Google Drive.
Your retention notebook now has a permanent home in Google Drive. You can return to the same workspace after the runtime closes.
Install the pinned packages
Notebook runtimes can start with package versions that differ from this project's tested environment. Version pins keep the analysis reproducible.
The environment uses pandas 3.0.5 for table operations. It uses scikit-learn 1.9.0 for churn modeling.
The ucimlrepo 0.0.7 package loads the retail dataset from the UCI Machine Learning Repository.
- Install all three pinned packages in the current runtime by running this cell:
%pip install pandas==3.0.5 scikit-learn==1.9.0 ucimlrepo==0.0.7
What does this cell do?
The %pip magic installs packages inside the notebook's current kernel. Each == pin requests the exact version tested for this project.
This installation can take a minute while Colab resolves the pinned packages. A busy cell indicator means the runtime is still working.
- Wait for the installation cell to finish running.
You should see installation output for all three packages without a failed cell indicator.
Installation cell failed?
- Confirm that your browser still has an internet connection.
- Rerun the installation cell once if the download was interrupted.
- Use help me diagnose why my Colab package installation cell failed if the cell continues to fail.
Verify the runtime and prepare the script
Installed-package metadata confirms which versions the active runtime can use. A final notebook cell reserves retention_analysis.py for the analysis code you build next.
Before you run the check, do you expect every installed package to match its requested version?
- Insert a new code cell beneath the installation cell.
- Verify the active package versions by running this cell:
# Read installed package versions from the active Colab runtime
import importlib.metadata
# Print one version per line for a clear environment check
print(importlib.metadata.version("pandas"))
print(importlib.metadata.version("scikit-learn"))
print(importlib.metadata.version("ucimlrepo"))
What does this code do?
- The importlib.metadata module reads package information from the active runtime.
- Each importlib.metadata.version() call returns the installed version for one package.
- The three print() calls produce a quick environment check that you can rerun after a fresh runtime starts.
You should see 3.0.5, 1.9.0, and 0.0.7 on separate lines.
✔️ All three versions match
That is the environment locked down. Your runtime now matches the versions used throughout the project.
ⓧ At least one version differs
A different result means the active runtime did not apply every requested pin.
- Return to the package installation cell above.
- Run the package installation cell again.
- Run the version-check cell again.
You should now see 3.0.5, 1.9.0, and 0.0.7 on separate lines.
ⓧ A package cannot be found
A missing-package result means the installation did not finish successfully for that package.
- Return to the package installation cell above.
- Confirm that the installation cell finishes without a failure indicator.
- Run the version-check cell again.
Package still missing?
Use help me diagnose the missing package with the installation output from your notebook.
- Insert one more code cell beneath the version output.
- Prepare the empty project script target by entering this line:
%%writefile retention_analysis.py
What does this cell prepare?
The %%writefile cell magic writes a cell's contents to retention_analysis.py when the cell runs. This prepared cell reserves the exact script filename used by the remaining steps.
- Keep the %%writefile retention_analysis.py cell unrun until the script body is added in the next step.
Before you inspect the notebook, how many setup cells do you expect to see?
- Confirm that the notebook title shows retention_decision_matrix.ipynb.
- Confirm that the installation cell contains the pinned package command.
- Confirm that the version-check output shows the three requested versions.
- Confirm that the final cell contains only %%writefile retention_analysis.py.
You should see three setup cells inside the saved notebook. The middle cell should display the verified package versions.
✔️ Awesome, I've got everything!
Your notebook is saved with the pinned environment verified. The empty retention_analysis.py target is ready for the analysis.
ⓧ I'd like to double check the full code
- Compare your package installation cell with this reference:
%pip install pandas==3.0.5 scikit-learn==1.9.0 ucimlrepo==0.0.7
Package installation cell
This cell pins the three packages to the exact project versions.
- Compare your version-check cell with this reference:
# Read installed package versions from the active Colab runtime
import importlib.metadata
# Print one version per line for a clear environment check
print(importlib.metadata.version("pandas"))
print(importlib.metadata.version("scikit-learn"))
print(importlib.metadata.version("ucimlrepo"))
Version-check cell
This cell prints one installed-package version per line.
- Compare your prepared script cell with this reference:
%%writefile retention_analysis.py
Prepared script cell
This cell names the future script without adding analysis code.
Your Colab workspace is reproducible and ready for data. Next up, you will load the retail transactions before inspecting their live headers.
Load and Clean the Retail Data
Your Google Colab workspace is ready. The next job is to turn the raw retail source into dependable transaction rows.
The official UCI Machine Learning Repository page does not expose a dependable live header table. Runtime schema mapping protects your analysis from fragile column-name assumptions.
In this step, get ready to:
- Print every live source header from dataset 502.
- Map five retail fields to stable internal names.
- Clean the purchases into an analysis-ready transaction table.
Define the project boundaries and mapping helper
The script needs stable time boundaries for the later customer analysis. Its mapping helper lets you select source columns from the headers that UCI returns at runtime.
- Return to the prepared %%writefile retention_analysis.py cell.
- Paste this first section below the magic line:
from pathlib import Path
from math import exp
import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
train_start = pd.Timestamp("2009-12-01")
train_end = pd.Timestamp("2010-12-01")
label_start = pd.Timestamp("2010-12-01")
label_end = pd.Timestamp("2011-03-01")
churn_threshold_days = 90
def choose_column(headers, business_field):
print(f"\nSelect the source column for {business_field}:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
while True:
answer = input("Enter the column number: ").strip()
try:
position = int(answer)
except ValueError:
print("Enter a whole number from the displayed list.")
continue
if 0 <= position < len(headers):
return headers[position]
print("That number is outside the displayed range.")
What does this code do?
- The imports make pandas available for transaction cleaning. They also prepare the modeling tools used later in the project.
- The five constants separate the observation period from the future churn-label period.
- The choose_column() helper displays each header with a numeric position.
- The input loop accepts a valid position before returning the selected header.
- Run the prepared writefile cell.
Colab confirms that it wrote the first section to retention_analysis.py.
File not updating?
- Confirm that %%writefile retention_analysis.py remains on the first line of the cell.
- Check that the cell finished without a Python syntax error.
- Ask for help with the writefile cell.
Fetch and map the live headers
The package can expose the full source table through data.original. The data.features fallback keeps the loader usable when the original table is unavailable.
- Place this function directly below choose_column() in the same writefile cell:
def load_and_clean_transactions():
dataset = fetch_ucirepo(id=502)
raw = getattr(dataset.data, "original", None)
if raw is None:
raw = dataset.data.features
raw = raw.copy().reset_index(drop=True)
headers = list(raw.columns)
print("\nLive headers returned by UCI:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
selected = {
"invoice_id": choose_column(headers, "invoice ID"),
"invoice_date": choose_column(headers, "invoice date and time"),
"quantity": choose_column(headers, "quantity"),
"unit_price": choose_column(headers, "unit price"),
"customer_id": choose_column(headers, "customer ID"),
}
if len(set(selected.values())) != len(selected):
raise ValueError("Each business field must map to a different source column.")
source_to_internal = {source: internal for internal, source in selected.items()}
transactions = raw[list(selected.values())].rename(columns=source_to_internal)
How does runtime mapping work?
- The fetch_ucirepo(id=502) call retrieves Online Retail II from UCI.
- The numbered header list gives you the actual columns returned during this run.
- The selected dictionary connects five business fields to your chosen source headers.
- The duplicate check prevents one source column from representing two different business fields.
- Run the writefile cell again.
Colab confirms that the expanded retention_analysis.py file was written.
Seeing an indentation error?
- Confirm that def load_and_clean_transactions(): starts at the far left.
- Keep every line inside the function indented by four spaces.
- Help me check the function indentation.
Clean and verify the transactions
Dates and purchase values must be valid before customer behavior can be measured. Cancelled invoices begin with c, so the cleaning rules remove them from positive-purchase activity.
- Place this continuation directly below the transactions = raw[list(selected.values())].rename(columns=source_to_internal) line:
transactions["invoice_date"] = pd.to_datetime(transactions["invoice_date"], errors="coerce")
transactions["quantity"] = pd.to_numeric(transactions["quantity"], errors="coerce")
transactions["unit_price"] = pd.to_numeric(transactions["unit_price"], errors="coerce")
transactions = transactions.dropna(subset=["invoice_id", "invoice_date", "quantity", "unit_price", "customer_id"])
transactions["invoice_id"] = transactions["invoice_id"].astype(str)
transactions["customer_id"] = transactions["customer_id"].astype(str)
transactions = transactions[
(transactions["quantity"] > 0)
& (transactions["unit_price"] > 0)
& (~transactions["invoice_id"].str.lower().str.startswith("c"))
].copy()
transactions["revenue"] = transactions["quantity"] * transactions["unit_price"]
print("\nSelected schema mapping:")
for internal, source in selected.items():
print(f" {source} -> {internal}")
print(f"Clean positive-purchase rows: {len(transactions):,}")
print("Clean date range:", transactions["invoice_date"].min(), "to", transactions["invoice_date"].max())
return transactions
What do the cleaning rules do?
- The conversion calls turn invalid dates or numbers into missing values.
- The dropna() call removes rows missing a required transaction field.
- The filters retain positive quantities from positive-priced purchases.
- The revenue column multiplies quantity by unit price for each retained line.
- Run the completed writefile cell.
The first fetch downloads a 43.5 MB workbook. A short wait while the cell stays busy is expected.
- Choose the number beside the source header for invoice ID when the first prompt appears.
- Choose the number beside the source header for invoice date when the second prompt appears.
- Choose the number beside the source header for quantity when the third prompt appears.
- Choose the number beside the source header for unit price when the fourth prompt appears.
- Choose the number beside the source header for customer ID when the fifth prompt appears.
Before you fetch the data, which output do you expect to appear first: the live headers or the cleaned summary?
- Load the script and call its data function by running this new Colab cell:
%run retention_analysis.py
transactions = load_and_clean_transactions()
What does this run do?
- The %run retention_analysis.py command loads the script into the current notebook session.
- The load_and_clean_transactions() call fetches the dataset before starting the five mapping prompts.
You'll see every live header with a numbered index. Your five choices then appear under the selected schema mapping.
The final lines report a cleaned positive-purchase row count. The date range falls within the documented 2009 to 2011 dataset coverage.
Mapping rejected or output missing?
- Choose a different source column for each of the five business fields.
- Use only whole numbers shown in the live header list.
- Ask for help matching the live headers.
That is the data foundation in place. Your script now turns the live retail source into clean positive-purchase rows.
✔️ Awesome, I've got everything!
Your retention_analysis.py file is saved. Its output shows the mapping, cleaned row count, and covered dates.
ⓧ I'd like to double check the full code
The complete retention_analysis.py file for this step is shown below.
from pathlib import Path
from math import exp
import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
train_start = pd.Timestamp("2009-12-01")
train_end = pd.Timestamp("2010-12-01")
label_start = pd.Timestamp("2010-12-01")
label_end = pd.Timestamp("2011-03-01")
churn_threshold_days = 90
def choose_column(headers, business_field):
print(f"\nSelect the source column for {business_field}:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
while True:
answer = input("Enter the column number: ").strip()
try:
position = int(answer)
except ValueError:
print("Enter a whole number from the displayed list.")
continue
if 0 <= position < len(headers):
return headers[position]
print("That number is outside the displayed range.")
def load_and_clean_transactions():
dataset = fetch_ucirepo(id=502)
raw = getattr(dataset.data, "original", None)
if raw is None:
raw = dataset.data.features
raw = raw.copy().reset_index(drop=True)
headers = list(raw.columns)
print("\nLive headers returned by UCI:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
selected = {
"invoice_id": choose_column(headers, "invoice ID"),
"invoice_date": choose_column(headers, "invoice date and time"),
"quantity": choose_column(headers, "quantity"),
"unit_price": choose_column(headers, "unit price"),
"customer_id": choose_column(headers, "customer ID"),
}
if len(set(selected.values())) != len(selected):
raise ValueError("Each business field must map to a different source column.")
source_to_internal = {source: internal for internal, source in selected.items()}
transactions = raw[list(selected.values())].rename(columns=source_to_internal)
transactions["invoice_date"] = pd.to_datetime(transactions["invoice_date"], errors="coerce")
transactions["quantity"] = pd.to_numeric(transactions["quantity"], errors="coerce")
transactions["unit_price"] = pd.to_numeric(transactions["unit_price"], errors="coerce")
transactions = transactions.dropna(subset=["invoice_id", "invoice_date", "quantity", "unit_price", "customer_id"])
transactions["invoice_id"] = transactions["invoice_id"].astype(str)
transactions["customer_id"] = transactions["customer_id"].astype(str)
transactions = transactions[
(transactions["quantity"] > 0)
& (transactions["unit_price"] > 0)
& (~transactions["invoice_id"].str.lower().str.startswith("c"))
].copy()
transactions["revenue"] = transactions["quantity"] * transactions["unit_price"]
print("\nSelected schema mapping:")
for internal, source in selected.items():
print(f" {source} -> {internal}")
print(f"Clean positive-purchase rows: {len(transactions):,}")
print("Clean date range:", transactions["invoice_date"].min(), "to", transactions["invoice_date"].max())
return transactions
Your transaction table is ready for customer-level analysis. Next, you'll build behavioral features and test the first churn definition.
Build Features and Expose Leakage
Your cleaned retail transactions are ready in Google Colab. The retention model needs one row per customer.
A model score can look excellent while measuring a shortcut hidden in the target. This step builds customer-level behavior features. It then exposes data leakage before that shortcut reaches a business decision.
In this step, get ready to:
- Aggregate observation-window purchases into one row per customer.
- Calculate recency, order frequency, customer value, customer age, and purchase cadence.
- Run a same-window churn test that exposes a circular target.
Build one row per customer
The existing train_start and train_end constants define the observation window. Every feature must describe customer behavior inside that fixed period.
- Switch back to the existing file-writing cell in Google Colab.
- Find the return transactions line at the end of load_and_clean_transactions().
- Add the customer aggregation function below load_and_clean_transactions() by pasting this code:
def build_customer_features(transactions):
feature_transactions = transactions[
(transactions["invoice_date"] >= train_start)
& (transactions["invoice_date"] < train_end)
].copy()
customers = feature_transactions.groupby("customer_id").agg(
first_purchase=("invoice_date", "min"),
last_purchase=("invoice_date", "max"),
frequency_orders=("invoice_id", "nunique"),
monetary_value=("revenue", "sum"),
)
customers.index.name = "customer_id"
customers["recency_days"] = (train_end - customers["last_purchase"]).dt.days.clip(lower=0)
customers["customer_age_days"] = (train_end - customers["first_purchase"]).dt.days.clip(lower=1)
customers["average_order_value"] = customers["monetary_value"] / customers["frequency_orders"]
repeat_intervals = (customers["frequency_orders"] - 1).clip(lower=1)
customers["purchase_cadence_days"] = (customers["customer_age_days"] / repeat_intervals).clip(lower=1)
return customers
What Does This Code Do?
- The date filter keeps positive purchases from train_start up to train_end.
- The pandas group operation turns many transaction lines into one customer row.
- The aggregation records each customer's first purchase, last purchase, unique order count, and total revenue.
- The calculated columns capture recency, customer age, average order value, and the typical gap between repeat purchases.
- The lower limits prevent negative day counts or division by zero.
- Rerun the existing file-writing cell to save the updated retention_analysis.py file.
- Load the updated script definitions into the notebook by running this command:
%run retention_analysis.py
What Does This Command Do?
The %run command loads the functions and constants from retention_analysis.py into the current notebook session. The script does not call the functions automatically at this stage.
- Use the same five numbered source-column selections from the previous step when the prompts appear.
- Create the cleaned transaction table and customer feature table by running:
transactions = load_and_clean_transactions()
customers = build_customer_features(transactions)
What Happens in This Check?
The first line reloads the live UCI transactions through your existing mapping workflow. The second line condenses those transactions into the customer-level feature table.
After the five prompts, you'll see the selected mapping and cleaned row summary again. The cell completes without an error once customers contains the new feature table.
Feature Table Did Not Build?
- Check that build_customer_features() starts below the complete load_and_clean_transactions() function.
- Confirm that each source field maps to a different numbered header.
- Rerun the file-writing cell before loading retention_analysis.py again.
- Help me debug the customer feature table.
Add the same-window leakage test
The next function turns the existing recency values into a temporary churn label. You will judge the resulting score after you run it.
- Return to the file-writing cell that contains retention_analysis.py.
- Add the leakage demonstration below build_customer_features() by pasting this code:
def demonstrate_leakage(customers):
naive_churn = (customers["recency_days"] > churn_threshold_days).astype(int)
naive_auc = roc_auc_score(naive_churn, customers["recency_days"])
print("\nLeakage demonstration")
print(f"Naive same-window ROC-AUC: {naive_auc:.3f}")
print("Warning: recency_days directly defines this target, so the score is leakage, not forward-looking predictive evidence.")
What Does This Test Measure?
- The naive_churn label marks customers whose recency exceeds churn_threshold_days.
- The ROC-AUC calculation checks how well recency_days ranks that label.
- The printed warning records whether this score represents genuine predictive evidence.
- Rerun the existing file-writing cell to save demonstrate_leakage() in retention_analysis.py.
- Reload the updated script definitions by running:
%run retention_analysis.py
Why Reload the Script?
The notebook session still holds the earlier version of the file. Running %run again brings the new demonstrate_leakage() function into the active session.
Run the circular score
Before you run this check, how trustworthy do you expect a score to be when the same field creates the label and ranks the customers?
- Run the leakage demonstration against the customer feature table:
demonstrate_leakage(customers)
What Does the Result Prove?
You'll see Naive same-window ROC-AUC: 1.000 or a score extremely close to it. The ranking looks exceptional because higher recency_days values directly create naive_churn.
The model has learned the rule used to manufacture the target. It has not predicted a later customer outcome.
Good catch. The suspicious score is now visible evidence of a circular churn definition.
Leakage Function Is Not Defined?
- Rerun the file-writing cell after adding demonstrate_leakage().
- Reload retention_analysis.py before calling the new function.
- Recreate customers with the feature-building cell if the Colab runtime restarted.
- Help me recover the leakage demonstration.
✔️ Awesome, I've got everything!
Your saved retention_analysis.py now builds customer-level features and exposes the same-window leakage score.
ⓧ I'd like to double check the full code
Compare your saved retention_analysis.py with this cumulative version. It includes the loading code from earlier plus both functions from this step.
from pathlib import Path
from math import exp
import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
train_start = pd.Timestamp("2009-12-01")
train_end = pd.Timestamp("2010-12-01")
label_start = pd.Timestamp("2010-12-01")
label_end = pd.Timestamp("2011-03-01")
churn_threshold_days = 90
def choose_column(headers, business_field):
print(f"\nSelect the source column for {business_field}:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
while True:
answer = input("Enter the column number: ").strip()
try:
position = int(answer)
except ValueError:
print("Enter a whole number from the displayed list.")
continue
if 0 <= position < len(headers):
return headers[position]
print("That number is outside the displayed range.")
def load_and_clean_transactions():
dataset = fetch_ucirepo(id=502)
raw = getattr(dataset.data, "original", None)
if raw is None:
raw = dataset.data.features
raw = raw.copy().reset_index(drop=True)
headers = list(raw.columns)
print("\nLive headers returned by UCI:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
selected = {
"invoice_id": choose_column(headers, "invoice ID"),
"invoice_date": choose_column(headers, "invoice date and time"),
"quantity": choose_column(headers, "quantity"),
"unit_price": choose_column(headers, "unit price"),
"customer_id": choose_column(headers, "customer ID"),
}
if len(set(selected.values())) != len(selected):
raise ValueError("Each business field must map to a different source column.")
source_to_internal = {source: internal for internal, source in selected.items()}
transactions = raw[list(selected.values())].rename(columns=source_to_internal)
transactions["invoice_date"] = pd.to_datetime(transactions["invoice_date"], errors="coerce")
transactions["quantity"] = pd.to_numeric(transactions["quantity"], errors="coerce")
transactions["unit_price"] = pd.to_numeric(transactions["unit_price"], errors="coerce")
transactions = transactions.dropna(subset=["invoice_id", "invoice_date", "quantity", "unit_price", "customer_id"])
transactions["invoice_id"] = transactions["invoice_id"].astype(str)
transactions["customer_id"] = transactions["customer_id"].astype(str)
transactions = transactions[
(transactions["quantity"] > 0)
& (transactions["unit_price"] > 0)
& (~transactions["invoice_id"].str.lower().str.startswith("c"))
].copy()
transactions["revenue"] = transactions["quantity"] * transactions["unit_price"]
print("\nSelected schema mapping:")
for internal, source in selected.items():
print(f" {source} -> {internal}")
print(f"Clean positive-purchase rows: {len(transactions):,}")
print("Clean date range:", transactions["invoice_date"].min(), "to", transactions["invoice_date"].max())
return transactions
def build_customer_features(transactions):
feature_transactions = transactions[
(transactions["invoice_date"] >= train_start)
& (transactions["invoice_date"] < train_end)
].copy()
customers = feature_transactions.groupby("customer_id").agg(
first_purchase=("invoice_date", "min"),
last_purchase=("invoice_date", "max"),
frequency_orders=("invoice_id", "nunique"),
monetary_value=("revenue", "sum"),
)
customers.index.name = "customer_id"
customers["recency_days"] = (train_end - customers["last_purchase"]).dt.days.clip(lower=0)
customers["customer_age_days"] = (train_end - customers["first_purchase"]).dt.days.clip(lower=1)
customers["average_order_value"] = customers["monetary_value"] / customers["frequency_orders"]
repeat_intervals = (customers["frequency_orders"] - 1).clip(lower=1)
customers["purchase_cadence_days"] = (customers["customer_age_days"] / repeat_intervals).clip(lower=1)
return customers
def demonstrate_leakage(customers):
naive_churn = (customers["recency_days"] > churn_threshold_days).astype(int)
naive_auc = roc_auc_score(naive_churn, customers["recency_days"])
print("\nLeakage demonstration")
print(f"Naive same-window ROC-AUC: {naive_auc:.3f}")
print("Warning: recency_days directly defines this target, so the score is leakage, not forward-looking predictive evidence.")
You now have customer-level behavior features plus proof that a circular churn label cannot support a retention decision. Next, you'll replace that shortcut with customer inactivity measured in the later 90-day window.
Predict 90-Day Churn and Explain Drivers
Your leakage test exposed why a target built from recency_days cannot prove future churn. The model now needs a label based on what customers do after the observation window.
A forward-looking label turns historical behavior into an honest prediction task. You will fit an interpretable logistic regression model. You will evaluate its probability rankings with ROC-AUC plus PR-AUC.
In this step, get ready to:
- Build a forward-looking 90-day churn label from the separate purchase window.
- Fit a balanced logistic baseline on a reproducible customer holdout.
- Translate six coefficients into business statements plus two retention recommendations.
Build the forward-looking model table
The observation window ends at train_end. The label window starts at label_start. A customer receives churned_90d = 1 when no positive purchase appears during that later window.
The model also needs comparable business units. Days become 30-day increments. Average order value becomes £10 increments.
- Return to the existing %%writefile retention_analysis.py cell in Google Colab.
- Add this function below demonstrate_leakage():
def build_model_table(customers, transactions):
label_transactions = transactions[
(transactions["invoice_date"] >= label_start)
& (transactions["invoice_date"] < label_end)
]
active_customer_ids = set(label_transactions["customer_id"].unique())
customers["churned_90d"] = (~customers.index.isin(active_customer_ids)).astype(int)
customers["recency_per_30d"] = customers["recency_days"] / 30.0
customers["aov_per_10gbp"] = customers["average_order_value"] / 10.0
customers["age_per_30d"] = customers["customer_age_days"] / 30.0
customers["cadence_per_30d"] = customers["purchase_cadence_days"] / 30.0
customers["order_band"] = pd.cut(
customers["frequency_orders"],
bins=[0, 2, 4, float("inf")],
labels=["1-2 orders", "3-4 orders", "5+ orders"],
include_lowest=True,
)
numeric_features = ["recency_per_30d", "aov_per_10gbp", "age_per_30d", "cadence_per_30d"]
order_features = pd.get_dummies(customers["order_band"], prefix="frequency", drop_first=True, dtype=float)
model_features = pd.concat([customers[numeric_features], order_features], axis=1)
for column in model_features.columns:
model_features[column] = model_features[column].clip(upper=model_features[column].quantile(0.99))
return customers, model_features
What does this code do?
- The label-window filter finds purchases from label_start up to label_end. The upper boundary stays outside the window.
- The order_band categories separate customers with 1 to 2 orders from customers with 3 to 4 orders or 5+ orders.
- The drop_first=True setting makes 1 to 2 orders the reference group.
- The 99th-percentile clipping limits extreme feature values before modeling. This reduces the influence of unusually large observations.
- Run the existing %%writefile cell to rewrite retention_analysis.py.
- Confirm Colab reports that retention_analysis.py was written.
Did the file-writing cell stop early?
Check that every line inside build_model_table() keeps four spaces of indentation. Confirm that the closing parenthesis for pd.cut() lines up with its opening assignment.
If the structure still looks wrong, use this prompt: Help me check the indentation and parentheses in build_model_table().
Fit and score the logistic baseline
A holdout set tests the model on customers excluded from fitting. Stratification keeps the churn-class balance represented across the training customers plus test customers.
The balanced class weighting gives both outcomes more influence during fitting. The model still returns a churn probability for each customer.
- Add the beginning of fit_logistic_model() below build_model_table():
def fit_logistic_model(customers, model_features):
target = customers["churned_90d"]
X_train, X_test, y_train, y_test = train_test_split(
model_features, target, test_size=0.25, random_state=42, stratify=target
)
model = LogisticRegression(max_iter=2000, class_weight="balanced")
model.fit(X_train, y_train)
test_probabilities = model.predict_proba(X_test)[:, 1]
roc_auc = roc_auc_score(y_test, test_probabilities)
pr_auc = average_precision_score(y_test, test_probabilities)
print("\nForward-label churn model")
print(f"Customer churn rate: {target.mean():.1%}")
print(f"Holdout ROC-AUC: {roc_auc:.3f}")
print(f"Holdout PR-AUC: {pr_auc:.3f}")
How does the holdout test work?
- The train_test_split() call assigns 75% of customers to training. It reserves 25% for evaluation.
- The fixed random_state=42 value makes the customer split reproducible.
- The predict_proba() call returns class probabilities from scikit-learn. Column 1 contains the modeled probability of churn.
- ROC-AUC measures ranking across classification thresholds. PR-AUC summarizes the precision-recall tradeoff for the churn class.
- Run the %%writefile cell again to persist the first part of the model function.
- Confirm the cell rewrites retention_analysis.py without stopping.
Having trouble with the model function?
Keep every line after def fit_logistic_model(customers, model_features): indented inside the function. Check that stratify=target remains inside the split call.
For focused help, use this prompt: Help me check the first part of fit_logistic_model().
Turn coefficients into retention evidence
A logistic coefficient describes a change in log-odds. Applying exp(coefficient) - 1 converts it into a percentage change in churn odds for the feature's stated business unit.
These results describe conditional associations within the model. They support targeted experiments without claiming that a feature causes churn.
- Continue fit_logistic_model() immediately below its Holdout PR-AUC print line with this coefficient logic:
unit_labels = {
"recency_per_30d": "each additional 30 days since the last purchase",
"aov_per_10gbp": "each additional £10 of average order value",
"age_per_30d": "each additional 30 days of customer history",
"cadence_per_30d": "each additional 30 days between purchases",
"frequency_3-4 orders": "3-4 orders versus 1-2 orders",
"frequency_5+ orders": "5+ orders versus 1-2 orders",
}
coefficients = pd.DataFrame({"feature": model_features.columns, "coefficient": model.coef_[0]})
coefficients["odds_change_pct"] = coefficients["coefficient"].apply(lambda value: (exp(value) - 1.0) * 100.0)
coefficients["absolute_coefficient"] = coefficients["coefficient"].abs()
coefficients = coefficients.sort_values("absolute_coefficient", ascending=False).reset_index(drop=True)
def coefficient_statement(row):
change = row["odds_change_pct"]
direction = "higher" if change >= 0 else "lower"
return f"{unit_labels.get(row['feature'], row['feature'])} is associated with {abs(change):.1f}% {direction} churn odds, holding the other model features constant."
coefficients["business_statement"] = coefficients.apply(coefficient_statement, axis=1)
print("\nCoefficient insights")
for statement in coefficients["business_statement"]:
print(f"- {statement}")
How do coefficients become business statements?
- The unit_labels dictionary connects each model feature to a unit a stakeholder can recognize.
- The odds_change_pct calculation converts each coefficient into a percentage change in churn odds.
- The absolute_coefficient column allows the table to rank associations by strength regardless of direction.
- The final sentence keeps the interpretation conditional on the other model features. That wording avoids a causal claim.
- Run the %%writefile cell to persist the coefficient logic.
- Confirm Colab rewrites retention_analysis.py.
- Continue the same function with the recommendation logic plus returned model artifacts:
recommendation_map = {
"recency_per_30d": "Trigger a low-cost reminder before another 30-day recency increment accumulates for customers already moving toward higher modeled risk.",
"aov_per_10gbp": "Prioritize proactive service recovery for higher-basket customers when their modeled risk rises, rather than defaulting to a broad discount.",
"age_per_30d": "Use lifecycle check-ins for long-tenure customers whose predicted risk is rising, and test whether recognition benefits restore activity.",
"cadence_per_30d": "Time replenishment or reminder messages just before the customer's typical purchase interval becomes unusually long.",
"frequency_3-4 orders": "Create a next-order nudge for customers with 3-4 historical orders when their modeled risk enters the high-risk band.",
"frequency_5+ orders": "Route high-frequency customers with rising modeled risk to a loyalty or personal outreach treatment.",
}
positive_risk = coefficients[coefficients["coefficient"] > 0].nlargest(2, "coefficient")
if len(positive_risk) < 2:
positive_risk = coefficients.nlargest(2, "absolute_coefficient")
recommendations = []
print("\nTwo coefficient-linked recommendations")
for number, row in enumerate(positive_risk.itertuples(), start=1):
recommendation = recommendation_map.get(row.feature, "Test a targeted retention treatment for customers high on this feature and measure incremental repurchase against a control group.")
recommendations.append(f"{recommendation} Model evidence: {row.business_statement}")
print(f"{number}. {recommendations[-1]}")
customers["churn_probability"] = model.predict_proba(model_features)[:, 1]
split = pd.Series("train", index=model_features.index)
split.loc[X_test.index] = "test"
model_dataset = model_features.copy()
model_dataset["churned_90d"] = target
model_dataset["split"] = split
metrics = pd.DataFrame([{"model": "LogisticRegression", "roc_auc": roc_auc, "pr_auc": pr_auc, "churn_rate": target.mean()}])
return customers, coefficients, recommendations, metrics, model_dataset
How are the final artifacts created?
- The recommendation selector looks for the two strongest positive-risk coefficients. Its fallback still returns two evidence-linked actions when fewer than two coefficients are positive.
- The churn_probability column scores every modeled customer. These scores support the retention matrix in the next step.
- The split column preserves which customers belonged to the training set or test set.
- The metrics table stores the holdout scores plus the customer churn rate for later export.
- At the bottom of the writefile cell, update main() to use the forward label plus logistic model:
def main():
transactions = load_and_clean_transactions()
customers = build_customer_features(transactions)
demonstrate_leakage(customers)
customers, model_features = build_model_table(customers, transactions)
customers, coefficients, recommendations, metrics, model_dataset = fit_logistic_model(customers, model_features)
Why update main?
The main() sequence now passes cleaned transactions into the customer feature table. It then passes those features into the forward-looking model.
- Run the completed %%writefile cell to rewrite retention_analysis.py.
- Prepare to enter the same five source-column selections when the script prompts you.
Before you run the analysis, do you expect the holdout scores to remain near perfect once churn comes from future purchases?
- Run the complete analysis in a new Colab code cell with this command:
%run retention_analysis.py
What should you see?
After the schema prompts, you will see the earlier leakage demonstration. The new forward-label section prints the customer churn rate plus holdout ROC-AUC and PR-AUC.
You will also see six coefficient statements. The final section prints exactly two recommendations tied to those statements.
Did the model run stop?
- Check that the five source fields map to different columns. Reusing one source column triggers the script's mapping check.
- Confirm that all continuation lines in fit_logistic_model() stay indented by four spaces.
- Confirm that exp, LogisticRegression, average_precision_score, plus train_test_split appear in the import section.
- For help with the exact failure, use this prompt: Help me debug my forward-label logistic model.
✔️ Awesome, I've got everything!
Your forward-looking model now prints holdout metrics. It also turns all six coefficients into business statements plus exactly two retention recommendations.
ⓧ I'd like to double check the full code
Compare your current retention_analysis.py with this cumulative version:
from pathlib import Path
from math import exp
import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
train_start = pd.Timestamp("2009-12-01")
train_end = pd.Timestamp("2010-12-01")
label_start = pd.Timestamp("2010-12-01")
label_end = pd.Timestamp("2011-03-01")
churn_threshold_days = 90
def choose_column(headers, business_field):
print(f"\nSelect the source column for {business_field}:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
while True:
answer = input("Enter the column number: ").strip()
try:
position = int(answer)
except ValueError:
print("Enter a whole number from the displayed list.")
continue
if 0 <= position < len(headers):
return headers[position]
print("That number is outside the displayed range.")
def load_and_clean_transactions():
dataset = fetch_ucirepo(id=502)
raw = getattr(dataset.data, "original", None)
if raw is None:
raw = dataset.data.features
raw = raw.copy().reset_index(drop=True)
headers = list(raw.columns)
print("\nLive headers returned by UCI:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
selected = {
"invoice_id": choose_column(headers, "invoice ID"),
"invoice_date": choose_column(headers, "invoice date and time"),
"quantity": choose_column(headers, "quantity"),
"unit_price": choose_column(headers, "unit price"),
"customer_id": choose_column(headers, "customer ID"),
}
if len(set(selected.values())) != len(selected):
raise ValueError("Each business field must map to a different source column.")
source_to_internal = {source: internal for internal, source in selected.items()}
transactions = raw[list(selected.values())].rename(columns=source_to_internal)
transactions["invoice_date"] = pd.to_datetime(transactions["invoice_date"], errors="coerce")
transactions["quantity"] = pd.to_numeric(transactions["quantity"], errors="coerce")
transactions["unit_price"] = pd.to_numeric(transactions["unit_price"], errors="coerce")
transactions = transactions.dropna(subset=["invoice_id", "invoice_date", "quantity", "unit_price", "customer_id"])
transactions["invoice_id"] = transactions["invoice_id"].astype(str)
transactions["customer_id"] = transactions["customer_id"].astype(str)
transactions = transactions[
(transactions["quantity"] > 0)
& (transactions["unit_price"] > 0)
& (~transactions["invoice_id"].str.lower().str.startswith("c"))
].copy()
transactions["revenue"] = transactions["quantity"] * transactions["unit_price"]
print("\nSelected schema mapping:")
for internal, source in selected.items():
print(f" {source} -> {internal}")
print(f"Clean positive-purchase rows: {len(transactions):,}")
print("Clean date range:", transactions["invoice_date"].min(), "to", transactions["invoice_date"].max())
return transactions
def build_customer_features(transactions):
feature_transactions = transactions[
(transactions["invoice_date"] >= train_start)
& (transactions["invoice_date"] < train_end)
].copy()
customers = feature_transactions.groupby("customer_id").agg(
first_purchase=("invoice_date", "min"),
last_purchase=("invoice_date", "max"),
frequency_orders=("invoice_id", "nunique"),
monetary_value=("revenue", "sum"),
)
customers.index.name = "customer_id"
customers["recency_days"] = (train_end - customers["last_purchase"]).dt.days.clip(lower=0)
customers["customer_age_days"] = (train_end - customers["first_purchase"]).dt.days.clip(lower=1)
customers["average_order_value"] = customers["monetary_value"] / customers["frequency_orders"]
repeat_intervals = (customers["frequency_orders"] - 1).clip(lower=1)
customers["purchase_cadence_days"] = (customers["customer_age_days"] / repeat_intervals).clip(lower=1)
return customers
def demonstrate_leakage(customers):
naive_churn = (customers["recency_days"] > churn_threshold_days).astype(int)
naive_auc = roc_auc_score(naive_churn, customers["recency_days"])
print("\nLeakage demonstration")
print(f"Naive same-window ROC-AUC: {naive_auc:.3f}")
print("Warning: recency_days directly defines this target, so the score is leakage, not forward-looking predictive evidence.")
def build_model_table(customers, transactions):
label_transactions = transactions[
(transactions["invoice_date"] >= label_start)
& (transactions["invoice_date"] < label_end)
]
active_customer_ids = set(label_transactions["customer_id"].unique())
customers["churned_90d"] = (~customers.index.isin(active_customer_ids)).astype(int)
customers["recency_per_30d"] = customers["recency_days"] / 30.0
customers["aov_per_10gbp"] = customers["average_order_value"] / 10.0
customers["age_per_30d"] = customers["customer_age_days"] / 30.0
customers["cadence_per_30d"] = customers["purchase_cadence_days"] / 30.0
customers["order_band"] = pd.cut(
customers["frequency_orders"],
bins=[0, 2, 4, float("inf")],
labels=["1-2 orders", "3-4 orders", "5+ orders"],
include_lowest=True,
)
numeric_features = ["recency_per_30d", "aov_per_10gbp", "age_per_30d", "cadence_per_30d"]
order_features = pd.get_dummies(customers["order_band"], prefix="frequency", drop_first=True, dtype=float)
model_features = pd.concat([customers[numeric_features], order_features], axis=1)
for column in model_features.columns:
model_features[column] = model_features[column].clip(upper=model_features[column].quantile(0.99))
return customers, model_features
def fit_logistic_model(customers, model_features):
target = customers["churned_90d"]
X_train, X_test, y_train, y_test = train_test_split(
model_features, target, test_size=0.25, random_state=42, stratify=target
)
model = LogisticRegression(max_iter=2000, class_weight="balanced")
model.fit(X_train, y_train)
test_probabilities = model.predict_proba(X_test)[:, 1]
roc_auc = roc_auc_score(y_test, test_probabilities)
pr_auc = average_precision_score(y_test, test_probabilities)
print("\nForward-label churn model")
print(f"Customer churn rate: {target.mean():.1%}")
print(f"Holdout ROC-AUC: {roc_auc:.3f}")
print(f"Holdout PR-AUC: {pr_auc:.3f}")
unit_labels = {
"recency_per_30d": "each additional 30 days since the last purchase",
"aov_per_10gbp": "each additional £10 of average order value",
"age_per_30d": "each additional 30 days of customer history",
"cadence_per_30d": "each additional 30 days between purchases",
"frequency_3-4 orders": "3-4 orders versus 1-2 orders",
"frequency_5+ orders": "5+ orders versus 1-2 orders",
}
coefficients = pd.DataFrame({"feature": model_features.columns, "coefficient": model.coef_[0]})
coefficients["odds_change_pct"] = coefficients["coefficient"].apply(lambda value: (exp(value) - 1.0) * 100.0)
coefficients["absolute_coefficient"] = coefficients["coefficient"].abs()
coefficients = coefficients.sort_values("absolute_coefficient", ascending=False).reset_index(drop=True)
def coefficient_statement(row):
change = row["odds_change_pct"]
direction = "higher" if change >= 0 else "lower"
return f"{unit_labels.get(row['feature'], row['feature'])} is associated with {abs(change):.1f}% {direction} churn odds, holding the other model features constant."
coefficients["business_statement"] = coefficients.apply(coefficient_statement, axis=1)
print("\nCoefficient insights")
for statement in coefficients["business_statement"]:
print(f"- {statement}")
recommendation_map = {
"recency_per_30d": "Trigger a low-cost reminder before another 30-day recency increment accumulates for customers already moving toward higher modeled risk.",
"aov_per_10gbp": "Prioritize proactive service recovery for higher-basket customers when their modeled risk rises, rather than defaulting to a broad discount.",
"age_per_30d": "Use lifecycle check-ins for long-tenure customers whose predicted risk is rising, and test whether recognition benefits restore activity.",
"cadence_per_30d": "Time replenishment or reminder messages just before the customer's typical purchase interval becomes unusually long.",
"frequency_3-4 orders": "Create a next-order nudge for customers with 3-4 historical orders when their modeled risk enters the high-risk band.",
"frequency_5+ orders": "Route high-frequency customers with rising modeled risk to a loyalty or personal outreach treatment.",
}
positive_risk = coefficients[coefficients["coefficient"] > 0].nlargest(2, "coefficient")
if len(positive_risk) < 2:
positive_risk = coefficients.nlargest(2, "absolute_coefficient")
recommendations = []
print("\nTwo coefficient-linked recommendations")
for number, row in enumerate(positive_risk.itertuples(), start=1):
recommendation = recommendation_map.get(row.feature, "Test a targeted retention treatment for customers high on this feature and measure incremental repurchase against a control group.")
recommendations.append(f"{recommendation} Model evidence: {row.business_statement}")
print(f"{number}. {recommendations[-1]}")
customers["churn_probability"] = model.predict_proba(model_features)[:, 1]
split = pd.Series("train", index=model_features.index)
split.loc[X_test.index] = "test"
model_dataset = model_features.copy()
model_dataset["churned_90d"] = target
model_dataset["split"] = split
metrics = pd.DataFrame([{"model": "LogisticRegression", "roc_auc": roc_auc, "pr_auc": pr_auc, "churn_rate": target.mean()}])
return customers, coefficients, recommendations, metrics, model_dataset
def main():
transactions = load_and_clean_transactions()
customers = build_customer_features(transactions)
demonstrate_leakage(customers)
customers, model_features = build_model_table(customers, transactions)
customers, coefficients, recommendations, metrics, model_dataset = fit_logistic_model(customers, model_features)
if __name__ == "__main__":
main()
Use this reference to compare function names, indentation, feature values, plus the current main() sequence.
You now have a leakage-aware churn score plus interpretable evidence for retention action. Next, you will combine that risk with customer value to decide where each treatment belongs.
Turn Risk and Value into Actions
Your logistic regression baseline now estimates each customer's 90-day churn probability. The model also explains which historical behaviors have the strongest associations with churn.
Risk alone cannot show where retention effort creates the most value. This step combines churn probability with a 12-month customer lifetime value proxy to produce four practical retention groups.
In this step, get ready to:
- Estimate customer value with a transparent 12-month run-rate proxy.
- Assign every customer to a value and risk decision group.
- Export six analysis artifacts for sharing and further investigation.
Calculate customer value and assign actions
The value proxy annualizes each customer's observed order rate at their average order value. Quantile tiers then separate the top 20%, middle 60%, and bottom 20% without claiming to forecast a customer's entire future relationship.
- In the %%writefile retention_analysis.py cell, find the end of fit_logistic_model().
- Start build_value_and_decision_matrix() below that function by adding this first segment:
def build_value_and_decision_matrix(customers):
observed_months = max((train_end - train_start).days / 30.4375, 1.0)
customers["monthly_order_rate"] = customers["frequency_orders"] / observed_months
customers["clv_12m_proxy"] = customers["monthly_order_rate"] * customers["average_order_value"] * 12.0
ranked_value = customers["clv_12m_proxy"].rank(method="first")
customers["value_tier"] = pd.qcut(ranked_value, q=[0.0, 0.20, 0.80, 1.0], labels=["Bottom 20%", "Middle 60%", "Top 20%"])
risk_cutoff = customers["churn_probability"].quantile(0.70)
customers["risk_level"] = customers["churn_probability"].apply(lambda probability: "High Risk" if probability >= risk_cutoff else "Low Risk")
customers["value_level"] = customers["value_tier"].apply(lambda tier: "High Value" if tier == "Top 20%" else "Low Value")
customers["decision_group"] = customers["value_level"].astype(str) + " + " + customers["risk_level"].astype(str)
group_order = ["High Value + Low Risk", "High Value + High Risk", "Low Value + Low Risk", "Low Value + High Risk"]
actions = {
"High Value + Low Risk": "Protect the VIP experience with recognition and early access.",
"High Value + High Risk": "Prioritize personal win-back outreach and service recovery.",
"Low Value + Low Risk": "Use low-cost cross-sell and loyalty journeys to grow value.",
"Low Value + High Risk": "Use automated reactivation tests and avoid expensive blanket discounts.",
}
rationales = {
"High Value + Low Risk": "These customers combine strong value with continued engagement, so retention should preserve trust without unnecessary incentives.",
"High Value + High Risk": "These customers place meaningful future revenue at risk, making focused intervention the highest-priority use of retention effort.",
"Low Value + Low Risk": "These customers are active but not yet high value, so scalable growth treatments fit better than costly one-to-one outreach.",
"Low Value + High Risk": "Expected value is limited and risk is high, so inexpensive experiments protect margin while testing recoverability.",
}
How are the tiers defined?
- The monthly_order_rate divides observed orders by the length of the 12-month feature window.
- The clv_12m_proxy combines monthly order rate with average order value. Multiplying by 12 expresses the estimate as a yearly run rate.
- The value_tier labels place customers into the Bottom 20%, Middle 60%, and Top 20% of the observed value ranking.
- The 70th percentile of churn_probability marks the highest-risk 30% of customers as High Risk.
- Continue the same function by placing this segment directly below the closing brace of rationales:
group_summary = customers.groupby("decision_group").agg(
customers=("churn_probability", "size"),
median_churn_probability=("churn_probability", "median"),
median_recency_days=("recency_days", "median"),
median_frequency_orders=("frequency_orders", "median"),
median_clv_12m_proxy=("clv_12m_proxy", "median"),
)
print("\nRetention decision groups")
strategy_rows = []
for group in group_order:
if group in group_summary.index:
row = group_summary.loc[group]
behavior = f"{int(row['customers']):,} customers; median risk {row['median_churn_probability']:.1%}; median recency {row['median_recency_days']:.0f} days; median orders {row['median_frequency_orders']:.0f}; median 12-month value proxy £{row['median_clv_12m_proxy']:,.0f}."
else:
behavior = "No customers landed in this group under the current thresholds."
strategy_rows.append({"group": group, "typical_behavior": behavior, "action": actions[group], "business_rationale": rationales[group]})
print(f"\n{group}")
print(f"- Typical behavior: {behavior}")
print(f"- Recommended action: {actions[group]}")
print(f"- Business rationale: {rationales[group]}")
strategy_table = pd.DataFrame(strategy_rows)
decision_matrix = pd.DataFrame(
{"Low Risk": ["High Value + Low Risk", "Low Value + Low Risk"], "High Risk": ["High Value + High Risk", "Low Value + High Risk"]},
index=["High Value", "Low Value"],
)
print("\n2 by 2 retention decision matrix")
print(decision_matrix)
return customers, strategy_table, decision_matrix, risk_cutoff
What does the decision layer produce?
- The grouped summary calculates customer count plus median risk, recency, frequency, and value for each decision group.
- The fixed group_order keeps all four strategies in a consistent business-facing order.
- The strategy_table pairs each group's observed behavior with an action and rationale.
- The decision_matrix presents those groups as a 2 by 2 value-risk framework.
The function is ready for an isolated check. This run uses the existing pipeline through the new value layer without exporting files.
Before you run this check, consider which group you expect to show the highest median 12-month value proxy.
- Run the %%writefile retention_analysis.py cell to save the updated script.
- Add a new code cell beneath the writefile cell.
- Use the same five numbered source-column choices from your earlier data-loading run when prompted.
- Run the value-layer check by pasting this code:
transactions = load_and_clean_transactions()
customers = build_customer_features(transactions)
demonstrate_leakage(customers)
customers, model_features = build_model_table(customers, transactions)
customers, coefficients, recommendations, metrics, model_dataset = fit_logistic_model(customers, model_features)
customers, strategy_table, decision_matrix, risk_cutoff = build_value_and_decision_matrix(customers)
What should you see?
You should see all four retention groups in the printed output. Each group includes its customer count, median behavior, recommended action, and business rationale.
The final table should place High Value and Low Value on the rows. Low Risk and High Risk should appear as the columns.
Missing a decision group?
- Confirm that risk_cutoff uses the 0.70 quantile of churn_probability.
- Check that only the Top 20% tier becomes High Value.
- Use Help me debug my retention decision groups..
Export the analysis artifacts
The decision system becomes reusable when its scores, evidence, and recommendations leave the notebook output. The export function writes five structured CSV files plus a stakeholder-readable Markdown summary.
- Add export_outputs() directly below build_value_and_decision_matrix() in the writefile cell:
def export_outputs(customers, coefficients, recommendations, metrics, model_dataset, strategy_table, decision_matrix, risk_cutoff):
customers.reset_index().to_csv("customer_scores.csv", index=False)
metrics.to_csv("model_metrics.csv", index=False)
coefficients.to_csv("logistic_coefficients.csv", index=False)
strategy_table.to_csv("retention_decision_matrix.csv", index=False)
model_dataset.to_csv("model_dataset.csv", index_label="customer_id")
top_statements = coefficients.head(3)["business_statement"].tolist()
summary_lines = [
"# Executive Summary: Retention Decision Matrix", "", "## Business question",
"Which customers should receive retention attention when both predicted 90-day inactivity and customer value matter?", "", "## Method",
f"Features use transactions in [{train_start.date()}, {train_end.date()}); the churn label uses purchases in [{label_start.date()}, {label_end.date()}).",
"The baseline is an interpretable logistic regression evaluated on a stratified customer holdout.",
"The value score is a transparent 12-month run-rate proxy, not a contractual lifetime-value forecast.", "", "## Holdout results",
f"- ROC-AUC: {metrics.loc[0, 'roc_auc']:.3f}", f"- PR-AUC: {metrics.loc[0, 'pr_auc']:.3f}", f"- Churn rate: {metrics.loc[0, 'churn_rate']:.1%}", f"- High-risk cutoff: {risk_cutoff:.1%}", "", "## Strongest modeled associations",
]
summary_lines.extend([f"- {statement}" for statement in top_statements])
summary_lines.extend(["", "## Recommended retention actions", f"1. {recommendations[0]}", f"2. {recommendations[1]}", "", "## Decision matrix", decision_matrix.to_string(), "", "## Limitations", "- Churn means no observed positive purchase in the next 90 days, not a confirmed customer cancellation.", "- The 12-month value figure is a historical run-rate proxy and does not model margin, acquisition cost, or discounting.", "- The holdout separates customers, while the feature and label windows provide the temporal separation. A later project should add rolling out-of-time validation.", "- Coefficients describe conditional associations in this model and do not prove that an intervention will cause retention."])
Path("executive_summary.md").write_text("\n".join(summary_lines), encoding="utf-8")
print("\nCreated portfolio files:")
for filename in ["customer_scores.csv", "model_metrics.csv", "logistic_coefficients.csv", "retention_decision_matrix.csv", "model_dataset.csv", "executive_summary.md"]:
print(f"- {filename}")
What does the export function preserve?
- The customer score file preserves behavior, churn probability, customer value, and the final decision group at customer level.
- The metric and coefficient files preserve the model evidence behind the recommendations.
- The model dataset preserves the feature columns plus the existing train and test assignments for the optional benchmark.
- The executive summary records the business question, temporal method, results, actions, matrix, and limitations.
Wire the complete workflow and verify it
The final main() function passes each stage's outputs into the next stage. One run now moves from raw transactions to exported retention artifacts.
- Replace the existing main() function and script entry point with this completed version:
def main():
transactions = load_and_clean_transactions()
customers = build_customer_features(transactions)
demonstrate_leakage(customers)
customers, model_features = build_model_table(customers, transactions)
customers, coefficients, recommendations, metrics, model_dataset = fit_logistic_model(customers, model_features)
customers, strategy_table, decision_matrix, risk_cutoff = build_value_and_decision_matrix(customers)
export_outputs(customers, coefficients, recommendations, metrics, model_dataset, strategy_table, decision_matrix, risk_cutoff)
if __name__ == "__main__":
main()
How does the completed workflow connect?
Each function returns the objects required by the next stage. The final call receives the customer scores, model evidence, recommendations, decision strategy, and calculated risk cutoff.
The script entry point calls main() whenever you run retention_analysis.py as a program.
- Run the %%writefile retention_analysis.py cell to write the completed script.
Before you run the final check, consider which six filenames should appear after the decision matrix.
- Run the completed analysis from a new Colab code cell:
%run retention_analysis.py
What should the final run show?
Use the same source-column mappings when the script prompts you. The output should print all four decision groups plus the 2 by 2 matrix.
The final section should list customer_scores.csv, model_metrics.csv, logistic_coefficients.csv, retention_decision_matrix.csv, model_dataset.csv, and executive_summary.md.
Seeing an export error?
- Confirm that from pathlib import Path appears at the top of retention_analysis.py.
- Check that export_outputs() appears above main().
- Use Help me fix my portfolio file export..
✔️ Awesome, I've got everything!
Your retention decision system now scores customers, assigns all four value-risk strategies, and exports six reusable analysis files.
ⓧ I'd like to double check the full code
Compare your retention_analysis.py with the complete script below. Confirm that the functions appear in this order.
from pathlib import Path
from math import exp
import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
train_start = pd.Timestamp("2009-12-01")
train_end = pd.Timestamp("2010-12-01")
label_start = pd.Timestamp("2010-12-01")
label_end = pd.Timestamp("2011-03-01")
churn_threshold_days = 90
def choose_column(headers, business_field):
print(f"\nSelect the source column for {business_field}:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
while True:
answer = input("Enter the column number: ").strip()
try:
position = int(answer)
except ValueError:
print("Enter a whole number from the displayed list.")
continue
if 0 <= position < len(headers):
return headers[position]
print("That number is outside the displayed range.")
def load_and_clean_transactions():
dataset = fetch_ucirepo(id=502)
raw = getattr(dataset.data, "original", None)
if raw is None:
raw = dataset.data.features
raw = raw.copy().reset_index(drop=True)
headers = list(raw.columns)
print("\nLive headers returned by UCI:")
for position, header in enumerate(headers):
print(f" {position}: {header}")
selected = {
"invoice_id": choose_column(headers, "invoice ID"),
"invoice_date": choose_column(headers, "invoice date and time"),
"quantity": choose_column(headers, "quantity"),
"unit_price": choose_column(headers, "unit price"),
"customer_id": choose_column(headers, "customer ID"),
}
if len(set(selected.values())) != len(selected):
raise ValueError("Each business field must map to a different source column.")
source_to_internal = {source: internal for internal, source in selected.items()}
transactions = raw[list(selected.values())].rename(columns=source_to_internal)
transactions["invoice_date"] = pd.to_datetime(transactions["invoice_date"], errors="coerce")
transactions["quantity"] = pd.to_numeric(transactions["quantity"], errors="coerce")
transactions["unit_price"] = pd.to_numeric(transactions["unit_price"], errors="coerce")
transactions = transactions.dropna(subset=["invoice_id", "invoice_date", "quantity", "unit_price", "customer_id"])
transactions["invoice_id"] = transactions["invoice_id"].astype(str)
transactions["customer_id"] = transactions["customer_id"].astype(str)
transactions = transactions[
(transactions["quantity"] > 0)
& (transactions["unit_price"] > 0)
& (~transactions["invoice_id"].str.lower().str.startswith("c"))
].copy()
transactions["revenue"] = transactions["quantity"] * transactions["unit_price"]
print("\nSelected schema mapping:")
for internal, source in selected.items():
print(f" {source} -> {internal}")
print(f"Clean positive-purchase rows: {len(transactions):,}")
print("Clean date range:", transactions["invoice_date"].min(), "to", transactions["invoice_date"].max())
return transactions
def build_customer_features(transactions):
feature_transactions = transactions[
(transactions["invoice_date"] >= train_start)
& (transactions["invoice_date"] < train_end)
].copy()
customers = feature_transactions.groupby("customer_id").agg(
first_purchase=("invoice_date", "min"),
last_purchase=("invoice_date", "max"),
frequency_orders=("invoice_id", "nunique"),
monetary_value=("revenue", "sum"),
)
customers.index.name = "customer_id"
customers["recency_days"] = (train_end - customers["last_purchase"]).dt.days.clip(lower=0)
customers["customer_age_days"] = (train_end - customers["first_purchase"]).dt.days.clip(lower=1)
customers["average_order_value"] = customers["monetary_value"] / customers["frequency_orders"]
repeat_intervals = (customers["frequency_orders"] - 1).clip(lower=1)
customers["purchase_cadence_days"] = (customers["customer_age_days"] / repeat_intervals).clip(lower=1)
return customers
def demonstrate_leakage(customers):
naive_churn = (customers["recency_days"] > churn_threshold_days).astype(int)
naive_auc = roc_auc_score(naive_churn, customers["recency_days"])
print("\nLeakage demonstration")
print(f"Naive same-window ROC-AUC: {naive_auc:.3f}")
print("Warning: recency_days directly defines this target, so the score is leakage, not forward-looking predictive evidence.")
def build_model_table(customers, transactions):
label_transactions = transactions[
(transactions["invoice_date"] >= label_start)
& (transactions["invoice_date"] < label_end)
]
active_customer_ids = set(label_transactions["customer_id"].unique())
customers["churned_90d"] = (~customers.index.isin(active_customer_ids)).astype(int)
customers["recency_per_30d"] = customers["recency_days"] / 30.0
customers["aov_per_10gbp"] = customers["average_order_value"] / 10.0
customers["age_per_30d"] = customers["customer_age_days"] / 30.0
customers["cadence_per_30d"] = customers["purchase_cadence_days"] / 30.0
customers["order_band"] = pd.cut(
customers["frequency_orders"],
bins=[0, 2, 4, float("inf")],
labels=["1-2 orders", "3-4 orders", "5+ orders"],
include_lowest=True,
)
numeric_features = ["recency_per_30d", "aov_per_10gbp", "age_per_30d", "cadence_per_30d"]
order_features = pd.get_dummies(customers["order_band"], prefix="frequency", drop_first=True, dtype=float)
model_features = pd.concat([customers[numeric_features], order_features], axis=1)
for column in model_features.columns:
model_features[column] = model_features[column].clip(upper=model_features[column].quantile(0.99))
return customers, model_features
def fit_logistic_model(customers, model_features):
target = customers["churned_90d"]
X_train, X_test, y_train, y_test = train_test_split(
model_features, target, test_size=0.25, random_state=42, stratify=target
)
model = LogisticRegression(max_iter=2000, class_weight="balanced")
model.fit(X_train, y_train)
test_probabilities = model.predict_proba(X_test)[:, 1]
roc_auc = roc_auc_score(y_test, test_probabilities)
pr_auc = average_precision_score(y_test, test_probabilities)
print("\nForward-label churn model")
print(f"Customer churn rate: {target.mean():.1%}")
print(f"Holdout ROC-AUC: {roc_auc:.3f}")
print(f"Holdout PR-AUC: {pr_auc:.3f}")
unit_labels = {
"recency_per_30d": "each additional 30 days since the last purchase",
"aov_per_10gbp": "each additional £10 of average order value",
"age_per_30d": "each additional 30 days of customer history",
"cadence_per_30d": "each additional 30 days between purchases",
"frequency_3-4 orders": "3-4 orders versus 1-2 orders",
"frequency_5+ orders": "5+ orders versus 1-2 orders",
}
coefficients = pd.DataFrame({"feature": model_features.columns, "coefficient": model.coef_[0]})
coefficients["odds_change_pct"] = coefficients["coefficient"].apply(lambda value: (exp(value) - 1.0) * 100.0)
coefficients["absolute_coefficient"] = coefficients["coefficient"].abs()
coefficients = coefficients.sort_values("absolute_coefficient", ascending=False).reset_index(drop=True)
def coefficient_statement(row):
change = row["odds_change_pct"]
direction = "higher" if change >= 0 else "lower"
return f"{unit_labels.get(row['feature'], row['feature'])} is associated with {abs(change):.1f}% {direction} churn odds, holding the other model features constant."
coefficients["business_statement"] = coefficients.apply(coefficient_statement, axis=1)
print("\nCoefficient insights")
for statement in coefficients["business_statement"]:
print(f"- {statement}")
recommendation_map = {
"recency_per_30d": "Trigger a low-cost reminder before another 30-day recency increment accumulates for customers already moving toward higher modeled risk.",
"aov_per_10gbp": "Prioritize proactive service recovery for higher-basket customers when their modeled risk rises, rather than defaulting to a broad discount.",
"age_per_30d": "Use lifecycle check-ins for long-tenure customers whose predicted risk is rising, and test whether recognition benefits restore activity.",
"cadence_per_30d": "Time replenishment or reminder messages just before the customer's typical purchase interval becomes unusually long.",
"frequency_3-4 orders": "Create a next-order nudge for customers with 3-4 historical orders when their modeled risk enters the high-risk band.",
"frequency_5+ orders": "Route high-frequency customers with rising modeled risk to a loyalty or personal outreach treatment.",
}
positive_risk = coefficients[coefficients["coefficient"] > 0].nlargest(2, "coefficient")
if len(positive_risk) < 2:
positive_risk = coefficients.nlargest(2, "absolute_coefficient")
recommendations = []
print("\nTwo coefficient-linked recommendations")
for number, row in enumerate(positive_risk.itertuples(), start=1):
recommendation = recommendation_map.get(row.feature, "Test a targeted retention treatment for customers high on this feature and measure incremental repurchase against a control group.")
recommendations.append(f"{recommendation} Model evidence: {row.business_statement}")
print(f"{number}. {recommendations[-1]}")
customers["churn_probability"] = model.predict_proba(model_features)[:, 1]
split = pd.Series("train", index=model_features.index)
split.loc[X_test.index] = "test"
model_dataset = model_features.copy()
model_dataset["churned_90d"] = target
model_dataset["split"] = split
metrics = pd.DataFrame([{"model": "LogisticRegression", "roc_auc": roc_auc, "pr_auc": pr_auc, "churn_rate": target.mean()}])
return customers, coefficients, recommendations, metrics, model_dataset
def build_value_and_decision_matrix(customers):
observed_months = max((train_end - train_start).days / 30.4375, 1.0)
customers["monthly_order_rate"] = customers["frequency_orders"] / observed_months
customers["clv_12m_proxy"] = customers["monthly_order_rate"] * customers["average_order_value"] * 12.0
ranked_value = customers["clv_12m_proxy"].rank(method="first")
customers["value_tier"] = pd.qcut(ranked_value, q=[0.0, 0.20, 0.80, 1.0], labels=["Bottom 20%", "Middle 60%", "Top 20%"])
risk_cutoff = customers["churn_probability"].quantile(0.70)
customers["risk_level"] = customers["churn_probability"].apply(lambda probability: "High Risk" if probability >= risk_cutoff else "Low Risk")
customers["value_level"] = customers["value_tier"].apply(lambda tier: "High Value" if tier == "Top 20%" else "Low Value")
customers["decision_group"] = customers["value_level"].astype(str) + " + " + customers["risk_level"].astype(str)
group_order = ["High Value + Low Risk", "High Value + High Risk", "Low Value + Low Risk", "Low Value + High Risk"]
actions = {
"High Value + Low Risk": "Protect the VIP experience with recognition and early access.",
"High Value + High Risk": "Prioritize personal win-back outreach and service recovery.",
"Low Value + Low Risk": "Use low-cost cross-sell and loyalty journeys to grow value.",
"Low Value + High Risk": "Use automated reactivation tests and avoid expensive blanket discounts.",
}
rationales = {
"High Value + Low Risk": "These customers combine strong value with continued engagement, so retention should preserve trust without unnecessary incentives.",
"High Value + High Risk": "These customers place meaningful future revenue at risk, making focused intervention the highest-priority use of retention effort.",
"Low Value + Low Risk": "These customers are active but not yet high value, so scalable growth treatments fit better than costly one-to-one outreach.",
"Low Value + High Risk": "Expected value is limited and risk is high, so inexpensive experiments protect margin while testing recoverability.",
}
group_summary = customers.groupby("decision_group").agg(
customers=("churn_probability", "size"),
median_churn_probability=("churn_probability", "median"),
median_recency_days=("recency_days", "median"),
median_frequency_orders=("frequency_orders", "median"),
median_clv_12m_proxy=("clv_12m_proxy", "median"),
)
print("\nRetention decision groups")
strategy_rows = []
for group in group_order:
if group in group_summary.index:
row = group_summary.loc[group]
behavior = f"{int(row['customers']):,} customers; median risk {row['median_churn_probability']:.1%}; median recency {row['median_recency_days']:.0f} days; median orders {row['median_frequency_orders']:.0f}; median 12-month value proxy £{row['median_clv_12m_proxy']:,.0f}."
else:
behavior = "No customers landed in this group under the current thresholds."
strategy_rows.append({"group": group, "typical_behavior": behavior, "action": actions[group], "business_rationale": rationales[group]})
print(f"\n{group}")
print(f"- Typical behavior: {behavior}")
print(f"- Recommended action: {actions[group]}")
print(f"- Business rationale: {rationales[group]}")
strategy_table = pd.DataFrame(strategy_rows)
decision_matrix = pd.DataFrame(
{"Low Risk": ["High Value + Low Risk", "Low Value + Low Risk"], "High Risk": ["High Value + High Risk", "Low Value + High Risk"]},
index=["High Value", "Low Value"],
)
print("\n2 by 2 retention decision matrix")
print(decision_matrix)
return customers, strategy_table, decision_matrix, risk_cutoff
def export_outputs(customers, coefficients, recommendations, metrics, model_dataset, strategy_table, decision_matrix, risk_cutoff):
customers.reset_index().to_csv("customer_scores.csv", index=False)
metrics.to_csv("model_metrics.csv", index=False)
coefficients.to_csv("logistic_coefficients.csv", index=False)
strategy_table.to_csv("retention_decision_matrix.csv", index=False)
model_dataset.to_csv("model_dataset.csv", index_label="customer_id")
top_statements = coefficients.head(3)["business_statement"].tolist()
summary_lines = [
"# Executive Summary: Retention Decision Matrix", "", "## Business question",
"Which customers should receive retention attention when both predicted 90-day inactivity and customer value matter?", "", "## Method",
f"Features use transactions in [{train_start.date()}, {train_end.date()}); the churn label uses purchases in [{label_start.date()}, {label_end.date()}).",
"The baseline is an interpretable logistic regression evaluated on a stratified customer holdout.",
"The value score is a transparent 12-month run-rate proxy, not a contractual lifetime-value forecast.", "", "## Holdout results",
f"- ROC-AUC: {metrics.loc[0, 'roc_auc']:.3f}", f"- PR-AUC: {metrics.loc[0, 'pr_auc']:.3f}", f"- Churn rate: {metrics.loc[0, 'churn_rate']:.1%}", f"- High-risk cutoff: {risk_cutoff:.1%}", "", "## Strongest modeled associations",
]
summary_lines.extend([f"- {statement}" for statement in top_statements])
summary_lines.extend(["", "## Recommended retention actions", f"1. {recommendations[0]}", f"2. {recommendations[1]}", "", "## Decision matrix", decision_matrix.to_string(), "", "## Limitations", "- Churn means no observed positive purchase in the next 90 days, not a confirmed customer cancellation.", "- The 12-month value figure is a historical run-rate proxy and does not model margin, acquisition cost, or discounting.", "- The holdout separates customers, while the feature and label windows provide the temporal separation. A later project should add rolling out-of-time validation.", "- Coefficients describe conditional associations in this model and do not prove that an intervention will cause retention."])
Path("executive_summary.md").write_text("\n".join(summary_lines), encoding="utf-8")
print("\nCreated portfolio files:")
for filename in ["customer_scores.csv", "model_metrics.csv", "logistic_coefficients.csv", "retention_decision_matrix.csv", "model_dataset.csv", "executive_summary.md"]:
print(f"- {filename}")
def main():
transactions = load_and_clean_transactions()
customers = build_customer_features(transactions)
demonstrate_leakage(customers)
customers, model_features = build_model_table(customers, transactions)
customers, coefficients, recommendations, metrics, model_dataset = fit_logistic_model(customers, model_features)
customers, strategy_table, decision_matrix, risk_cutoff = build_value_and_decision_matrix(customers)
export_outputs(customers, coefficients, recommendations, metrics, model_dataset, strategy_table, decision_matrix, risk_cutoff)
if __name__ == "__main__":
main()
You have completed the decision layer. Your analysis now turns churn scores into customer-level priorities plus evidence-linked retention actions.
Secret mission
Benchmark a Nonlinear Churn Model
Your logistic baseline tells a clear coefficient story. This optional benchmark tests whether nonlinear interactions improve holdout ranking. It compares feature rankings before adding the result to your portfolio outputs.
Clean Up Your Resources
Clean Up Your Resources
Google Colab provides free compute access for this project. The workflow creates no paid cloud infrastructure.
Choose whether to keep your retention analysis, pause until a fresh runtime starts, or delete every project resource.
Resources you used:
- Saved notebook in Google Drive (retention_decision_matrix.ipynb).
- Analysis scripts in temporary Colab storage (retention_analysis.py, stretch_model.py).
- Six core portfolio outputs: customer_scores.csv, model_metrics.csv, logistic_coefficients.csv, retention_decision_matrix.csv, model_dataset.csv, executive_summary.md.
- Nonlinear benchmark output (boosting_permutation_importance.csv).
Keep everything running
No cleanup is needed. Choose this option if you plan to develop the model further or publish the analysis later.
- Keep retention_decision_matrix.ipynb in Google Drive.
- Download both analysis scripts to your portfolio folder.
- Download every generated CSV file from the Colab runtime.
- Download executive_summary.md from the Colab runtime.
- Rerun the pinned package-install cell whenever Colab starts a fresh runtime.
Pause - I'll come back to this later
Preserve the notebook while allowing the temporary runtime to expire. A fresh runtime needs the existing package-install cell before the analysis runs again.
- Download retention_analysis.py from the current runtime.
- Download stretch_model.py from the current runtime.
- Download every generated .csv file you want to preserve.
- Download executive_summary.md before leaving the runtime.
- Leave retention_decision_matrix.ipynb in Google Drive.
- Close the Colab browser tab when you finish working.
- Restore the environment later by rerunning the existing package-install cell.
Delete - I don't want to use this again
Remove the session files before deleting the saved notebook. The source dataset remains available from the UCI Machine Learning Repository if you rebuild the analysis later.
- Select both analysis scripts in the Colab sidebar file browser (retention_analysis.py, stretch_model.py).
- Add all generated CSV files to the selection: customer_scores.csv, model_metrics.csv, logistic_coefficients.csv, retention_decision_matrix.csv, model_dataset.csv, boosting_permutation_importance.csv.
- Add executive_summary.md to the selection.
- Use the file browser's delete action to remove the selected session files.
- Confirm that the Colab file browser no longer lists any project scripts or generated outputs.
Your temporary session copies are now gone. The saved notebook remains in Google Drive until you remove it separately.
- Close the Colab notebook tab.
- Open Google Drive.
- Locate retention_decision_matrix.ipynb in the folder where you saved it.
- Use the notebook's context menu to move it to the trash.
- Confirm that the original Google Drive folder no longer lists retention_decision_matrix.ipynb.
Nice Work!
Nice Work!
You did it! Your retention decision system now turns retail transactions into leakage-aware churn predictions, customer-value priorities, evidence-linked actions, and portfolio-ready outputs.
You've learned how to:
- Build a leakage-aware 90-day churn label using separate observation and label windows. Engineer customer-level features for recency, order frequency, spending, customer age, and purchase cadence.
- Evaluate an interpretable logistic regression baseline with ROC-AUC and PR-AUC. Translate model coefficients into percentage changes in churn odds. Connect the strongest risk associations to two retention recommendations.
- Combine churn probability with a 12-month CLV proxy to create a four-cell retention decision matrix. Export customer scores, model metrics, coefficient insights, retention actions, and an executive summary.
- Secret Mission: Benchmarked the logistic baseline against histogram gradient boosting. Compared holdout performance with ROC-AUC and PR-AUC. Checked whether the top permutation-importance features agreed with the strongest logistic coefficients.
Ready to quiz yourself?