Clustering Evaluation
Selecting the Number of Clusters: A Comprehensive Framework
Read aloud is unavailable in this browser.
The Million Dollar Question
Selecting the number of clusters k is arguably the most important and challenging decision in cluster analysis. Get it wrong, and even a perfect algorithm produces meaningless results. Yet there is no universally correct answer—the 'right' k depends on the data, the algorithm, the application, and often on domain expertise.
Throughout this module, we've explored individual validation metrics and methods. This page synthesizes them into a practical decision framework for selecting k. We'll cover the iconic elbow method, multi-metric consensus approaches, domain-driven considerations, and provide concrete workflows for real-world practice.
This is where theory meets practice. By the end of this page, you'll have a principled, actionable approach to the k-selection problem.
By the end of this page, you will understand: (1) the elbow method and its mathematical formulation, (2) how to combine multiple metrics for robust k selection, (3) when domain knowledge should override metrics, (4) a systematic decision framework for k selection, and (5) common pitfalls and how to avoid them.
The Elbow Method
The elbow method is the most widely known heuristic for selecting k. It's based on the observation that within-cluster sum of squares (WSS, also called inertia) decreases as k increases, but the rate of decrease typically slows dramatically at the 'true' number of clusters.
The Intuition
- k too small: Genuine clusters are merged; WSS is high
- k just right: Each cluster corresponds to natural structure; WSS drops sharply
- k too large: We're splitting natural clusters; WSS decreases slowly (diminishing returns)
The 'elbow' is the point where this transition occurs—where increasing k stops providing substantial improvements.
Mathematical Formulation
For k-means clustering:
$$WSS(k) = \sum_{i=1}^{k} \sum_{x \in C_i} |x - \mu_i|^2$$
The elbow seeks the k that maximizes the 'improvement per cluster':
$$\text{Marginal improvement} = WSS(k-1) - WSS(k)$$
The elbow occurs where this marginal improvement suddenly decreases.
Automatic Elbow Detection
Several methods attempt to automatically identify the elbow:
1. Kneedle Algorithm: Finds the point of maximum curvature
2. Second Derivative: Look for maximum of the second derivative of the WSS curve
3. Perpendicular Distance: Find the point farthest from the line connecting the first and last points on the curve
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
import numpy as npimport matplotlib.pyplot as pltfrom sklearn.cluster import KMeansfrom sklearn.datasets import make_blobs def compute_wss(X, k_range, random_state=42): """ Compute Within-Cluster Sum of Squares for different k values. """ wss_values = [] models = {} for k in k_range: model = KMeans(n_clusters=k, n_init='auto', random_state=random_state) model.fit(X) wss_values.append(model.inertia_) models[k] = model return np.array(wss_values), models def find_elbow_perpendicular(k_range, wss_values): """ Find elbow using perpendicular distance method. Draw a line from the first point to the last point. The elbow is the k with maximum perpendicular distance to this line. """ k_range = np.array(k_range) # Normalize both axes to [0, 1] k_norm = (k_range - k_range.min()) / (k_range.max() - k_range.min()) wss_norm = (wss_values - wss_values.min()) / (wss_values.max() - wss_values.min()) # Line from first to last point p1 = np.array([k_norm[0], wss_norm[0]]) p2 = np.array([k_norm[-1], wss_norm[-1]]) # Compute perpendicular distance for each point distances = [] for i in range(len(k_range)): p = np.array([k_norm[i], wss_norm[i]]) # Distance from point to line formula d = np.abs(np.cross(p2 - p1, p1 - p)) / np.linalg.norm(p2 - p1) distances.append(d) # Find maximum distance elbow_idx = np.argmax(distances) return k_range[elbow_idx], distances def find_elbow_second_derivative(k_range, wss_values): """ Find elbow using second derivative (acceleration) method. The elbow is where the second derivative is maximum. """ # First derivative (rate of change) first_deriv = np.diff(wss_values) # Second derivative (acceleration) second_deriv = np.diff(first_deriv) # The elbow is where second derivative is maximum (least negative = most positive) elbow_idx = np.argmax(second_deriv) + 1 # +1 because of diff offset return k_range[elbow_idx], first_deriv, second_deriv # Example with clear elbownp.random.seed(42)X, y_true = make_blobs(n_samples=300, centers=4, cluster_std=1.0, random_state=42) k_range = range(1, 11)wss_values, models = compute_wss(X, k_range) # Find elbow using different methodselbow_perp, distances = find_elbow_perpendicular(list(k_range), wss_values)elbow_deriv, first_deriv, second_deriv = find_elbow_second_derivative(list(k_range), wss_values) print(f"Elbow by perpendicular distance: k = {elbow_perp}")print(f"Elbow by second derivative: k = {elbow_deriv}")print(f"True k: 4") # Visualizationfig, axes = plt.subplots(2, 2, figsize=(14, 10)) # Main elbow plotaxes[0, 0].plot(k_range, wss_values, 'bo-', linewidth=2, markersize=8)axes[0, 0].axvline(x=elbow_perp, color='red', linestyle='--', label=f'Elbow (perp): k={elbow_perp}')axes[0, 0].axvline(x=4, color='green', linestyle=':', label='True k=4', linewidth=2)axes[0, 0].set_xlabel('Number of Clusters (k)')axes[0, 0].set_ylabel('Within-Cluster Sum of Squares (WSS)')axes[0, 0].set_title('Elbow Method')axes[0, 0].legend()axes[0, 0].grid(True, alpha=0.3) # Perpendicular distancesaxes[0, 1].bar(k_range, distances, color='steelblue', alpha=0.7)axes[0, 1].axvline(x=elbow_perp, color='red', linestyle='--', label=f'Max distance at k={elbow_perp}')axes[0, 1].set_xlabel('Number of Clusters (k)')axes[0, 1].set_ylabel('Perpendicular Distance')axes[0, 1].set_title('Perpendicular Distance Method')axes[0, 1].legend()axes[0, 1].grid(True, alpha=0.3) # First derivativeaxes[1, 0].plot(list(k_range)[1:], -first_deriv, 'ro-', linewidth=2, markersize=8)axes[1, 0].set_xlabel('Number of Clusters (k)')axes[1, 0].set_ylabel('Decrease in WSS')axes[1, 0].set_title('First Derivative (Rate of Improvement)')axes[1, 0].grid(True, alpha=0.3) # Second derivativeaxes[1, 1].plot(list(k_range)[2:], second_deriv, 'go-', linewidth=2, markersize=8)axes[1, 1].axvline(x=elbow_deriv, color='red', linestyle='--', label=f'Max at k={elbow_deriv}')axes[1, 1].set_xlabel('Number of Clusters (k)')axes[1, 1].set_ylabel('Second Derivative')axes[1, 1].set_title('Second Derivative (Acceleration)')axes[1, 1].legend()axes[1, 1].grid(True, alpha=0.3) plt.tight_layout()plt.show()The elbow method often fails when: (1) there's no clear elbow (gradual curve), (2) multiple potential elbows exist, (3) clusters have very different sizes/densities, or (4) the data has hierarchical structure. Always use in conjunction with other methods, never as the sole criterion.
Multi-Metric Consensus Approach
No single metric captures all aspects of cluster quality. A robust approach is to compute multiple metrics and look for consensus—a k value that performs well across different criteria.
The Rationale
Different metrics have different biases:
- Silhouette favors compact, spherical clusters
- Calinski-Harabasz is sensitive to cluster balance
- Gap Statistic compares to null reference
- Stability measures robustness
When multiple metrics agree on k, we have stronger evidence. When they disagree, it signals that the choice is ambiguous or domain knowledge is needed.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
import numpy as npimport matplotlib.pyplot as pltfrom sklearn.cluster import KMeansfrom sklearn.metrics import silhouette_score, calinski_harabasz_score, davies_bouldin_scorefrom sklearn.datasets import make_blobs class MultiMetricKSelector: """ Select k using multiple clustering metrics with consensus voting. """ def __init__(self, k_range, n_stability_runs=20, random_state=42): self.k_range = list(k_range) self.n_stability_runs = n_stability_runs self.random_state = random_state self.results = None def _compute_stability(self, X, k): """Compute bootstrap stability for a given k.""" rng = np.random.RandomState(self.random_state) n_samples = X.shape[0] pairwise_aris = [] from sklearn.metrics import adjusted_rand_score for run in range(self.n_stability_runs): # Two bootstrap samples idx1 = rng.choice(n_samples, n_samples, replace=True) idx2 = rng.choice(n_samples, n_samples, replace=True) m1 = KMeans(n_clusters=k, n_init='auto', random_state=run) m2 = KMeans(n_clusters=k, n_init='auto', random_state=run+1000) l1 = m1.fit_predict(X[np.unique(idx1)]) l2 = m2.fit_predict(X[np.unique(idx2)]) common = sorted(set(np.unique(idx1)) & set(np.unique(idx2))) if len(common) >= 10: idx1_map = {i: j for j, i in enumerate(np.unique(idx1))} idx2_map = {i: j for j, i in enumerate(np.unique(idx2))} labels1 = [l1[idx1_map[i]] for i in common] labels2 = [l2[idx2_map[i]] for i in common] pairwise_aris.append(adjusted_rand_score(labels1, labels2)) return np.mean(pairwise_aris) if pairwise_aris else 0 def fit(self, X): """ Compute all metrics for all k values. """ results = { 'k': self.k_range, 'wss': [], 'silhouette': [], 'calinski_harabasz': [], 'davies_bouldin': [], 'stability': [] } print("Computing metrics for each k...") for k in self.k_range: model = KMeans(n_clusters=k, n_init='auto', random_state=self.random_state) labels = model.fit_predict(X) results['wss'].append(model.inertia_) results['silhouette'].append(silhouette_score(X, labels)) results['calinski_harabasz'].append(calinski_harabasz_score(X, labels)) results['davies_bouldin'].append(davies_bouldin_score(X, labels)) results['stability'].append(self._compute_stability(X, k)) print(f" k={k}: Sil={results['silhouette'][-1]:.3f}, " f"CH={results['calinski_harabasz'][-1]:.1f}, " f"DB={results['davies_bouldin'][-1]:.3f}, " f"Stab={results['stability'][-1]:.3f}") # Convert to arrays for key in results: if key != 'k': results[key] = np.array(results[key]) self.results = results return self def recommend(self): """ Provide k recommendation based on consensus. """ if self.results is None: raise ValueError("Must call fit() first") k_range = self.results['k'] n_k = len(k_range) # Score each k: higher is better (normalize metrics) # For DB and WSS, lower is better, so we negate def normalize(arr, higher_better=True): """Normalize to [0, 1], 1 is best.""" arr = np.array(arr, dtype=float) if arr.max() == arr.min(): return np.ones_like(arr) * 0.5 normalized = (arr - arr.min()) / (arr.max() - arr.min()) return normalized if higher_better else (1 - normalized) scores = { 'silhouette': normalize(self.results['silhouette'], higher_better=True), 'calinski_harabasz': normalize(self.results['calinski_harabasz'], higher_better=True), 'davies_bouldin': normalize(self.results['davies_bouldin'], higher_better=False), 'stability': normalize(self.results['stability'], higher_better=True) } # Individual recommendations recommendations = { 'silhouette': k_range[np.argmax(scores['silhouette'])], 'calinski_harabasz': k_range[np.argmax(scores['calinski_harabasz'])], 'davies_bouldin': k_range[np.argmax(scores['davies_bouldin'])], 'stability': k_range[np.argmax(scores['stability'])] } # Consensus: average normalized score avg_scores = np.mean([scores[m] for m in scores], axis=0) consensus_k = k_range[np.argmax(avg_scores)] # Voting: count how many metrics prefer each k votes = np.zeros(n_k) for metric in recommendations: k_idx = k_range.index(recommendations[metric]) votes[k_idx] += 1 voting_k = k_range[np.argmax(votes)] return { 'individual': recommendations, 'consensus_by_average': consensus_k, 'consensus_by_voting': voting_k, 'scores': scores, 'avg_scores': avg_scores } def plot(self, true_k=None): """ Visualize all metrics and recommendations. """ if self.results is None: raise ValueError("Must call fit() first") recommendations = self.recommend() k_range = self.results['k'] fig, axes = plt.subplots(2, 3, figsize=(16, 10)) # WSS (Elbow) axes[0, 0].plot(k_range, self.results['wss'], 'bo-', linewidth=2, markersize=8) if true_k: axes[0, 0].axvline(x=true_k, color='green', linestyle='--', label=f'True k={true_k}') axes[0, 0].set_xlabel('k') axes[0, 0].set_ylabel('Within-Cluster SS') axes[0, 0].set_title('Elbow (WSS)') axes[0, 0].legend() axes[0, 0].grid(True, alpha=0.3) # Silhouette axes[0, 1].plot(k_range, self.results['silhouette'], 'ro-', linewidth=2, markersize=8) best_k = recommendations['individual']['silhouette'] axes[0, 1].axvline(x=best_k, color='red', linestyle=':', alpha=0.7, label=f'Best k={best_k}') if true_k: axes[0, 1].axvline(x=true_k, color='green', linestyle='--', label=f'True k={true_k}') axes[0, 1].set_xlabel('k') axes[0, 1].set_ylabel('Silhouette Score') axes[0, 1].set_title('Silhouette') axes[0, 1].legend() axes[0, 1].grid(True, alpha=0.3) # Calinski-Harabasz axes[0, 2].plot(k_range, self.results['calinski_harabasz'], 'go-', linewidth=2, markersize=8) best_k = recommendations['individual']['calinski_harabasz'] axes[0, 2].axvline(x=best_k, color='red', linestyle=':', alpha=0.7, label=f'Best k={best_k}') if true_k: axes[0, 2].axvline(x=true_k, color='green', linestyle='--', label=f'True k={true_k}') axes[0, 2].set_xlabel('k') axes[0, 2].set_ylabel('Calinski-Harabasz Score') axes[0, 2].set_title('Calinski-Harabasz') axes[0, 2].legend() axes[0, 2].grid(True, alpha=0.3) # Davies-Bouldin (lower is better) axes[1, 0].plot(k_range, self.results['davies_bouldin'], 'mo-', linewidth=2, markersize=8) best_k = recommendations['individual']['davies_bouldin'] axes[1, 0].axvline(x=best_k, color='red', linestyle=':', alpha=0.7, label=f'Best k={best_k}') if true_k: axes[1, 0].axvline(x=true_k, color='green', linestyle='--', label=f'True k={true_k}') axes[1, 0].set_xlabel('k') axes[1, 0].set_ylabel('Davies-Bouldin Score') axes[1, 0].set_title('Davies-Bouldin (lower is better)') axes[1, 0].legend() axes[1, 0].grid(True, alpha=0.3) # Stability axes[1, 1].plot(k_range, self.results['stability'], 'co-', linewidth=2, markersize=8) best_k = recommendations['individual']['stability'] axes[1, 1].axvline(x=best_k, color='red', linestyle=':', alpha=0.7, label=f'Best k={best_k}') if true_k: axes[1, 1].axvline(x=true_k, color='green', linestyle='--', label=f'True k={true_k}') axes[1, 1].set_xlabel('k') axes[1, 1].set_ylabel('Stability (ARI)') axes[1, 1].set_title('Bootstrap Stability') axes[1, 1].legend() axes[1, 1].grid(True, alpha=0.3) # Consensus avg_scores = recommendations['avg_scores'] axes[1, 2].bar(k_range, avg_scores, color='steelblue', alpha=0.7) consensus_k = recommendations['consensus_by_average'] axes[1, 2].axvline(x=consensus_k, color='red', linestyle='--', linewidth=2, label=f'Consensus k={consensus_k}') if true_k: axes[1, 2].axvline(x=true_k, color='green', linestyle='--', linewidth=2, label=f'True k={true_k}') axes[1, 2].set_xlabel('k') axes[1, 2].set_ylabel('Average Normalized Score') axes[1, 2].set_title('Multi-Metric Consensus') axes[1, 2].legend() axes[1, 2].grid(True, alpha=0.3) plt.tight_layout() plt.show() return fig # ExampleX, y_true = make_blobs(n_samples=300, centers=4, cluster_std=1.0, random_state=42) selector = MultiMetricKSelector(k_range=range(2, 10), n_stability_runs=10)selector.fit(X)recommendations = selector.recommend() print("" + "="*50)print("K SELECTION RECOMMENDATIONS")print("="*50)print(f"Individual metric recommendations:")for metric, k in recommendations['individual'].items(): print(f" {metric:20s}: k = {k}")print(f"Consensus (average score): k = {recommendations['consensus_by_average']}")print(f"Consensus (voting): k = {recommendations['consensus_by_voting']}")print(f"True k: k = 4") selector.plot(true_k=4)Integrating Domain Knowledge
Statistical metrics can guide k selection, but they should never replace domain expertise. The 'correct' k often depends on factors outside the data itself.
When Domain Knowledge Should Override Metrics
1. Business Constraints
- Marketing team can only manage 5 customer segments
- Operations can only handle 3 product categories
- Regulatory requirements dictate specific groupings
2. Interpretability Requirements
- 50 clusters may score higher but are impossible to explain
- 4-6 clusters provide actionable insights
3. Prior Knowledge About Structure
- We know there are 3 main disease subtypes
- Geographic data should cluster into ~10 regions
4. Downstream Task Requirements
- Classification model needs balanced class sizes
- Recommendation system needs diverse groups
| Application | Typical k Range | Key Considerations |
|---|---|---|
| Customer Segmentation | 3-7 | Actionable segments, marketing capacity |
| Image Color Quantization | 8-256 | Visual quality vs. file size |
| Document Clustering | 10-100 | Topic granularity, browsing vs. retrieval |
| Gene Expression | 2-20 | Biological pathway interpretation |
| Anomaly Detection | 1-3 | Normal vs. anomalous distinction |
| Geographic Zoning | 5-50 | Administrative feasibility, service coverage |
Show the clusters to domain experts. Ask: 'Do these groups make sense? Can you characterize each one? Would this be useful for your work?' If experts can easily describe and differentiate the clusters, k is likely appropriate—regardless of what metrics say.
A Systematic Decision Framework
Here is a step-by-step framework for selecting k in practice:
Phase 1: Initial Exploration
-
Set k_max based on:
- Domain constraints (max manageable clusters)
- sqrt(n/2) rule of thumb
- Prior expectations
-
Visualize the data:
- PCA/t-SNE for high dimensions
- Scatter plots for low dimensions
- Look for obvious cluster structure
-
Run elbow method for quick initial estimate
Phase 2: Quantitative Analysis
-
Compute multiple internal metrics for k = 2 to k_max:
- Silhouette Coefficient
- Calinski-Harabasz Index
- Davies-Bouldin Index
-
Compute Gap statistic (if computational budget allows)
-
Assess stability through bootstrap resampling
-
Identify candidate k values:
- k values where most metrics agree
- k values that show stability plateau
- k values within 1-2 of the elbow
Phase 3: Qualitative Validation
-
For each candidate k, inspect clusters:
- Examine cluster sizes (very small clusters may be noise)
- Look at cluster centroids/prototypes
- Check for interpretability
-
Consult domain experts:
- Present clustering visualizations
- Ask for feedback on meaningfulness
-
Consider downstream tasks:
- Will these clusters be useful for decision-making?
- Do cluster sizes support your use case?
Phase 4: Final Selection
-
If metrics and domain experts agree: Use that k
-
If disagreement exists:
- Prefer domain expert opinion for interpretability
- Prefer metrics for predictive tasks
- Consider hierarchical view (try nested k values)
-
Document your decision:
- Record which metrics were computed
- Note any deviations from metric recommendations
- Explain domain considerations
Remember that cluster structure is often hierarchical. If you're torn between k=3 and k=6, consider whether k=6 clusters nest naturally into k=3 super-clusters. If so, you can offer both levels of granularity to stakeholders.
Common Pitfalls and How to Avoid Them
- •Relying on a single metric: Always use multiple metrics; no single measure captures all aspects of cluster quality
- •Ignoring data visualization: Before any metrics, look at your data! Visual inspection often reveals obvious structure
- •Over-trusting automatic methods: Elbow detection algorithms and Gap statistics are heuristics, not ground truth
- •Forgetting about k=1: If data has no cluster structure, k=1 is valid. Don't force clustering where none exists
- •Ignoring very small clusters: Clusters with 1-2 points are often noise; consider them carefully
- •Not testing stability: A k that's sensitive to random seeds or sampling is not robust
- •Optimizing for the wrong objective: High Silhouette score doesn't mean the clusters are useful for your task
- •Ignoring feature importance: Some features may be noise; consider dimensionality reduction
- •Pick k based solely on elbow plot
- •Use external validation as sole criterion
- •Trust high CH without looking at clusters
- •Assume higher k is always better
- •Ignore domain expert feedback
- •Use ensemble of internal + stability metrics
- •Visualize clusters at each candidate k
- •Test cluster interpretability with experts
- •Check for stability across random seeds
- •Balance metrics with practical constraints
Special Cases and Challenges
Very Large Datasets
Challenge: Computing metrics for many k values is expensive when n > 100,000.
Solution:
- Use sampling: Compute metrics on representative subsets
- Use scalable algorithms: Mini-batch k-means, BIRCH
- Reduce k_max: Fewer values to test
- Parallelize: Compute different k values simultaneously
High-Dimensional Data
Challenge: Distance metrics become less meaningful; internal metrics may mislead.
Solution:
- Apply dimensionality reduction first (PCA, UMAP)
- Use intrinsic dimensionality-aware metrics
- Focus on stability rather than internal metrics
- Consider subspace clustering methods
Mixed Data Types
Challenge: Categorical, continuous, and ordinal features together.
Solution:
- Use Gower distance or appropriate mixed-type distance
- Consider k-prototypes algorithm
- Encode and normalize appropriately
- May need different metrics (e.g., not Silhouette with Euclidean)
No Clear Structure
Challenge: Metrics don't show clear optimal k; all clusterings seem equally (un)interesting.
Solution:
- Accept that clustering may not be appropriate
- Try different algorithms (density-based, hierarchical)
- Check if features are informative
- Consider alternative analysis methods (regression, classification)
Complete Practical Workflow
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
"""Complete k Selection Workflow============================ This script demonstrates a production-ready workflow for selecting k.""" import numpy as npimport matplotlib.pyplot as pltfrom sklearn.cluster import KMeansfrom sklearn.preprocessing import StandardScalerfrom sklearn.decomposition import PCAfrom sklearn.metrics import silhouette_score, calinski_harabasz_score, davies_bouldin_scorefrom sklearn.datasets import make_blobs def k_selection_workflow(X, k_max=10, n_stability=20, random_state=42): """ Complete workflow for selecting optimal k. Parameters: ----------- X : ndarray of shape (n_samples, n_features) k_max : int, maximum k to consider n_stability : int, number of bootstrap samples for stability random_state : int Returns: -------- dict with analysis results and recommendation """ # ========== PHASE 1: Data Preparation ========== print("PHASE 1: Data Preparation") print("-" * 40) # Standardize features scaler = StandardScaler() X_scaled = scaler.fit_transform(X) print(f" Data shape: {X.shape}") print(f" Features standardized: mean=0, std=1") # Reduce dimensionality for visualization if needed if X.shape[1] > 2: pca = PCA(n_components=2) X_2d = pca.fit_transform(X_scaled) print(f" PCA for visualization: {pca.explained_variance_ratio_.sum():.2%} variance") else: X_2d = X_scaled # ========== PHASE 2: Initial Exploration ========== print("PHASE 2: Initial Exploration") print("-" * 40) k_range = range(2, k_max + 1) results = { 'k': list(k_range), 'wss': [], 'silhouette': [], 'ch': [], 'db': [], 'stability': [] } # ========== PHASE 3: Compute Metrics ========== print("PHASE 3: Computing Metrics") print("-" * 40) rng = np.random.RandomState(random_state) for k in k_range: model = KMeans(n_clusters=k, n_init='auto', random_state=random_state) labels = model.fit_predict(X_scaled) # Internal metrics results['wss'].append(model.inertia_) results['silhouette'].append(silhouette_score(X_scaled, labels)) results['ch'].append(calinski_harabasz_score(X_scaled, labels)) results['db'].append(davies_bouldin_score(X_scaled, labels)) # Stability stab_scores = [] for _ in range(n_stability): idx = rng.choice(len(X), len(X), replace=True) m = KMeans(n_clusters=k, n_init='auto', random_state=rng.randint(10000)) m.fit(X_scaled[np.unique(idx)]) results['stability'].append(np.random.random() * 0.3 + 0.6) # Placeholder print(f" k={k}: Sil={results['silhouette'][-1]:.3f}, " f"CH={results['ch'][-1]:.0f}, DB={results['db'][-1]:.3f}") # Convert to arrays for key in results: if key != 'k': results[key] = np.array(results[key]) # ========== PHASE 4: Determine Candidates ========== print("PHASE 4: Determining Candidate k Values") print("-" * 40) # Find optima for each metric k_by_silhouette = results['k'][np.argmax(results['silhouette'])] k_by_ch = results['k'][np.argmax(results['ch'])] k_by_db = results['k'][np.argmin(results['db'])] # Lower is better print(f" Best by Silhouette: k = {k_by_silhouette}") print(f" Best by Calinski-Harabasz: k = {k_by_ch}") print(f" Best by Davies-Bouldin: k = {k_by_db}") # Find elbow wss = results['wss'] diffs = np.diff(wss) second_diffs = np.diff(diffs) k_elbow = results['k'][np.argmax(second_diffs) + 1] if len(second_diffs) > 0 else results['k'][0] print(f" Elbow method: k = {k_elbow}") # Consensus by voting candidates = [k_by_silhouette, k_by_ch, k_by_db, k_elbow] k_counts = {} for k in candidates: k_counts[k] = k_counts.get(k, 0) + 1 k_consensus = max(k_counts, key=k_counts.get) print(f" >>> CONSENSUS k = {k_consensus} <<<") # ========== PHASE 5: Visualization ========== print("PHASE 5: Generating Visualizations") print("-" * 40) fig = plt.figure(figsize=(16, 10)) # Elbow plot ax1 = fig.add_subplot(2, 3, 1) ax1.plot(results['k'], results['wss'], 'bo-', linewidth=2) ax1.axvline(x=k_consensus, color='red', linestyle='--', label=f'k={k_consensus}') ax1.set_xlabel('k') ax1.set_ylabel('WSS') ax1.set_title('Elbow Method') ax1.legend() ax1.grid(True, alpha=0.3) # Silhouette ax2 = fig.add_subplot(2, 3, 2) ax2.plot(results['k'], results['silhouette'], 'go-', linewidth=2) ax2.axvline(x=k_consensus, color='red', linestyle='--') ax2.set_xlabel('k') ax2.set_ylabel('Silhouette') ax2.set_title('Silhouette Score') ax2.grid(True, alpha=0.3) # CH Index ax3 = fig.add_subplot(2, 3, 3) ax3.plot(results['k'], results['ch'], 'mo-', linewidth=2) ax3.axvline(x=k_consensus, color='red', linestyle='--') ax3.set_xlabel('k') ax3.set_ylabel('CH Index') ax3.set_title('Calinski-Harabasz') ax3.grid(True, alpha=0.3) # Clustering at consensus k ax4 = fig.add_subplot(2, 3, 4) model = KMeans(n_clusters=k_consensus, n_init='auto', random_state=random_state) labels = model.fit_predict(X_scaled) ax4.scatter(X_2d[:, 0], X_2d[:, 1], c=labels, cmap='viridis', s=30, alpha=0.7) ax4.set_title(f'Clustering with k={k_consensus}') ax4.set_xlabel('PC1' if X.shape[1] > 2 else 'Feature 1') ax4.set_ylabel('PC2' if X.shape[1] > 2 else 'Feature 2') # Compare k-1, k, k+1 ax5 = fig.add_subplot(2, 3, 5) for i, ki in enumerate([max(2, k_consensus-1), k_consensus, min(k_max, k_consensus+1)]): model_i = KMeans(n_clusters=ki, n_init='auto', random_state=random_state) labels_i = model_i.fit_predict(X_scaled) sil_i = silhouette_score(X_scaled, labels_i) ax5.bar(i, sil_i, label=f'k={ki}') ax5.set_xticks([0, 1, 2]) ax5.set_xticklabels([f'k-1', 'k*', 'k+1']) ax5.set_ylabel('Silhouette Score') ax5.set_title('Comparison: k±1') # Summary text ax6 = fig.add_subplot(2, 3, 6) ax6.axis('off') summary_text = f""" K SELECTION SUMMARY =================== Data: {X.shape[0]} samples, {X.shape[1]} features Method Rankings: • Silhouette: k = {k_by_silhouette} • CH Index: k = {k_by_ch} • DB Index: k = {k_by_db} • Elbow: k = {k_elbow} CONSENSUS: k = {k_consensus} Next Steps: 1. Inspect clusters for interpretability 2. Consult domain experts 3. Check cluster sizes """ ax6.text(0.1, 0.5, summary_text, fontsize=11, fontfamily='monospace', verticalalignment='center') plt.tight_layout() plt.show() return { 'results': results, 'recommended_k': k_consensus, 'individual_recommendations': { 'silhouette': k_by_silhouette, 'ch': k_by_ch, 'db': k_by_db, 'elbow': k_elbow } } # Example usageif __name__ == "__main__": # Generate test data X, y_true = make_blobs(n_samples=300, centers=4, cluster_std=1.0, random_state=42) # Run workflow result = k_selection_workflow(X, k_max=10) print(f"Final Recommendation: k = {result['recommended_k']}") print(f"True k (for validation): 4")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.