Clustering
Group similar items without labels (k-means).
Create a free account Try part of it here
Why it matters
Splitting customers into groups is one of the oldest jobs in marketing. Clustering does it from data instead of hunches. The same tool can sort thousands of support tickets, reviews or survey answers by topic, once each text is turned into an embedding. That's how teams find themes that nobody labeled. Clustering is also your first model with no right answers to check against. So you'll judge it in a new way: by whether the groups make sense and are useful.
What you'll be able to do
- Explain unsupervised learning and what clustering can and can't promise
- Run k-means and describe its assign-then-average loop
- Choose k with the elbow method, silhouette scores and judgment
- Profile clusters into segments, and cluster text through embeddings
What it covers
- Learning without labels
- k-means in four steps
- k-means by hand on six customers
- Scale the features first
- How many clusters?
- From clusters to segments
- Clustering text with embeddings
- Limits worth knowing
Learning without labels
Picture a drawer full of mixed buttons. Nobody tells you the categories. You still sort them: big and small, round and square, bright and dull. You found the groups by looking at the buttons themselves.
Everything so far in this phase was supervised learning. Each customer came with the answer, such as churned or not, and the model learned to predict it. Unsupervised learning gets only the features, with no answers. It looks for structure on its own.
Clustering is the most common kind. It puts similar items in the same group, called a cluster.
This changes how you judge the result. There's no accuracy to compute, because there are no true groups to compare with. A clustering is good if:
- the groups are tight (members are close to each other),
- the groups are well apart from each other,
- and the groups are useful: you can describe them and act on them.
Show the answer
There are no true groups to compare against
With no answer key, you judge clusters by whether they're tight, well apart, and useful.
k-means in four steps
Imagine three food trucks arriving at a park full of people. Each person walks to the nearest truck. Then each truck drives to the middle of its own crowd, to be closer to its customers. Now some people find a different truck is nearest, so they switch. The trucks move again. After a few rounds, nobody switches and the trucks stop moving.
That's k-means. The trucks are the centers of the clusters. The letter k is the number of clusters, which you choose. Here are the steps:
- Pick k and place k centers. A simple way is to put them on randomly chosen data points.
- Assign: every point joins its nearest center.
- Update: each center moves to the average of the points that joined it.
- Repeat steps 2 and 3 until the assignments stop changing.
To track progress, k-means uses one number, the inertia. For each point, take its distance to its own center and square it. Then add up all those squares. A small inertia means points sit close to their centers, so the clusters are tight.
In plain words: inertia is the total squared distance from every point to its center. The assign and update steps never raise it, so the loop always settles down.
It can settle somewhere poor, though, depending on where the centers started. So scikit-learn runs the whole thing several times from different starts and keeps the run with the lowest inertia. The setting n_init says how many runs. scikit-learn also starts the centers spread apart, a method called k-means++, which helps.
points = [2, 4, 9] center = sum(points) / len(points) print(center)
Show the answer
5.0
2 + 4 + 9 is 15, and 15 / 3 is 5.0. The update step moves each center to the average of its points.
Press Next step to alternate the two moves from this section. New start shows that the first guess matters.
k-means by hand on six customers
Six customers placed 1, 2, 3, 10, 11 and 12 orders last year. You ask for k = 2 clusters, and start the two centers on the first two customers, at 1 and 2.
Round 1. Assign: the customer with 1 order is nearest center 1. Everyone else is nearer center 2 (for 3 orders, the gap to 2 is 1, but the gap to 1 is 2). Update: center 1 keeps one customer, so it stays at 1. Center 2 moves to the average of 2, 3, 10, 11 and 12. That's 38 ÷ 5 = 7.6.
Round 2. Assign again. The customer with 2 orders is 1 away from center 1 and 5.6 away from center 2, so they switch. The customer with 3 orders switches too. Update: center 1 moves to the average of 1, 2 and 3, which is 2. Center 2 moves to the average of 10, 11 and 12, which is 11.
Round 3. Nobody switches, so the centers stay at 2 and 11. Done.
Inertia: the gaps to the centers are 1, 0, 1 for the first group and 1, 0, 1 for the second. Each squared gap is 1 or 0, and they add up to 4.
The code below runs the same loop in plain Python. It stops when the centers stop moving, which happens on the same round the assignments stop changing.
orders = [1, 2, 3, 10, 11, 12] centers = [1.0, 2.0] # start on the first two customers for round_number in range(1, 10): # Assign: each customer joins the nearest center labels = [min(range(len(centers)), key=lambda j: abs(x - centers[j])) for x in orders] # Update: each center moves to the average of its customers new_centers = [] for j in range(len(centers)): members = [x for x, label in zip(orders, labels) if label == j] new_centers.append(sum(members) / len(members)) print(f"round {round_number}: labels {labels} centers {new_centers}") if new_centers == centers: break centers = new_centers inertia = sum((x - centers[label]) ** 2 for x, label in zip(orders, labels)) print("inertia:", inertia)
The labels say which center each customer joined: 0 for the first, 1 for the second. In round 1 they are [0, 1, 1, 1, 1, 1] and the centers move to 1.0 and 7.6. In round 2 two customers switch, giving [0, 0, 0, 1, 1, 1], and the centers move to 2.0 and 11.0. Round 3 changes nothing, so the loop stops. The inertia is 4.0, matching the hand calculation. With one feature, the distance is the size of the gap, abs(x - center). With more features, k-means uses the straight-line (Euclidean) distance from the Vector arithmetic and length lesson.
Show the answer
The center at 11
The gap to 11 is 4 and the gap to 2 is 5. The nearest center wins, so they join the cluster at 11.
Scale the features first
Distances drive every step of k-means. So the units of each feature matter a lot.
Say you cluster customers on two features. Average order value is in dollars, from tens to hundreds. Discount share is the fraction of orders bought on discount, from 0 to 1. Take two customers whose order values differ by $50 and whose discount shares differ by 0.5.
- Order value adds 50 × 50 = 2,500 to the squared distance.
- Discount share adds 0.5 × 0.5 = 0.25.
Order value swamps discount share, and you'd get clusters by order value alone. The fix, from the Preparing data lesson, is to standardize each feature first. That rescales a column so its average is 0 and its typical spread (its standard deviation) is 1. Then every feature gets a fair say. In scikit-learn, StandardScaler does this.
Show the answer
Clusters split almost entirely by order value
Distances in dollars are hundreds of times larger, so order value dominates.
How many clusters?
k-means never picks k for you. So you run it for several values of k and compare two numbers.
Inertia always falls as k grows. More centers means every point has a center closer by. In the extreme, give every customer their own center and the inertia is zero. So the lowest inertia can't be the goal. Instead, look for the elbow: the point where adding another cluster stops helping much. Picture a bent arm. The curve drops steeply, then bends and flattens.
The silhouette score asks a different question. For each point, is it much closer to its own cluster than to the next nearest cluster? The score runs from −1 to 1:
- near 1: the point sits snugly inside its own cluster;
- near 0: it's on the border between two clusters;
- below 0: it's probably in the wrong cluster.
The silhouette score for a whole clustering is the average over all points. Higher is better.
Go deeper How one point's silhouette is worked out
For one point, measure two averages:
- is the average distance from the point to the other points in its own cluster.
- is the average distance from the point to the points in the nearest other cluster.
Then:
Here is the point's silhouette, and means whichever of the two is larger.
In plain words: take how far away the other cluster is, subtract how far away your own cluster is, and divide by the larger of the two. If your own cluster is much closer, the answer is near 1.
Worked example, using the six customers from before. Take the customer with 3 orders. Its own cluster also holds 1 and 2, at distances 2 and 1, so = 1.5. The other cluster holds 10, 11 and 12, at distances 7, 8 and 9, so = 8. Then = (8 − 1.5) ÷ 8 = 6.5 ÷ 8 = 0.8125. That's close to 1: this customer clearly belongs where it is.
x = 3 # the customer we're scoring own = [1, 2] # the other customers in its cluster other = [10, 11, 12] # the customers in the nearest other cluster a = sum(abs(x - p) for p in own) / len(own) b = sum(abs(x - p) for p in other) / len(other) print("a =", a, " b =", b, " silhouette =", (b - a) / max(a, b))
It prints a = 1.5 b = 8.0 silhouette = 0.8125. scikit-learn's silhouette_score does this for every point and returns the average.
The code below loads the shop's customers and uses three features: orders last year, average order value and discount share. It scales them, then runs k-means for k from 1 to 7. For each k, it prints the inertia and the silhouette score, and plots the inertia. The silhouette needs at least two clusters, so k = 1 shows a dash.
import pandas as pd import requests import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score from sklearn.preprocessing import StandardScaler df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) features = ["orders_last_year", "avg_order_value", "discount_share"] X = StandardScaler().fit_transform(df[features]) ks, inertia = list(range(1, 8)), [] for k in ks: km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X) inertia.append(km.inertia_) sil = f"{silhouette_score(X, km.labels_):.3f}" if k > 1 else " - " print(f"k={k} inertia {km.inertia_:6.0f} silhouette {sil}") plt.plot(ks, inertia, marker="o") plt.xlabel("number of clusters k") plt.ylabel("inertia")
The biggest drops in inertia come at k = 2 and k = 3. After that, the curve flattens. The silhouette score is nearly tied between 2 and 3. So the numbers narrow it down to two choices, and usefulness decides between them. The next step is to look at what three clusters mean.
Show the answer
Zero
Each customer gets its own center, so every distance is zero. That's why the lowest inertia can't be how you choose k.
From clusters to segments
k-means gives each customer a cluster number: 0, 1 or 2. On its own, that number means nothing. To make it useful, you profile each cluster. Take the average of every feature per cluster. Include features the clustering didn't use, like churn and lifetime value (the total a customer spends with you over time).
The code below runs k-means with k = 3 and adds the cluster number as a new column. Then groupby averages the columns per cluster. The churn column is the share of customers in the cluster who left.
import pandas as pd import requests from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) features = ["orders_last_year", "avg_order_value", "discount_share"] X = StandardScaler().fit_transform(df[features]) df["cluster"] = KMeans(n_clusters=3, n_init=10, random_state=0).fit_predict(X) profile = df.groupby("cluster").agg( customers=("id", "count"), orders=("orders_last_year", "mean"), order_value=("avg_order_value", "mean"), discount=("discount_share", "mean"), churn=("churned", "mean"), ltv=("lifetime_value", "mean"), ).round(2) pd.set_option("display.width", 120) print(profile)
Three recognizable groups, or segments, fall out:
- Bargain hunters: the most orders, the smallest baskets, and over half their orders discounted.
- Loyal regulars: fewer orders but the biggest baskets. They rarely use discounts and have by far the highest lifetime value.
- Occasional shoppers: about two orders a year and mid-sized baskets. Around 40% of them churn.
Now compare k = 2. The loyal regulars and the occasional shoppers land in one cluster with a 25% churn rate. That number describes neither group, and the at-risk segment disappears. That's why k = 3 is the better choice here, even though the scores barely separated the options.
For marketers: this is where clustering earns its keep. Send a discount-led message to the first group and a premium or loyalty message to the second. Give the third a re-engagement program. Each segment is described in terms a campaign can use.
Clustering text with embeddings
k-means works on any list of numbers, so it can group text too. First you turn each text into an embedding, which you met in Phase 1. It's a list of numbers that captures a text's meaning. Texts that mean similar things get nearby lists. Then k-means groups the texts by meaning.
Here are a dozen support messages with no labels. The code asks the embeddings service for one vector per message. Then it runs k-means with k = 3 and prints the messages in each cluster.
import numpy as np import requests from sklearn.cluster import KMeans messages = [ "Where is my parcel? Tracking hasn't changed in a week.", "How do I return an unworn sweater?", "The jacket runs small. Should I size up?", "Delivery was promised Friday and nothing has arrived.", "Still waiting on my refund for the boots.", "What size fits a 34 inch waist?", "Can I get express shipping to Denver?", "Can I exchange this for a different color?", "Sleeves are too tight and the length is short.", "My package says delivered but it isn't here.", "I returned it two weeks ago. Where's my refund?", "Is the medium a loose fit?", ] data = requests.post("https://api.gradient.test/v1/embeddings", json={"input": messages}).json()["data"] E = np.array([d["embedding"] for d in data]) labels = KMeans(n_clusters=3, n_init=10, random_state=0).fit_predict(E) for c in range(3): print(f"cluster {c}:") for m, l in zip(messages, labels): if l == c: print(" ", m)
Shipping, returns and sizing questions separate cleanly. Real teams have thousands of tickets, not twelve. The usual next step is to send a few messages from each cluster to an LLM and ask it to name the theme. That turns a pile of text into a labeled map of what customers are asking about.
Limits worth knowing
- You choose k, and k-means always delivers. Ask for 4 clusters and you get 4, even from data with no real groups at all.
- It expects round blobs of similar size. Other methods handle other shapes. DBSCAN grows clusters from crowded areas, so it finds odd shapes and flags lonely points as outliers. Hierarchical clustering builds a family tree of groups that you can cut at any level. Gaussian mixtures give each point a probability of belonging to each cluster, instead of one firm answer.
- Clusters describe the data you have. Before you build a strategy on them, re-run on newer data and check that the same segments come back.
Show the answer
It still returns 4 clusters
k-means always splits the data into exactly k clusters. It's up to you to check whether those clusters are real and useful.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does the k-means update step do?