Marketing wants customer segments it can target next quarter, and the last set was invented in a workshop. Build them, and decide what would make them worth trusting. Work it yourself before reading the walkthrough.
The situation
Fellgate Outdoor sells outdoor gear online. You have twelve months of orders covering 1,671 customers and 2,674 orders. Marketing wants segments loaded into the email platform, and a campaign built on them will run for the next three months.
The data
| File | What it holds |
|---|---|
| fellgate-order-lines.csv | Order lines with customer, date, destination, channel, prices, discounts and returns |
| fellgate-products.csv | Cost, category and weight per SKU |
| fellgate-shipping-rates.csv | Carrier cost by zone and weight band, not needed here but supplied with the set |
What the room believes
- The customer base contains natural groups that clustering will find.
- The right number of segments will be obvious from an elbow in the inertia curve.
- Segments that hold up when you rerun the model are stable enough to plan a quarter around.
- A customer’s segment is a property of the customer.
Definition of done
- A stated rule for choosing the number of segments, written before you see any segments.
- A verdict on each of the four beliefs, with the evidence.
- Segment definitions marketing could load into an email platform: a size, a profile and a sentence describing who they are.
- Evidence that the segments are worth acting on, and a clear statement of any way in which they are not.
- A recommendation on how often the segmentation has to be rebuilt, with the number that justifies it.
Four questions worth asking before you fit anything
What units are your features in, and does the distance metric care? If you rerun with a different sample of customers, do you get the same segments? If you build the segments today and run the campaign in six months, is the same customer still in the same segment? And which of those three questions does a silhouette score answer?
If you want to go further
- Run the clustering once without scaling the features and once with. Compare the silhouette scores, then explain the result.
- Work out what agreement two labelings of the same customers would show by pure chance, given your segment sizes, and use that as the floor for any persistence claim.
- Decide whether any of your segments is too small for the email platform to be worth targeting, and say what the threshold should be.
- Write the sentence that goes at the top of the handoff telling marketing how long the segments last.
When you are done, read the walkthrough. It uses the same data and reaches segments that pass two tests and fail the third. The interesting comparison is not which segments you found, it is whether you tested the thing that decides whether a campaign built on them works.
Marketing wants customer segments and the last set was invented in a workshop. K-means gives you segments in four lines. The work is proving they are worth acting on, and this set passes two tests out of three.
The situation. Fellgate Outdoor has 1,671 customers and 2,674 orders across 365 days. Marketing wants segments it can target in the email platform, and it wants them to still mean something when the campaign runs in three months.
What the business is left with. Segment definitions with sizes, profiles and an explicit statement of how long each one stays true.
Attempt it first. The brief has the data and the question with none of the answers.
Contents
Section 1Problem Definition
No code yet. Clustering will always return clusters. Deciding in advance what would make them worth using is the only thing standing between a useful segmentation and a colourful one.
Business objective
Produce customer segments marketing can load into the email platform and target for the next quarter. Each segment needs a size, a description a human can act on, and a reason to believe it is real.
What would make this fail
| Failure | Why it happens | The test |
|---|---|---|
| One giant segment and three specks | K-means splits on whichever feature has the widest numeric range, which is almost never the most meaningful one | Segment sizes, before anything else |
| Segments that move if you rerun it | K-means starts from random centroids and a small change in the sample can rearrange everything | Refit on resamples and measure agreement |
| Segments that do not survive the quarter | A customer who was in one segment when you built it can be in another by the time the campaign lands | Score a later period with the same model and count who stayed |
| Segments nobody can target | A segment below a few per cent of the base is not a campaign, it is a rounding error | Smallest segment as a share of customers |
The decision rule, written before looking
Choose the number of segments by three criteria in this order. It has to be stable under resampling, at a mean adjusted Rand index of 0.75 or better. No segment may hold fewer than 5 per cent of customers. Of whatever survives those two, take the best silhouette.
Why silhouette comes last
It is the metric everyone reports and the weakest of the three for this decision. It measures how tight and separated the clusters are in feature space, which is a statement about geometry, not about whether marketing can use the result. Section 4a shows the naive run scoring a better silhouette than the correct one.
Hypotheses
- H1. The customer base contains natural groups that clustering will find.
- H2. The right number of segments will be obvious from an elbow in the inertia curve.
- H3. Segments that are stable under resampling are stable enough to run a quarterly campaign against.
- H4. A customer’s segment is a property of the customer.
Section 2Data Collection
The same three files the profit leak audit used, reduced to one row per customer.
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score, adjusted_rand_score
SEED = 20260822
lines = pd.read_csv('data/fellgate-order-lines.csv').drop_duplicates()
prod = pd.read_csv('data/fellgate-products.csv')
d = lines.merge(prod, on='sku', validate='many_to_one')
d['dt'] = pd.to_datetime(d['order_date'])
d['rev'] = d['quantity'] * d['unit_price'] - d['line_discount']
d['returned'] = d['line_status'] == 'returned'
print('order lines :', len(d))
print('customers :', d['customer_id'].nunique())
order lines : 4,516
customers : 1,671
Section 3Data Preprocessing
3aDuplicates and schema checks
The duplicated export rows come out on load, as they did in the profit leak audit. The check that matters here is different: clustering needs exactly one row per customer, and a duplicate would give that customer two votes on where the centroids land.
o = d.groupby('order_id').agg(cust=('customer_id', 'first'), dt=('dt', 'first'),
rev=('rev', 'sum'), ret=('returned', 'sum'),
disc=('line_discount', 'sum'))
print('orders :', len(o))
print('customers :', o['cust'].nunique())
orders : 2,674
customers : 1,671
3bHandling categorical mess
Nothing to clean. Country and channel are consistent, and neither goes into the clustering: a segmentation built partly on geography will hand you geography back and call it a discovery.
3cDealing with outliers
K-means is a mean-based method, so a single extreme customer drags a centroid toward itself. Worth checking the spread before deciding whether to intervene.
AS_OF = d['dt'].max() + pd.Timedelta(days=1)
f = o.groupby('cust').agg(frequency=('rev', 'size'), monetary=('rev', 'sum'),
last=('dt', 'max'), disc=('disc', 'sum'))
f['recency'] = (AS_OF - f['last']).dt.days
f['aov'] = f['monetary'] / f['frequency']
f['discount_rate'] = (f['disc'] / f['monetary'].replace(0, np.nan)).fillna(0.0)
FEATURES = ['recency', 'frequency', 'monetary', 'aov', 'discount_rate']
print(f[FEATURES].describe().T[['mean', 'std', 'min', 'max']].round(2).to_string())
mean std min max
recency 123.46 98.73 1.00 365.00
frequency 1.60 0.84 1.00 6.00
monetary 232.07 182.68 11.20 1345.00
aov 145.39 95.67 11.20 709.00
discount_rate 0.01 0.04 0.00 0.25
No customer is far enough out to distort a centroid on its own. The most a single customer has spent is 1,345.00 pounds against a mean of 232.07, which is a tail rather than an outlier. Nothing is trimmed.
3dHandling missing values
None, except one that is manufactured rather than missing: discount rate is undefined for a customer who has spent nothing, and no customer has. The fillna in 3c is defensive rather than necessary, and it is left in because a regenerated dataset could produce one.
3eHandling skewed data
| Feature | Mean | Median | Skew |
|---|---|---|---|
| recency | 123.46 | 97.00 | 0.89 |
| frequency | 1.60 | 1.00 | 1.49 |
| monetary | 232.07 | 189.00 | 1.52 |
| aov | 145.39 | 125.80 | 1.56 |
| discount_rate | 0.01 | 0.00 | 3.50 |
Monetary and average order value are right-skewed, as spend always is. Standardising in 3f handles the scale problem but not the shape, and that is a deliberate choice: a log transform would compress exactly the difference between a good customer and a great one, which is the difference marketing is paying for.
3fData types and normalisation
This is the step the whole project turns on.
print('standard deviation of each feature, in its own units')
print(f[FEATURES].std().round(2).to_string())
print()
print('widest over narrowest :',
round(f[FEATURES].std().max() / f[FEATURES].std().min(), 1), 'to 1')
standard deviation of each feature, in its own units
recency 98.73
frequency 0.84
monetary 182.68
aov 95.67
discount_rate 0.04
widest over narrowest : 4554.5 to 1
Standard deviation of each feature before scaling. monetary is 4554.5 times wider than discount_rate, so an unscaled distance is almost entirely a monetary distance.
What happens if you skip this line
Euclidean distance adds the squared difference of every feature. With monetary spanning 4554.5 times more ground than discount_rate, the other four features contribute almost nothing to the distance.
K-means will still return clusters. They will be spend bands, and you did not need a clustering algorithm to cut customers into spend bands.
scaler = StandardScaler().fit(f[FEATURES])
X = scaler.transform(f[FEATURES])
print('after scaling, every feature has standard deviation', round(X.std(axis=0).mean(), 3))
after scaling, every feature has standard deviation 1.0
Section 4Exploratory Data Analysis
4aTarget variable analysis
There is no target. That is what makes this unsupervised, and it is why the evaluation in section 7 has to be built rather than looked up. What stands in for a target here is the naive run, so there is something to beat.
naive = KMeans(n_clusters=4, n_init=10, random_state=SEED).fit_predict(f[FEATURES].values)
print('sizes :', np.bincount(naive).tolist())
print('silhouette :', round(silhouette_score(f[FEATURES].values, naive,
random_state=SEED), 4))
sizes : [669, 146, 541, 315]
silhouette : 0.367
The trap, and it is a good one
The unscaled run scores a silhouette of 0.367. The correctly scaled run in section 7 scores 0.3333.
By the metric everyone reports, doing it wrong looks better. It looks better because clustering on one feature produces tight, well separated bands, and monetary is doing all the work. A high silhouette is evidence of geometry, not of insight.
4bNumerical variables
The five features, and why each is in.
| Feature | What it captures | Why it earns a place |
|---|---|---|
| recency | Days since the last order | The strongest single predictor of whether someone buys again, in every retail business anyone has measured |
| frequency | Number of orders | Separates a habit from a one-off |
| monetary | Total spend | What the customer is worth so far |
| aov | Average order value | Separates many small baskets from few large ones, which monetary alone cannot |
| discount_rate | Share of spend given away | Distinguishes a customer who buys from one who buys on offer, and marketing treats those differently |
4cCategorical variables
Deliberately excluded. Country, channel and product category are all available and none is used. Feed a category into a clustering and the clustering will return that category to you, which feels like a finding and is not one. They are far more useful afterwards, as a description of segments the numbers found on their own.
4dRelationships between variables
Frequency and monetary correlate strongly, because spending more usually means buying more often. That is not a reason to drop one. K-means has no coefficients to destabilise, and the pair together carries information neither has alone: a customer with high monetary and low frequency is a different animal from one with both high.
4eTesting our hypotheses
Two of the four can be answered before any segment is named.
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. Natural groups exist | Not supported | The best silhouette across every k tried is 0.3333. A number that low means slices of one continuous cloud, not separate groups |
| H2. An elbow will make k obvious | Not supported | Inertia falls smoothly from 6,122 at k=2 to 2,346 at k=8 with no corner to point at |
| H3. Resampling stability is enough | Answered in 7d | It is not, and that is the main finding |
| H4. Segment is a property of the customer | Answered in 7d | It is a property of the customer and the date |
4fSubgroups
The one subgroup worth checking before modelling is the single-order customer, because they have no frequency signal at all and there are a lot of them. They are kept in. A segmentation that silently excludes most of the base is not a segmentation of the base.
Section 5Feature Engineering
5aThe leakage trap
Unsupervised work has no target, so it cannot leak a label. It has a subtler version of the same fault: putting a feature in that already encodes the answer you want.
The clustering that discovers what you told it
Add a customer lifetime value column, or a hand-made loyalty tier, and the segments will come back organised by it. That looks like a discovery and is a restatement. Every feature here is a raw behavioural count or ratio, and nothing is derived from a business rule that already sorts customers.
The same argument rules out anything computed after the segmentation would be used. Next quarter’s spend would separate the segments beautifully and would not exist when the campaign is built.
5bNew features
Three of the five are constructed rather than counted, and each earns its place by capturing something the raw columns cannot.
| Constructed | From | What it adds |
|---|---|---|
| recency | Latest order date against a fixed as-of date | A date is not a distance. Days since is |
| aov | monetary divided by frequency | Two customers who have each spent 400 pounds are different customers if one did it in one order and the other in five |
| discount_rate | Discount given divided by spend | Absolute discount just tracks size. The share is the behaviour |
Fix the as-of date, always
recency is measured against a stated as-of date, not against today. Use today() and the segmentation silently changes every time anyone reruns the notebook, and no two people ever see the same segments. Section 7d turns this from a hygiene point into the main result.
5cEncoding
Nothing to encode, because nothing categorical is in the feature set. Had country gone in, one-hot encoding it would have created five columns each with a standard deviation near 0.5, quietly outvoting the five behavioural features they sat alongside. Mixing categorical and continuous features in a distance-based method is a decision, not a default.
5dFeature selection
Five features, chosen before fitting and not tuned afterwards. Selecting features by which set produces the prettiest clusters is circular: there is no held-out truth to check the choice against, so the only thing being optimised is the appearance of the result.
Section 6Model Selection
| Question | Choice | Why not the obvious alternative |
|---|---|---|
| Which algorithm | K-means | Marketing needs segments that can be described in a sentence and applied to a new customer with one calculation. Hierarchical clustering gives a dendrogram nobody will read, and DBSCAN would leave a large share of customers unassigned, which is not a segmentation an email platform can use |
| How many segments | Stability first, size second, silhouette third | The elbow is the standard answer and section 4e shows there is no elbow here. Choosing on silhouette alone would have picked the same k in this case and for the wrong reason |
| How to measure stability | Adjusted Rand index across 30 resamples at 80 per cent | Rerunning with a different seed only tests initialisation. Resampling tests whether the structure survives a different sample of customers, which is the question |
| How to prove it is usable | Score a later window with the same fitted model | Every internal metric measures the clustering against itself. Only a later period answers whether a campaign built on this will still be aimed at the right people |
Adjusted Rand, in one line
It compares two labelings of the same customers and answers how much they agree beyond what agreement you would get by chance. One is identical, zero is chance, and negative is worse than chance. It does not care what the labels are called, which matters because k-means numbers its clusters arbitrarily.
Section 7Model Training
7aBaselines
Two baselines, and the second is the one that matters.
| Baseline | What it is | Result |
|---|---|---|
| The naive run | K-means on unscaled features, k=4 | Silhouette 0.367, largest segment 40.0% of customers, split almost entirely on monetary |
| Chance agreement | What two labelings agree on with no structure at all | 44.0% of customers, given segment sizes of 32%, 8%, 60% |
Any segmentation has to beat the first on usefulness and the second on persistence. It manages one of the two.
7bComparing candidates
rows = []
for k in range(2, 9):
km = KMeans(n_clusters=k, n_init=10, random_state=SEED).fit(X)
sil = silhouette_score(X, km.labels_, random_state=SEED)
smallest = np.bincount(km.labels_).min() / len(X)
rows.append((k, km.inertia_, sil, smallest))
print(f'k={k} inertia {km.inertia_:8.1f} silhouette {sil:.4f} '
f'smallest segment {smallest:.1%}')
k=2 inertia 6122.0 silhouette 0.2984 smallest segment 33.5%
k=3 inertia 4906.9 silhouette 0.3333 smallest segment 8.4%
k=4 inertia 3994.1 silhouette 0.2992 smallest segment 7.7%
k=5 inertia 3250.6 silhouette 0.3180 smallest segment 7.6%
k=6 inertia 2842.8 silhouette 0.3111 smallest segment 7.4%
k=7 inertia 2543.5 silhouette 0.3064 smallest segment 5.8%
k=8 inertia 2346.4 silhouette 0.3102 smallest segment 1.9%
Inertia falls smoothly, as it always does, because adding a centroid can only reduce it. There is no elbow. Silhouette peaks at k=3 and every value sits between 0.2984 and 0.3333, a range too narrow to decide anything on.
7cHyperparameter tuning
The only hyperparameter that matters is k, and it is tuned on stability rather than fit.
rng = np.random.default_rng(SEED)
for k in range(2, 9):
base = KMeans(n_clusters=k, n_init=10, random_state=SEED).fit(X)
aris = []
for b in range(30):
idx = rng.choice(len(X), size=int(len(X) * 0.8), replace=False)
alt = KMeans(n_clusters=k, n_init=10, random_state=SEED + b + 1).fit(X[idx])
aris.append(adjusted_rand_score(base.labels_[idx], alt.labels_))
print(f'k={k} mean ARI {np.mean(aris):.4f} worst {np.min(aris):.4f}')
k=2 mean ARI 0.9375 worst 0.8741
k=3 mean ARI 0.9247 worst 0.4716
k=4 mean ARI 0.9301 worst 0.3995
k=5 mean ARI 0.8992 worst 0.7264
k=6 mean ARI 0.7633 worst 0.4622
k=7 mean ARI 0.9442 worst 0.8237
k=8 mean ARI 0.8654 worst 0.6503
Silhouette never rises above 0.3333 at any k, while stability stays high almost everywhere. The two metrics are measuring different things and only one of them separates the candidates.
Stability does not discriminate either: every k from 2 to 7 clears the 0.75 threshold. Applying the rule from section 1 in order, that leaves size and then silhouette, and the answer is k=3 with a mean adjusted Rand of 0.9247.
A rule that does not narrow much is still worth writing down
Stability eliminated only k=8 here. That is a weak filter on this data and it would have been a decisive one on a noisier base. The value of fixing the rule in advance is not that it always cuts hard, it is that the choice cannot be reverse-engineered from the segments you liked the look of.
7dFinal evaluation
What the segments are
K = 3
km = KMeans(n_clusters=K, n_init=10, random_state=SEED).fit(X)
f['segment'] = km.labels_
prof = f.groupby('segment').agg(
customers=('frequency', 'size'), recency=('recency', 'median'),
frequency=('frequency', 'mean'), monetary=('monetary', 'mean'),
aov=('aov', 'mean'), discount_rate=('discount_rate', 'mean'))
prof['share'] = prof['customers'] / len(f)
prof['revenue_share'] = f.groupby('segment')['monetary'].sum() / f['monetary'].sum()
print(prof.round(3).to_string())
# do the segments actually differ, or has a distance metric just cut one cloud
for c in FEATURES:
groups = [f.loc[f['segment'] == s, c].values for s in sorted(f['segment'].unique())]
fstat, pv = stats.f_oneway(*groups)
print(f'{c:15s} F {fstat:8.1f} p {pv:.3g}')
recency F 99.4 p below the smallest number floating point can represent
frequency F 645.9 p below the smallest number floating point can represent
monetary F 1145.3 p below the smallest number floating point can represent
aov F 191.9 p below the smallest number floating point can represent
discount_rate F 2568.6 p below the smallest number floating point can represent
| Segment | Customers | Share | Revenue share | Median recency | Orders | Spend | AOV | Discount | Return rate |
|---|---|---|---|---|---|---|---|---|---|
| Best customers | 532 | 31.8% | 59.7% | 62 days | 2.41 | 434.81 | 205.50 | 0.9% | 21.6% |
| Recent, still small | 141 | 8.4% | 6.2% | 113 days | 1.32 | 170.00 | 132.91 | 12.9% | 15.6% |
| Lapsed one-off buyers | 998 | 59.7% | 34.2% | 120 days | 1.21 | 132.77 | 115.11 | 0.2% | 14.8% |
The largest segment by headcount is the smallest by value. Best customers are 31.8% of customers and 59.7% of revenue.
The separation test says the segments genuinely differ on every feature, with the strongest split on discount_rate. That is necessary and nowhere near sufficient: a one-way test will find differences between any three groups a distance metric has just been asked to make as different as possible.
The test that decides it
Split the year into two equal windows, build the segmentation on the first, then ask two separate questions of the second.
def window_features(frame, as_of):
"""The same five features, computed inside one window."""
g = frame.groupby('cust').agg(frequency=('rev', 'size'), monetary=('rev', 'sum'),
last=('dt', 'max'), disc=('disc', 'sum'))
g['recency'] = (as_of - g['last']).dt.days
g['aov'] = g['monetary'] / g['frequency']
g['discount_rate'] = (g['disc'] / g['monetary'].replace(0, np.nan)).fillna(0.0)
return g[FEATURES]
mid = d['dt'].min() + (d['dt'].max() - d['dt'].min()) / 2
w1, w2 = o[o['dt'] <= mid], o[o['dt'] > mid]
f1 = window_features(w1, mid + pd.Timedelta(days=1))
f2 = window_features(w2, d['dt'].max() + pd.Timedelta(days=1))
both = sorted(set(f1.index) & set(f2.index))
sc1 = StandardScaler().fit(f1)
m1 = KMeans(n_clusters=K, n_init=10, random_state=SEED).fit(sc1.transform(f1))
# A. refit from scratch on the later window: are the DEFINITIONS reproducible
sc2 = StandardScaler().fit(f2)
m2 = KMeans(n_clusters=K, n_init=10, random_state=SEED).fit(sc2.transform(f2))
lab1 = pd.Series(m1.labels_, index=f1.index).loc[both].values
refit = pd.Series(m2.labels_, index=f2.index).loc[both].values
# B. keep the first model and score the later window: do CUSTOMERS stay put
carried = m1.predict(sc1.transform(f2.loc[both]))
print('customers in both windows :', len(both))
print('A refit ARI :', round(adjusted_rand_score(lab1, refit), 4))
print('B carried ARI :', round(adjusted_rand_score(lab1, carried), 4))
print('B stayed in same segment :', round((lab1 == carried).mean(), 4))
customers in both windows : 380
A refit ARI : -0.0078
B carried ARI : -0.013
B stayed in same segment : 0.3737
The same segmentation, tested three ways. Resampling within the period agrees at 0.9247. Across six months, both tests sit at zero.
Stable, reproducible, and not usable the way marketing wants
Within a single period the segmentation is about as stable as clustering gets. Resample 80 per cent of customers thirty times and the labelings agree at 0.9247.
Across six months it collapses. Refitting on the later window gives an adjusted Rand of -0.0078, which is chance. Keeping the original model and scoring the later window gives -0.013, and only 37.4% of customers keep their segment, against 44.0% you would get by shuffling the labels.
The segments are real. They are a description of a moment, not a property of a customer.
Why it moves, which is not a bug
Recency is measured from the end of the window, so a customer who bought in February is recent in the first window and lapsed in the second without doing anything. Frequency and spend reset with the window too.
That is exactly what a trailing behavioural segmentation does, and it is why the answer is to re-score rather than to abandon it. What would be wrong is to build a quarterly campaign on a January snapshot and assume the audience is still the audience in April.
Section 8Documentation and Handoff
Ship the segments, with an expiry date
3 segments, all targetable, the smallest holding 141 customers at 8.4% of the base. Best customers are 31.8% of customers and 59.7% of revenue.
Re-score monthly. A segment assignment is valid for weeks, not quarters: after six months only 37.4% of customers are still in the segment you put them in, which is below chance.
The segment definitions, for the email platform
| Segment | Who they are | What to send them | Size |
|---|---|---|---|
| Best customers | Ordered 2.4 times on average, 435 pounds spent, last seen 62 days ago | Early access and new range. Do not discount them, they buy at 0.9% off already | 532 (31.8%) |
| Recent, still small | One or two orders, 170 pounds, and the heaviest discount users at 12.9% | Second purchase nudge on full price lines. They are the only segment where a discount habit is already forming | 141 (8.4%) |
| Lapsed one-off buyers | 1.21 orders on average, last seen 120 days ago | Win-back, and measure it properly. They are 59.7% of the base and 34.2% of revenue | 998 (59.7%) |
What to do, and who owns it
| Action | Detail | Owner |
|---|---|---|
| Re-score every month | One scheduled job applying the saved scaler and centroids. Do not refit unless the profiles have drifted | Analytics |
| Put the as-of date on every export | A segment list without the date it was built is not interpretable a month later | Analytics |
| Treat the segment as an attribute with a timestamp | In the email platform, store segment and scored_on. Any campaign older than a month reads the newer score | CRM |
| Use triggers for anything time sensitive | Win-back and second purchase nudges should fire on a customer’s own behaviour, not on a monthly batch. Segments are for planning, triggers are for sending | CRM |
| Rerun the persistence test each quarter | Two windows, refit and carried. If the carried agreement ever rises, the base has changed and the cadence can relax | Analytics |
What not to do
- Do not report the silhouette as a quality score. The wrong run scored 0.367 and the right one 0.3333.
- Do not build a quarterly campaign on one snapshot. Below chance persistence at six months is the headline number here.
- Do not add more segments to make them feel more precise. k=8 puts 1.9% of customers in the smallest segment, which no one can target.
- Do not name the segments before profiling them. The names here are derived from the numbers, and they would change if the numbers did.
Reproducibility
| Item | Value |
|---|---|
| Files | fellgate-order-lines.csv, fellgate-products.csv |
| Customers | 1,671 from 2,674 orders |
| As-of date | 2026-08-01, fixed, not today() |
| Features | recency, frequency, monetary, aov, discount_rate |
| Scaling | StandardScaler, fitted on the training window only |
| Algorithm | KMeans, n_init=10, random_state=20260822 |
| k chosen by | mean adjusted Rand at or above 0.75 across 30 resamples, no segment below 5 percent of customers, then the best silhouette of what remains |
| Libraries | pandas, numpy, scipy.stats, scikit-learn |
What to take from this
- Scale before you cluster, or you are ranking on one column. A 4554.5 to 1 spread means the other features are decoration.
- A better silhouette can mean a worse segmentation. The naive run wins that metric and loses the argument.
- Stability under resampling and stability over time are different questions. This segmentation passes the first at 0.9247 and fails the second at -0.013.
- Fix the as-of date. Recency measured from today makes the segmentation unreproducible by construction.
- Decide the rule for k before you see the segments. Otherwise you are choosing the answer and calling it a method.
- A segmentation with an expiry date is still worth having. The failure here is not the clustering, it is the assumption that a customer has a segment rather than a segment this month.
Marketing asked for segments. The segments exist, they are targetable, and the most useful thing in the handoff is the sentence saying how long they last. That sentence is the difference between a campaign aimed at the right people and one aimed at where the right people were in January.
Before you act on a segment difference
Statistical Significance Calculator
Segments differ on every feature by construction, so the interesting question is whether a campaign result differs between them. Put the two conversion rates and the segment sizes in and it tells you whether the gap is real. The smallest segment here is 141 customers, which is less power than it looks.
Free, no signup. Pairs with the Sample Size Calculator.
The layer underneath this
Ecommerce Dashboard: A Free Excel Template
Recency, frequency and spend per customer are the inputs to everything above. This template builds them from an order export, which is the step before any clustering starts.
Free Excel template, LAD branded. No signup.
Companion projects. Cohorts, Retention and LTV runs on the same customers and asks what they are worth rather than who they are. The Profit Leak Audit builds the contribution model both of them lean on.