No target and no labels: turning 180,000 health encounters into member profiles, then letting K-means find the patient archetypes.
Companion notebook: lesson-05-clustering.ipynb — worked examples and self-checking exercises.
What clustering is for, and what it can’t do.
| Regression (Lessons 1–4) | Clustering (this lesson) | |
|---|---|---|
| Input | Features and a target | Features only |
| Goal | Predict the target | Group similar rows |
| “Correct” means | Low error on new data | Groups that are compact, separated, and useful |
| Validation | Holdout score | Internal indices + business judgment |
| Output | A number per row | A label per row + group profiles |
Why cluster members at all?
K-means will always return k clusters, even from pure noise. Finding groups is easy; finding groups that are real and meaningful is the work.
From encounters to one row per member.
enc = pd.read_parquet("data/health/encounters.parquet")
mem = pd.read_csv("data/health/members.csv")
e = enc[enc["service_date"].dt.year == 2024]
counts = e.pivot_table(index="member_id",
columns="service_category",
values="encounter_id",
aggfunc="count", fill_value=0)
counts.columns = [c.lower().replace(" ", "_")
for c in counts.columns]
prof = counts.reindex(mem["member_id"], fill_value=0)
prof["total_allowed"] = (e.groupby("member_id")["allowed"]
.sum().reindex(prof.index, fill_value=0))
prof["active_months"] = (e.assign(m=e["service_date"].dt.month)
.groupby("member_id")["m"].nunique()
.reindex(prof.index, fill_value=0))
180,472 encounters in 2024, 22.6 per member, become an 8,000 × 10 table:
| Feature | Mean | Median | Max |
|---|---|---|---|
| office_visit | 3.30 | 2 | 33 |
| pharmacy | 12.73 | 4 | 133 |
| emergency | 0.39 | 0 | 15 |
| inpatient | 0.10 | 0 | 7 |
| total_allowed | 9,952 | 1,714 | 421,889 |
| active_months | 7.07 | 6 | 12 |
reindex keeps members with no encounters: they’re a real (healthy) group, not missing data.
from sklearn.preprocessing import StandardScaler
X = StandardScaler().fit_transform(np.log1p(prof))
| Preparation | Cluster sizes | Agreement with truth (ARI) |
|---|---|---|
| Raw values | 7,501 · 420 · 79 | 0.178 |
| Standardized | 5,004 · 2,438 · 558 | 0.918 |
| log1p + standardized | 4,868 · 2,501 · 631 | 0.926 |
ARI (adjusted Rand index) compares two labelings: 1 = identical, 0 = chance. We can compute it only because this book was simulated from known archetypes; real data never gives you this.
Centroids, distances, and dimensions.
$$\min_{C_1,\dots,C_k}\;\sum_{j=1}^{k}\sum_{i\in C_j}\lVert \mathbf{x}_i - \boldsymbol{\mu}_j\rVert^2$$
from sklearn.cluster import KMeans
km = KMeans(n_clusters=3, n_init=10, random_state=0).fit(X)
km.labels_ # cluster of each member
km.cluster_centers_ # 3 × 10, in standardized units
km.inertia_ # within-cluster sum of squares: 37,097
Each run can land in a different local minimum. n_init=10 runs ten starts and keeps the best; random_state makes it reproducible. Cluster numbers are arbitrary, so name clusters by their profile, never by their label.
500 random points; how much farther is the farthest than the nearest?
| 2 features | 22× |
| 10 features | 1.6× |
| 100 features | 0.40× |
| 500 features | 0.16× |
When every point is about equally far from every other, “nearest centroid” loses meaning. Keep clustering features few and purposeful, or reduce dimension first (e.g. principal components).
Elbow: inertia 47,844 → 37,097 → 33,628 for k = 2, 3, 4. The big gain stops after 3.
Silhouette (−1 to 1): how much closer each member is to its own cluster than to the next. 0.408 at k = 2, 0.393 at k = 3, then ≈ 0.21.
The silhouette slightly prefers 2; the elbow and the business prefer 3. Splitting off the 8% of members who drive 60% of cost is worth it. Metrics guide k; they don’t choose it.
From centroids to archetypes a care manager recognizes.
Healthy: below average on everything; active only 4 months a year.
Chronic-Maintenance: steady pharmacy and lab, 11 active months, but no more ER or inpatient use than average.
Heavy-Use: +2 sd on emergency, inpatient, imaging, and surgery.
| Mean per member | Healthy | Chronic | Heavy |
|---|---|---|---|
| Members | 4,868 | 2,501 | 631 |
| Pharmacy fills | 2.4 | 26.2 | 38.6 |
| Specialist visits | 0.3 | 2.8 | 9.1 |
| Emergency visits | 0.09 | 0.33 | 2.98 |
| Inpatient stays | 0.01 | 0.05 | 1.04 |
| Active months | 4.3 | 11.3 | 11.9 |
| Allowed, 2024 | 1,419 | 9,892 | 76,017 |
| Share of members | 61% | 31% | 8% |
| Share of cost | 9% | 31% | 60% |
| True ↓ / cluster → | Healthy | Chronic | Heavy |
|---|---|---|---|
| Healthy | 4,833 | 73 | 0 |
| Chronic-Maintenance | 35 | 2,319 | 28 |
| Heavy-Use | 0 | 109 | 603 |
96.9% of members sit on the diagonal; ARI 0.926. The errors sit at the borders: heavy users having a quiet year look chronic.
| Cluster (2024) | Mean 2024 | Mean 2025 | Median 2025 |
|---|---|---|---|
| Healthy | 1,419 | 3,812 | 875 |
| Chronic | 9,892 | 15,894 | 7,787 |
| Heavy-Use | 76,017 | 47,062 | 25,333 |
The groups still separate cost a year later, but they drift toward the middle: regression to the mean. Some heavy users recover; some healthy members get sick. Never price next year off this year’s extremes.
archetypes.csv (ARI) and against 2025 cost| Inertia k = 3 | 37,097 |
| Silhouette k = 2 · 3 | 0.408 · 0.393 |
| Cluster sizes | 4,868 · 2,501 · 631 |
| Heavy-Use share of cost | 60% |
| ARI vs archetypes | 0.926 |
Save the labels: Lesson 6 turns them into rules.
Centroids don’t fit in an underwriting manual. Surrogate decision trees turn clusters into a handful of readable rules.