Title: Clustering in Python with k-Means

Date: 2023-06-25

Category: Machine Learning

Tags: Machine Learning, Python

Authors: James D. Triveri

Summary: Clustering in Python with k-Means

k-means clustering is a simple iterative clustering algorithm that partitions a dataset into a pre-determined number of clusters k, based on a pre-determined distance metric. Unlike supervised classification algorithms that have some notion of a target class, the objects comprising the input to k-means do not come with an associated target. For this reason, k-means clustering and its variations are considered instances of unsupervised learning techniques.

In this post, details of the k-means algorithm are covered in detail. After laying the theoretical foundation, we walk through a Python implementation of k-means, and explore how to determine an optimal value for k. We’ll also highlight a variant of k-means with an improved initialization scheme known as kmeans++. Finally, we examine how to leverage the clustering routines available in scikit-learn.

## Background

Given a set of observations \((x_{1}, x_{2}, \cdots, x_{n})\), where each observation is a \(p\)-dimensional vector, k-means attempts to partition the \(n\) observations into \(k\) clusters \(C = {C_{1}, C_{2}, \cdots, C_{k}}\) such that the intra-cluster sum of squares is minimized. The k-means objective function is defined as

\[ J = \sum_{n=1}^{N} \sum_{k=1}^{K} r_{nk}||x_{n} - \mu_{k}||^{2}, \]

which represents the sum of the squares of the distances of each data point to its assigned vector \(\mu_{k}\). \(r_{nk}\) is given by

\[ \DeclareMathOperator*{\argmin}{arg\,min_{j}} r_{nk} = \begin{cases} 1 & \mbox{if} \argmin ||x_{n}-\mu_{j}||^{2} \\ 0 & \mbox{otherwise} \end{cases} \]

which states that the \(n^{th}\) observation gets assigned to the cluster whose centroid is nearest.

Conventional k-means usually begins with random selection of k observations for the initial centroids. The algorithm proceeds by assigning each observation to its nearest centroid. Upon completion, the k centroids are updated using the latest cluster assignments. This is an example of an iterative refinement technique, which ceases only after the change in distance between consecutive iterations for all observations is less than some pre-determined threshold.

The two stages correspond to the expectation and maximization steps of the Expectation Maximization algorithm. The value of \(J\) is reduced after each EM cycle, so convergence is guaranteed, but not necessarily to the global minimum. Since k-means can be run quickly, various combinations of starting points and number of clusters k are run in turn to see how changing the observations selected for the initial k means varies the ultimate formation of the k clusters.

## Implementation

In what follows, we present an implementation of k-means that uses the conventional method of centroid intialization. In this version, the `kmeans`

function takes as input data representing the observations of interest, *k* for the number of clusters and *max_cycles*, which limits the number of allowable EM cycles before iteration ceases. The function returns a dictionary containing:

```
cycles: Number of cycles required
all_means: Progression of centroids for all cycles
all_assign: Cluster assignments for each observation at each iteration
all_dists: Distances from observations to each of the k centroids
final_means: Centroids at the final iteration
final_assign: Final cluster assignments for each observation as 1-D array
final_dists: Final distances from observations to each of the k centroids
total_cost: The total variance over all observations
```

The implmentation is provided below:

