Discovering Groups in Unlabeled Data
Dr. Wei Xing
School of Mathematics and Statistics, University of Sheffield
Welcome to Week 9! Over the past few weeks, we've been building up a comprehensive toolkit for machine learning. Last week, we explored Principal Component Analysis (PCA), which helped us find hidden structure in high-dimensional data by identifying directions of maximum variance. PCA transformed our data into a more compact representation while preserving the most important information.
This week, we're tackling a fundamentally different but equally important question: How do we group similar data points together when we have no labels?
Think about it—in all our previous topics (linear regression, logistic regression, decision trees), we always had labels. We knew which emails were spam, which tumors were malignant, which houses sold for what price. But what if we don't have labels? What if we simply have data and want to discover natural groupings?
This is the realm of unsupervised learning, and specifically, clustering. Today we'll learn two fundamental clustering algorithms that have stood the test of time: K-means and Hierarchical Clustering.
Before diving into algorithms, let's appreciate why clustering matters. Here are three compelling real-world applications:
Customer Segmentation in Marketing: Imagine you're running an e-commerce platform with millions of users. You have data on their browsing history, purchase patterns, time spent on site, and preferred categories. You don't have predefined customer "types"—but you want to discover natural groups of similar customers so you can tailor marketing strategies. Are there "budget shoppers"? "Premium buyers"? "Window shoppers"? Clustering can reveal these hidden segments.
Image Compression: A color image contains millions of pixels, each with its own RGB value. But often, an image doesn't use all possible colors—maybe it's dominated by blues and greens (ocean scene) or reds and browns (desert landscape). We can cluster similar colors together and replace each pixel with its cluster center color. This dramatically reduces storage while maintaining visual quality.
Gene Expression Analysis: In bioinformatics, researchers measure expression levels of thousands of genes across different conditions. By clustering genes with similar expression patterns, we can identify groups of genes that work together—perhaps they're all involved in immune response, or cell division. These discoveries lead to new biological insights.
All these problems share a common pattern: we have data with no labels, and we want to discover natural groupings where items within a group are similar to each other.
Let's make this concrete. Look at the scatter plot below—it shows data points in 2D space (imagine they're customers plotted by "annual spending" vs "visit frequency"):

Question for you: If I asked you to organize these points into 3 groups, how would you do it?
You'd probably start by visually identifying regions where points cluster together, then draw boundaries around them. Your brain naturally does this pattern recognition. The question is: how do we teach a computer to do this systematically?
Let's define clustering formally:
Clustering: Given data points where each , partition them into groups (clusters) such that:
- Points within the same cluster are similar to each other
- Points in different clusters are dissimilar from each other
The key challenge is defining what "similar" means. For most clustering algorithms, similarity is measured by distance—points that are close together in space are considered similar.
Crucial distinction from classification: In supervised classification, we have training labels that tell us the correct categories. In clustering, we're discovering the categories ourselves. There's no "ground truth" to compare against—clustering is about finding useful structure in data.
Now let's develop our first clustering algorithm. Here's the intuitive insight that leads to K-means:
Thought experiment: Suppose someone told you where the "center" of each of the 3 groups is. Then clustering would be easy—just assign each point to whichever center is closest! But wait—we don't know where the centers are...
Brilliant twist: What if we:
This is exactly what K-means clustering does! It's an iterative algorithm that alternates between two steps:
The name "K-means" comes from having clusters, each represented by its mean (center point).
Let's walk through the algorithm in detail.
First, we need to initialize cluster centers (called centroids). The simplest approach is to randomly select data points from our dataset as the initial centroids.
Notation: Let's denote our centroids as where each .
For each data point , we assign it to the nearest centroid. We compute the distance from to each centroid and choose the closest one:
Here, is the cluster assignment for point (a number from 1 to ), and is the squared Euclidean distance:
Geometric interpretation: This assignment step creates Voronoi regions around each centroid—imagine drawing boundaries where all points on one side are closer to one centroid, and points on the other side are closer to another.
After all points have been assigned, we update each centroid to be the mean of all points assigned to it:
where is the set of all points assigned to cluster , and is the number of points in that cluster.
Why the mean? The mean minimizes the sum of squared distances to all points in the cluster—it's the optimal center point!
We repeat steps 2 and 3 (assignment and update) until convergence. Convergence occurs when:
In practice, K-means typically converges in 10-50 iterations for most datasets.
Let's see K-means working through iterations:

