Index Ai Academy
Phase 3 · Machine learning · Unsupervised

Clustering

Group similar items without labels (k-means).

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Learning without labels
  2. k-means in four steps
  3. k-means by hand on six customers
  4. Scale the features first
  5. How many clusters?
  6. From clusters to segments
  7. Clustering text with embeddings
  8. 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.
Quick check
Why is there no accuracy score for clustering?
  1. There are no true groups to compare against
  2. It takes too long to compute
  3. Accuracy only works on numbers
  4. Clusters are always correct
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:

  1. Pick k and place k centers. A simple way is to put them on randomly chosen data points.
  2. Assign: every point joins its nearest center.
  3. Update: each center moves to the average of the points that joined it.
  4. 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.

Quick check
What does this print?
points = [2, 4, 9]
center = sum(points) / len(points)
print(center)
  1. 4
  2. 9
  3. 15
  4. 5.0
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.

Try it now

Press Next step to alternate the two moves from this section. New start shows that the first guess matters.

Worked example

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)
It runs right here in your browser, with nothing to install.

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.

Quick check
The centers ended at 2 and 11. A new customer placed 7 orders last year. Which center do they join?
  1. Neither: k-means can't place new customers
  2. Both, half each
  3. The center at 11
  4. The center at 2
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.

Quick check
You run k-means without scaling on order value (dollars) and discount share (0 to 1). What's the likely result?
  1. Exactly the same clusters as with scaling
  2. k-means refuses to run
  3. Clusters split almost entirely by order value
  4. Clusters split almost entirely by discount share
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:

  • aa is the average distance from the point to the other points in its own cluster.
  • bb is the average distance from the point to the points in the nearest other cluster.

Then:

s=b−amax⁡(a,b)s = \frac{b - a}{\max(a, b)}

Here ss is the point's silhouette, and max⁡(a,b)\max(a, b) 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 aa = 1.5. The other cluster holds 10, 11 and 12, at distances 7, 8 and 9, so bb = 8. Then ss = (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.

Quick check
You have 600 customers and run k-means with k = 600. What is the inertia?
  1. The same as with k = 1
  2. It can't be calculated
  3. Zero
  4. As large as it can be
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.
Quick check
You run k-means with k = 4 on customer data that has no real groups at all. What happens?
  1. It returns 1 cluster
  2. It raises an error saying there are no groups
  3. It still returns 4 clusters
  4. It returns 0 clusters
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

Create a free account

Try a quiz question

What does the k-means update step do?

  1. Moves each center to the average of the points assigned to it
  2. Assigns each point to the center that's nearest to it
  3. Removes outliers that lie far away from every center
  4. Chooses k, by testing which number fits the data best

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson