Segment Retail Customers with KMeans

Build a KMeans model that turns retail transactions into customer segments.

Introduction

30 Second Summary

A retailer can record thousands of purchases yet still struggle to see which customers are loyal or drifting away. Treating every shopper the same leaves useful patterns buried in the sales history.

In this project, you will build a customer segmentation notebook in Kaggle Notebooks using genuine UK online retail transactions. The finished analysis turns line items into RFM customer profiles before KMeans reveals groups with a marketing action for each one.

What You'll Build

Picture running your notebook to reveal color-coded customer groups beside a table that tells the retailer how to retain, win back, cross-sell to, or nurture each segment.

By the end of this project, you'll have:

  • A reproducible notebook that downloads real retail transactions before showing the invoice rows, data types, and missing values behind the analysis.
  • A customer-level view that turns hundreds of thousands of line items into Recency, Frequency, and Monetary behavior for each shopper.
  • A clear before-and-after experiment showing why feature scaling matters, followed by a final model selected with silhouette scores and explained through a color-coded chart plus a business action table.
  • Secret Mission: Use the fitted scaler and model to assign a brand-new customer to an existing segment with a recommended marketing action.

Are there any prerequisites?

You need a Kaggle account plus basic experience with pandas. The cloud notebook supplies the workspace, so no local Python installation is required.

Before We Start

First, lock in what you are building before the hands-on work begins. Your one-sentence commitment will connect the customer-segmentation model to the retailer's need for tailored marketing.

Set Up Your Kaggle Workspace

Your customer-segmentation model depends on a consistent runtime. Local installation differences could distract you from the machine learning work.

Kaggle Notebooks supplies a cloud-based Python runtime with pandas plus scikit-learn. This workspace keeps your project reproducible without local setup.

In this step, get ready to:
  • Create a Python notebook named kmeans-online-retail-segmentation.
  • Configure the latest notebook environment without an accelerator.
  • Verify Python 3.13.15 plus every required library version.
Create your notebook

The notebook becomes the single workspace for your code plus its output. You use your existing Kaggle account here, so no new credentials are needed.

  • Visit Kaggle in your browser.
  • Sign in with your existing Kaggle account.
  • Click Create in the top navigation.
  • Select New Notebook.
  • Keep the notebook language as Python.
  • Select the notebook title at the top of the page.
  • Enter kmeans-online-retail-segmentation as the notebook name.

You now see a Python notebook canvas with kmeans-online-retail-segmentation at the top. Your project has a named cloud workspace.

Notebook not opening?

  • Refresh Kaggle after confirming that your browser completed the sign-in flow.
  • Try creating the notebook again from the Create menu.
  • Help me open a new Kaggle Notebook with my existing account.
Configure the session

The latest environment keeps your notebook aligned with the versions checked by this project. This tabular workflow uses the CPU because pandas plus scikit-learn do not benefit from GPU access.

  • Open Session options in the notebook sidebar.
  • Select the latest environment under Environment.
  • Confirm the Accelerator control stays disabled.

Your session now shows the latest environment with no accelerator. The notebook is configured for the CPU workflow used throughout this project.

Verify the environment

Kaggle updates its notebook images over time. A version check proves that your session contains the exact runtime used by this project before you begin the analysis.

  • Add the project imports to the first notebook cell by pasting this code:
# Step 1: Imports and environment verification
import platform
from importlib.metadata import version

import kagglehub
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

What do these imports prepare?

  • The platform module reads the active Python version.
  • The version function reads installed package versions.
  • KaggleHub provides the dataset download function used by this notebook.
  • Matplotlib provides the charts used to compare clusters.
  • The scikit-learn imports prepare clustering, scaling, plus model evaluation.
  • Run the first cell by clicking its play icon.

The cell returns to an idle state without an error panel. That completed execution confirms every required import is available.

Imports not completing?

Confirm that Environment still uses the latest environment. An older image can contain different package versions.

Run the cell again after checking the session setting. Help me diagnose a failed import in my Kaggle Notebook.

  • Place the cursor below the import from StandardScaler in the same cell.
  • Add the expected versions plus comparison logic by pasting this code:
EXPECTED_PYTHON = "3.13.15"
EXPECTED_VERSIONS = {
    "kagglehub": "1.0.2",
    "pandas": "3.0.6",
    "scikit-learn": "1.9.1",
    "matplotlib": "3.11.2",
}

python_version = platform.python_version()
python_status = "OK" if python_version == EXPECTED_PYTHON else "CHECK"
print(f"Python: {python_version} [{python_status}]")

for package_name, expected_version in EXPECTED_VERSIONS.items():
    installed_version = version(package_name)
    status = "OK" if installed_version == expected_version else "CHECK"
    print(f"{package_name}: {installed_version} [{status}]")

What does the version check do?

  • The EXPECTED_PYTHON value stores the required interpreter version.
  • The EXPECTED_VERSIONS dictionary pairs each package with its required version.
  • The Python comparison assigns either OK or CHECK to python_status.
  • The loop reads each installed package version. It prints a status beside every result.

Before you run this check, do you expect every status to read OK?

  • Run the completed first cell by clicking its play icon.

You will see Python 3.13.15 with OK. You will also see OK beside KaggleHub 1.0.2, pandas 3.0.6, scikit-learn 1.9.1, plus Matplotlib 3.11.2.

Your workspace is ready for reproducible analysis. The notebook now has every import plus the verified environment required by the customer-segmentation workflow.

Seeing a CHECK status?