What's happening here?
Notice how the algorithm always makes progress—each iteration either reduces the total distance from points to their centroids or keeps it the same (when converged). This is guaranteed by the algorithm's design!
An important characteristic of K-means is that it tends to find spherical (roughly circular) clusters of similar size. Why?
Geometric reasoning: K-means assigns points based purely on Euclidean distance to centroids. This creates decision boundaries that are perpendicular bisectors between centroids—resulting in regions that are convex and roughly circular around each center.
Think about it: if you have two centroids, the boundary between their regions is the line where points are equidistant from both. This creates a straight-line boundary—and when you have multiple centroids, you get polygon-like regions that approximate circles.
Implication: K-means works best when:
Understanding where K-means struggles is crucial for knowing when to use it. Here are two classic failure cases:

Left panel - Non-convex shapes: Two crescent-shaped clusters (half-moons). K-means incorrectly splits them because it can only create straight-line boundaries. The distance-based assignment cannot capture the curved, interlocking structure.
Right panel - Different sizes and densities: Three clusters where one is much larger and sparser than the others. K-means tries to make clusters of similar variance, so it incorrectly splits the large cluster and merges parts of different clusters.
Takeaway: If your data has non-spherical clusters, very different cluster sizes, or complex shapes, K-means may not be the right tool. You might need more sophisticated methods like DBSCAN or Gaussian Mixture Models (beyond our course scope).
Now let's see how easy K-means is to use in scikit-learn:
from sklearn.cluster import KMeans
import numpy as np
# Assuming X is your data matrix (n_samples, n_features)
kmeans = KMeans(n_clusters=3, random_state=42)
kmeans.fit(X)
# Get cluster assignments
labels = kmeans.labels_ # Array of 0, 1, 2 indicating cluster for each point
# Get cluster centers
centers = kmeans.cluster_centers_ # Array of shape (3, n_features)
Key parameters:
n_clusters: The number of clusters (you must specify this!)random_state: Sets the random seed for reproducible resultsn_init: Number of times to run K-means with different initializations (default is 10—takes the best result)The fit() method runs the entire K-means algorithm. After fitting:
labels_ gives you the cluster assignment for each data pointcluster_centers_ gives you the final centroid positionsHere's a complete working example:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
# Generate synthetic data with 3 natural clusters
X, y_true = make_blobs(n_samples=300, centers=3, n_features=2,
random_state=42)
# Apply K-means
kmeans = KMeans(n_clusters=3, random_state=42)
kmeans.fit(X)
y_pred = kmeans.labels_
# Visualize results
plt.scatter(X[:, 0], X[:, 1], c=y_pred, cmap='viridis')
plt.scatter(kmeans.cluster_centers_[:, 0],
kmeans.cluster_centers_[:, 1],
marker='X', s=200, c='red')
plt.show()
What's happening:
make_blobs creates synthetic data with clear clusters (useful for testing)Try this yourself! Change n_clusters to 2 or 4 and see what happens. Does K=3 give the best result visually?
Unlike supervised learning where the number of classes is given by the data, in clustering we must choose —the number of clusters. This is both an art and a science.
Domain knowledge first: Sometimes domain expertise tells you . For example:
But often, we don't know in advance. We need a data-driven approach.
The elbow method is the most popular technique for choosing . It's based on a key insight:
Key metric - Inertia: K-means minimizes something called within-cluster sum of squares (WCSS), also known as inertia:
This measures the total squared distance from all points to their assigned centroids. Lower inertia means tighter, more compact clusters.
Observation: As we increase , inertia always decreases. In the extreme, if (number of points), inertia is zero—each point is its own cluster!
The elbow insight: Plot inertia vs. . Initially, inertia drops rapidly as we add clusters—we're capturing real structure. But after the "right" , adding more clusters gives diminishing returns. The curve looks like an arm, and we choose at the elbow where the rate of decrease slows down sharply.