```
import itertools
import numpy as np
import pandas as pd
import scipy
from scipy.spatial import distance
def kmeans(data, k=3, max_cycles=25):
"""
Return means and cluster assignments for a given
dataset `data`.
Step I : Assignment step: Assign each observation to the cluster whose
mean has the least squared Euclidean distance, this is
intuitively the nearest mean.
Step II: Update step; Calculate New cluster centroids for each
observation.
"""
if isinstance(data, pd.DataFrame):
= data.values
data
# Add index column to data.
= np.atleast_2d(np.arange(0, data.shape[0]))
tmpid = np.concatenate([tmpid.T, data], axis=1)a
X
# Conventional initialization: Select k random means from X.
= np.random.choice(X.shape[0], k, replace=False)
init_indx = X[init_indx][:, 1:]
init_means = init_means
centroids = [], [], []
all_means, all_assign, all_dists
# Initialize indicator and distance matricies which indicate cluster
# membership for each observation.
= np.zeros(shape=(X.shape[0], k))
init_clust_assign = np.zeros_like(init_clust_assign)
init_clust_dists
all_means.append(init_means)
all_assign.append(init_clust_assign)
all_dists.append(init_clust_dists)
for c in itertools.count(start=1):
= np.zeros(shape=(X.shape[0], k))
clust_assign = np.zeros_like(clust_assign)
clust_dists
for i in range(X.shape[0]): # Step I (Assignment Step)
= X[i, 1:]
iterobs = [distance.euclidean(j, iterobs) for j in centroids]
iterdists = np.min(iterdists)
min_dist = np.argmin(iterdists)
min_indx = 1
clust_assign[i, min_indx] = min_dist
clust_dists[i, min_indx]
# Test for convergence.
if np.allclose(clust_dists, all_dists[-1], atol=1e-9) or c>= max_cycles:
break
all_assign.append(clust_assign)
all_dists.append(clust_dists)= np.zeros_like(init_means)
centroids
# Step II (Update Step).
for i in enumerate(centroids):
= i[0], i[1]
iter_k, iter_v
# Use indicies from clust_assign to retrieve elements from X
# in order to calculate centroid updates.
= X[np.nonzero(clust_assign[:, iter_k])][:, 1:].mean(axis=0)
iter_mean = iter_mean
centroids[iter_k]
all_means.append(centroids)
# Calculate cost function over all centroids.
= (all_dists[-1]**2).sum()
total_cost
# Return final_assign as a 1-D array representing final cluster assignment.
= np.apply_along_axis(
final_assign lambda a: np.where(a==1)[0][0], axis=1, arr=all_assign[-1]
)
= {
dresults 'k': k, 'cycles': c, 'all_means': all_means, 'all_assign': all_assign,
'all_dists': all_dists, 'final_means': all_means[-1],
'final_assign': final_assign, 'final_dists': all_dists[-1],
'total_cost': total_cost
}
return(dresults)
```

At the very start of the function definition we append an identifer column which allows us to pair observations to their instance identifiers. To demonstrate, we use the dataset found here, which contains sample mean and standard deviation of unemployment data for each of the 50 states:

```
import pandas as pd
= pd.read_csv("https://gist.githubusercontent.com/jtrive84/ea0f275a9ef010415392189e64c71fc3/raw/4bb56a71c5b597c16513c48183dde799ddf9ec51/unemp.csv")
df
# For visualizations.
= df['mean'].values
x1 = df['stddev'].values
x2
# Pass as input to kmeans function.
= df[['mean','stddev']].values
X
= kmeans(X, k=3) results
```

Because `kmeans`

returns the progression of centroid values and cluster assignments after each E-M step, we can create a visualization that demonstrates how assignments and centroids change with each iteration. We next plot cluster assignments at four iterations:

```
"""
Plotting various iterations of k-means cluster assignments.
"""
import matplotlib.pyplot as plt
import seaborn as sns
# Call kmeans with k=3.
= kmeans(X, k=3, max_cycles=25)
output = output['all_means']
am = output['all_dists']
ad = output['all_assign']
aa # Plot cluster assignments on 2x2 lattice plot.
= plt.figure()
fig
# Plot initial means.
= fig.add_subplot(221)
ax1 ="white", marker="o", fillstyle='none', markeredgecolor='black', markeredgewidth=.90)
ax1.plot(x1, x2,color"k-means: Random Initialization ", loc="left")
ax1.set_title(= am[0][0,:]
m1a, m2a = am[0][1,:]
m1b, m2b = am[0][2,:]
m1c, m2c
="#FF0080",marker="D",markersize=12,fillstyle='none',markeredgewidth=1.30)
ax1.plot(m1a,m2a,color="#1A8B55",marker="D",markersize=12,fillstyle='none',markeredgewidth=1.30)
ax1.plot(m1b,m2b,color="#0080FF",marker="D",markersize=12,fillstyle='none',markeredgewidth=1.30)
ax1.plot(m1c,m2c,color="#FF0080",marker="+",markersize=28,fillstyle='none',markeredgewidth=1.50)
ax1.plot(m1a,m2a,color="#1A8B55",marker="+",markersize=28,fillstyle='none',markeredgewidth=1.50)
ax1.plot(m1b,m2b,color="#0080FF",marker="+",markersize=28,fillstyle='none',markeredgewidth=1.50)
ax1.plot(m1c,m2c,color="#FF0080",marker="o")
ax1.plot(m1a,m2a,color="#1A8B55",marker="o")
ax1.plot(m1b,m2b,color="#0080FF",marker="o")
ax1.plot(m1c,m2c,color; plt.yticks([])
plt.xticks([])
# 1st Assignment Step.
= fig.add_subplot(222)
ax2 = am[0][0,:]
m1a, m2a = am[0][1,:]
m1b, m2b = am[0][2,:]
m1c, m2c
="#FF0080",marker="D",markersize=12,fillstyle='none',markeredgewidth=1.30)
ax2.plot(m1a,m2a,color="#1A8B55",marker="D",markersize=12,fillstyle='none',markeredgewidth=1.30)
ax2.plot(m1b,m2b,color="#0080FF",marker="D",markersize=12,fillstyle='none',markeredgewidth=1.30)
ax2.plot(m1c,m2c,color="#FF0080",marker="+",markersize=28,fillstyle='none',markeredgewidth=1.50)
ax2.plot(m1a,m2a,color="#1A8B55",marker="+",markersize=28,fillstyle='none',markeredgewidth=1.50)
ax2.plot(m1b,m2b,color="#0080FF",marker="+",markersize=28,fillstyle='none',markeredgewidth=1.50)
ax2.plot(m1c,m2c,color
# Retrieve points assigned to clusters.
= aa[1]
aa1 = X[np.nonzero(aa1[:,0])][:,0:]
X0 = X0[:, 0], X0[:, 1]
x01, x02
= X[np.nonzero(aa1[:,1])][:,0:]
X1 = X1[:, 0], X1[:, 1]
x11, x12
= X[np.nonzero(aa1[:, 2])][:, 0:]
X2 = X2[:, 0], X2[:, 1]
x21, x22
="#FF0080", marker="o")
ax2.scatter(x01, x02, color="#1A8B55", marker="o")
ax2.scatter(x11, x12, color="#0080FF", marker="o")
ax2.scatter(x21, x22, color"k-means: First Assignment Step", loc="left")
ax2.set_title(; plt.yticks([])
plt.xticks([])
# 5th assignment step.
= fig.add_subplot(223)
ax3 = am[4][0, :]; m1b, m2b = am[4][1, :]; m1c, m2c = am[4][2, :]
m1a, m2a ="#FF0080", marker="D", markersize=12, fillstyle='none', markeredgewidth=1.30)
ax3.plot(m1a, m2a, color="#1A8B55", marker="D", markersize=12, fillstyle='none', markeredgewidth=1.30)
ax3.plot(m1b, m2b, color="#0080FF", marker="D", markersize=12, fillstyle='none', markeredgewidth=1.30)
ax3.plot(m1c, m2c, color="#FF0080", marker="+", markersize=28, fillstyle='none', markeredgewidth=1.50)
ax3.plot(m1a, m2a, color="#1A8B55", marker="+", markersize=28, fillstyle='none', markeredgewidth=1.50)
ax3.plot(m1b, m2b, color="#0080FF", marker="+", markersize=28, fillstyle='none', markeredgewidth=1.50)
ax3.plot(m1c, m2c, color
# Retrieve points assigned to clusters.
= aa[5]
aa1 = X[np.nonzero(aa1[:, 0])][:, 0:]
X0 = X0[:,0], X0[:, 1]
x01, x02
= X[np.nonzero(aa1[:, 1])][:, 0:]
X1 = X1[:, 0], X1[:, 1]
x11, x12
= X[np.nonzero(aa1[:, 2])][:, 0:]
X2 = X2[:, 0], X2[:, 1]
x21, x22
="#FF0080", marker="o")
ax3.scatter(x01, x02, color="#1A8B55", marker="o")
ax3.scatter(x11, x12, color="#0080FF", marker="o")
ax3.scatter(x21, x22, color"k-means: Fourth Assignment Step", loc="left")
ax3.set_title(; plt.yticks([])
plt.xticks([])
# Final assignment step.
= fig.add_subplot(224)
ax4 = am[-1][0, :]; m1b, m2b = am[-1][1, :]; m1c, m2c = am[-1][2, :]
m1a, m2a ="#FF0080", marker="D", markersize=12, fillstyle='none', markeredgewidth=1.30)
ax4.plot(m1a, m2a, color="#1A8B55", marker="D", markersize=12, fillstyle='none', markeredgewidth=1.30)
ax4.plot(m1b, m2b, color="#0080FF", marker="D", markersize=12, fillstyle='none', markeredgewidth=1.30)
ax4.plot(m1c, m2c, color="#FF0080", marker="+", markersize=28, fillstyle='none', markeredgewidth=1.50)
ax4.plot(m1a, m2a, color="#1A8B55", marker="+", markersize=28, fillstyle='none', markeredgewidth=1.50)
ax4.plot(m1b, m2b, color="#0080FF", marker="+", markersize=28, fillstyle='none', markeredgewidth=1.50)
ax4.plot(m1c, m2c, color
# Retrieve points assigned to clusters.
= aa[-1]
aa1 = X[np.nonzero(aa1[:, 0])][:, 0:]
X0 = X0[:, 0], X0[:, 1]
x01, x02
= X[np.nonzero(aa1[:, 1])][:, 0:]
X1 = X1[:, 0], X1[:, 1]
x11, x12
= X[np.nonzero(aa1[:, 2])][:, 0:]
X2 = X2[:, 0], X2[:, 1]
x21, x22
="#FF0080", marker="o")
ax4.scatter(x01, x02, color="#1A8B55", marker="o")
ax4.scatter(x11, x12, color="#0080FF", marker="o")
ax4.scatter(x21, x22, color"k-means: Final Assignment Step", loc="left")
ax4.set_title(; plt.yticks([])
plt.xticks([])
fig.tight_layout()=.05, hspace=0.1, left=.05, right=.95)
fig.subplots_adjust(wspace plt.show()
```