Return to Session options to confirm that Environment uses the latest environment. Run the version-check cell again after correcting that setting.

Compare the reported version with the expected version printed beside it. Help me resolve a CHECK status in my Kaggle environment verification cell.

✔️ Awesome, I've got everything!

Your first cell now contains the complete environment check. Every required version reports OK.

ⓧ I'd like to double check the full code

# Step 1: Imports and environment verification
import platform
from importlib.metadata import version

import kagglehub
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

EXPECTED_PYTHON = "3.13.15"
EXPECTED_VERSIONS = {
    "kagglehub": "1.0.2",
    "pandas": "3.0.6",
    "scikit-learn": "1.9.1",
    "matplotlib": "3.11.2",
}

python_version = platform.python_version()
python_status = "OK" if python_version == EXPECTED_PYTHON else "CHECK"
print(f"Python: {python_version} [{python_status}]")

for package_name, expected_version in EXPECTED_VERSIONS.items():
    installed_version = version(package_name)
    status = "OK" if installed_version == expected_version else "CHECK"
    print(f"{package_name}: {installed_version} [{status}]")

Your Kaggle workspace is configured plus verified. Next, you will connect the notebook to genuine retail transactions and inspect the first customer invoices.

Download and Inspect Retail Data

Your verified Kaggle Notebook environment is ready. The next goal is connecting it to genuine retail invoices.

Seeing real invoice rows is the first visible proof that your analysis uses business data. A non-empty pandas DataFrame gives the customer analysis evidence from actual purchases.

In this step, get ready to:
  • Download the selected one-year Online Retail II file from Kaggle.
  • Load the five required transaction columns into a pandas DataFrame.
  • Display the table shape, first rows, data types, and missing-value counts.
Add the data intake cell

The dataset handle identifies the source on Kaggle. KaggleHub uses the file name to retrieve the selected CSV.

  • Use the notebook's cell controls to insert a new code cell directly below the environment-verification cell.
  • Add the complete download and inspection workflow to the new cell by pasting this code:
# Step 2: Download and inspect the real transaction data
DATASET_HANDLE = "jillwang87/online-retail-ii"
DATASET_FILE = "online_retail_09_10.csv"

data_file = kagglehub.dataset_download(
    DATASET_HANDLE,
    path=DATASET_FILE,
)

transactions = pd.read_csv(
    data_file,
    usecols=["InvoiceNo", "Quantity", "InvoiceDate", "UnitPrice", "CustomerID"],
    dtype={"InvoiceNo": "string"},
    parse_dates=["InvoiceDate"],
    date_format="mixed",
    encoding="latin1",
)

print(f"Transaction table shape: {transactions.shape}")
print(transactions.head())
print("\nData types:")
print(transactions.dtypes)
print("\nMissing values:")
print(transactions.isna().sum())

What does this cell do?

  • The DATASET_HANDLE value identifies the Online Retail II dataset.
  • The DATASET_FILE value selects the CSV containing transactions from the chosen year.
  • The kagglehub.dataset_download() call downloads the selected file. data_file stores its local path.
  • The CSV reader loads only the five columns needed for customer analysis.
  • The InvoiceNo column stays as text so invoice identifiers retain their original form.
  • The InvoiceDate column becomes date-time data for later calculations.
  • The final lines print the table size. They also print a preview, data types, and missing-value counts.

✔️ Awesome, I've got everything!

Great. Your dataset cell is ready to run.

ⓧ I'd like to double check the full code

# Step 1: Imports and environment verification
import platform
from importlib.metadata import version

import kagglehub
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

EXPECTED_PYTHON = "3.13.15"
EXPECTED_VERSIONS = {
    "kagglehub": "1.0.2",
    "pandas": "3.0.6",
    "scikit-learn": "1.9.1",
    "matplotlib": "3.11.2",
}

python_version = platform.python_version()
python_status = "OK" if python_version == EXPECTED_PYTHON else "CHECK"
print(f"Python: {python_version} [{python_status}]")

for package_name, expected_version in EXPECTED_VERSIONS.items():
    installed_version = version(package_name)
    status = "OK" if installed_version == expected_version else "CHECK"
    print(f"{package_name}: {installed_version} [{status}]")

# Step 2: Download and inspect the real transaction data
DATASET_HANDLE = "jillwang87/online-retail-ii"
DATASET_FILE = "online_retail_09_10.csv"

data_file = kagglehub.dataset_download(
    DATASET_HANDLE,
    path=DATASET_FILE,
)

transactions = pd.read_csv(
    data_file,
    usecols=["InvoiceNo", "Quantity", "InvoiceDate", "UnitPrice", "CustomerID"],
    dtype={"InvoiceNo": "string"},
    parse_dates=["InvoiceDate"],
    date_format="mixed",
    encoding="latin1",
)

print(f"Transaction table shape: {transactions.shape}")
print(transactions.head())
print("\nData types:")
print(transactions.dtypes)
print("\nMissing values:")
print(transactions.isna().sum())
Run and inspect the transaction cell

The cell produces four checkpoints for the download. Together, they confirm that the file contains usable transaction records.

Before you run the cell, predict whether every transaction row contains a customer ID.

  • Run the new cell using its play control.

You'll see a non-empty table shape followed by the first transaction rows. The data types and missing-value counts appear underneath.

  • Check that the first dimension in Transaction table shape is greater than zero.
  • Confirm the preview contains InvoiceNo, Quantity, InvoiceDate, UnitPrice, and CustomerID.
  • Confirm InvoiceDate appears as date-time data in the data-types output.
  • Review the missing-value counts for each of the five columns.

