k-Means: A Bad Fixed Point
Math 123: Mathematical Aspects of Data Analysis — Fall 2026
This example takes apart the bad fixed point of Lloyd’s algorithm: four points at the corners of an \(L\times1\) rectangle and \(k=2\). The optimal clustering is left versus right, with cost \(1\), but Lloyd’s algorithm can stop at bottom versus top, with cost \(L^2\). We look at both fixed points, at which starting centers lead to which one, and at how often each seeding rule falls into the trap.
Figure style
# A muted ink colour and a translucent grid read correctly against both the
# light and the dark version of this site, so the figures need no theme switch.
INK = "#7c7c78"
GRID = "rgba(128, 128, 128, 0.28)"
# Dark bold text with a white halo stays legible over the points in both themes.
LABEL_FONT = dict(color="#1a1a19", size=13, weight="bold",
shadow="0 0 3px white, 0 0 3px white, 0 0 2px white")
CLUSTER_COLOR = ["#2a78d6", "#eb6834"]
# One colour per place Lloyd's algorithm can end up.
OUTCOME_COLOR = {"optimum": "#1baf7a", "bad": "#e34948", "one cluster": "#6250d6"}
OUTCOME_NAME = {"optimum": "left / right (cost 1)",
"bad": f"bottom / top (cost L² = {L**2:g})",
"one cluster": f"one empty cluster (cost L² + 1 = {L**2 + 1:g})"}
def style(fig, height=420, title=None, showlegend=None):
"""Apply the shared layout to a plot with axes."""
fig.update_layout(
template="simple_white",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(color=INK, size=13),
title=dict(text=title, x=0, xanchor="left", font=dict(size=15)),
margin=dict(l=70, r=30, t=60 if title else 30, b=55),
height=height,
showlegend=showlegend,
hovermode="closest",
)
fig.update_xaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
fig.update_yaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
return fig
def equal_aspect(fig, n_panels):
"""Make one unit the same length on both axes of every subplot."""
for i in range(1, n_panels + 1):
suffix = "" if i == 1 else str(i)
fig.layout[f"yaxis{suffix}"].scaleanchor = f"x{suffix}"
fig.layout[f"xaxis{suffix}"].constrain = "domain"Two fixed points
This is the lloyd function from the “k-means in practice” box in Lecture 12, changed only to also report how many update steps it took.
def lloyd(X, centers, max_iter=100):
centers = np.array(centers, dtype=float)
labels = None
steps = 0
for _ in range(max_iter):
dists = ((X[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) # (n, k)
new_labels = dists.argmin(axis=1) # assignment step
if labels is not None and np.array_equal(new_labels, labels):
break
labels = new_labels
steps += 1
for l in range(len(centers)): # update step
if np.any(labels == l):
centers[l] = X[labels == l].mean(axis=0)
cost = ((X - centers[labels]) ** 2).sum()
return labels, centers, cost, stepsStart once from the two bad centers \((\tfrac L2,0)\) and \((\tfrac L2,1)\) of the lecture, and once from two points near the middles of the short sides.
starts = {
"bad": np.array([[L / 2, 0], [L / 2, 1]]),
"optimum": np.array([[0.4, 0.5], [L - 0.4, 0.5]]),
}
runs = {name: lloyd(X, c) for name, c in starts.items()}
for name, (labels, centers, cost, steps) in runs.items():
print(f"{name:8s} labels {labels}, centers {centers.tolist()}, "
f"cost {cost:g} after {steps} update step(s)")
print(f"\nratio of the two costs: {runs['bad'][2] / runs['optimum'][2]:g} = L^2")bad labels [0 1 0 1], centers [[1.5, 0.0], [1.5, 1.0]], cost 9 after 1 update step(s)
optimum labels [0 0 1 1], centers [[0.0, 0.5], [3.0, 0.5]], cost 1 after 1 update step(s)
ratio of the two costs: 9 = L^2
From the bad start the first update step reproduces the starting centers exactly, so the assignment never changes and the algorithm stops with cost \(L^2\). Both final configurations satisfy part 3 of the termination theorem: every center is the mean of its cluster and every point is at a nearest center. Nothing in the stopping rule distinguishes the good one from the bad one.
Plot both fixed points
xlim, ylim = (-0.75, L + 0.75), (-0.75, 1.75)
gx, gy = np.meshgrid(np.linspace(*xlim, 300), np.linspace(*ylim, 150))
grid = np.c_[gx.ravel(), gy.ravel()]
two_colors = [[0, CLUSTER_COLOR[0]], [0.5, CLUSTER_COLOR[0]],
[0.5, CLUSTER_COLOR[1]], [1, CLUSTER_COLOR[1]]]
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.08, subplot_titles=[
f"Optimum: left / right, cost {runs['optimum'][2]:g}",
f"Bad fixed point: bottom / top, cost {runs['bad'][2]:g}"])
for col, name in enumerate(["optimum", "bad"], start=1):
labels, centers, cost, _ = runs[name]
cells = ((grid[:, None, :] - centers[None]) ** 2).sum(axis=2).argmin(axis=1)
fig.add_trace(go.Heatmap(x=gx[0], y=gy[:, 0], z=cells.reshape(gx.shape),
colorscale=two_colors, zmin=0, zmax=1, opacity=0.18,
showscale=False, hoverinfo="skip"), row=1, col=col)
fig.add_trace(go.Scatter(x=X[:, 0], y=X[:, 1], mode="markers+text", showlegend=False,
text=NAMES, textposition="middle left", textfont=LABEL_FONT,
marker=dict(size=14, color=[CLUSTER_COLOR[l] for l in labels],
line=dict(color="white", width=2)),
customdata=labels,
hovertemplate="%{text}: cluster %{customdata}<extra></extra>"),
row=1, col=col)
fig.add_trace(go.Scatter(x=centers[:, 0], y=centers[:, 1], mode="markers", showlegend=False,
marker=dict(symbol="x", size=14, color=CLUSTER_COLOR,
line=dict(color="white", width=1.5)),
hovertemplate="center (%{x:.2f}, %{y:.2f})<extra></extra>"),
row=1, col=col)
fig.update_xaxes(range=xlim, title="first coordinate")
fig.update_yaxes(range=ylim)
fig.update_yaxes(title="second coordinate", col=1)
equal_aspect(fig, 2)
style(fig, height=380)
fig.show()Stuck in a basin
A fixed point that the slightest nudge escapes would be harmless in practice. This one is not like that. Move both bad centers by up to \(0.1\) in each coordinate, at random, and run Lloyd’s algorithm again.
final costs after 1000 nudges: [9.]
Every run returns to cost \(L^2\). As long as both bottom corners stay closer to the lower center and both top corners to the upper one, the first assignment is still bottom versus top, and the first update step snaps the centers back to \((\tfrac L2,0)\) and \((\tfrac L2,1)\). The bad fixed point is a genuine local minimum with a whole region of starting centers around it, its basin of attraction.
Two initial centers make four numbers, so we cannot draw the basin directly, but we can draw slices of it: fix the first center \(\bfmu_1\), and color each possible position of the second center \(\bfmu_2\) by where Lloyd’s algorithm ends up.
def outcome(cost):
"""Name the fixed point that a final cost belongs to."""
if np.isclose(cost, 1):
return "optimum"
if np.isclose(cost, L**2):
return "bad"
if np.isclose(cost, L**2 + 1):
return "one cluster"
return "other"
bx, by = np.meshgrid(np.linspace(-1.5, L + 1.5, 161), np.linspace(-2, 3, 121))
fixed_mu1 = {"μ1 = (L/2, 0), a bad center": (L / 2, 0),
"μ1 = x1 = (0, 0), a corner": (0, 0)}
basins = {}
for title, mu1 in fixed_mu1.items():
basins[title] = np.array([outcome(lloyd(X, [mu1, (a, b)])[2])
for a, b in zip(bx.ravel(), by.ravel())]).reshape(bx.shape)
names, counts = np.unique(basins[title], return_counts=True)
print(title, {str(nm): f"{c / counts.sum():.1%}" for nm, c in zip(names, counts)})μ1 = (L/2, 0), a bad center {'bad': '2.2%', 'one cluster': '23.7%', 'optimum': '74.0%'}
μ1 = x1 = (0, 0), a corner {'bad': '2.1%', 'one cluster': '8.9%', 'optimum': '88.9%'}
Plot the basin slices
order = ["optimum", "bad", "one cluster"]
colorscale = [[edge, OUTCOME_COLOR[nm]] for i, nm in enumerate(order)
for edge in (i / len(order), (i + 1) / len(order))]
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.08, subplot_titles=list(fixed_mu1))
for col, (title, mu1) in enumerate(fixed_mu1.items(), start=1):
z = np.vectorize(order.index)(basins[title])
fig.add_trace(go.Heatmap(x=bx[0], y=by[:, 0], z=z, colorscale=colorscale,
zmin=-0.5, zmax=len(order) - 0.5, opacity=0.55, showscale=False,
text=np.vectorize(OUTCOME_NAME.get)(basins[title]),
hovertemplate="μ2 = (%{x:.2f}, %{y:.2f})<br>%{text}<extra></extra>"),
row=1, col=col)
fig.add_trace(go.Scatter(x=X[:, 0], y=X[:, 1], mode="markers+text", showlegend=False,
text=NAMES, textposition="middle left", textfont=LABEL_FONT,
marker=dict(size=10, color=INK, line=dict(color="white", width=2)),
hoverinfo="skip"), row=1, col=col)
fig.add_trace(go.Scatter(x=[mu1[0]], y=[mu1[1]], mode="markers", showlegend=False,
marker=dict(symbol="x", size=16, color="#1a1a19",
line=dict(color="white", width=1.5)),
hovertemplate="μ1 (fixed)<extra></extra>"), row=1, col=col)
for nm in order: # legend entries for the heatmap colors
fig.add_trace(go.Scatter(x=[None], y=[None], mode="markers", name=OUTCOME_NAME[nm],
marker=dict(symbol="square", size=12, color=OUTCOME_COLOR[nm],
opacity=0.7)))
fig.update_xaxes(range=[bx.min(), bx.max()], title="first coordinate of μ2")
fig.update_yaxes(range=[by.min(), by.max()])
fig.update_yaxes(title="second coordinate of μ2", col=1)
equal_aspect(fig, 2)
style(fig, height=470, showlegend=True)
fig.update_layout(legend=dict(orientation="h", x=0, y=-0.18, xanchor="left", yanchor="top"),
margin=dict(b=110))
fig.show()Three things show up.
- The red region has positive area, so the bad fixed point is reached from a whole region of starts, not just one exact position. It is only a thin sliver in these slices, though, so centers placed uniformly at random in the picture would rarely land in it.
- There is a third fixed point, in purple: if \(\bfmu_2\) starts far enough away that no data point is closest to it, its cluster is empty, \(\bfmu_2\) stays where it is, \(\bfmu_1\) moves to the mean of all four points, and the algorithm stops with cost \(L^2+1\). This is why practical initializations put the centers at data points.
- In the right panel, \(\bfmu_1\) sits on the corner \(\bfx_1\), as in the first step of k-means++. Of the other three corners, only \(\bfx_2\), directly above \(\bfx_1\), lies in the red region, while \(\bfx_3\) and \(\bfx_4\) lie in the green one. The sliver is thin, but a data point sits right in it. This answers the “(check this!)” in the lecture: from \(\bfmu_1=\bfx_1\) and \(\bfmu_2\in\{\bfx_3,\bfx_4\}\), Lloyd’s algorithm finds the left/right optimum.
How often does seeding fall in?
Now restrict the starting centers to two distinct data points, as both standard seedings do. There are only \(\binom42=6\) such pairs, so we can try them all.
start at x1, x2: final cost 9 (bad)
start at x3, x4: final cost 9 (bad)
start at x1, x3: final cost 1 (optimum)
start at x2, x4: final cost 1 (optimum)
start at x1, x4: final cost 1 (optimum)
start at x2, x3: final cost 1 (optimum)
Plot all six starts
fig = make_subplots(rows=2, cols=3, horizontal_spacing=0.05, vertical_spacing=0.16,
subplot_titles=[f"start x{i + 1}, x{j + 1}: cost {pair_runs[(i, j)][2]:g}"
for i, j in pairs])
for idx, p in enumerate(pairs):
row, col = idx // 3 + 1, idx % 3 + 1
labels, centers, cost, _ = pair_runs[p]
start = X[list(p)]
for l in range(k): # path of each center
fig.add_trace(go.Scatter(x=[start[l, 0], centers[l, 0]], y=[start[l, 1], centers[l, 1]],
mode="lines", line=dict(color=CLUSTER_COLOR[l], width=2, dash="dot"),
showlegend=False, hoverinfo="skip"), row=row, col=col)
fig.add_trace(go.Scatter(x=X[:, 0], y=X[:, 1], mode="markers", showlegend=False,
marker=dict(size=11, color=[CLUSTER_COLOR[l] for l in labels],
line=dict(color="white", width=2)),
text=NAMES, customdata=labels,
hovertemplate="%{text}: cluster %{customdata}<extra></extra>"),
row=row, col=col)
fig.add_trace(go.Scatter(x=start[:, 0], y=start[:, 1], mode="markers", showlegend=False,
marker=dict(symbol="circle-open", size=22, color=CLUSTER_COLOR,
line=dict(width=2.5)),
hovertemplate="initial center (%{x:g}, %{y:g})<extra></extra>"),
row=row, col=col)
fig.add_trace(go.Scatter(x=centers[:, 0], y=centers[:, 1], mode="markers", showlegend=False,
marker=dict(symbol="x", size=13, color=CLUSTER_COLOR,
line=dict(color="white", width=1.5)),
hovertemplate="final center (%{x:.2f}, %{y:.2f})<extra></extra>"),
row=row, col=col)
fig.update_xaxes(range=[-0.5, L + 0.5], showticklabels=False)
fig.update_yaxes(range=[-0.5, 1.5], showticklabels=False)
equal_aspect(fig, 6)
style(fig, height=420)
fig.update_annotations(font=dict(size=13))
fig.show()Only the two vertical pairs, \(\{\bfx_1,\bfx_2\}\) and \(\{\bfx_3,\bfx_4\}\), are bad. With both centers on the same short side, their bisector is horizontal, so it splits bottom from top. This gives the failure probabilities of the two seedings exactly.
- Random data points: the pair is uniform among the \(6\), so Lloyd’s algorithm fails with probability \(\tfrac26=\tfrac13\), whatever \(L\) is. As \(L\) grows the failure gets worse (its cost is \(L^2\)) but no rarer, and the expected cost \(\tfrac23\cdot1+\tfrac13L^2\) grows without bound.
- k-means++: whichever corner is chosen first, the bad second choice is its vertical neighbor, at squared distance \(1\), against \(L^2\) and \(L^2+1\) for the other two corners. So it fails with probability \(1/(2L^2+2)\), and the expected cost is \(1+(L^2-1)/(2L^2+2)<\tfrac32\) for every \(L\).
To check these numbers, here is k-means++ seeding written out, followed by a simulation of both seedings for several widths \(L\).
def kmeans_pp(X, k, rng):
"""Choose k initial centers from the rows of X by k-means++ seeding."""
centers = [X[rng.integers(len(X))]]
for _ in range(1, k):
D2 = ((X[:, None, :] - np.array(centers)[None]) ** 2).sum(axis=2).min(axis=1)
centers.append(X[rng.choice(len(X), p=D2 / D2.sum())])
return np.array(centers)
def random_points(X, k, rng):
"""Choose k distinct rows of X uniformly at random."""
return X[rng.choice(len(X), size=k, replace=False)]
def failure_rate(seeding, L, trials, rng):
"""Fraction of seeded Lloyd runs on the L x 1 rectangle that miss the optimum."""
XL = np.array([[0, 0], [0, 1], [L, 0], [L, 1]], dtype=float)
return np.mean([lloyd(XL, seeding(XL, 2, rng))[2] > 1 + 1e-9 for _ in range(trials)])
rng = np.random.default_rng(1)
widths = np.array([1.25, 1.5, 2, 3, 4, 6, 8])
trials = 20_000
sim = {"random data points": [failure_rate(random_points, w, trials, rng) for w in widths],
"k-means++": [failure_rate(kmeans_pp, w, trials, rng) for w in widths]}
print(" L random (sim / exact) k-means++ (sim / exact)")
for w, r, p in zip(widths, sim["random data points"], sim["k-means++"]):
print(f"{w:5.2f} {r:.4f} / {1/3:.4f} {p:.4f} / {1 / (2 * w**2 + 2):.4f}") L random (sim / exact) k-means++ (sim / exact)
1.25 0.3361 / 0.3333 0.1989 / 0.1951
1.50 0.3329 / 0.3333 0.1515 / 0.1538
2.00 0.3362 / 0.3333 0.0980 / 0.1000
3.00 0.3311 / 0.3333 0.0512 / 0.0500
4.00 0.3313 / 0.3333 0.0292 / 0.0294
6.00 0.3371 / 0.3333 0.0136 / 0.0135
8.00 0.3318 / 0.3333 0.0081 / 0.0077
Plot the failure probabilities
Ls = np.linspace(1, widths.max(), 200)
exact = {"random data points": np.full_like(Ls, 1 / 3), "k-means++": 1 / (2 * Ls**2 + 2)}
fig = go.Figure()
for color, name in zip(CLUSTER_COLOR, exact):
fig.add_trace(go.Scatter(x=Ls, y=exact[name], mode="lines", name=f"{name} (exact)",
line=dict(color=color, width=2), legendgroup=name,
hovertemplate="L = %{x:.2f}<br>exact %{y:.4f}<extra>" + name + "</extra>"))
fig.add_trace(go.Scatter(x=widths, y=sim[name], mode="markers", name=f"{name} (simulated)",
marker=dict(color=color, size=9, line=dict(color="white", width=2)),
legendgroup=name,
hovertemplate="L = %{x:.2f}<br>simulated %{y:.4f}<extra>" + name + "</extra>"))
fig.add_annotation(x=Ls[-1], y=np.log10(exact[name][-1]), text=name, showarrow=False,
xanchor="right", yanchor="bottom", yshift=4, font=dict(color=INK, size=13))
fig.update_xaxes(title="width L of the rectangle", range=[1, widths.max() + 0.2])
ticks = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5]
fig.update_yaxes(title="probability of missing the optimum", type="log",
range=[np.log10(0.004), np.log10(0.6)],
tickvals=ticks, ticktext=[f"{t:g}" for t in ticks])
style(fig, height=430, showlegend=True)
fig.update_layout(legend=dict(x=0.02, y=0.02, xanchor="left", yanchor="bottom"))
fig.show()The simulations agree with the exact values to within sampling error. k-means++ helps exactly where the problem is worst: the wider the rectangle, the bigger the disaster at the bad fixed point, and the less likely k-means++ is to land there.
Restarts in scikit-learn
sklearn.cluster.KMeans uses both remedies at once. With init="random" it seeds from random data points, and with n_init it keeps the best of that many runs, so it fails only if every run fails, with probability \((\tfrac13)^{\texttt{n\_init}}\) here.
from sklearn.cluster import KMeans
seeds = range(300)
for init, n_init in [("random", 1), ("random", 3), ("random", 10), ("k-means++", 1)]:
costs = [KMeans(n_clusters=2, init=init, n_init=n_init, random_state=s).fit(X).inertia_
for s in seeds]
rate = np.mean(np.array(costs) > 1 + 1e-9)
print(f'init="{init}", n_init={n_init:2d}: missed the optimum in {rate:.1%} of {len(seeds)} seeds')init="random", n_init= 1: missed the optimum in 35.3% of 300 seeds
init="random", n_init= 3: missed the optimum in 2.3% of 300 seeds
init="random", n_init=10: missed the optimum in 0.0% of 300 seeds
init="k-means++", n_init= 1: missed the optimum in 0.3% of 300 seeds
The rates for init="random" fall roughly like \(\tfrac13\), \(\tfrac1{27}\), and \(3^{-10}\). scikit-learn’s "k-means++" fails even less often than the \(1/(2L^2+2)=5\%\) computed above, because it is a greedy variant: for each new center it draws a few candidates by the \(D^2\) rule (two, when \(k=2\)) and keeps the one that lowers the cost the most. It picks the bad vertical neighbor only if every candidate is that neighbor.
Even so, no seeding rule makes Lloyd’s algorithm exact. The bad fixed point is still there, and its basin (Figure 2) still contains two of the six pairs of data points; good seeding just makes it unlikely that we start inside it.