Which produces the following:

The centroids have changed noticably when comparing the upper-left and the bottom-right subplots. However, we arbitrarily chose 3 as the number of cluster centroids. We next discuss how to determine k, which corresponds to the number of centroids that minimizes the cost function \(J\) across all observations.

## Determining the Optimal k

A common method used in assessing the optimal number of centroids k to use for clustering is the elbow method, which plots the sum of squared residuals as a function of the number of clusters across all observations. If we caculate the sum of squared residuals from a single centroid model as compared with the same measure from the two-centroid model, it’s clear that the two-centroid model will have a lower total variance since the larger residuals in the \(k=1\) model will almost surely be reduced when \(k=2\). However, at a certain point increasing k will have little impact on the intra-cluster variance, and the elbow method can be used to help identify this point. In the code that follows, we leverage our `kmeans`

function and the sample dataset introduced earlier to assess total variance as a function of k, the number of centroids, with \(1 \leq k \leq 10\):

```
"""
Demonstrating the relationship between total variance as a function of the
number of clusters to determine the optimal number of centroids.
"""
= plt.figure()
fig set(context='notebook', style='darkgrid', font_scale=1)
sns.= fig.add_subplot(111)
ax
ax.set_title("Total Variance as a Function of Cluster Count", fontsize=18,
="left", color="#FF6666", weight="bold"
loc
)True)
ax.grid("Number of Clusters", fontsize=15, labelpad=12.5, color="black")
ax.set_xlabel("Total Variance", fontsize=15, labelpad=12.5, color="black")
ax.set_ylabel(=3., linewidth=2., color="grey")
ax.axvline(x"--", linewidth=2.25, color="#FF6666")
ax.plot(x, y, ="o", color="black", alpha=.90)
ax.scatter(x, y, marker='both', which='major', labelsize=12, pad=10)
ax.tick_params(axis
# Add arrow highlighting k=3.
"optimal k=3", fontsize=16, xy=(3,18.85), xytext=(4,30),
ax.annotate(=dict(
arrowprops='black', width=.6, headwidth=8, shrink=.05),)
facecolor plt.show()
```