That's the data connection working. Your notebook is now reading genuine retail invoices.

Cell stops before the preview?

  • Confirm that you are still signed into Kaggle.
  • Confirm that DATASET_HANDLE matches the dataset handle in the full-code tab.
  • Confirm that DATASET_FILE matches the selected CSV file name.

If the cell still stops, help me diagnose why the Kaggle dataset cell is not reaching the table preview.

Your notebook now holds genuine invoice data. Next, you'll convert those transaction lines into one comparable record per customer.

Build Customer RFM Features

Your notebook now holds genuine retail transactions from the selected CSV. The preview proved that the data is ready for analysis.

KMeans needs one comparable feature vector per customer. RFM condenses repeated purchases into three business signals.

In this step, get ready to:
  • Filter out incomplete or invalid transaction rows.
  • Calculate revenue using a consistent analysis date.
  • Aggregate Recency, Frequency, and Monetary behavior for each customer.
Remove invalid purchase rows

Customer behavior should come from completed purchases with identifiable customers. Missing identifiers or cancellation invoices would distort the customer summary.

  • Insert a new code cell directly below the transaction-inspection output.
  • Create clean by pasting this code into the new cell:
# Step 3: Clean transactions and engineer RFM features
clean = transactions.dropna(subset=["CustomerID", "InvoiceDate"]).copy()
clean = clean[~clean["InvoiceNo"].str.startswith("C", na=False)].copy()
clean = clean[(clean["Quantity"] > 0) & (clean["UnitPrice"] > 0)].copy()
clean["CustomerID"] = clean["CustomerID"].astype("int64")

What does this cleaning code do?

  • The first filter removes rows without a CustomerID or InvoiceDate.
  • The prefix check excludes cancellation invoices beginning with C.
  • The numeric filter keeps rows with positive Quantity values plus positive UnitPrice values.
  • The final conversion stores customer identifiers as int64 values.
  • Run the cleaning cell.

The cell finishes without an exception.

Good progress. Your clean table now contains the purchase rows that can represent customer behavior.

Cleaning cell not completing?

  • Confirm that the transaction-loading cell from earlier has run in the current notebook session.
  • Compare every column name with InvoiceNo, Quantity, InvoiceDate, UnitPrice, and CustomerID.
  • Use this prompt if the filters still fail: Help me diagnose why my transaction-cleaning cell fails in this customer RFM notebook.
Aggregate one row per customer

Revenue turns each product line into spend. A snapshot date one day after the latest invoice gives every customer the same recency reference point.

pandas groups the cleaned rows by customer. The resulting table holds one behavioral record for each customer.

  • Insert a second code cell directly below the cleaning cell.
  • Engineer the customer-level RFM table by pasting this code:
clean["Revenue"] = clean["Quantity"] * clean["UnitPrice"]

snapshot_date = clean["InvoiceDate"].max() + pd.Timedelta(days=1)

rfm = clean.groupby("CustomerID").agg(
    Recency=("InvoiceDate", lambda dates: (snapshot_date - dates.max()).days),
    Frequency=("InvoiceNo", "nunique"),
    Monetary=("Revenue", "sum"),
)

FEATURES = ["Recency", "Frequency", "Monetary"]
print(f"Clean transactions: {len(clean):,}")
print(f"Customers in RFM table: {len(rfm):,}")
print(rfm.head())

What does this feature code do?

  • Revenue multiplies Quantity by UnitPrice for every retained product line.
  • The snapshot_date creates a shared endpoint for calculating days since each customer's latest purchase.
  • The aggregation calculates days since the latest invoice as Recency.
  • The same aggregation counts unique invoices as Frequency.
  • The aggregation sums revenue as Monetary.
  • The FEATURES list fixes the column order that the model uses later.

Before you run the cell, predict whether each customer still occupies many rows.

  • Run the RFM cell.

You'll first see non-zero counts for clean transactions plus customers. You'll then see a non-empty table indexed by CustomerID.

The table contains exactly Recency, Frequency, and Monetary as feature columns. You have converted repeated product lines into one model-ready record per customer.

RFM table not appearing?

  • Confirm that the cleaning cell completed before you ran the aggregation cell.
  • Check that snapshot_date uses the latest date from clean.
  • Compare the indentation inside agg() with the reference code below.
  • Use this prompt for help with the aggregation: Help me troubleshoot why my customer-indexed RFM table is empty or missing its three feature columns.

✔️ Awesome, I've got everything!

Great. Your notebook now contains the cleaned transaction data plus the complete customer-level RFM table.

ⓧ I'd like to double check the full code

# Step 1: Imports and environment verification
import platform
from importlib.metadata import version

import kagglehub
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

EXPECTED_PYTHON = "3.13.15"
EXPECTED_VERSIONS = {
    "kagglehub": "1.0.2",
    "pandas": "3.0.6",
    "scikit-learn": "1.9.1",
    "matplotlib": "3.11.2",
}

python_version = platform.python_version()
python_status = "OK" if python_version == EXPECTED_PYTHON else "CHECK"
print(f"Python: {python_version} [{python_status}]")

for package_name, expected_version in EXPECTED_VERSIONS.items():
    installed_version = version(package_name)
    status = "OK" if installed_version == expected_version else "CHECK"
    print(f"{package_name}: {installed_version} [{status}]")

