Monocurl Essays
Series Download

k-means: finding groups nobody labeled

Score a grouping by its sum of squared distances, lower it with Lloyd's two alternating steps, prove the algorithm must stop, and see where it goes wrong.

22 min·Early college·

AssumesMean, median and standard deviation: what each one saysThe Pythagorean theorem: a proof you can see

k-means on thirty points with three centers (diamonds), started on the first three points of the list. Assignment steps (A) recolor each point by its nearest center, and update steps (U) move each center to the mean of its points. The bars record the sum of squared distances after each step.
Monocurl source
mesh panel = Panel
mesh before = Plain(put_k, put_k, PTS, take(PTS, 3))
mesh live = []

slide "run"
    play Wait(1)
    before = []
    live = Live(u: 0)
    play Fade(0.6)
    play Wait(0.5)
    live.u = 5
    play Lerp(7, [&live], linear)
    play Wait(2)

Uses the shared definitions for this lesson's figures.

Look at the thirty gray points at the start of the animation. You see three groups at once and could circle them with a pencil. Nobody told you that there are three, or which point belongs where. Now hand the same data to a program, as a list of sixty numbers, and ask it for the groups.

The earlier lessons of this series are no help. Each of them had a right answer attached to every example, a price or a label, and a loss that measured how far the model’s answers were from the right ones. Here there are no right answers to miss. Finding structure in data without labels is called unsupervised learning, and grouping is its most basic task. Before we can ask a program to find good groups, we have to say what makes a grouping good.

A score for a grouping

Start with the smallest interesting case: six numbers on a line,

$$ 1, \quad 2, \quad 4, \quad 6, \quad 9, \quad 10, $$

to be split into two groups. The split $\{1, 2, 4\}$, $\{6, 9, 10\}$ looks reasonable, and so does $\{1, 2\}$, $\{4, 6, 9, 10\}$. To choose we need a number. A good group is tight: its members are close to one another, or, what is easier to compute, close to a single central point. Take the mean of each group as its center and add up the squared distances from each number to the center of its group, as the variance does for one group.

For the first split the means are $\frac73 \approx 2.33$ and $\frac{25}{3} \approx 8.33$, and the sum is

$$ \frac{16 + 1 + 25}{9} + \frac{49 + 4 + 25}{9} = \frac{42}{9} + \frac{78}{9} = \frac{120}{9} \approx 13.33. $$

For the second the means are $1.5$ and $7.25$, and the sum is $0.5 + 22.75 = 23.25$. The first split is tighter.

In the plane, $|x - \mu|^2$ is the sum of the squared differences of the two coordinates, by the Pythagorean theorem. Squaring means a point far from its center costs much more than a near one.

Pause and try

Compute $W$ for the split $\{1, 2, 4, 6\}$, $\{9, 10\}$. Is it better than $13.33$?

Show answerHide answer

The means are $3.25$ and $9.5$. The first cluster contributes $2.25^2 + 1.25^2 + 0.75^2 + 2.75^2 = 5.0625 + 1.5625 + 0.5625 + 7.5625 = 14.75$ and the second $0.25 + 0.25 = 0.5$, so $W = 15.25$. It is worse.

Why not try every grouping

We now have a precise problem, and for six numbers a way to solve it: compute $W$ for every split and keep the best. Each number goes to one of two clusters, so there are $2^6 = 64$ ways to label them. For the thirty points of the figure and three clusters there are $3^{30}$, which is about $2 \times 10^{14}$, and for a thousand points the count has 478 digits. Trying everything is out.

Look again at the definition of $J$. It has two kinds of unknowns, the clusters and the centers, and each kind is easy to choose once the other is known. If someone hands us the centers, the best clusters are immediate: every point should go to its nearest center, since that makes its own term as small as possible. If someone hands us the clusters, each center should go wherever it makes its cluster’s sum smallest. We have been using the mean, and that needs to be justified.

The identity says more than “the mean is best”: it prices every other choice. Putting the center of $n$ points at distance $d$ from their mean costs exactly $nd^2$ extra.

The sum of squared distances from the four numbers 4, 6, 9, 10 to a center c, as a function of c. It is a parabola whose lowest point is at the mean, 7.25. A center at 6.2 is 1.05 away from the mean and costs 4 \times 1.05^2 = 4.41 more.
The sum of squared distances from the four numbers $4, 6, 9, 10$ to a center $c$, as a function of $c$. It is a parabola whose lowest point is at the mean, $7.25$. A center at $6.2$ is $1.05$ away from the mean and costs $4 \times 1.05^2 = 4.41$ more.
Monocurl source
let fq = Frame(4, 10.5, 0, 60, [0, 0.15, 0], 6.2, 3.0)
let XS = [4, 6, 9, 10]
let S = |c| sum(map(XS, |x| (x - c) ^ 2))
let m = mean(XS)
mesh plot = [
    GridLines(fq, [5, 6, 7, 8, 9, 10], [10, 20, 30, 40, 50, 60]),
    Seg(at(fq, 4, 0), at(fq, 10.5, 0), SLATE, 2.5),
    Seg(at(fq, 4, 0), at(fq, 4, 60), SLATE, 2.5),
    map([4, 5, 6, 7, 8, 9, 10], |v| center{[fx(fq, v), fy(fq, 0) - 0.3, 0]} Math(to_string(v), 0.72, SLATE)),
    map([0, 20, 40, 60], |v| center{[fx(fq, 4) - 0.34, fy(fq, v), 0]} Math(to_string(v), 0.72, SLATE)),
    center{[fx(fq, 7.25), fy(fq, 0) - 0.66, 0]} Note("center c", 0.74),
    center{[fx(fq, 4) + 1.55, fy(fq, 60) + 0.28, 0]} Note("sum of squared distances", 0.74)
]
mesh curve = stroke{AZURE, 4} Polyline(filter(map(sample(4, 10.5, 140), |c| at(fq, c, S(c))), |p| p[1] <= fy(fq, 60)))
mesh marks = [
    stroke{SLATE, 2.5} dashed{[0.1, 0.07]} Line(at(fq, 6.2, 0), at(fq, 6.2, S(6.2))),
    stroke{LEAF, 2.5} dashed{[0.1, 0.07]} Line(at(fq, m, 0), at(fq, m, S(m))),
    map(XS, |x| Pt(at(fq, x, 0), EMBER, 0.1)),
    Pt(at(fq, 6.2, S(6.2)), INK, 0.1),
    Pt(at(fq, m, S(m)), LEAF, 0.11),
    center{at(fq, 5.35, S(6.2) + 3)} Math(to_string(S(6.2), 2), 0.8, INK),
    center{at(fq, m + 0.6, S(m) - 6)} Math(to_string(S(m), 2), 0.8, LEAF),
    center{at(fq, m + 0.95, 6)} Cap(str_join(["mean ", to_string(m, 2)]), 0.72, LEAF)
]