Note the characteristic elbow shape, after which the total variance changes very little with the inclusion of additional centroids:

## Alternative Initialization Methods

One of the shortcomings of conventional k-means is that ocassionally the approximation found can be arbitrarily bad with respect to the objective function compared to the optimal clustering. The k-means++ algorithm attempts to address this shortcoming by providing an alternative initialization scheme. In what follows, \(D(x)\) denotes the shortest distance from a data point \(x\) to the closest center \(C_{i}\) already selected:

- Take one center \(C_{1}\), chosen uniformly at random from the initial dataset.

- Take a new center \(C_{i}\), choosing \(x \in X\) with probability \(\frac {D(x)^{2}}{\sum_{x \in X} D(x)^{2}}\).

- Repeat Step 2 until we have taken \(k\) centroids in total.

- Proceed with standard k-means algorithm.

From step 2, the expression \(\frac {D(x)^{2}}{\sum_{x \in X} D(x)^{2}}\) can be interpreted as meaning observations having a greater distance from existing centroids will be given a higher probability of being selected when the next centroid is determined. In essence, this is an attempt to spread the initial centroids as far as possible from one another prior to commencing k-means.

We can encapsulate the k-means++ initialization method within a function that takes the dataset and number of clusters k as input. A sample implementation is given by `kmeanspp`

:

```
def kmeanspp(X, k):
"""
Return cluster centroids using k-means++ initialization technique.
Parameters
----------
X:
The input dataset.
k:
The number of centroids to return.
"""
# First centroids selected uniformly at random.
= [X[np.random.choice(range(X.shape[0]), 1)[0], :]]
centroids
# Determine distance of each observation from chosen centroids.
for i in range(k - 1):
= np.array(
dsq max(distance.euclidean(X[i, :], j)**2 for j in centroids)
[for i in range(X.shape[0])]
)
= np.random.choice(range(X.shape[0]), 1, p=dsq/np.sum(dsq))[0]
r_indx
# Ensure same index not selected multiple times.
if dsq[r_indx]==0:
while True:
= np.random.choice(range(X.shape[0]), 1, p=dsq/np.sum(dsq))[0]
r_indx if dsq[r_indx]!=0:
break
centroids.append(X[r_indx, :])return(centroids)
```

If we call `kmeanspp`

with our original dataset and k=3, the initial centroids have much better spread than when using conventional initialization:

## Clustering in scikit-learn

The k-means clustering algorithm can be found in scikit-learn’s `sklearn.cluster`

module, where `KMeans`

is one of the many available clustering algorithms. Information on these algorithms can be found here. In this section, we leverage the `KMeans`

class to generate cluster assignments.

We start by scaling our variables so that no single explanatory variable contributes disproportionately to the overall variation in the dataset.

```
from sklearn.preprocessing import StandardScaler
# Read in data as before.
= pd.read_csv("unemp.csv")
df = df[['mean', 'stddev']].values
X
= StandardScaler().fit(X)
sclr = sclr.transform(X) X
```

This transforms each explanatory variable onto a standard normal basis having zero mean and unit variance. Next we pass `X`

to the fit method of `KMeans`

:

```
import numpy as np
from sklearn.cluster import KMeans
= KMeans(n_clusters=3).fit(X)
kmeans
# Get final cluster assignments.
= kmeans.labels_
labels
# Get final cluster centroids.
= kmeans.cluster_centers_
centroids
# Predict cluster assignment for new observations.
.515, .73337])) kmeans.predict(np.atleast_2d([
```