Unsupervised Validation & Evaluation Metrics
Derive internal cluster validation mathematics, the Silhouette Coefficient, Davies-Bouldin index, Adjusted Rand Index chance correction, and zero-dependency implementations in Python and Rust.
In supervised learning, evaluating performance is straightforward because ground truth labels are known. In unsupervised clustering and manifold discovery, external labels are absent in production, making model evaluation inherently challenging.
Validation requires mathematical criteria that evaluate the intrinsic geometry of partitions: compactness (cohesion) within clusters and isolation (separation) between distinct clusters.
Evaluates whether samples belong to their assigned cluster versus neighboring clusters.
1. Internal Validation: The Silhouette Coefficient#
The Silhouette Coefficient (Rousseeuw, 1987) measures how well each individual point has been clustered by contrasting intra-cluster tightness against inter-cluster separation.
Step 1: Intra-Cluster Dissimilarity $a(i)$
Let point belong to cluster . Compute the average distance between and all other points in the same cluster :
quantifies the compactness of cluster around point . Small denotes high cluster cohesion.
Step 2: Nearest-Cluster Dissimilarity $b(i)$
For any other cluster , compute the average distance from point to all points in :
Define as the minimum average distance across all alternative clusters:
The cluster that achieves this minimum is the "neighboring cluster" of point .
Step 3: Normalize the Discrepancy
The Silhouette score is the normalized difference between inter-cluster separation and intra-cluster cohesion:
Interpretation of Values:#
- : . Point is tightly clustered and far from its nearest neighboring cluster.
- : . Point sits directly on the boundary between two clusters.
- : . Point is closer to neighboring cluster points than its assigned cluster, indicating a severe misassignment.
2. The Davies-Bouldin Index#
The Davies-Bouldin (DB) Index evaluates clustering quality by computing the maximum ratio of intra-cluster dispersion to inter-cluster centroid separation.
Let have centroid . The cluster dispersion is the average distance of points to their centroid:
The separation between clusters and is the Euclidean distance between centroids .
The similarity ratio evaluates the overlap between clusters and :
The Davies-Bouldin Index is the average over all clusters of the worst-case similarity ratio:
3. External Validation: Adjusted Rand Index (ARI)#
When ground truth class labels are available for benchmark validation, we can measure the agreement between predicted partitions and true partitions .
The Rand Index (RI) considers all unique pairs of points :
- : Number of pairs assigned to the same cluster in both and .
- : Number of pairs in the same cluster in , but different in .
- : Number of pairs in different clusters in , but same in .
- : Number of pairs assigned to different clusters in both and .
Chance Correction#
The raw Rand Index does not approach zero for random cluster assignments. The Adjusted Rand Index (ARI) applies Hubert and Arabie's generalized chance correction:
Under a generalized hypergeometric null model of random clusterings:
- : Identical partitions.
- : Cluster assignments no better than random guessing.
- : Cluster assignments actively disagree with ground truth.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations compute the Silhouette Score and Davies-Bouldin Index from scratch in pure Python and pure Rust.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103import math from typing import List, Dict class UnsupervisedEvaluator: @staticmethod def _euclidean_dist(a: List[float], b: List[float]) -> float: return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) @classmethod def silhouette_score(cls, X: List[List[float]], labels: List[int]) -> float: num_samples = len(X) unique_labels = list(set(labels)) if len(unique_labels) <= 1: return 0.0 # Partition points by cluster label clusters: Dict[int, List[int]] = {l: [] for l in unique_labels} for idx, l in enumerate(labels): clusters[l].append(idx) silhouette_values = [] for i in range(num_samples): current_label = labels[i] same_cluster_indices = clusters[current_label] # 1. Compute a(i) if len(same_cluster_indices) <= 1: silhouette_values.append(0.0) continue a_i = sum( cls._euclidean_dist(X[i], X[j]) for j in same_cluster_indices if j != i ) / (len(same_cluster_indices) - 1) # 2. Compute b(i) b_i = float("inf") for other_label, other_indices in clusters.items(): if other_label == current_label: continue mean_dist_other = sum( cls._euclidean_dist(X[i], X[j]) for j in other_indices ) / len(other_indices) b_i = min(b_i, mean_dist_other) # 3. Compute s(i) denom = max(a_i, b_i) s_i = (b_i - a_i) / denom if denom > 0 else 0.0 silhouette_values.append(s_i) return sum(silhouette_values) / num_samples @classmethod def davies_bouldin_index(cls, X: List[List[float]], labels: List[int]) -> float: unique_labels = list(set(labels)) k = len(unique_labels) if k <= 1: return 0.0 num_features = len(X[0]) # Compute centroids and scatters centroids: Dict[int, List[float]] = {} scatters: Dict[int, float] = {} for l in unique_labels: indices = [idx for idx, label in enumerate(labels) if label == l] size = len(indices) centroid = [ sum(X[i][feat] for i in indices) / size for feat in range(num_features) ] centroids[l] = centroid scatter = sum(cls._euclidean_dist(X[i], centroid) for i in indices) / size scatters[l] = scatter # Compute max R_ij per cluster db_sum = 0.0 for l1 in unique_labels: max_r = 0.0 for l2 in unique_labels: if l1 == l2: continue m_ij = cls._euclidean_dist(centroids[l1], centroids[l2]) if m_ij > 0: r_ij = (scatters[l1] + scatters[l2]) / m_ij max_r = max(max_r, r_ij) db_sum += max_r return db_sum / k if __name__ == "__main__": X = [ [1.0, 1.0], [1.1, 1.2], [0.9, 1.0], # Cluster 0 [8.0, 8.0], [8.2, 8.1], [7.9, 8.0], # Cluster 1 ] labels = [0, 0, 0, 1, 1, 1] sil = UnsupervisedEvaluator.silhouette_score(X, labels) db = UnsupervisedEvaluator.davies_bouldin_index(X, labels) print(f"Silhouette Score (Higher => Better): {sil:.4f}") print(f"Davies-Bouldin Index (Lower => Better): {db:.4f}")
5. Active Knowledge Checks#
What does a negative Silhouette score s(i) < 0 indicate for a specific data point i?
Why does the Davies-Bouldin Index evaluate to a lower score for superior cluster partitions?