In this example, there's a clear elbow at , suggesting 3 is the optimal number of clusters.
Here's how to create an elbow plot:
inertias = []
K_range = range(1, 11)
for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42)
kmeans.fit(X)
inertias.append(kmeans.inertia_)
plt.plot(K_range, inertias, marker='o')
plt.xlabel('Number of Clusters K')
plt.ylabel('Inertia')
plt.title('Elbow Method')
plt.show()
How to read the plot:
Reality check: Sometimes there's no clear elbow! The curve might be smooth. In such cases:
Before we move on to hierarchical clustering, here are some important practical tips:
K-means is sensitive to initial centroid placement. Different random initializations can lead to different final clusters (local minima).
Solution: scikit-learn's default n_init=10 runs K-means 10 times with different initializations and returns the best result (lowest inertia). This makes results much more reliable.
K-means uses Euclidean distance, so feature scaling is important! If one feature is in meters (range 0-1000) and another is in millimeters (range 0-1000000), the millimeter feature will dominate the distance calculation.
Best practice: Standardize features before clustering:
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
kmeans.fit(X_scaled)
K-means is very fast! Computational complexity is where:
This scales well to large datasets (millions of points).
This section provides mathematical depth for interested students. MPS311 students can skip to Section 16.
K-means has a clear mathematical objective it's trying to minimize. Understanding this helps explain why the algorithm works.
K-means minimizes the within-cluster sum of squares:
where:
This objective measures how compact the clusters are. Smaller means points are closer to their centroids.
The two-step iteration is actually coordinate descent on this objective:
Assignment step (fix , optimize ): Given current centroids, assigning each point to its nearest centroid provably minimizes . Why? If we assigned point to any other centroid, the distance would be larger, increasing .
Update step (fix , optimize ): Given current assignments, the mean of assigned points provably minimizes . This follows from calculus: taking the derivative of with respect to and setting to zero gives:
Solving: (the mean!)
Since each step reduces (or maintains) , and is bounded below by 0, the algorithm must converge. However, it may converge to a local minimum rather than the global minimum—hence the importance of multiple random initializations.
Now let's explore our second clustering method. K-means has a significant limitation: we must choose in advance. What if we want to:
This is where hierarchical clustering shines!
Think about real-world taxonomies:
These have nested hierarchical structure—smaller groups are contained within larger groups. Hierarchical clustering reveals this kind of structure in data.
Hierarchical clustering builds a tree (called a dendrogram) that shows how clusters merge. There are two approaches:
Agglomerative (bottom-up): Start with each point as its own cluster, progressively merge the closest pairs until everything is one cluster. This is most common and what we'll focus on.
Divisive (top-down): Start with all points in one cluster, progressively split until each point is its own cluster. Less common in practice.
Here's the basic algorithm:
Key advantage: We don't need to choose beforehand! The dendrogram shows all possible clusterings, and we can "cut" the tree at different heights to get different numbers of clusters.
When we have clusters with multiple points (not just singletons), how do we measure the distance between two clusters? This is a crucial design choice.
There are three main linkage criteria:
Distance between two clusters = minimum distance between any pair of points from the two clusters:
Characteristics:
Geometric intuition: Like finding the narrowest "bridge" between two clusters.
Distance between two clusters = maximum distance between any pair of points from the two clusters:
Characteristics:
Geometric intuition: Ensures all points in merged cluster are within a certain distance from each other.
Distance between two clusters = average distance between all pairs of points from the two clusters:
Characteristics:
Geometric intuition: Considers all pairwise distances, not just extreme cases.
The output of hierarchical clustering is a dendrogram—a tree diagram showing the merge hierarchy. Let's learn to read one:

How to read this:
Extracting clusters: To get clusters, draw a horizontal line through the dendrogram and count how many vertical lines it crosses.
Example in the figure:
Insight: The dendrogram shows which clusterings are "natural". Large vertical gaps indicate clear separation—good places to cut!
Scikit-learn makes hierarchical clustering straightforward:
from sklearn.cluster import AgglomerativeClustering
# Create and fit hierarchical clustering
hc = AgglomerativeClustering(n_clusters=3, linkage='average')
labels = hc.fit_predict(X)
# labels now contains cluster assignments (0, 1, 2)
Key parameters:
n_clusters: How many clusters to extract (where to cut the dendrogram)linkage: Choose from 'single', 'complete', 'average', 'ward'
'ward' minimizes within-cluster variance (similar to K-means objective)Note: AgglomerativeClustering doesn't return the full dendrogram by default—it just gives you the final clustering with clusters.
To actually see the dendrogram, we use scipy.cluster.hierarchy:
from scipy.cluster.hierarchy import dendrogram, linkage
import matplotlib.pyplot as plt
# Compute linkage matrix
Z = linkage(X, method='average')
# Plot dendrogram
plt.figure(figsize=(10, 5))
dendrogram(Z)
plt.xlabel('Data Point Index')
plt.ylabel('Distance')
plt.title('Hierarchical Clustering Dendrogram')
plt.show()
What's happening:
linkage() performs the hierarchical clustering and returns a linkage matrixdendrogram() visualizes this matrix as a treemethod parameter specifies the linkage criterionTip for large datasets: If you have thousands of points, dendrograms become unreadable. Use the truncate_mode parameter:
dendrogram(Z, truncate_mode='lastp', p=30) # Show only last 30 merges
Here's a full workflow:
import numpy as np
import matplotlib.pyplot as plt
from scipy.cluster.hierarchy import dendrogram, linkage
from sklearn.cluster import AgglomerativeClustering
from sklearn.datasets import make_blobs
# Generate data
X, _ = make_blobs(n_samples=50, centers=3, random_state=42)
# Compute linkage for dendrogram
Z = linkage(X, method='average')
# Plot dendrogram
plt.figure(figsize=(10, 4))
dendrogram(Z)
plt.title('Dendrogram')
plt.show()
# Extract 3 clusters
hc = AgglomerativeClustering(n_clusters=3, linkage='average')
labels = hc.fit_predict(X)
# Visualize clusters
plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis')
plt.title('Hierarchical Clustering Result (K=3)')
plt.show()
Exercise: Try running this with different linkage methods ('single', 'complete', 'ward'). How do the dendrograms and final clusterings differ?
Both algorithms are valuable tools, but they have different strengths. Here's a practical comparison:
K-means: where is number of iterations
Hierarchical: or worse depending on implementation
✅ You know (number of clusters) in advance
✅ Large datasets (n > 10,000 points)
✅ You want fast results
✅ Clusters are roughly spherical and similar in size
✅ You don't need a hierarchical structure
Example scenario: Segmenting 1 million customers into 5 predefined marketing segments.
✅ You don't know and want to explore
✅ Small-medium datasets (n < 5,000)
✅ You want to visualize the dendrogram
✅ You need nested cluster structure
✅ Clusters may have irregular shapes (with single linkage)
Example scenario: Exploring gene expression patterns for 100 genes to discover functional groups, with the flexibility to examine 3, 5, or 10 clusters.
For very large datasets where you want both speed and hierarchy:
Let's see clustering in action with a realistic example. Suppose we're analyzing customer data for an online retailer:
Features:
Goal: Segment customers to tailor marketing strategies.
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
# Assuming customer_data is a DataFrame with the features above
X = customer_data[['spending', 'visits', 'order_value', 'age']].values
# Scale features (very important for K-means!)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Apply K-means with K=4 (chosen via elbow method)
kmeans = KMeans(n_clusters=4, random_state=42)
labels = kmeans.fit_predict(X_scaled)
# Examine cluster centers (inverse transform to original scale)
centers_original = scaler.inverse_transform(kmeans.cluster_centers_)
Interpreting the clusters (example results):
Key insight: The cluster centers tell you the "archetype" of each segment. Marketing teams can create targeted campaigns for each archetype.
This section is for MPS439 students who want to understand K-means at a deeper level by implementing it themselves. MPS311 students can skip to the Summary.
Now we'll implement K-means from scratch using only NumPy. This will deepen your understanding of what sklearn is doing under the hood—and show you how conceptually simple the algorithm is!
We'll need four key functions:
Let's build this step by step.
First, we need to compute distances from all points to all centroids. The naive approach would use nested loops, but NumPy's broadcasting makes this elegant and fast:
def compute_distances(X, centroids):
"""
Compute Euclidean distance from each point to each centroid.
Parameters:
X: array of shape (n, d) - data points
centroids: array of shape (K, d) - cluster centers
Returns:
distances: array of shape (n, K) - distances[i, k] is distance from point i to centroid k
"""
# Expand dimensions for broadcasting
# X[:, np.newaxis, :] has shape (n, 1, d)
# centroids has shape (K, d), broadcasts to (n, K, d)
diff = X[:, np.newaxis, :] - centroids
# Compute squared distances: sum over feature dimension
distances = np.sqrt(np.sum(diff**2, axis=2))
return distances
Understanding broadcasting:
X[:, np.newaxis, :] creates shape (n, 1, d)centroids has shape (K, d)Why this is fast: NumPy operations are vectorized in C—much faster than Python loops!
Now the core iteration steps:
def assign_clusters(distances):
"""
Assign each point to nearest centroid.
Parameters:
distances: array of shape (n, K)
Returns:
labels: array of shape (n,) with values in {0, 1, ..., K-1}
"""
return np.argmin(distances, axis=1)
def update_centroids(X, labels, K):
"""
Compute new centroids as mean of assigned points.
Parameters:
X: array of shape (n, d)
labels: array of shape (n,)
K: number of clusters
Returns:
centroids: array of shape (K, d)
"""
n, d = X.shape
centroids = np.zeros((K, d))
for k in range(K):
# Find points assigned to cluster k
cluster_points = X[labels == k]
if len(cluster_points) > 0:
centroids[k] = cluster_points.mean(axis=0)
else:
# Empty cluster: reinitialize randomly
centroids[k] = X[np.random.randint(0, n)]
return centroids
Key details:
np.argmin(distances, axis=1) finds the minimum along each row (for each point)Here's the complete K-means implementation:
import numpy as np
def kmeans_from_scratch(X, K, max_iters=100, tol=1e-4):
"""
K-means clustering implementation from scratch.
Parameters:
X: array of shape (n, d) - data points
K: number of clusters
max_iters: maximum number of iterations
tol: convergence tolerance
Returns:
labels: cluster assignments
centroids: final cluster centers
history: list of centroids at each iteration (for visualization)
"""
n, d = X.shape
# Initialize: randomly select K points as initial centroids
idx = np.random.choice(n, K, replace=False)
centroids = X[idx].copy()
history = [centroids.copy()]
for iteration in range(max_iters):
# Assignment step
distances = compute_distances(X, centroids)
labels = assign_clusters(distances)
# Update step
new_centroids = update_centroids(X, labels, K)
# Check convergence
centroid_shift = np.linalg.norm(new_centroids - centroids)
centroids = new_centroids
history.append(centroids.copy())
if centroid_shift < tol:
print(f"Converged after {iteration + 1} iterations")
break
return labels, centroids, history
# Helper functions from previous sections
def compute_distances(X, centroids):
diff = X[:, np.newaxis, :] - centroids
distances = np.sqrt(np.sum(diff**2, axis=2))
return distances
def assign_clusters(distances):
return np.argmin(distances, axis=1)
def update_centroids(X, labels, K):
n, d = X.shape
centroids = np.zeros((K, d))
for k in range(K):
cluster_points = X[labels == k]
if len(cluster_points) > 0:
centroids[k] = cluster_points.mean(axis=0)
else:
centroids[k] = X[np.random.randint(0, n)]
return centroids
What we track:
history: Stores centroids at each iteration (useful for visualization)centroid_shift: Measures how much centroids moved (for convergence)Let's verify that our implementation matches sklearn:
from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
# Generate test data
X, y_true = make_blobs(n_samples=300, centers=4, random_state=42)
# Our implementation
np.random.seed(42)
labels_ours, centroids_ours, history = kmeans_from_scratch(X, K=4)
# Sklearn implementation
kmeans_sk = KMeans(n_clusters=4, random_state=42, n_init=1)
labels_sk = kmeans_sk.fit_predict(X)
# Compare results
print(f"Our inertia: {np.sum((X - centroids_ours[labels_ours])**2):.2f}")
print(f"Sklearn inertia: {kmeans_sk.inertia_:.2f}")
# Visualize
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].scatter(X[:, 0], X[:, 1], c=labels_ours, cmap='viridis')
axes[0].scatter(centroids_ours[:, 0], centroids_ours[:, 1],
marker='X', s=200, c='red')
axes[0].set_title('Our Implementation')
axes[1].scatter(X[:, 0], X[:, 1], c=labels_sk, cmap='viridis')
axes[1].scatter(kmeans_sk.cluster_centers_[:, 0],
kmeans_sk.cluster_centers_[:, 1],
marker='X', s=200, c='red')
axes[1].set_title('Sklearn Implementation')
plt.show()
What to check:
One benefit of our implementation is we saved the centroid history. Let's visualize how centroids move during training:
# Plot multiple iterations
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
iterations_to_plot = [0, 1, 2, 5, 10, len(history)-1]
for idx, iter_num in enumerate(iterations_to_plot):
ax = axes[idx // 3, idx % 3]
# Compute labels for this iteration
distances = compute_distances(X, history[iter_num])
labels = assign_clusters(distances)
# Plot
ax.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', alpha=0.6)
ax.scatter(history[iter_num][:, 0], history[iter_num][:, 1],
marker='X', s=200, c='red', edgecolors='black')
ax.set_title(f'Iteration {iter_num}')
plt.tight_layout()
plt.show()
What you'll observe:
This visualization helps build intuition for the algorithm's behavior!
From implementing K-means yourself, you should now appreciate:
Simplicity: The core algorithm is remarkably simple—just repeated assignment and update
Vectorization matters: NumPy's broadcasting makes the distance computation efficient without loops
Convergence is guaranteed: Each iteration reduces (or maintains) the objective function, so convergence is certain
Local minima are real: Different initializations can yield different results—hence sklearn's n_init parameter
Edge cases: Handling empty clusters requires special care (reinitialize or remove)
Challenge exercise: Modify the implementation to:
Let's consolidate the key concepts from this lecture:
Clustering is unsupervised learning for discovering natural groupings in data without labels. Unlike classification, we're not predicting known categories—we're discovering hidden structure.
Applications range from customer segmentation to image compression to gene expression analysis—anywhere we want to find similar groups.
Core idea: Iteratively assign points to nearest centroids, then update centroids as cluster means. Repeat until convergence.
Strengths:
Limitations:
Choosing K: Use the elbow method—plot inertia vs. K and look for the "elbow" where diminishing returns begin.
Core idea: Build a tree (dendrogram) showing how clusters merge from bottom-up. Start with individual points, progressively merge closest pairs.
Strengths:
Limitations:
Linkage criteria define cluster distance:
Use K-means when:
Use hierarchical when:
Always remember:
As you reflect on this lecture, here are the essential points to remember:
K-means is iterative optimization: It alternates between assignment (points to clusters) and update (cluster centers), guaranteed to converge by reducing within-cluster variance at each step.
The elbow method guides K selection: Plot inertia vs. K and choose the elbow point where additional clusters give diminishing returns—though domain knowledge should inform your final choice.
Hierarchical clustering builds a merge tree: The dendrogram shows clustering structure at all scales, allowing you to cut at different heights to extract different numbers of clusters without re-running the algorithm.
Choose the right tool: K-means for speed and large data with known K; hierarchical for exploration and interpretability with small-medium data.
Distance metrics and scaling matter: Both algorithms rely on distance calculations, so feature scaling is crucial. Different linkage criteria in hierarchical clustering give different cluster shapes.
Well done completing Week 9! You now have two powerful unsupervised learning methods in your toolkit. These clustering algorithms reveal hidden patterns in unlabeled data—a fundamental capability in modern data science.
Next week, we'll explore deep learning and neural networks, entering the realm that has revolutionized AI in recent years. See you then!
For questions or clarification, please use the Blackboard discussion forum or attend office hours.