Uses the shared definitions for this lesson's figures.

Lloyd’s algorithm

Neither half of the problem is hard, and each needs the answer to the other. So guess one, solve for the other, and go back and forth.

Run it on the six numbers with $k = 2$, starting the centers at the first two numbers, $\mu_1 = 1$ and $\mu_2 = 2$.

Assignment. The number 1 is nearest to $\mu_1$. Every other number is nearer to $\mu_2 = 2$. The clusters are $\{1\}$ and $\{2, 4, 6, 9, 10\}$, and $J = 0 + (0 + 4 + 16 + 49 + 64) = 133$.

Update. The means are $\mu_1 = 1$ and $\mu_2 = 31/5 = 6.2$. Now $J = 17.64 + 4.84 + 0.04 + 7.84 + 14.44 = 44.8$.

Pause and try

Carry out the next assignment step and update step from $\mu_1 = 1$, $\mu_2 = 6.2$. Give the clusters, the new centers, and $J$ after each step.

Show answerHide answer

Assignment: the number 2 is at distance 1 from $\mu_1$ and $4.2$ from $\mu_2$, so it switches. The number 4 is at distance 3 from $\mu_1$ and $2.2$ from $\mu_2$, so it stays. The clusters are $\{1, 2\}$ and $\{4, 6, 9, 10\}$, and $J = (0 + 1) + (4.84 + 0.04 + 7.84 + 14.44) = 28.16$. Update: $\mu_1 = 1.5$ and $\mu_2 = 29/4 = 7.25$, and $J = 0.5 + 22.75 = 23.25$.

The figure continues to the end.

  1. Assignment from centers 1 and 2: only the number 1 is nearer to the first center. J = 133.
  2. Update: each center moves to the mean of its numbers, 1 and 6.2. J falls to 44.8.
  3. Assignment: the number 2 is now nearer to the first center and switches. J = 28.16.
  4. Update: the centers move to 1.5 and 7.25. J = 23.25.
  5. Assignment: 4 is at distance 2.5 from the first center and 3.25 from the second, so it switches. J = 18.94.
  6. Update: the centers move to 2.33 and 8.33, and J = 13.33. In the next assignment step nothing changes, so the algorithm stops.
Lloyd’s algorithm on six numbers with two centers (diamonds, drawn above the line). A spoke joins each number to its center. The bars on the right record the objective $J$ after each assignment step (A) and update step (U).
Monocurl source
let xl = |v| -3.6 + 0.38 * v
let put = |p| [xl(p[0]), -0.75, 0]
let put_c = |c| [xl(c[0]), 0.75, 0]
let fl = Frame(0, 6, 0, 140, [2.5, -0.45, 0], 2.1, 2.3)
let R1 = lloyd(LINE, [[1, 0], [2, 0]])
let Live1 = |u| {
    let s = state_at(R1, u)
    return [
        Cluster(put, put_c, LINE, s[0], s[1], 0.09),
        Stairs(fl, R1, LINE, u),
        center{[2.5, 1.45, 0]} scale{1.3} color{INK} Number(cost(LINE, s[0], s[1]), 2),
        map([0, 1], |j| center{[xl(s[1][j][0]) + 0.3 * (2 * j - 1), 1.18, 0]} scale{1.0} color{COLS[j]} Number(s[1][j][0], 2))
    ]
}
mesh base = [
    Seg([xl(0), -0.75, 0], [xl(11), -0.75, 0], SLATE, 2.5),
    map(range(0, 12), |v| [
        Seg([xl(v), -0.82, 0], [xl(v), -0.68, 0], SLATE, 2),
        center{[xl(v), -1.12, 0]} Math(to_string(v), 0.72, SLATE)
    ]),
    StairsFrame(fl, [0, 50, 100], 6),
    center{[2.5, 1.95, 0]} Note("objective J", 0.74),
    center{[xl(5.5), 1.75, 0]} Note("centers", 0.74),
    center{[xl(5.5), -1.6, 0]} Note("the six numbers", 0.74)
]
mesh live = Live1(u: 0)

slide "Assignment from centers 1 and 2: only the number 1 is nearer to the first center. J = 133."
    play Fade(0.6)

slide "Update: each center moves to the mean of its numbers, 1 and 6.2. J falls to 44.8."
    live.u = 1
    play Lerp(1.3, [&live])

slide "Assignment: the number 2 is now nearer to the first center and switches. J = 28.16."
    live.u = 2
    play Lerp(0.8, [&live])

slide "Update: the centers move to 1.5 and 7.25. J = 23.25."
    live.u = 3
    play Lerp(1.3, [&live])

slide "Assignment: 4 is at distance 2.5 from the first center and 3.25 from the second, so it switches. J = 18.94."
    live.u = 4
    play Lerp(0.8, [&live])

slide "Update: the centers move to 2.33 and 8.33, and J = 13.33. In the next assignment step nothing changes, so the algorithm stops."
    live.u = 5
    play Lerp(1.3, [&live])

Uses the shared definitions for this lesson's figures.

The run ends at the clusters $\{1, 2, 4\}$ and $\{6, 9, 10\}$ with $W = 13.33$, the tighter of the two splits we scored at the beginning. Checking all 64 labelings confirms that no split of these six numbers does better.

