k-means From Scratch
The algorithm in four steps, and the three assumptions it makes silently
Eight weeks of supervised learning: you had the answers and searched for the rule. Now the answers are gone. Nobody labelled these customers, and the question is whether they fall into groups at all.
k-means, in full
- Place k centres at random.
- Assign every point to its nearest centre.
- Move each centre to the mean of the points assigned to it.
- Repeat 2 and 3 until nothing moves.
rng = np.random.default_rng(0)
X = np.vstack([rng.normal([0, 0], 0.6, (60, 2)),
rng.normal([4, 4], 0.6, (60, 2)),
rng.normal([0, 5], 0.6, (60, 2))])
centres = X[rng.choice(len(X), 3, replace=False)]
for step in range(1, 11):
d = ((X[:, None] - centres[None, :]) ** 2).sum(axis=2)
labels = d.argmin(axis=1)
new = np.array([X[labels == k].mean(axis=0) for k in range(3)])
shift = np.abs(new - centres).max()
centres = new
if step <= 3 or shift < 1e-6:
print('step %2d largest centre movement %.6f' % (step, shift))
if shift < 1e-6:
break
print('\nfinal centres:')
print(np.round(centres[centres[:, 0].argsort()], 2))
step 2 largest centre movement 1.994041
step 3 largest centre movement 0.043246
step 4 largest centre movement 0.000000
final centres:
[[-0. 4.94]
[-0. 0.1 ]
[ 3.83 4.02]]
Converged in a handful of passes, and the centres landed on the three clusters the data was built from. Twelve lines, no library.
The library version, and the argument that matters
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
prep = make_pipeline(SimpleImputer(strategy='median'), StandardScaler())
Z = prep.fit_transform(df[NUM])
from sklearn.cluster import KMeans
import numpy as np
km = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Z)
print('cluster sizes:', np.bincount(km.labels_))
print('inertia: %.1f' % km.inertia_)
print('iterations to converge:', km.n_iter_)
tiny = np.bincount(km.labels_).argmin()
print('\nthe smallest cluster, in full:')
print(df.loc[km.labels_ == tiny,
['customer_id', 'tenure_months', 'support_calls',
'monthly_charges']].to_string(index=False))
inertia: 3012.8
iterations to converge: 8
the smallest cluster, in full:
customer_id tenure_months support_calls monthly_charges
C00008 48 97 76.42
One of your four segments has a single member
It is the customer with 97 support calls from week 2. k-means partitions everything, so a point far from all the others gets its own centre rather than being flagged as unusual, and you have spent a quarter of your segmentation on one person. This is the “every point belongs somewhere” assumption failing in the most visible way possible, and it is why day 4's DBSCAN, which can label a point as noise, matters.
n_init exists because k-means gets stuck
The result depends on where the centres started. A bad start converges to a bad local optimum, and the algorithm has no way to notice. n_init=10 runs the whole thing ten times from different starts and keeps the lowest inertia. Setting n_init=1 to save time is how you get a different segmentation every Monday.
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
prep = make_pipeline(SimpleImputer(strategy='median'), StandardScaler())
Z = prep.fit_transform(df[NUM])
from sklearn.cluster import KMeans
import numpy as np
print('%8s %14s %14s' % ('n_init', 'best inertia', 'worst inertia'))
for n_init in [1, 5, 25]:
results = [KMeans(n_clusters=5, n_init=n_init, random_state=s).fit(Z).inertia_
for s in range(8)]
print('%8d %14.1f %14.1f' % (n_init, min(results), max(results)))
1 2498.7 2628.4
5 2498.7 2498.7
25 2498.7 2498.7
Scaling decides the answer
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.cluster import KMeans
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
import numpy as np
PAIR = ['monthly_charges', 'total_charges']
raw = SimpleImputer(strategy='median').fit_transform(df[PAIR])
scaler = StandardScaler().fit(raw)
for name, data in [('unscaled', raw), ('scaled ', scaler.transform(raw))]:
centres = KMeans(n_clusters=3, n_init=10, random_state=0).fit(data).cluster_centers_
if name.strip() == 'scaled':
centres = scaler.inverse_transform(centres)
order = centres[:, 1].argsort()
print('%s centres (monthly, total):' % name)
print(np.round(centres[order], 1))
[[ 53.1 665.4]
[ 69.5 2118.9]
[ 75.9 4450.9]]
scaled centres (monthly, total):
[[ 28.9 590.1]
[ 70.3 1101.6]
[ 75.2 3673. ]]
Total charges runs to 7,600 and monthly charges to 106, so unscaled the distance is almost entirely about the total. The three clusters differ hugely in total charges, 665, 2,119, 4,451, and barely at all in monthly charges, 53 to 76. Monthly charges has been ignored.
Scaled, the first cluster's monthly charge drops to 29, which is the no-internet group finally being allowed to separate itself. Neither answer is wrong arithmetic; only one of them is the question you meant to ask.
What k-means assumes
| Assumption | Consequence when false |
|---|---|
| Clusters are roughly spherical | Elongated groups get cut in half |
| Clusters are similar in size | Large ones get split, small ones absorbed |
| Every point belongs somewhere | Outliers drag centres toward themselves |
| You know k | You do not, and the algorithm will not tell you |
from sklearn.cluster import KMeans
from sklearn.datasets import make_moons
from sklearn.metrics import adjusted_rand_score
X, truth = make_moons(n_samples=400, noise=0.06, random_state=0)
labels = KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(X)
print('two interleaved crescents, k-means agreement with the truth: %.3f'
% adjusted_rand_score(truth, labels))
print('(1.0 is perfect, 0.0 is random)')
(1.0 is perfect, 0.0 is random)
k-means fails completely here, because it can only draw straight boundaries between centres and a crescent is not a ball. Day 4's DBSCAN solves exactly this.
Day 1 takeaway
k-means alternates between assigning points to the nearest centre and moving centres to the mean. It depends on the starting position, so keepn_init high. It depends completely on scaling. And it assumes round, similarly sized clusters, when that is false it fails without warning you.