The profitable customers a revenue ranking can't show you
Clustering a synthetic distributor's customer book with k-means, and the high-margin, fast-growing segment a revenue ranking structurally buries.
A sales report that ranks customers by revenue answers one question: who is big. It cannot answer who is profitable, who is growing, or who is quietly leaving, because those answers do not live in revenue. They live in the joint distribution of several behaviors at once, and a one-column ranking flattens all of them into a single line.
I built an interactive demo of exactly this, on a synthetic book of accounts for a national construction-supplies distributor. Everything below is generated data (a seeded numpy script, no real company or customer), so the numbers prove a method, not a business result. You can click through the live version here:
Open the interactive demo (revenue view vs cluster view toggle, the profit-concentration curve, and per-segment action cards).
The setup
The dataset is engineered so that three economically opposite groups of customers overlap in revenue. That overlap is the whole point: if the groups separated cleanly on the revenue axis, you would not need anything more than a sort. They do not, so a revenue ranking blends them, and the interesting structure only appears when you look at behavior.
Each of the 3,000 accounts carries a trailing-twelve-month behavior profile: annual revenue, gross margin percent, order frequency, recency (days since the last order), product-line breadth, average order value, return rate, year-over-year growth, special-pricing share, and service-ticket count. That is the classic RFM (recency, frequency, monetary) view, extended with margin, growth, breadth, and cost-to-serve so the model can tell a profitable account from a merely large one.
The method
The monetary and count features are heavily skewed, so they get a log transform, and then everything is z-score standardized. That standardization is not optional: k-means measures distance, so if you leave revenue in raw dollars it drowns out every other feature and you have effectively just re-sorted by revenue.
The algorithm is plain k-means (k-means++ initialization, Lloyd iterations), written in numpy so the whole thing runs with no extra installs and stays legible. The number of segments comes from two signals that agree: the elbow of within-cluster inertia, and the Davies-Bouldin index (lower is better). Both land on five.
| k | Inertia | Davies-Bouldin |
|---|---|---|
| 3 | 17,364 | 1.316 |
| 4 | 15,860 | 1.344 |
| 5 | 12,036 | 1.211 (best) |
| 6 | 10,855 | 1.424 |
What the five segments look like
k-means recovered the five engineered groups at 94 to 100 percent purity. Ordered by share of margin dollars:
| Segment | Accounts | Revenue share | Margin $ share | Avg margin | Avg YoY |
|---|---|---|---|---|---|
| Core Loyal | 32.7% | 33.2% | 42.0% | 29.7% | +5.2% |
| Hidden Gem | 14.8% | 11.8% | 18.9% | 37.6% | +22.3% |
| Silent Attriter | 15.2% | 14.7% | 17.2% | 27.3% | -17.5% |
| Low-Margin Whale | 8.0% | 35.1% | 16.4% | 10.9% | +3.2% |
| Transactional | 29.2% | 5.1% | 5.6% | 25.9% | +0.9% |
The single most useful column is not on that table directly. It is margin efficiency: margin-dollar share divided by revenue share. Above 1.0 means a group returns more profit than its revenue weight would suggest.
- Hidden Gem: 1.60 (the most profit per revenue dollar)
- Core Loyal: 1.26
- Silent Attriter: 1.17
- Transactional: 1.10
- Low-Margin Whale: 0.47 (the least)
The part a revenue ranking cannot show
Take the same accounts, rank them by revenue, and look at the top 20 percent, the slice a leaderboard or an "our biggest customers" view would surface. It is a blend: about 40 percent Low-Margin Whales, 38 percent Core Loyal, 16 percent Silent Attriters, and only 6 percent Hidden Gems. Three very different situations, mashed into one bucket labeled "important."
- Low-Margin Whales sit at the very top of the revenue ranking and look like the best customers you have. They return the least profit per revenue dollar (efficiency 0.47) and carry the highest cost to serve (about 22 service tickets each). The play is to renegotiate terms, not to chase more of the same volume.
- Hidden Gems sit in the middle of the revenue ranking (around the 46th percentile), so they never appear on a top-revenue list at all. Yet they are the most profit-efficient group (1.60) and the fastest growing (+22 percent year over year). The play is to grow and protect them. This is the segment a revenue dashboard structurally cannot show you.
- Silent Attriters also sit mid-pack on trailing revenue while declining 17.5 percent year over year, with 116 days since their last order. On a revenue report they still look fine. The play is to intervene before the revenue actually falls off.
One sentence: a revenue ranking mashes "grow them," "save them," and "renegotiate them" into one line, and clustering on joint behavior separates them back into three different plays.
Where this kind of model should run
Worth being honest about the architecture, because it is a common mistake. This demo computes the clusters offline (the seeded numpy script runs k-means once) and ships the labeled accounts as a static JSON that the page simply renders. That is deliberate, and it is also the right shape at real scale: you would not run the clustering inside the BI tool. Power BI's R and Python visuals are both capped at 150,000 rows (switching languages does not raise the cap), and script-based transforms are slow and fragile on a personal gateway. The model belongs upstream: a scheduled job or a warehouse model computes the labels and lands them in a table, and the dashboard simply presents the result. Keep the heavy compute where it is governed, fast, and shared, and let the report stay a report.
The interactive version, with the revenue-vs-cluster toggle and the profit-concentration curve, is here. The underlying data and the generator are linked from that page, so the whole result is reproducible from a single seed.