Clustering Evaluation
Internal Metrics: Silhouette and Calinski-Harabasz
Read aloud is unavailable in this browser.
The Challenge of Evaluating Clusters
Clustering algorithms produce partitions of data, but unlike supervised learning, there is no ground truth to compare against. This creates a fundamental challenge: how do we know if the clustering is any good?
Internal validation metrics address this by measuring cluster quality using only intrinsic properties of the data and the resulting cluster assignments. They quantify intuitive notions like 'points within a cluster should be similar' and 'points in different clusters should be dissimilar.'
This page provides a deep, rigorous exploration of two of the most widely-used internal metrics: the Silhouette Coefficient and the Calinski-Harabasz Index. We will derive their mathematical foundations, understand their geometric interpretations, analyze their strengths and weaknesses, and learn when to use each.
By the end of this page, you will understand: (1) the mathematical derivation and geometric intuition behind Silhouette scores, (2) the variance-ratio formulation of the Calinski-Harabasz Index, (3) how to interpret and compare these metrics, (4) computational considerations for large datasets, and (5) the inherent limitations and biases of internal validation metrics.
Internal vs. External Validation
Before diving into specific metrics, it's crucial to understand the distinction between internal and external validation paradigms:
Internal Validation evaluates clustering quality using only:
- The data points themselves (their features/coordinates)
- The cluster assignments produced by the algorithm
External Validation evaluates clustering quality by comparing against:
- Known ground-truth labels (class memberships)
- External reference partitions
Internal metrics are essential because ground truth is often unavailable in real clustering applications—if we knew the true labels, we likely wouldn't need clustering in the first place.
| Aspect | Internal Validation | External Validation |
|---|---|---|
| Ground truth required | No | Yes |
| Primary use case | Model selection, hyperparameter tuning | Algorithm benchmarking, method comparison |
| What it measures | Compactness and separation | Agreement with known structure |
| Examples | Silhouette, CH, Davies-Bouldin | ARI, NMI, Fowlkes-Mallows |
| Bias toward | Spherical, convex clusters | Structure matching ground truth |
Internal metrics inherently encode assumptions about cluster geometry—typically favoring compact, spherical, well-separated clusters. Consequently, they may give poor scores to valid non-convex or density-based clusterings. Always interpret internal metrics within the context of what structure you expect in your data.
The Silhouette Coefficient: Foundation
The Silhouette Coefficient, introduced by Peter Rousseeuw in 1987, provides a per-sample measure of how appropriately each data point is assigned to its cluster. It elegantly balances two competing objectives:
- Cohesion: Points should be close to other points in the same cluster
- Separation: Points should be far from points in other clusters
The genius of the Silhouette formulation is that it combines these into a single normalized score that is intuitive to interpret.
Mathematical Definition
For a data point xᵢ assigned to cluster Cₖ, we define:
a(i) = average distance from xᵢ to all other points in the same cluster Cₖ
$$a(i) = \frac{1}{|C_k| - 1} \sum_{j \in C_k, j eq i} d(x_i, x_j)$$
b(i) = minimum average distance from xᵢ to points in any other cluster
$$b(i) = \min_{l eq k} \frac{1}{|C_l|} \sum_{j \in C_l} d(x_i, x_j)$$
The cluster achieving this minimum is called the nearest neighboring cluster of xᵢ—the cluster that xᵢ would be assigned to if removed from its current cluster.
The Silhouette Score
The silhouette score for point xᵢ is:
$$s(i) = \frac{b(i) - a(i)}{\max{a(i), b(i)}}$$
This can be equivalently written as:
$$s(i) = \begin{cases} 1 - \frac{a(i)}{b(i)} & \text{if } a(i) < b(i) \ 0 & \text{if } a(i) = b(i) \ \frac{b(i)}{a(i)} - 1 & \text{if } a(i) > b(i) \end{cases}$$
The overall Silhouette Coefficient for a clustering is the mean of all individual silhouette scores:
$$\bar{s} = \frac{1}{n} \sum_{i=1}^{n} s(i)$$
The silhouette score measures how much more similar a point is to its own cluster compared to other clusters. A score of +1 means the point is perfectly placed (very far from neighboring clusters, very close to its own). A score of 0 means the point is on the boundary between clusters. A score of -1 means the point is likely misclassified (closer to another cluster than its assigned one).
Interpreting Silhouette Scores
Understanding what silhouette scores mean at different scales is essential for practical use:
| Score Range | Interpretation |
|---|---|
| 0.71 – 1.00 | Strong structure; clusters are dense and well-separated |
| 0.51 – 0.70 | Reasonable structure; may be worth investigating further |
| 0.26 – 0.50 | Weak structure; clusters may be overlapping |
| ≤ 0.25 | No substantial structure; may have incorrect k or data not clusterable |
These thresholds are heuristics, not absolute rules. Context matters enormously—some datasets have inherently overlapping structure where a score of 0.4 represents the best achievable clustering.
Per-Cluster Analysis
The mean silhouette score aggregates information, but examining per-cluster silhouette distributions reveals much more:
- Cluster-wise means: Identify clusters that are well-defined vs. poorly defined
- Width of distribution: Clusters with wide score ranges may span dense cores and sparse peripheries
- Negative scores: Points with negative silhouettes are potentially misclassified
A silhouette plot visualizes these distributions by showing sorted silhouette values for each cluster, making it easy to spot problematic clusters.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
import numpy as npimport matplotlib.pyplot as pltfrom sklearn.metrics import silhouette_samples, silhouette_scorefrom sklearn.cluster import KMeansfrom sklearn.datasets import make_blobs # Generate sample dataX, y_true = make_blobs(n_samples=500, centers=4, cluster_std=0.8, random_state=42) # Perform clusteringkmeans = KMeans(n_clusters=4, random_state=42, n_init='auto')cluster_labels = kmeans.fit_predict(X) # Compute silhouette scoressilhouette_avg = silhouette_score(X, cluster_labels)sample_silhouette_values = silhouette_samples(X, cluster_labels) print(f"Overall Silhouette Score: {silhouette_avg:.4f}") # Per-cluster analysisn_clusters = len(np.unique(cluster_labels))for i in range(n_clusters): cluster_silhouette = sample_silhouette_values[cluster_labels == i] cluster_size = len(cluster_silhouette) cluster_mean = np.mean(cluster_silhouette) negative_count = np.sum(cluster_silhouette < 0) print(f"Cluster {i}:") print(f" Size: {cluster_size}") print(f" Mean silhouette: {cluster_mean:.4f}") print(f" Min: {np.min(cluster_silhouette):.4f}, Max: {np.max(cluster_silhouette):.4f}") print(f" Potentially misassigned (s < 0): {negative_count} ({100*negative_count/cluster_size:.1f}%)") # Create silhouette plotfig, ax = plt.subplots(1, 1, figsize=(8, 6))y_lower = 10 for i in range(n_clusters): cluster_silhouette = sample_silhouette_values[cluster_labels == i] cluster_silhouette.sort() y_upper = y_lower + len(cluster_silhouette) color = plt.cm.viridis(float(i) / n_clusters) ax.fill_betweenx(np.arange(y_lower, y_upper), 0, cluster_silhouette, facecolor=color, edgecolor=color, alpha=0.7) ax.text(-0.05, y_lower + 0.5 * len(cluster_silhouette), str(i)) y_lower = y_upper + 10 ax.axvline(x=silhouette_avg, color="red", linestyle="--", label=f"Mean: {silhouette_avg:.3f}")ax.set_xlabel("Silhouette Coefficient")ax.set_ylabel("Cluster")ax.set_title("Silhouette Plot for Cluster Analysis")ax.legend()plt.tight_layout()plt.show()Diagnosing Clustering Issues
Silhouette analysis helps diagnose specific problems:
- All clusters have low scores: Wrong number of clusters k, or data lacks natural cluster structure
- One cluster has much lower scores: That cluster may be absorbing noise or merging distinct groups
- Many negative scores in a cluster: Consider splitting that cluster or reassigning those points
- Scores vary wildly within clusters: Clusters may have irregular shapes or varying densities
Computational Considerations for Silhouette
Computational Complexity
The naïve computation of silhouette scores has complexity O(n²) because it requires computing pairwise distances between all points. For large datasets, this becomes prohibitive:
| Dataset Size | Pairwise Comparisons | Memory for Distance Matrix |
|---|---|---|
| 1,000 | 500,000 | ~4 MB |
| 10,000 | 50 million | ~400 MB |
| 100,000 | 5 billion | ~40 GB |
| 1,000,000 | 500 billion | ~4 TB |
This quadratic scaling presents serious challenges for large-scale clustering evaluation.
Approximation Strategies
Several approaches can make silhouette computation tractable for large datasets:
1. Sampling-Based Approximation
- Compute silhouette on a representative sample
- Use stratified sampling from each cluster
- Confidence intervals via bootstrap resampling
2. Simplified Silhouette
- Replace a(i) with distance to cluster centroid
- Replace b(i) with minimum distance to other centroids
- Reduces to O(n·k) complexity
- Less accurate but much faster
3. Batch Processing
- Compute silhouette in chunks
- Avoids storing full distance matrix
- Still O(n²) but constant memory
4. Approximate Nearest Neighbors
- Use KD-trees, ball trees, or locality-sensitive hashing
- Approximate b(i) using nearest neighbor structures
- Can reduce to O(n log n) in favorable conditions
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
import numpy as npfrom sklearn.metrics import silhouette_scorefrom sklearn.cluster import KMeansfrom scipy.spatial.distance import cdist def simplified_silhouette(X, labels, centroids): """ Simplified silhouette using distances to centroids instead of full pairwise distances. Complexity: O(n * k) instead of O(n²). Parameters: ----------- X : ndarray of shape (n_samples, n_features) labels : ndarray of shape (n_samples,) - cluster assignments centroids : ndarray of shape (n_clusters, n_features) Returns: -------- float : simplified silhouette score """ n_samples = X.shape[0] n_clusters = len(centroids) # Distance from each point to each centroid: O(n * k) distances_to_centroids = cdist(X, centroids, metric='euclidean') silhouette_scores = np.zeros(n_samples) for i in range(n_samples): cluster_i = labels[i] # a(i): distance to own centroid a_i = distances_to_centroids[i, cluster_i] # b(i): minimum distance to other centroids other_distances = distances_to_centroids[i, np.arange(n_clusters) != cluster_i] b_i = np.min(other_distances) # Silhouette score if max(a_i, b_i) == 0: silhouette_scores[i] = 0 else: silhouette_scores[i] = (b_i - a_i) / max(a_i, b_i) return np.mean(silhouette_scores) def sampled_silhouette(X, labels, sample_size=1000, n_bootstrap=10, random_state=42): """ Estimate silhouette score using stratified sampling with bootstrap confidence intervals. Parameters: ----------- X : ndarray of shape (n_samples, n_features) labels : ndarray of shape (n_samples,) sample_size : int, number of samples to use n_bootstrap : int, number of bootstrap iterations random_state : int Returns: -------- tuple : (mean estimate, standard error) """ rng = np.random.RandomState(random_state) n_samples = X.shape[0] unique_labels = np.unique(labels) bootstrap_scores = [] for b in range(n_bootstrap): # Stratified sampling sample_indices = [] for label in unique_labels: label_indices = np.where(labels == label)[0] # Sample proportionally from each cluster n_from_cluster = max(1, int(sample_size * len(label_indices) / n_samples)) sampled = rng.choice(label_indices, size=min(n_from_cluster, len(label_indices)), replace=False) sample_indices.extend(sampled) sample_indices = np.array(sample_indices) X_sample = X[sample_indices] labels_sample = labels[sample_indices] score = silhouette_score(X_sample, labels_sample) bootstrap_scores.append(score) return np.mean(bootstrap_scores), np.std(bootstrap_scores) # Example usagefrom sklearn.datasets import make_blobs # Generate larger datasetX, y = make_blobs(n_samples=10000, centers=5, random_state=42)kmeans = KMeans(n_clusters=5, random_state=42, n_init='auto')labels = kmeans.fit_predict(X) # Exact silhouette (for comparison)exact_score = silhouette_score(X, labels)print(f"Exact Silhouette Score: {exact_score:.4f}") # Simplified silhouettesimplified_score = simplified_silhouette(X, labels, kmeans.cluster_centers_)print(f"Simplified Silhouette Score: {simplified_score:.4f}") # Sampled silhouette with CImean_est, std_err = sampled_silhouette(X, labels, sample_size=1000, n_bootstrap=20)print(f"Sampled Silhouette Score: {mean_est:.4f} ± {1.96*std_err:.4f} (95% CI)")The Calinski-Harabasz Index
The Calinski-Harabasz Index (CH), also known as the Variance Ratio Criterion (VRC), was introduced by Tadeusz Caliński and Jerzy Harabasz in 1974. It takes a fundamentally different approach from Silhouette: rather than measuring per-point cohesion and separation, it computes a global ratio of between-cluster variance to within-cluster variance.
The intuition is straightforward: good clusterings have tight clusters (low within-cluster variance) that are far apart (high between-cluster variance). The CH index formalizes this as a single ratio.
Mathematical Formulation
Let X be a dataset with n points in d dimensions, partitioned into k clusters. Define:
Within-cluster dispersion (W): $$W = \sum_{j=1}^{k} \sum_{x \in C_j} |x - \mu_j|^2$$
where μⱼ is the centroid of cluster Cⱼ.
Between-cluster dispersion (B): $$B = \sum_{j=1}^{k} |C_j| \cdot |\mu_j - \mu|^2$$
where μ is the global centroid (mean of all points) and |Cⱼ| is the size of cluster j.
The Calinski-Harabasz Index is then:
$$CH = \frac{B / (k-1)}{W / (n-k)} = \frac{B}{W} \cdot \frac{n-k}{k-1}$$
The CH index is essentially an F-statistic from one-way ANOVA, treating cluster assignments as groups. B/(k-1) is the between-group mean square, W/(n-k) is the within-group mean square. Higher values indicate better-defined clusters, just as higher F-statistics indicate significant group differences in ANOVA.
Interpretation
- Higher CH values indicate better clustering (more separation, less dispersion within clusters)
- No upper bound: Values can range from 0 to infinity
- Scale-dependent: Absolute values aren't meaningful across different datasets
- Best used comparatively: Compare CH values for different k values on the same data
Unlike silhouette scores which range from -1 to 1, there's no universal threshold for 'good' CH values. The metric is primarily used to compare clusterings:
$$k^* = \arg\max_k CH(k)$$
where k* is the optimal number of clusters.
Mathematical Properties of Calinski-Harabasz
Variance Decomposition
The CH index is built on the fundamental total variance decomposition:
$$T = W + B$$
where:
- T = Total Sum of Squares = Σᵢ ‖xᵢ - μ‖²
- W = Within-cluster Sum of Squares
- B = Between-cluster Sum of Squares
This identity always holds, meaning that as B increases, W must decrease for fixed T. The CH index measures the ratio in a degrees-of-freedom adjusted manner.
Behavior with Varying k
Understanding how CH behaves as k changes is critical for its use:
As k approaches 1: B approaches 0 (all points in one cluster → no between-cluster variance), so CH → 0.
As k approaches n: Each point becomes its own cluster, W → 0, but the (n-k)/(k-1) term → 0 faster, so CH typically decreases.
At the 'true' k: We expect a local maximum in CH, where the clustering captures natural structure without overfitting.
This behavior makes CH useful for selecting k: plot CH(k) for k = 2, 3, ..., K and look for the maximum.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
import numpy as npimport matplotlib.pyplot as pltfrom sklearn.metrics import calinski_harabasz_scorefrom sklearn.cluster import KMeansfrom sklearn.datasets import make_blobs def compute_variance_decomposition(X, labels): """ Compute the within-cluster (W), between-cluster (B), and total (T) sum of squares. """ n = X.shape[0] global_centroid = np.mean(X, axis=0) unique_labels = np.unique(labels) # Total sum of squares T = np.sum((X - global_centroid) ** 2) # Within-cluster sum of squares W = 0 for label in unique_labels: cluster_points = X[labels == label] cluster_centroid = np.mean(cluster_points, axis=0) W += np.sum((cluster_points - cluster_centroid) ** 2) # Between-cluster sum of squares B = 0 for label in unique_labels: cluster_points = X[labels == label] cluster_centroid = np.mean(cluster_points, axis=0) B += len(cluster_points) * np.sum((cluster_centroid - global_centroid) ** 2) return T, W, B def manual_calinski_harabasz(X, labels): """ Manual implementation of CH index for understanding. """ n = X.shape[0] k = len(np.unique(labels)) T, W, B = compute_variance_decomposition(X, labels) # CH = (B / (k-1)) / (W / (n-k)) ch = (B / (k - 1)) / (W / (n - k)) return ch, T, W, B # Generate data with 4 true clustersX, y_true = make_blobs(n_samples=500, centers=4, cluster_std=1.0, random_state=42) # Compute CH for different values of kk_range = range(2, 11)ch_scores = []variance_data = [] for k in k_range: kmeans = KMeans(n_clusters=k, random_state=42, n_init='auto') labels = kmeans.fit_predict(X) # Using sklearn ch_sklearn = calinski_harabasz_score(X, labels) # Manual computation for verification ch_manual, T, W, B = manual_calinski_harabasz(X, labels) ch_scores.append(ch_sklearn) variance_data.append({'k': k, 'T': T, 'W': W, 'B': B, 'CH': ch_sklearn}) print(f"k={k}: CH={ch_sklearn:.2f}, W={W:.2f}, B={B:.2f}, W+B={W+B:.2f}, T={T:.2f}") # Plot CH vs kfig, axes = plt.subplots(1, 2, figsize=(14, 5)) # CH score plotaxes[0].plot(k_range, ch_scores, 'bo-', linewidth=2, markersize=8)axes[0].axvline(x=4, color='red', linestyle='--', label='True k=4')optimal_k = k_range[np.argmax(ch_scores)]axes[0].scatter([optimal_k], [max(ch_scores)], color='green', s=200, zorder=5, label=f'Optimal k={optimal_k}')axes[0].set_xlabel('Number of Clusters (k)')axes[0].set_ylabel('Calinski-Harabasz Score')axes[0].set_title('CH Score vs Number of Clusters')axes[0].legend()axes[0].grid(True, alpha=0.3) # Variance decomposition plotW_values = [v['W'] for v in variance_data]B_values = [v['B'] for v in variance_data] axes[1].bar(k_range, W_values, label='Within (W)', alpha=0.7)axes[1].bar(k_range, B_values, bottom=W_values, label='Between (B)', alpha=0.7)axes[1].set_xlabel('Number of Clusters (k)')axes[1].set_ylabel('Sum of Squares')axes[1].set_title('Variance Decomposition: W + B = T')axes[1].legend() plt.tight_layout()plt.show()Comparing Silhouette and Calinski-Harabasz
Both Silhouette and CH are widely used, but they have distinct characteristics that make each more suitable in different scenarios:
| Criterion | Silhouette | Calinski-Harabasz |
|---|---|---|
| Computational complexity | O(n²) for full computation | O(n·k) — much faster |
| Score range | [-1, 1] — interpretable | [0, ∞) — no absolute meaning |
| Per-sample analysis | Yes — inspect individual points | No — global metric only |
| Cluster shape bias | Prefers convex, similar-sized clusters | Prefers convex, balanced clusters |
| Density variation | Handles somewhat | Sensitive to density differences |
| Finding optimal k | Maximum value heuristic | Maximum value heuristic |
| Implementation | Distance-based | Variance-based (centroid) |
- •You need per-point diagnostics
- •You want interpretable scores
- •Dataset size is manageable (< 10K points)
- •You need to identify misassigned points
- •Using distance-based clustering (DBSCAN, hierarchical)
- •You need fast computation at scale
- •Using centroid-based clustering (k-means)
- •Only need global quality assessment
- •Screening many clusterings quickly
- •Dataset has convex, well-separated clusters
When computational resources permit, use both metrics along with the Davies-Bouldin Index. If all three agree on the optimal k, you have strong evidence for that choice. If they disagree, investigate the clusterings manually and consider domain knowledge.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.