In the plane nothing changes except the distance formula. The thirty points of the opening figure are listed at the end of this section. Take $k = 3$ and start the centers on the first three points of the list, $(6, 4.9)$, $(4.9, 6.3)$ and $(1.1, 1.6)$.

  1. Three centers are placed on the first three data points. No point has a cluster yet.
  2. Assignment: every point joins its nearest center. The objective is 100.6.
  3. Update: each center moves to the mean of its cluster, and the objective falls to 42.9.
  4. Assignment: two points near the top are now nearer to the orange center and switch. 35.8.
  5. Update: the blue and orange centers follow their clusters. 32.2.
  6. One more point switches (30.0) and the centers settle (29.0). The next assignment changes nothing: stop.
Lloyd’s algorithm on the thirty points, with $k = 3$ and the centers started on the first three points of the list. Left: clusters and centers. Right: the objective after each half step.
Monocurl source
mesh panel = Panel
mesh before = Plain(put_k, put_k, PTS, take(PTS, 3))
mesh live = []

slide "Three centers are placed on the first three data points. No point has a cluster yet."
    play Fade(0.5)

slide "Assignment: every point joins its nearest center. The objective is 100.6."
    before = []
    live = Live(u: 0)
    play Fade(0.7)

slide "Update: each center moves to the mean of its cluster, and the objective falls to 42.9."
    live.u = 1
    play Lerp(1.4, [&live])

slide "Assignment: two points near the top are now nearer to the orange center and switch. 35.8."
    live.u = 2
    play Lerp(0.8, [&live])

slide "Update: the blue and orange centers follow their clusters. 32.2."
    live.u = 3
    play Lerp(1.4, [&live])

slide "One more point switches (30.0) and the centers settle (29.0). The next assignment changes nothing: stop."
    live.u = 5
    play Lerp(2.2, [&live])

Uses the shared definitions for this lesson's figures.

After three rounds the centers sit at $(6.96, 2.43)$, $(5.01, 6.28)$ and $(1.89, 1.79)$, each cluster has ten points, and $W = 29.05$. The objective went $100.6,\ 42.9,\ 35.8,\ 32.2,\ 30.0,\ 29.0$.

Why it must stop

In both runs the objective fell at every step, and the algorithm stopped after a few rounds. Neither was luck.

The bound $k^n$ is the same astronomical number as before, and it is only a guarantee. In practice a few dozen rounds are typical.

It stops, but not always at the best grouping

The theorem says the algorithm stops. It does not say where. Run it on the same thirty points again, with one change: start the centers on the second, third and fourth points of the list instead of the first three.

Two runs of Lloyd’s algorithm on the same thirty points with $k = 3$. Left: centers started on points 1, 2, 3 of the list. Right: started on points 2, 3, 4. The second run stops with one center holding two of the groups and two centers sharing the third, at four times the sum of squared distances.
Monocurl source
let fa = Frame(0, 9.5, 0, 8.5, [-1.9, -0.2, 0], 3.3, 2.95)
let fb = Frame(0, 9.5, 0, 8.5, [1.95, -0.2, 0], 3.3, 2.95)
let put_a = |p| at(fa, p[0], p[1])
let put_b = |p| at(fb, p[0], p[1])
let RB = lloyd(PTS, [PTS[1], PTS[2], PTS[3]])
let Both = |u| {
    let sa = state_at(RUN, u)
    let sb = state_at(RB, u)
    return [
        Cluster(put_a, put_a, PTS, sa[0], sa[1]),
        Cluster(put_b, put_b, PTS, sb[0], sb[1]),
        center{[-0.9, 1.85, 0]} scale{1.1} color{LEAF} Number(cost(PTS, sa[0], sa[1]), 1),
        center{[2.95, 1.85, 0]} scale{1.1} color{ROSE} Number(cost(PTS, sb[0], sb[1]), 1)
    ]
}
mesh frames = [
    Scatter(fa, [0, 2, 4, 6, 8], [0, 2, 4, 6, 8]),
    Scatter(fb, [0, 2, 4, 6, 8], [0, 2, 4, 6, 8]),
    center{[-2.4, 1.85, 0]} Cap("start 1, 2, 3", 0.74, INK),
    center{[1.45, 1.85, 0]} Cap("start 2, 3, 4", 0.74, INK)
]
mesh both = Both(u: 0)

slide "two starts"
    play Wait(1)
    both.u = 5
    play Lerp(7, [&both], linear)
    play Wait(2)

Uses the shared definitions for this lesson's figures.

The second run stops after three rounds at $W = 116.8$, four times the $29.05$ of the first. Two of its centers started in the lower-left group and split it between them. The third was left to cover both other groups, and no single step can repair that: every point is already assigned to its nearest center, and every center is already at the mean of its points.

A clustering at which Lloyd’s algorithm stops is a local optimum: neither step can improve it. The clustering with the smallest possible $W$ is the global optimum. The algorithm finds a local optimum, and which one depends on the start, as with gradient descent on a surface with several dips. The standard defense is to run it from many different starts and keep the result with the lowest $W$. Among all starts on three distinct points of our list, 88% reach $29.05$ and the rest stop at $116.8$ or worse.

Choosing k

So far $k$ was given. When it is not, the natural idea is to let the objective choose: compute the best $W$ found for each $k$ and compare.

Pause and try

What is the smallest possible $W$ when $k = n$, one cluster per point? What does that say about choosing $k$ by minimizing $W$?

Show answerHide answer

With every point its own cluster, each center sits on its point and $W = 0$. No smaller value is possible, so minimizing $W$ over $k$ always answers $k = n$, which describes nothing. More clusters always fit the points more closely, just as a polynomial of higher degree always fits its training points more closely. Low $W$ alone cannot be the criterion.