# Step 2: Download and inspect the real transaction data
DATASET_HANDLE = "jillwang87/online-retail-ii"
DATASET_FILE = "online_retail_09_10.csv"

data_file = kagglehub.dataset_download(
    DATASET_HANDLE,
    path=DATASET_FILE,
)

transactions = pd.read_csv(
    data_file,
    usecols=["InvoiceNo", "Quantity", "InvoiceDate", "UnitPrice", "CustomerID"],
    dtype={"InvoiceNo": "string"},
    parse_dates=["InvoiceDate"],
    date_format="mixed",
    encoding="latin1",
)

print(f"Transaction table shape: {transactions.shape}")
print(transactions.head())
print("\nData types:")
print(transactions.dtypes)
print("\nMissing values:")
print(transactions.isna().sum())

# Step 3: Clean transactions and engineer RFM features
clean = transactions.dropna(subset=["CustomerID", "InvoiceDate"]).copy()
clean = clean[~clean["InvoiceNo"].str.startswith("C", na=False)].copy()
clean = clean[(clean["Quantity"] > 0) & (clean["UnitPrice"] > 0)].copy()
clean["CustomerID"] = clean["CustomerID"].astype("int64")
clean["Revenue"] = clean["Quantity"] * clean["UnitPrice"]

snapshot_date = clean["InvoiceDate"].max() + pd.Timedelta(days=1)

rfm = clean.groupby("CustomerID").agg(
    Recency=("InvoiceDate", lambda dates: (snapshot_date - dates.max()).days),
    Frequency=("InvoiceNo", "nunique"),
    Monetary=("Revenue", "sum"),
)

FEATURES = ["Recency", "Frequency", "Monetary"]
print(f"Clean transactions: {len(clean):,}")
print(f"Customers in RFM table: {len(rfm):,}")
print(rfm.head())

Your customer-level feature table is ready. Next, you'll test how raw feature ranges shape a distance-based model.

Test an Unscaled Baseline

Your customer-level RFM table now turns the retail history into one behavioral row per shopper. The next test asks how KMeans behaves when those three columns keep their original units.

KMeans groups customers by measuring the distance between their feature values. This baseline deliberately uses raw values so the largest numerical range can steer the clusters.

In this step, get ready to:
  • Compare the raw ranges of the three RFM features.
  • Fit a four-cluster baseline directly to the unscaled customer data.
  • Visualize how the raw feature ranges shape the customer groups.
Expose the raw feature ranges

The minimum and maximum values reveal how much numerical space each feature occupies. The baseline then assigns every customer to one of four clusters without changing those ranges.

  • Add a code cell directly below the RFM cell from earlier.
  • Expose the ranges and fit the raw baseline by pasting this code into the new cell:
# Step 4: Fit an intentionally flawed unscaled baseline
print("\nRaw feature ranges:")
print(rfm[FEATURES].agg(["min", "max"]).T)

raw_model = KMeans(
    n_clusters=4,
    random_state=42,
    n_init="auto",
)
rfm["RawCluster"] = raw_model.fit_predict(rfm[FEATURES])

What does this code do?

  • The feature summary prints the smallest and largest values in each RFM column.
  • The raw_model configuration creates four customer clusters with a repeatable starting state.
  • The fit_predict() call trains the baseline directly on rfm[FEATURES].
  • The returned label for each customer is stored in RawCluster.

Before you run the cell, which feature do you expect to span the widest numerical range?

  • Run the new code cell.

You'll see that Monetary spans a much larger range than Recency or Frequency. You have exposed the intended flaw: spend arrives with far more numerical weight than the other features.

Baseline cell not completing?

  • Run the RFM cell from earlier again if rfm is unavailable.
  • Check that FEATURES contains the three column names with matching capitalization.
  • Ask for help with the baseline cell: Help me troubleshoot why my unscaled KMeans baseline is not producing feature ranges or RawCluster labels..
Profile the raw clusters

A cluster profile replaces thousands of customer labels with one summary row per group. The means reveal which behavior has shaped each cluster most strongly.

  • Add a code cell below the baseline cell.
  • Calculate the raw cluster profile by pasting this code into the new cell:
raw_profile = rfm.groupby("RawCluster").agg(
    CustomerCount=("RawCluster", "size"),
    RecencyMean=("Recency", "mean"),
    FrequencyMean=("Frequency", "mean"),
    MonetaryMean=("Monetary", "mean"),
).round(2)
print("\nUnscaled cluster profile:")
print(raw_profile)

What does this code do?

  • The grouping collects customers with the same RawCluster label.
  • The CustomerCount value records the size of each raw segment.
  • The three mean columns summarize the typical Recency, Frequency, and Monetary behavior in each segment.
  • The final print displays the complete baseline profile for comparison.
  • Run the profile cell.

You'll see four rows indexed by RawCluster. The MonetaryMean values separate the groups much more sharply than the other feature means.

That result gives you direct evidence that the raw model is organizing customers mainly around spend.

Profile output missing?

  • Run the baseline cell again if RawCluster is missing from rfm.
  • Compare each aggregation name with the profile code shown above.
  • Ask for help with the profile: Help me diagnose why raw_profile is not displaying four unscaled customer clusters..
Visualize the spend-dominated split

A scatter plot turns the profile into a customer-level view. Matplotlib places Frequency on the horizontal axis and Monetary on the vertical axis.

  • Add a code cell below the profile cell.
  • Draw the colored baseline scatter plot by pasting this code into the new cell:
plt.figure(figsize=(8, 5))
raw_scatter = plt.scatter(
    rfm["Frequency"],
    rfm["Monetary"],
    c=rfm["RawCluster"],
    cmap="viridis",
    alpha=0.6,
)
plt.xlabel("Frequency")
plt.ylabel("Monetary")
plt.title("Unscaled KMeans Baseline")
plt.colorbar(raw_scatter, label="RawCluster")
plt.show()

What does this code do?

  • The scatter plot places one customer at each Frequency and Monetary coordinate.
  • The RawCluster labels choose the color of each customer point.
  • The color bar connects each plotted color to its raw cluster number.
  • The title identifies this chart as the deliberately unscaled baseline.

Before you run the final cell, which axis do you expect to define most of the visible separation?

  • Run the scatter plot cell.

You'll see a colored customer scatter plot titled Unscaled KMeans Baseline. The cluster colors separate mainly along the Monetary axis.

Your baseline is complete. The feature ranges, profile, and chart all expose the same spend-dominated result.

Chart not appearing?

  • Run the baseline cell again if RawCluster is unavailable.
  • Check that the final line calls plt.show().
  • Ask for help with the chart: Help me troubleshoot why my unscaled Frequency-versus-Monetary scatter plot is not displaying..

✔️ Awesome, I've got everything!

Your notebook now contains the complete unscaled baseline.

ⓧ I'd like to double check the full code

# Step 1: Imports and environment verification
import platform
from importlib.metadata import version

import kagglehub
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

EXPECTED_PYTHON = "3.13.15"
EXPECTED_VERSIONS = {
    "kagglehub": "1.0.2",
    "pandas": "3.0.6",
    "scikit-learn": "1.9.1",
    "matplotlib": "3.11.2",
}

python_version = platform.python_version()
python_status = "OK" if python_version == EXPECTED_PYTHON else "CHECK"
print(f"Python: {python_version} [{python_status}]")

for package_name, expected_version in EXPECTED_VERSIONS.items():
    installed_version = version(package_name)
    status = "OK" if installed_version == expected_version else "CHECK"
    print(f"{package_name}: {installed_version} [{status}]")

# Step 2: Download and inspect the real transaction data
DATASET_HANDLE = "jillwang87/online-retail-ii"
DATASET_FILE = "online_retail_09_10.csv"

data_file = kagglehub.dataset_download(
    DATASET_HANDLE,
    path=DATASET_FILE,
)

transactions = pd.read_csv(
    data_file,
    usecols=["InvoiceNo", "Quantity", "InvoiceDate", "UnitPrice", "CustomerID"],
    dtype={"InvoiceNo": "string"},
    parse_dates=["InvoiceDate"],
    date_format="mixed",
    encoding="latin1",
)

print(f"Transaction table shape: {transactions.shape}")
print(transactions.head())
print("\nData types:")
print(transactions.dtypes)
print("\nMissing values:")
print(transactions.isna().sum())

# Step 3: Clean transactions and engineer RFM features
clean = transactions.dropna(subset=["CustomerID", "InvoiceDate"]).copy()
clean = clean[~clean["InvoiceNo"].str.startswith("C", na=False)].copy()
clean = clean[(clean["Quantity"] > 0) & (clean["UnitPrice"] > 0)].copy()
clean["CustomerID"] = clean["CustomerID"].astype("int64")
clean["Revenue"] = clean["Quantity"] * clean["UnitPrice"]

snapshot_date = clean["InvoiceDate"].max() + pd.Timedelta(days=1)

rfm = clean.groupby("CustomerID").agg(
    Recency=("InvoiceDate", lambda dates: (snapshot_date - dates.max()).days),
    Frequency=("InvoiceNo", "nunique"),
    Monetary=("Revenue", "sum"),
)

FEATURES = ["Recency", "Frequency", "Monetary"]
print(f"Clean transactions: {len(clean):,}")
print(f"Customers in RFM table: {len(rfm):,}")
print(rfm.head())

# Step 4: Fit an intentionally flawed unscaled baseline
print("\nRaw feature ranges:")
print(rfm[FEATURES].agg(["min", "max"]).T)

raw_model = KMeans(
    n_clusters=4,
    random_state=42,
    n_init="auto",
)
rfm["RawCluster"] = raw_model.fit_predict(rfm[FEATURES])

raw_profile = rfm.groupby("RawCluster").agg(
    CustomerCount=("RawCluster", "size"),
    RecencyMean=("Recency", "mean"),
    FrequencyMean=("Frequency", "mean"),
    MonetaryMean=("Monetary", "mean"),
).round(2)
print("\nUnscaled cluster profile:")
print(raw_profile)

plt.figure(figsize=(8, 5))
raw_scatter = plt.scatter(
    rfm["Frequency"],
    rfm["Monetary"],
    c=rfm["RawCluster"],
    cmap="viridis",
    alpha=0.6,
)
plt.xlabel("Frequency")
plt.ylabel("Monetary")
plt.title("Unscaled KMeans Baseline")
plt.colorbar(raw_scatter, label="RawCluster")
plt.show()

Your baseline has made the distance problem visible. Next, you will rebalance the customer features and select the strongest cluster count.

Scale and Profile Final Segments

Your unscaled KMeans baseline exposed how Monetary can dominate a distance-based model. You can now correct that imbalance before assigning the final customer segments.

Feature clipping limits the influence of extreme customers. Feature scaling gives Recency, Frequency, and Monetary equal influence before a silhouette score selects the cluster count.

In this step, get ready to:
  • Balance the three RFM features before modeling.
  • Select the strongest cluster count with silhouette scores.
  • Translate the final segments into marketing actions.
Cap and standardize the RFM features

The 99th-percentile caps reduce the pull from extreme feature values. StandardScaler then centers each feature before scaling it to unit variance.

  • Add a code cell beneath the unscaled baseline cell in the notebook from earlier.
  • Prepare the model inputs by pasting this code into the new cell:
# Step 5: Cap outliers, scale features, select k, and fit the final model
cap_values = rfm[FEATURES].quantile(0.99)
model_data = rfm[FEATURES].clip(upper=cap_values, axis="columns")

scaler = StandardScaler()
scaled_rfm = scaler.fit_transform(model_data)

What does this code do?

  • The cap_values Series stores the 99th-percentile limit for each RFM feature.
  • The clip() call limits values above their feature-specific caps.
  • The fitted scaler stores the statistics needed to standardize future customer records consistently.
  • The scaled_rfm array gives the clustering model balanced inputs.
  • Run the cell using its run control.

The cell completes without an error. The notebook now holds capped data plus a fitted scaler.

Cell not completing?

Confirm that the earlier RFM cell has run in the current session. The new code depends on rfm plus FEATURES.

Run the earlier import cell if StandardScaler is unavailable. Help me troubleshoot the scaling cell.

Compare candidate cluster counts

A silhouette score measures how closely each customer fits its assigned cluster compared with other clusters. Higher scores indicate clearer separation within this candidate set.

  • Add another code cell beneath the scaling cell.
  • Evaluate candidates from 2 through 6 clusters by pasting this code:
silhouette_scores = {}
for cluster_count in range(2, 7):
    candidate_model = KMeans(
        n_clusters=cluster_count,
        random_state=42,
        n_init="auto",
    )
    candidate_labels = candidate_model.fit_predict(scaled_rfm)
    silhouette_scores[cluster_count] = silhouette_score(
        scaled_rfm,
        candidate_labels,
    )

score_series = pd.Series(silhouette_scores, name="SilhouetteScore")
best_k = int(score_series.idxmax())
print("\nSilhouette scores:")
print(score_series.round(4))
print(f"Selected cluster count: {best_k}")

How is the model selected?

  • The loop fits one candidate model for each cluster count from 2 through 6.
  • Each candidate receives a silhouette score based on its labels.
  • The score_series Series makes the candidate results easy to compare.
  • The idxmax() result becomes best_k for the final model.

Before you run the comparison, which candidate cluster count do you think produces the highest score?

  • Run the candidate-evaluation cell using its run control.

You will see one silhouette score for each candidate. The final line shows the cluster count selected for best_k.

Missing silhouette scores?

Confirm that the scaling cell completed before this cell. The candidate models require scaled_rfm.

Check that the loop body remains indented beneath the for line. Help me fix the candidate-model loop.

Profile and visualize the final segments

The selected cluster count now defines the final customer groups. A profile summarizes each group using customer count plus average RFM behavior.

  • Add a code cell beneath the silhouette-score cell.
  • Fit the final model by pasting this code:
final_model = KMeans(
    n_clusters=best_k,
    random_state=42,
    n_init="auto",
)
rfm["Cluster"] = final_model.fit_predict(scaled_rfm)

profile = rfm.groupby("Cluster").agg(
    CustomerCount=("Cluster", "size"),
    RecencyMean=("Recency", "mean"),
    FrequencyMean=("Frequency", "mean"),
    MonetaryMean=("Monetary", "mean"),
).round(2)

benchmarks = rfm[FEATURES].median()

What does the final fit create?

  • The final_model uses the cluster count chosen by the silhouette comparison.
  • The Cluster column stores the final label for every customer.
  • The profile table summarizes customer volume plus mean RFM behavior for each cluster.
  • The benchmarks Series stores the median value of each original RFM feature.
  • Run the final-model cell using its run control.

The cell completes without an error. The rfm table now contains final cluster assignments.

Final model not fitting?

Confirm that the candidate cell printed a value for best_k. The final model uses that value as its cluster count.

Check that scaled_rfm still exists in the active notebook session. Help me diagnose the final KMeans fit.

Cluster labels become useful when they lead to a business decision. The next function compares each cluster with the overall customer medians.

  • Add a code cell beneath the final-model cell.
  • Assign a marketing action to each cluster by pasting this code:
def recommend_action(cluster_row):
    recent = cluster_row["RecencyMean"] <= benchmarks["Recency"]
    frequent = cluster_row["FrequencyMean"] >= benchmarks["Frequency"]
    valuable = cluster_row["MonetaryMean"] >= benchmarks["Monetary"]

    if recent and valuable:
        return "Reward and retain"
    if not recent and valuable:
        return "Win-back campaign"
    if frequent:
        return "Cross-sell bundles"
    return "Low-cost nurture"


profile["RecommendedAction"] = profile.apply(recommend_action, axis="columns")
print("\nFinal segment profile:")
print(profile.to_string())

How are actions recommended?

  • The function checks whether a cluster is recent compared with the customer median.
  • It separately checks whether the cluster is frequent or valuable compared with the corresponding medians.
  • The ordered rules map each cluster to one marketing action.
  • The completed profile table prints every cluster with its size, feature means, and recommendation.
  • Run the recommendation cell using its run control.

You will see a final segment profile with CustomerCount, RecencyMean, FrequencyMean, MonetaryMean, and RecommendedAction columns.

Recommended actions missing?

Confirm that the final-model cell created both profile and benchmarks before this cell ran.