The lowest within-cluster sum of squares found for each k from 1 to 8 on the thirty points (for each k, the best of ten runs started on different points of the list). It falls steeply up to k = 3 and slowly afterwards. The bend is called the elbow.
The lowest within-cluster sum of squares found for each $k$ from 1 to 8 on the thirty points (for each $k$, the best of ten runs started on different points of the list). It falls steeply up to $k = 3$ and slowly afterwards. The bend is called the elbow.
Monocurl source
let fe = Frame(0.5, 8.5, 0, 300, [-0.1, 0.1, 0], 6.2, 3.0)
let wrap30 = |v| v - 30 * (v // 30)
let best_w = |k| min_of(map(range(0, 10), |r| final_cost(lloyd(PTS, map(range(0, k), |j| PTS[wrap30(r + j * (30 // k))])))))
let WS = map(range(1, 9), best_w)
mesh plot = [
    GridLines(fe, [1, 2, 3, 4, 5, 6, 7, 8], [50, 100, 150, 200, 250, 300]),
    Seg(at(fe, 0.5, 0), at(fe, 8.5, 0), SLATE, 2.5),
    Seg(at(fe, 0.5, 0), at(fe, 0.5, 300), SLATE, 2.5),
    map(range(1, 9), |k| center{[fx(fe, k), fy(fe, 0) - 0.3, 0]} Math(to_string(k), 0.72, SLATE)),
    map([0, 100, 200, 300], |v| center{[fx(fe, 0.5) - 0.42, fy(fe, v), 0]} Math(to_string(v), 0.72, SLATE)),
    center{[fx(fe, 4.5), fy(fe, 0) - 0.66, 0]} Note("number of clusters k", 0.74),
    center{[fx(fe, 0.5) + 1.7, fy(fe, 300) + 0.28, 0]} Note("lowest W found", 0.74)
]
mesh curve = [
    Path(map(range(1, 9), |k| at(fe, k, WS[k - 1])), AZURE, 3.5),
    map(range(1, 9), |k| Pt(at(fe, k, WS[k - 1]), AZURE, 0.09)),
    Ring(at(fe, 3, WS[2]), LEAF, 0.17, 3.5),
    map(range(1, 9), |k| center{[fx(fe, k) + [0.42, 0.62][k == 1], fy(fe, WS[k - 1]) + [0.3, 0][k == 1], 0]} Math(to_string(WS[k - 1], 1), 0.74, INK)),
    center{at(fe, 3.75, 105)} Cap("elbow", 0.76, LEAF)
]

Uses the shared definitions for this lesson's figures.

Going from one cluster to two lowers $W$ by $156$, and from two to three by $93$. After that each extra cluster buys less than $8$. Up to $k = 3$ a new center captures a real group; beyond it, new centers only cut existing groups into pieces. Choosing the $k$ at the bend is the elbow method. It is a rule of thumb with no theorem behind it, and many data sets show no clear bend.

Where k-means fails

The assignment step has a geometric consequence that limits what k-means can find. Take two centers, $\mu_1 = (a_1, b_1)$ and $\mu_2 = (a_2, b_2)$. A point $(x, y)$ is nearer to $\mu_1$ when $(x - a_1)^2 + (y - b_1)^2 < (x - a_2)^2 + (y - b_2)^2$. Expand both sides. The terms $x^2 + y^2$ cancel, and what is left is

$$ 2(a_2 - a_1)\,x + 2(b_2 - b_1)\,y < a_2^2 + b_2^2 - a_1^2 - b_1^2, $$

a linear inequality. So the boundary between two k-means clusters is always a straight line, the perpendicular bisector of the segment joining their centers, and with $k = 2$ the clusters are the two sides of a line.

Groups that no line separates are therefore out of reach. Take 8 points on a circle of radius 1 and 16 points on a circle of radius 3 around the same middle point: two rings, obvious to the eye.

Two rings of points around the same center. Left: the grouping by ring, with both centers at the common middle; its sum of squared distances is 8 \times 1^2 + 16 \times 3^2 = 152. Right: the result of Lloyd's algorithm with k = 2, started on one point of each ring. The clusters are the two sides of the dashed line, and the sum is lower.
Two rings of points around the same center. Left: the grouping by ring, with both centers at the common middle; its sum of squared distances is $8 \times 1^2 + 16 \times 3^2 = 152$. Right: the result of Lloyd’s algorithm with $k = 2$, started on one point of each ring. The clusters are the two sides of the dashed line, and the sum is lower.
Monocurl source
let RINGS = flatten([
    map(range(0, 8), |i| [5 + cos(PI / 4 * i), 4 + sin(PI / 4 * i)]),
    map(range(0, 16), |i| [5 + 3 * cos(PI / 8 * i + PI / 16), 4 + 3 * sin(PI / 8 * i + PI / 16)])
])
let BY_RING = map(range(0, 24), |i| [0, 1][i >= 8])
let fa = Frame(1.4, 8.6, 0.4, 7.6, [-1.9, -0.2, 0], 3.1, 3.1)
let fb = Frame(1.4, 8.6, 0.4, 7.6, [1.95, -0.2, 0], 3.1, 3.1)
let put_a = |p| at(fa, p[0], p[1])
let put_b = |p| at(fb, p[0], p[1])
let RR = lloyd(RINGS, [RINGS[0], RINGS[8]])
let LAST = RR[len(RR) - 1]
let mid = [(LAST[1][0][0] + LAST[1][1][0]) / 2, (LAST[1][0][1] + LAST[1][1][1]) / 2]
let dir = normalize([LAST[1][0][1] - LAST[1][1][1], LAST[1][1][0] - LAST[1][0][0], 0])
mesh frames = [
    Scatter(fa, [2, 4, 6, 8], [2, 4, 6]),
    Scatter(fb, [2, 4, 6, 8], [2, 4, 6])
]
mesh left = [
    map(range(0, 24), |i| Pt(put_a(RINGS[i]), COLS[BY_RING[i]], 0.08)),
    Diamond(put_a([5, 4]), SLATE)
]
mesh right = [
    stroke{SLATE, 2.5} dashed{[0.12, 0.08]} Line(put_b([mid[0] - 3.6 * dir[0], mid[1] - 3.6 * dir[1]]), put_b([mid[0] + 3.6 * dir[0], mid[1] + 3.6 * dir[1]])),
    Cluster(put_b, put_b, RINGS, LAST[0], LAST[1], 0.08)
]
mesh titles = [
    center{[-2.45, 1.85, 0]} Cap("by ring", 0.74, INK),
    center{[-0.95, 1.85, 0]} Math(str_join(["W = ", to_string(cost(RINGS, BY_RING, [[5, 4], [5, 4]]), 1)]), 0.8, INK),
    center{[1.3, 1.85, 0]} Cap("k-means", 0.74, INK),
    center{[2.9, 1.85, 0]} Math(str_join(["W = ", to_string(LAST[2], 1)]), 0.8, ROSE)
]

Uses the shared definitions for this lesson's figures.

Lloyd’s algorithm cuts the picture in half and reports $W = 98.1$. Notice that this is lower than the $152$ of the grouping by ring. Even a perfect minimizer of $W$ would refuse the rings, so no better start could help: the objective itself prefers the wrong answer. k-means looks for compact blobs, because that is what a small sum of squared distances to a center describes.

Two more limits follow from the same formula. Distances add the coordinates as they are, so the result depends on the units: measuring one coordinate in centimeters instead of meters multiplies its differences by 100 and lets it dominate. Coordinates are usually rescaled to comparable spreads first. And because the cost is squared, one point very far from the rest can drag a center toward itself.

Worked examples

Stepping back

The obstacle was a search over an astronomically large set of groupings. What got us past it was noticing that the objective has two blocks of unknowns and is easy in each block separately, then alternating between the two. That idea, improving one block while holding the other fixed, comes with a guarantee and a price that we saw in full: the objective can only go down, so the process stops, and where it stops depends on where it began.

The objective also deserves the blame or credit for what is found. k-means returns compact, roughly round groups because the sum of squared distances to a center rewards exactly those.

Check yourself

A run of Lloyd’s algorithm has just finished an update step with $J = 40$. In the next assignment step, three points change cluster. Which statement must be true?

Problems

The first three are computations, the next two combine ideas, and the last two extend the lesson.

Problem 1

Run Lloyd’s algorithm on the numbers $1, 3, 4, 8, 10$ with $k = 2$, starting from centers $1$ and $3$. Give the clusters and centers after each round and the final $W$.

Show solutionHide solution

Round 1. Assignment: $1$ goes to the first center; $3, 4, 8, 10$ are nearer to $3$. Update: centers $1$ and $\frac{25}{4} = 6.25$. Round 2. Assignment: $3$ is at distance 2 from the first center and $3.25$ from the second, so it switches; $4$ is at $3$ and $2.25$, so it stays. Clusters $\{1, 3\}$, $\{4, 8, 10\}$. Update: centers $2$ and $\frac{22}{3} \approx 7.33$. Round 3. Assignment: $4$ is at distance 2 from the first center and $3.33$ from the second, so it switches. Clusters $\{1, 3, 4\}$, $\{8, 10\}$. Update: centers $\frac83 \approx 2.67$ and $9$. Round 4. $4$ is at $1.33$ and $5$; $8$ is at $5.33$ and $1$. Nothing changes, so the algorithm stops. $W = \frac{25 + 1 + 16}{9} + (1 + 1) = \frac{42}{9} + 2 \approx 6.67$.

Problem 2

The clusters $\{(1, 1), (3, 1)\}$ and $\{(6, 5), (6, 7), (9, 6)\}$ are given. Find their means and $W$.

Show solutionHide solution

The means are $(2, 1)$ and $(7, 6)$. In the first cluster each point is at squared distance $1$, contributing $2$. In the second the squared distances are $1 + 1 = 2$, $1 + 1 = 2$ and $4 + 0 = 4$, contributing $8$. So $W = 10$.

Problem 3

Verify the identity $\sum (x_i - c)^2 = \sum (x_i - \bar x)^2 + n(\bar x - c)^2$ for the numbers $1, 2, 6$ and $c = 5$. Then use it to say how much the sum of squared distances of a cluster of 20 points rises if its center is placed $0.5$ away from the mean.

Show solutionHide solution

The mean is $3$. Left side: $16 + 9 + 1 = 26$. Right side: $(4 + 1 + 9) + 3 \times (3 - 5)^2 = 14 + 12 = 26$. For 20 points and a center at distance $0.5$ from the mean, the extra cost is $n d^2 = 20 \times 0.25 = 5$, whatever the arrangement of the points.

Problem 4

Two centers are at $\mu_1 = (1, 2)$ and $\mu_2 = (5, 4)$. (a) Find the equation of the line that separates the points nearer to $\mu_1$ from those nearer to $\mu_2$. (b) To which center is $(3, 3.5)$ assigned? Check with distances.

Show solutionHide solution

(a) By the inequality of the lesson, a point is nearer to $\mu_1$ when $2(5 - 1)x + 2(4 - 2)y < 25 + 16 - 1 - 4$, that is $8x + 4y < 36$, or $2x + y < 9$. The boundary is the line $2x + y = 9$. It passes through the midpoint $(3, 3)$ of the two centers, and its slope $-2$ is perpendicular to the slope $\frac12$ of the segment joining them. (b) At $(3, 3.5)$, $2x + y = 9.5 > 9$, so the point is nearer to $\mu_2$. Check: the squared distances are $4 + 2.25 = 6.25$ to $\mu_1$ and $4 + 0.25 = 4.25$ to $\mu_2$.

Problem 5

The four points $(0, 0)$, $(a, 0)$, $(0, b)$, $(a, b)$ are the corners of a rectangle with $a, b > 0$. Consider the clustering $V$ into left and right edges and the clustering $H$ into bottom and top edges. (a) Compute $W$ for each. (b) Show that both are local optima of Lloyd’s algorithm. (c) With $a = 4$ and $b = 1$, which is better? What happens if the first coordinate is then measured in a unit ten times larger, so that $a = 0.4$?

Show solutionHide solution

(a) In $V$ the centers are $(0, b/2)$ and $(a, b/2)$ and every point is at distance $b/2$ from its center, so $W_V = 4 \cdot b^2/4 = b^2$. In $H$ the centers are $(a/2, 0)$ and $(a/2, b)$, and $W_H = a^2$. (b) In $V$, a point’s squared distance to its own center is $b^2/4$ and to the other center $a^2 + b^2/4$, which is larger, so the assignment step changes nothing; the centers are already the means. The same argument with $a$ and $b$ exchanged covers $H$. (c) With $a = 4$, $b = 1$: $W_V = 1$ and $W_H = 16$, so $V$ is better. With $a = 0.4$: $W_V = 1$ still, and $W_H = 0.16$, so $H$ is better. The points have not changed, only the unit of one coordinate, and the preferred clustering flipped. This is why coordinates are rescaled before clustering.

Problem 6

(More clusters, lower $W$.) Suppose a clustering into $k < n$ clusters has $W > 0$. Show that there is a clustering into $k + 1$ clusters with strictly smaller $W$. Conclude that the smallest possible $W$ strictly decreases as $k$ increases, until it reaches 0.

Show solutionHide solution

Since $W > 0$, some cluster $C$ contains a point $p$ that is not at the mean $m$ of $C$. Remove $p$ from $C$ and make it a cluster by itself. Its own contribution drops from $|p - m|^2 > 0$ to 0. The remaining points of $C$ contributed $\sum |x_i - m|^2$ before, and afterwards they contribute the sum of squared distances to their new mean, which by the theorem on the mean is no larger than their sum of squared distances to any other point, in particular to $m$. So the total strictly decreases. Applying this to the best clustering with $k$ clusters shows that the best with $k + 1$ is strictly better whenever the former has $W > 0$. Hence the minimum of $W$ falls strictly with $k$ until it is 0, and $W$ alone cannot choose $k$.

Problem 7

(Within plus between.) Let $\bar x$ be the mean of all $n$ points, and let cluster $C_j$ have $n_j$ points and mean $\mu_j$. (a) Show that the total sum of squares $T = \sum_i |x_i - \bar x|^2$ satisfies $T = W + B$, where $B = \sum_j n_j\,|\mu_j - \bar x|^2$. (b) Check it on the rectangle with $a = 4$, $b = 1$ and the clustering into left and right edges. (c) Why does this show that minimizing $W$ is the same as pushing the cluster centers far apart?

Show solutionHide solution

(a) Apply the identity of the theorem on the mean to each cluster separately, with $c = \bar x$: $\sum_{x_i \in C_j} |x_i - \bar x|^2 = \sum_{x_i \in C_j} |x_i - \mu_j|^2 + n_j|\mu_j - \bar x|^2$. Adding over the clusters, the left sides add up to $T$, the first terms on the right to $W$ and the second to $B$. (b) The overall mean is $(2, 0.5)$, and each corner is at squared distance $4 + 0.25$ from it, so $T = 17$. We found $W = 1$. The centers $(0, 0.5)$ and $(4, 0.5)$ are each at squared distance 4 from $(2, 0.5)$ and each cluster has 2 points, so $B = 2 \cdot 4 + 2 \cdot 4 = 16$, and $1 + 16 = 17$. (c) $T$ depends only on the data and not on the clustering. So a clustering has the smallest $W$ exactly when it has the largest $B$, and $B$ measures how far the cluster means are spread from the overall mean, weighted by cluster size.

Shared definitions used by this lesson's figures
# house kit: prepended to every essay figure after the std imports

# palette. AZURE and EMBER are the two actors, LEAF marks an answer, ROSE a problem
let PAPER = [1, 1, 1, 1]
let INK = [0.08, 0.09, 0.11, 1]
let SLATE = [0.40, 0.44, 0.50, 1]
let MIST = [0.85, 0.86, 0.88, 1]
let AZURE = [0.13, 0.42, 0.68, 1]
let EMBER = [0.89, 0.42, 0.12, 1]
let LEAF = [0.13, 0.55, 0.36, 1]
let ROSE = [0.78, 0.22, 0.24, 1]
let PLUM = [0.47, 0.30, 0.66, 1]
let GOLD = [0.85, 0.62, 0.08, 1]

background = PAPER

# an opaque pastel of col; use for fills so overlaps do not darken
let tint = |col, amount = 0.18| [
    1 - (1 - col[0]) * amount,
    1 - (1 - col[1]) * amount,
    1 - (1 - col[2]) * amount,
    1
]

# text: Cap for labels, Note for quiet labels, Math for typeset maths
let Cap = |txt, size = 0.75, col = INK| color{col} Text(txt, size, "Charter")
let Note = |txt, size = 0.65| color{SLATE} Text(txt, size, "Charter")
let Math = |tex, size = 0.9, col = INK| color{col} Tex(tex, size)

# a paper rectangle to sit behind text that crosses lines
let Plate = |pos, size| center{pos} fill{PAPER} stroke{alpha{0} PAPER, 0} Rect(size)

# shapes with fill and stroke both set, so no stray black appears
let Pt = |pos, col = INK, r = 0.075| center{pos} fill{col} stroke{col, 1} Circle(r, 48)
let Ring = |pos, col = INK, r = 0.1, w = 3| center{pos} fill{PAPER} stroke{col, w} Circle(r, 48)
let Seg = |a, b, col = INK, w = 3| stroke{col, w} Line(a, b)
let Path = |pts, col = INK, w = 3| stroke{col, w} Polyline(pts)
let Region = |pts, col| fill{col} stroke{alpha{0} col, 0} Polygon(pts)
let Outline = |pts, col = INK, w = 3| fill{alpha{0} PAPER} stroke{col, w} Polygon(pts)
let Shape = |pts, col, amount = 0.18, w = 3| fill{tint(col, amount)} stroke{col, w} Polygon(pts)
let Disc = |pos, r, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Circle(r, 96)
let Box = |pos, size, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Rect(size)
# a line from a to b ending in a small solid arrowhead
let Tip = |a, b, col = INK, w = 3, head = 0.17| {
    let d = normalize([b[0] - a[0], b[1] - a[1], 0])
    let n = [-d[1], d[0], 0]
    let base = [b[0] - d[0] * head, b[1] - d[1] * head, 0]
    return [
        stroke{col, w} Line(a, base),
        fill{col} stroke{col, 1} Polygon([
            b,
            [base[0] + n[0] * head * 0.42, base[1] + n[1] * head * 0.42, 0],
            [base[0] - n[0] * head * 0.42, base[1] - n[1] * head * 0.42, 0]
        ])
    ]
}

# plots. a frame is [x0, x1, y0, y1, centre, width, height]: a data window and the scene box it fills
let Frame = |x0, x1, y0, y1, c = [0, 0, 0], w = 6.4, h = 3.6| [x0, x1, y0, y1, c, w, h]
let fx = |fr, x| fr[4][0] + (x - (fr[0] + fr[1]) / 2) * fr[5] / (fr[1] - fr[0])
let fy = |fr, y| fr[4][1] + (y - (fr[2] + fr[3]) / 2) * fr[6] / (fr[3] - fr[2])
let at = |fr, x, y| [fx(fr, x), fy(fr, y), 0]

# axes through the data origin (or the nearest edge of the window), with optional names
let Axes = |fr, x_name = "", y_name = "", col = SLATE| {
    let ox = clamp(fr[0], 0, fr[1])
    let oy = clamp(fr[2], 0, fr[3])
    var parts = [
        Tip(at(fr, fr[0], oy), [fx(fr, fr[1]) + 0.25, fy(fr, oy), 0], col, 2.5, 0.15),
        Tip(at(fr, ox, fr[2]), [fx(fr, ox), fy(fr, fr[3]) + 0.25, 0], col, 2.5, 0.15)
    ]
    if (x_name != "") {
        parts .= center{[fx(fr, fr[1]) + 0.5, fy(fr, oy), 0]} Math(x_name, 0.8, col)
    }
    if (y_name != "") {
        parts .= center{[fx(fr, ox), fy(fr, fr[3]) + 0.5, 0]} Math(y_name, 0.8, col)
    }
    return parts
}

# tick marks with numbers at the listed data values
let XTicks = |fr, xs, col = SLATE, places = 0| block {
    let oy = fy(fr, clamp(fr[2], 0, fr[3]))
    for (x in xs) {
        . Seg([fx(fr, x), oy - 0.07, 0], [fx(fr, x), oy + 0.07, 0], col, 2)
        . center{[fx(fr, x), oy - 0.33, 0]} Math(to_string(x, places), 0.72, col)
    }
}
let YTicks = |fr, ys, col = SLATE, places = 0| block {
    let ox = fx(fr, clamp(fr[0], 0, fr[1]))
    for (y in ys) {
        . Seg([ox - 0.07, fy(fr, y), 0], [ox + 0.07, fy(fr, y), 0], col, 2)
        . center{[ox - 0.36, fy(fr, y), 0]} Math(to_string(y, places), 0.72, col)
    }
}

# faint grid lines at the listed data values
let GridLines = |fr, xs, ys, col = MIST| block {
    for (x in xs) {
        . Seg(at(fr, x, fr[2]), at(fr, x, fr[3]), col, 1.5)
    }
    for (y in ys) {
        . Seg(at(fr, fr[0], y), at(fr, fr[1], y), col, 1.5)
    }
}

# the graph of f over [a, b], drawn in the frame. values are clamped to the window
let Graph = |fr, f, a, b, col = AZURE, w = 4, n = 160|
    stroke{col, w} Polyline(map(sample(a, b, n), |x| at(fr, x, clamp(fr[2], f(x), fr[3]))))

# the region between the graph of f and the x axis over [a, b]
let AreaUnder = |fr, f, a, b, col = AZURE, amount = 0.2, n = 80| {
    var pts = [at(fr, a, 0)]
    for (x in sample(a, b, n)) {
        pts .= at(fr, x, clamp(fr[2], f(x), fr[3]))
    }
    pts .= at(fr, b, 0)
    return fill{tint(col, amount)} stroke{alpha{0} col, 0} Polygon(pts)
}

# thirty points in the plane, in a fixed order
let PTS = [[6, 4.9], [4.9, 6.3], [1.1, 1.6], [2.3, 1.2], [6.5, 2.8], [2.2, 2.3], [1.8, 1.2], [4.6, 6.8], [3.8, 6.3], [4.7, 6.8], [7.6, 3.4], [5.5, 2.1], [7.6, 2.7], [5.4, 5.2], [7.7, 2.4], [6, 5.6], [7.1, 2.5], [2, 1.2], [0.6, 0.9], [2.3, 3.1], [2.2, 2.6], [6.8, 1.9], [2.6, 2.3], [5.5, 6.3], [6, 2.4], [6.3, 2.2], [4.9, 6.7], [1.8, 1.5], [8.5, 1.9], [4.3, 7.9]]
# six numbers on a line, written as points with second coordinate 0
let LINE = [[1, 0], [2, 0], [4, 0], [6, 0], [9, 0], [10, 0]]
let dist2 = |p, q| (p[0] - q[0]) ^ 2 + (p[1] - q[1]) ^ 2
# assignment step: nearest center; on a tie a point keeps its cluster, otherwise the lowest index wins
let assign = |pts, cs, prev| map(range(0, len(pts)), |i| {
    let d = map(cs, |c| dist2(pts[i], c))
    let m = min_of(d)
    if (len(prev) > 0) {
        if (d[prev[i]] <= m + 0.000000001) { return prev[i] }
    }
    var best = 0
    for (j in range(0, len(cs))) {
        let r = len(cs) - 1 - j
        if (d[r] <= m) { best = r }
    }
    return best
})
# update step: each center moves to the mean of its points; a center with no points stays
let centers = |pts, a, old| map(range(0, len(old)), |j| {
    let mine = filter(range(0, len(pts)), |i| a[i] == j)
    if (len(mine) == 0) { return old[j] }
    return [mean(map(mine, |i| pts[i][0])), mean(map(mine, |i| pts[i][1]))]
})
let cost = |pts, a, cs| sum(map(range(0, len(pts)), |i| dist2(pts[i], cs[a[i]])))
let changed = |a, b| sum(map(range(0, len(a)), |i| [0, 1][a[i] != b[i]]))
# Lloyd's algorithm. returns the states [assignment, centers, cost] after every half step
let lloyd = |pts, start| {
    var a = assign(pts, start, [])
    var cs = start
    var out = [[a, cs, cost(pts, a, cs)]]
    var going = 1
    for (t in range(0, 40)) {
        if (going == 1) {
            let a0 = a
            let c1 = centers(pts, a0, cs)
            let s1 = [a0, c1, cost(pts, a0, c1)]
            out .= s1
            let a1 = assign(pts, c1, a0)
            if (changed(a0, a1) == 0) {
                going = 0
            } else {
                let s2 = [a1, c1, cost(pts, a1, c1)]
                out .= s2
            }
            a = a1
            cs = c1
        }
    }
    return out
}
let final_cost = |run| run[len(run) - 1][2]
# the picture u half steps into a run: assignments switch at whole numbers, centers glide in between
let state_at = |run, u| {
    let k = min(to_int(floor(u + 0.0001)), len(run) - 1)
    let n = min(k + 1, len(run) - 1)
    let f = clamp(0, u - k, 1)
    return [run[k][0], map(range(0, len(run[k][1])), |j| [
        run[k][1][j][0] + f * (run[n][1][j][0] - run[k][1][j][0]),
        run[k][1][j][1] + f * (run[n][1][j][1] - run[k][1][j][1])
    ])]
}
let COLS = [AZURE, EMBER, PLUM, LEAF, GOLD, ROSE, SLATE, INK]
let Diamond = |pos, col, r = 0.13| fill{col} stroke{INK, 2.5} Polygon([
    [pos[0] - r, pos[1], 0], [pos[0], pos[1] + r, 0], [pos[0] + r, pos[1], 0], [pos[0], pos[1] - r, 0]
])
# points colored by cluster, a spoke from each to its center, and the centers as diamonds
let Cluster = |put, put_c, pts, a, cs, r = 0.065| [
    map(range(0, len(pts)), |i| Seg(put(pts[i]), put_c(cs[a[i]]), tint(COLS[a[i]], 0.45), 1.5)),
    map(range(0, len(pts)), |i| Pt(put(pts[i]), COLS[a[i]], r)),
    map(range(0, len(cs)), |j| Diamond(put_c(cs[j]), COLS[j]))
]
let Plain = |put, put_c, pts, cs, r = 0.065| [
    map(pts, |p| Pt(put(p), SLATE, r)),
    map(range(0, len(cs)), |j| Diamond(put_c(cs[j]), COLS[j]))
]
let Scatter = |fr, xs, ys| [
    GridLines(fr, xs, ys),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[1], fr[2]), SLATE, 2.5),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[0], fr[3]), SLATE, 2.5),
    map(xs, |v| center{[fx(fr, v), fy(fr, fr[2]) - 0.28, 0]} Math(to_string(v), 0.72, SLATE)),
    map(ys, |v| center{[fx(fr, fr[0]) - 0.26, fy(fr, v), 0]} Math(to_string(v), 0.72, SLATE)),
    center{[fx(fr, fr[1]) - 0.1, fy(fr, fr[2]) - 0.28, 0]} Math("x", 0.8, SLATE),
    center{[fx(fr, fr[0]) - 0.26, fy(fr, fr[3]) + 0.02, 0]} Math("y", 0.8, SLATE)
]
# the objective after every half step so far, as bars: dark after an assignment step, light after an update step
let Stairs = |fr, run, pts, u| {
    let k = min(to_int(floor(u + 0.0001)), len(run) - 1)
    let s = state_at(run, u)
    let live = cost(pts, s[0], s[1])
    let Bar = |i, v| Region([at(fr, i + 0.18, 0), at(fr, i + 0.82, 0), at(fr, i + 0.82, v), at(fr, i + 0.18, v)], [INK, SLATE][i - 2 * (i // 2)])
    return [
        map(range(0, k + 1), |i| Bar(i, run[i][2])),
        map(filter([k + 1], |i| u - k > 0.001 and i < len(run) and i - 2 * (i // 2) == 1), |i| Bar(i, live))
    ]
}
let StairsFrame = |fr, ys, n| [
    GridLines(fr, [], ys),
    Seg(at(fr, 0, 0), at(fr, fr[1], 0), SLATE, 2.5),
    Seg(at(fr, 0, 0), at(fr, 0, fr[3]), SLATE, 2.5),
    map(ys, |v| center{[fx(fr, 0) - 0.36, fy(fr, v), 0]} Math(to_string(v), 0.72, SLATE)),
    map(range(0, n), |i| center{[fx(fr, i + 0.5), fy(fr, 0) - 0.28, 0]} Cap(["A", "U"][i - 2 * (i // 2)], 0.7, [INK, SLATE][i - 2 * (i // 2)]))
]
let fk = Frame(0, 9.5, 0, 8.5, [-1.55, 0.05, 0], 3.8, 3.4)
let put_k = |p| at(fk, p[0], p[1])
let fs = Frame(0, 6, 0, 110, [2.5, -0.45, 0], 2.1, 2.3)
let RUN = lloyd(PTS, take(PTS, 3))
let Live = |u| {
    let s = state_at(RUN, u)
    return [
        Cluster(put_k, put_k, PTS, s[0], s[1]),
        Stairs(fs, RUN, PTS, u),
        center{[2.5, 1.45, 0]} scale{1.3} color{INK} Number(cost(PTS, s[0], s[1]), 1)
    ]
}
let Panel = [
    Scatter(fk, [0, 2, 4, 6, 8], [0, 2, 4, 6, 8]),
    StairsFrame(fs, [0, 50, 100], 6),
    center{[2.5, 1.95, 0]} Note("sum of squared distances", 0.72)
]