Keep the function body indented beneath recommend_action(). Help me fix the segment-action function.

A paired Matplotlib figure connects model selection with the resulting customer assignments. The first chart compares scores while the second shows the final segments.

  • Add one final code cell beneath the segment-profile cell.
  • Create the model-selection chart plus the customer-segment chart by pasting this code:
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].bar(score_series.index.astype(str), score_series.values, color="steelblue")
axes[0].set_xlabel("Number of clusters")
axes[0].set_ylabel("Silhouette score")
axes[0].set_title("Selecting the Cluster Count")

final_scatter = axes[1].scatter(
    rfm["Frequency"],
    rfm["Monetary"],
    c=rfm["Cluster"],
    cmap="viridis",
    alpha=0.6,
)
axes[1].set_xlabel("Frequency")
axes[1].set_ylabel("Monetary")
axes[1].set_title("Final Customer Segments")
fig.colorbar(final_scatter, ax=axes[1], label="Cluster")
fig.tight_layout()
plt.show()

What will the charts show?

  • The left chart displays one bar for each tested cluster count.
  • The right chart plots Frequency against Monetary for every customer.
  • The Cluster labels control the customer colors.
  • The color bar connects each color with its numeric cluster label.

Before you run the final cell, where do you expect the clearest color separation to appear in the customer scatter plot?

  • Run the visualization cell using its run control.

You will see a silhouette-score bar chart beside a colored Frequency-versus-Monetary scatter plot. The profile output above the figure links each cluster to a recommended marketing action.

Charts not appearing?

Confirm that the silhouette cell created score_series. Confirm that the final-model cell added Cluster to rfm.

Run the earlier import cell if plt is unavailable. Help me troubleshoot the final charts.

✔️ Awesome, I've got everything!

Great work. Confirm that every new cell has run successfully in kmeans-online-retail-segmentation.ipynb.

ⓧ I'd like to double check the full code

# Step 1: Imports and environment verification
import platform
from importlib.metadata import version

import kagglehub
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

EXPECTED_PYTHON = "3.13.15"
EXPECTED_VERSIONS = {
    "kagglehub": "1.0.2",
    "pandas": "3.0.6",
    "scikit-learn": "1.9.1",
    "matplotlib": "3.11.2",
}

python_version = platform.python_version()
python_status = "OK" if python_version == EXPECTED_PYTHON else "CHECK"
print(f"Python: {python_version} [{python_status}]")

for package_name, expected_version in EXPECTED_VERSIONS.items():
    installed_version = version(package_name)
    status = "OK" if installed_version == expected_version else "CHECK"
    print(f"{package_name}: {installed_version} [{status}]")

# Step 2: Download and inspect the real transaction data
DATASET_HANDLE = "jillwang87/online-retail-ii"
DATASET_FILE = "online_retail_09_10.csv"

data_file = kagglehub.dataset_download(
    DATASET_HANDLE,
    path=DATASET_FILE,
)

transactions = pd.read_csv(
    data_file,
    usecols=["InvoiceNo", "Quantity", "InvoiceDate", "UnitPrice", "CustomerID"],
    dtype={"InvoiceNo": "string"},
    parse_dates=["InvoiceDate"],
    date_format="mixed",
    encoding="latin1",
)

print(f"Transaction table shape: {transactions.shape}")
print(transactions.head())
print("\nData types:")
print(transactions.dtypes)
print("\nMissing values:")
print(transactions.isna().sum())

# Step 3: Clean transactions and engineer RFM features
clean = transactions.dropna(subset=["CustomerID", "InvoiceDate"]).copy()
clean = clean[~clean["InvoiceNo"].str.startswith("C", na=False)].copy()
clean = clean[(clean["Quantity"] > 0) & (clean["UnitPrice"] > 0)].copy()
clean["CustomerID"] = clean["CustomerID"].astype("int64")
clean["Revenue"] = clean["Quantity"] * clean["UnitPrice"]

snapshot_date = clean["InvoiceDate"].max() + pd.Timedelta(days=1)

rfm = clean.groupby("CustomerID").agg(
    Recency=("InvoiceDate", lambda dates: (snapshot_date - dates.max()).days),
    Frequency=("InvoiceNo", "nunique"),
    Monetary=("Revenue", "sum"),
)

FEATURES = ["Recency", "Frequency", "Monetary"]
print(f"Clean transactions: {len(clean):,}")
print(f"Customers in RFM table: {len(rfm):,}")
print(rfm.head())

# Step 4: Fit an intentionally flawed unscaled baseline
print("\nRaw feature ranges:")
print(rfm[FEATURES].agg(["min", "max"]).T)

raw_model = KMeans(
    n_clusters=4,
    random_state=42,
    n_init="auto",
)
rfm["RawCluster"] = raw_model.fit_predict(rfm[FEATURES])

raw_profile = rfm.groupby("RawCluster").agg(
    CustomerCount=("RawCluster", "size"),
    RecencyMean=("Recency", "mean"),
    FrequencyMean=("Frequency", "mean"),
    MonetaryMean=("Monetary", "mean"),
).round(2)
print("\nUnscaled cluster profile:")
print(raw_profile)

plt.figure(figsize=(8, 5))
raw_scatter = plt.scatter(
    rfm["Frequency"],
    rfm["Monetary"],
    c=rfm["RawCluster"],
    cmap="viridis",
    alpha=0.6,
)
plt.xlabel("Frequency")
plt.ylabel("Monetary")
plt.title("Unscaled KMeans Baseline")
plt.colorbar(raw_scatter, label="RawCluster")
plt.show()

# Step 5: Cap outliers, scale features, select k, and fit the final model
cap_values = rfm[FEATURES].quantile(0.99)
model_data = rfm[FEATURES].clip(upper=cap_values, axis="columns")

scaler = StandardScaler()
scaled_rfm = scaler.fit_transform(model_data)

silhouette_scores = {}
for cluster_count in range(2, 7):
    candidate_model = KMeans(
        n_clusters=cluster_count,
        random_state=42,
        n_init="auto",
    )
    candidate_labels = candidate_model.fit_predict(scaled_rfm)
    silhouette_scores[cluster_count] = silhouette_score(
        scaled_rfm,
        candidate_labels,
    )

score_series = pd.Series(silhouette_scores, name="SilhouetteScore")
best_k = int(score_series.idxmax())
print("\nSilhouette scores:")
print(score_series.round(4))
print(f"Selected cluster count: {best_k}")

final_model = KMeans(
    n_clusters=best_k,
    random_state=42,
    n_init="auto",
)
rfm["Cluster"] = final_model.fit_predict(scaled_rfm)

profile = rfm.groupby("Cluster").agg(
    CustomerCount=("Cluster", "size"),
    RecencyMean=("Recency", "mean"),
    FrequencyMean=("Frequency", "mean"),
    MonetaryMean=("Monetary", "mean"),
).round(2)

benchmarks = rfm[FEATURES].median()


def recommend_action(cluster_row):
    recent = cluster_row["RecencyMean"] <= benchmarks["Recency"]
    frequent = cluster_row["FrequencyMean"] >= benchmarks["Frequency"]
    valuable = cluster_row["MonetaryMean"] >= benchmarks["Monetary"]

    if recent and valuable:
        return "Reward and retain"
    if not recent and valuable:
        return "Win-back campaign"
    if frequent:
        return "Cross-sell bundles"
    return "Low-cost nurture"


profile["RecommendedAction"] = profile.apply(recommend_action, axis="columns")
print("\nFinal segment profile:")
print(profile.to_string())

fig, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].bar(score_series.index.astype(str), score_series.values, color="steelblue")
axes[0].set_xlabel("Number of clusters")
axes[0].set_ylabel("Silhouette score")
axes[0].set_title("Selecting the Cluster Count")

final_scatter = axes[1].scatter(
    rfm["Frequency"],
    rfm["Monetary"],
    c=rfm["Cluster"],
    cmap="viridis",
    alpha=0.6,
)
axes[1].set_xlabel("Frequency")
axes[1].set_ylabel("Monetary")
axes[1].set_title("Final Customer Segments")
fig.colorbar(final_scatter, ax=axes[1], label="Cluster")
fig.tight_layout()
plt.show()

That completes the model-selection loop. Your final clusters now have balanced inputs, a measured cluster count, visual assignments, and marketing actions.

Secret mission

Segment a Brand-New Customer

Use the fitted preprocessing pipeline to segment a hypothetical new customer. You will return the customer's predicted cluster plus its recommended marketing action without retraining the model.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your notebook, pause its session, or remove it completely. The resources you created in Kaggle Notebooks have no ongoing charges.

Resources you used:

  • A saved Kaggle Notebook named kmeans-online-retail-segmentation with the public Online Retail II dataset attached.
  • An active notebook session holding the downloaded transaction CSV.
  • The in-memory segmentation state containing the RFM table plus the fitted scaler and KMeans models.

Keep everything running

No action is needed while you continue working. Saving a notebook version preserves your analysis outputs.

  • Select Save Version in the notebook toolbar to preserve the current code and outputs.
  • Leave the interactive session running while you continue analyzing customers.

That protects the analysis you just finished. Your customer segments and new-customer prediction remain available in the notebook.

Pause - I'll come back to this later

Ending the interactive session frees the live runtime while keeping the saved notebook available for your return.

  • Select Save Version in the notebook toolbar.
  • Open Active Events from the Kaggle notebook interface.
  • Stop the current interactive notebook session.

Your completed analysis stays saved after the live runtime stops.

Delete - I don't want to use this again

Deleting the notebook is permanent, which can feel drastic. This option fits when you no longer want to keep the analysis.

  • Open Active Events from the Kaggle notebook interface.
  • Stop the current interactive notebook session.
  • Return to your Kaggle work area.
  • Locate kmeans-online-retail-segmentation in your notebook list.
  • Remove kmeans-online-retail-segmentation from your Kaggle work area.
  • Complete the deletion confirmation.

The notebook no longer appears in your Kaggle work area. The stopped session no longer holds the downloaded CSV or the in-memory segmentation state.

Nice Work!

Nice Work!

You did it! You transformed genuine online retail transactions into customer segments in Kaggle Notebooks. Your final KMeans model now gives each segment a marketing action.

You've learned how to:

  • Load genuine retail transactions from Kaggle. Build a cleaned RFM customer table with one behavioral record per customer.
  • Expose the unscaled KMeans failure through raw feature ranges. Confirm why feature scaling matters in the cluster profile. See the same spend-dominated pattern in the scatter plot.
  • Choose the final cluster count with silhouette scores. Visualize the customer assignments. Convert every segment profile into a recommended marketing action.
  • Secret Mission: Reuse the fitted scaler and KMeans model on a brand-new customer. Assign the customer to an existing segment. Return the matching recommended action.

Ready to quiz yourself?