Spaces:
Running
Running
| """ | |
| π― PRODUCTION-GRADE CLUSTERING ENGINE | |
| ===================================== | |
| This is a real ML engineer-level clustering engine that matches | |
| manual sklearn performance. No shortcuts, no fake results. | |
| Key features: | |
| 1. Proper numeric-only feature selection for standard algorithms | |
| 2. K-Prototypes for mixed data (numeric + categorical) | |
| 3. K-Modes for categorical-only data | |
| 4. Optimal hyperparameter tuning for each algorithm | |
| 5. Multiple algorithm comparison | |
| 6. Accurate metrics calculation | |
| 7. Best algorithm auto-selection | |
| Supported Algorithms: | |
| - KMeans: Numeric data only | |
| - DBSCAN: Numeric data only | |
| - Hierarchical: Numeric data only | |
| - GMM: Numeric data only | |
| - Spectral: Numeric data only | |
| - K-Prototypes: Mixed data (numeric + categorical) β¨ | |
| - K-Modes: Categorical-only data β¨ | |
| Author: DataVision AI | |
| """ | |
| import logging | |
| import numpy as np | |
| import pandas as pd | |
| from typing import Optional, List, Dict, Any, Tuple | |
| from dataclasses import dataclass | |
| from sklearn.preprocessing import StandardScaler, LabelEncoder | |
| from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering, SpectralClustering | |
| from sklearn.mixture import GaussianMixture | |
| from sklearn.metrics import silhouette_score, calinski_harabasz_score, davies_bouldin_score | |
| from sklearn.decomposition import PCA | |
| from sklearn.neighbors import NearestNeighbors | |
| logger = logging.getLogger(__name__) | |
| class ClusteringResult: | |
| """Result from clustering analysis""" | |
| success: bool | |
| algorithm: str | |
| n_clusters: int | |
| silhouette_score: float | |
| calinski_harabasz_score: float | |
| davies_bouldin_score: float | |
| inertia: Optional[float] | |
| labels: np.ndarray | |
| cluster_distribution: Dict[str, int] | |
| feature_columns: List[str] | |
| n_samples: int | |
| n_features: int | |
| pca_variance_explained: float | |
| model: Any | |
| scaler: Any | |
| data_type: str # 'numeric', 'categorical', 'mixed' | |
| error: Optional[str] = None | |
| class ProductionClusteringEngine: | |
| """ | |
| Production-grade clustering engine that matches manual sklearn quality. | |
| This engine: | |
| 1. Uses ONLY numeric features for standard algorithms | |
| 2. Uses K-Prototypes for MIXED data (numeric + categorical) | |
| 3. Uses K-Modes for CATEGORICAL-only data | |
| 4. Properly scales data with StandardScaler | |
| 5. Tests multiple n_clusters values to find optimal | |
| 6. Calculates accurate metrics | |
| 7. Can compare all algorithms to find the best | |
| """ | |
| # Algorithms for different data types | |
| NUMERIC_ALGORITHMS = ['kmeans', 'dbscan', 'hierarchical', 'gmm', 'spectral'] | |
| MIXED_ALGORITHMS = ['kprototypes'] # For numeric + categorical | |
| CATEGORICAL_ALGORITHMS = ['kmodes'] # For categorical only | |
| def __init__(self): | |
| self.scaler = None | |
| self.model = None | |
| self.labels = None | |
| self.feature_columns = [] | |
| self.pca = None | |
| self.data_type = 'numeric' | |
| def detect_data_type(self, df: pd.DataFrame) -> str: | |
| """ | |
| Detect the type of data in the dataframe. | |
| Returns: | |
| - 'numeric': Only numeric columns | |
| - 'categorical': Only categorical columns | |
| - 'mixed': Both numeric and categorical columns | |
| """ | |
| numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() | |
| categorical_cols = df.select_dtypes(include=['object', 'category']).columns.tolist() | |
| # Exclude datetime | |
| datetime_cols = df.select_dtypes(include=['datetime64']).columns.tolist() | |
| has_numeric = len(numeric_cols) > 0 | |
| has_categorical = len(categorical_cols) > 0 | |
| if has_numeric and has_categorical: | |
| return 'mixed' | |
| elif has_categorical: | |
| return 'categorical' | |
| else: | |
| return 'numeric' | |
| def prepare_data( | |
| self, | |
| df: pd.DataFrame, | |
| exclude_columns: List[str] = None, | |
| algorithm: str = 'kmeans' | |
| ) -> Tuple[Any, List[str], str]: | |
| """ | |
| Prepare data for clustering based on algorithm type. | |
| Returns: | |
| - X: Prepared data (numpy array or dataframe) | |
| - feature_columns: List of column names used | |
| - data_type: 'numeric', 'categorical', or 'mixed' | |
| """ | |
| exclude_columns = exclude_columns or [] | |
| # Make a copy | |
| df_clean = df.copy() | |
| # Drop excluded columns | |
| for col in exclude_columns: | |
| if col in df_clean.columns: | |
| df_clean = df_clean.drop(columns=[col]) | |
| # Drop datetime columns | |
| datetime_cols = df_clean.select_dtypes(include=['datetime64']).columns.tolist() | |
| df_clean = df_clean.drop(columns=datetime_cols, errors='ignore') | |
| # Detect data type | |
| data_type = self.detect_data_type(df_clean) | |
| logger.info(f"π Data type detected: {data_type}") | |
| numeric_cols = df_clean.select_dtypes(include=[np.number]).columns.tolist() | |
| categorical_cols = df_clean.select_dtypes(include=['object', 'category']).columns.tolist() | |
| logger.info(f" Numeric columns ({len(numeric_cols)}): {numeric_cols}") | |
| logger.info(f" Categorical columns ({len(categorical_cols)}): {categorical_cols}") | |
| # Prepare based on algorithm | |
| if algorithm in ['kprototypes']: | |
| # K-Prototypes: Use BOTH numeric and categorical | |
| if data_type == 'numeric': | |
| logger.warning(" K-Prototypes requires categorical data, falling back to KMeans") | |
| algorithm = 'kmeans' | |
| else: | |
| # Prepare mixed data for K-Prototypes | |
| df_mixed = df_clean[numeric_cols + categorical_cols].copy() | |
| # Fill missing values | |
| for col in numeric_cols: | |
| df_mixed[col] = df_mixed[col].fillna(df_mixed[col].median()) | |
| for col in categorical_cols: | |
| df_mixed[col] = df_mixed[col].fillna('Unknown') | |
| # Get indices of categorical columns for K-Prototypes | |
| cat_indices = list(range(len(numeric_cols), len(numeric_cols) + len(categorical_cols))) | |
| feature_columns = numeric_cols + categorical_cols | |
| return df_mixed.values, feature_columns, 'mixed', cat_indices | |
| elif algorithm in ['kmodes']: | |
| # K-Modes: Use ONLY categorical | |
| if len(categorical_cols) == 0: | |
| logger.warning(" K-Modes requires categorical data, none found") | |
| return None, [], 'categorical', [] | |
| df_cat = df_clean[categorical_cols].copy() | |
| df_cat = df_cat.fillna('Unknown') | |
| return df_cat.values, categorical_cols, 'categorical', [] | |
| else: | |
| # Standard algorithms: Use ONLY numeric | |
| df_numeric = df_clean[numeric_cols].copy() | |
| # Fill NaN with median | |
| df_numeric = df_numeric.fillna(df_numeric.median()) | |
| # Drop zero-variance columns | |
| zero_var_cols = df_numeric.columns[df_numeric.std() == 0].tolist() | |
| if zero_var_cols: | |
| logger.info(f" Dropping {len(zero_var_cols)} zero-variance columns") | |
| df_numeric = df_numeric.drop(columns=zero_var_cols) | |
| feature_columns = df_numeric.columns.tolist() | |
| X = df_numeric.values | |
| logger.info(f" Final data shape: {X.shape}") | |
| return X, feature_columns, 'numeric', [] | |
| def find_optimal_k( | |
| self, | |
| X_scaled: np.ndarray, | |
| min_k: int = 2, | |
| max_k: int = 10 | |
| ) -> Tuple[int, Dict[int, float]]: | |
| """ | |
| Find optimal number of clusters using Silhouette score. | |
| """ | |
| max_k = min(max_k, len(X_scaled) - 1, 15) | |
| max_k = max(max_k, min_k) | |
| scores = {} | |
| best_k = min_k | |
| best_score = -1 | |
| logger.info(f"π Finding optimal k (testing {min_k} to {max_k})...") | |
| for k in range(min_k, max_k + 1): | |
| try: | |
| kmeans = KMeans(n_clusters=k, random_state=42, n_init=10, max_iter=300) | |
| labels = kmeans.fit_predict(X_scaled) | |
| if len(set(labels)) > 1: | |
| score = silhouette_score(X_scaled, labels) | |
| scores[k] = score | |
| if score > best_score: | |
| best_score = score | |
| best_k = k | |
| logger.info(f" k={k}: Silhouette={score:.4f}") | |
| except Exception as e: | |
| logger.warning(f" k={k}: Failed - {e}") | |
| logger.info(f" β Optimal k={best_k} with Silhouette={best_score:.4f}") | |
| return best_k, scores | |
| def cluster_kmeans(self, X_scaled: np.ndarray, n_clusters: int) -> Tuple[np.ndarray, Any, float]: | |
| """Run K-Means clustering.""" | |
| model = KMeans( | |
| n_clusters=n_clusters, | |
| random_state=42, | |
| n_init=10, | |
| max_iter=300, | |
| algorithm='lloyd' | |
| ) | |
| labels = model.fit_predict(X_scaled) | |
| inertia = model.inertia_ | |
| return labels, model, inertia | |
| def cluster_kprototypes( | |
| self, | |
| X: np.ndarray, | |
| n_clusters: int, | |
| categorical_indices: List[int] | |
| ) -> Tuple[np.ndarray, Any, float]: | |
| """ | |
| Run K-Prototypes clustering for MIXED data (numeric + categorical). | |
| This is the proper way to cluster mixed data - NOT by encoding categoricals! | |
| """ | |
| try: | |
| from kmodes.kprototypes import KPrototypes | |
| model = KPrototypes( | |
| n_clusters=n_clusters, | |
| init='Huang', | |
| n_init=5, | |
| random_state=42, | |
| verbose=0 | |
| ) | |
| labels = model.fit_predict(X, categorical=categorical_indices) | |
| cost = model.cost_ | |
| logger.info(f" K-Prototypes cost: {cost:.2f}") | |
| return labels, model, cost | |
| except ImportError: | |
| logger.error("kmodes package not installed. Run: pip install kmodes") | |
| raise | |
| def cluster_kmodes(self, X: np.ndarray, n_clusters: int) -> Tuple[np.ndarray, Any, float]: | |
| """ | |
| Run K-Modes clustering for CATEGORICAL-only data. | |
| """ | |
| try: | |
| from kmodes.kmodes import KModes | |
| model = KModes( | |
| n_clusters=n_clusters, | |
| init='Huang', | |
| n_init=5, | |
| random_state=42, | |
| verbose=0 | |
| ) | |
| labels = model.fit_predict(X) | |
| cost = model.cost_ | |
| logger.info(f" K-Modes cost: {cost:.2f}") | |
| return labels, model, cost | |
| except ImportError: | |
| logger.error("kmodes package not installed. Run: pip install kmodes") | |
| raise | |
| def cluster_dbscan(self, X_scaled: np.ndarray, eps: float = None, min_samples: int = 5) -> Tuple[np.ndarray, Any, int]: | |
| """Run DBSCAN clustering.""" | |
| if eps is None: | |
| k = min(min_samples, len(X_scaled) - 1) | |
| nn = NearestNeighbors(n_neighbors=k) | |
| nn.fit(X_scaled) | |
| distances, _ = nn.kneighbors(X_scaled) | |
| sorted_distances = np.sort(distances[:, -1]) | |
| eps = np.percentile(sorted_distances, 90) | |
| logger.info(f" Auto-detected eps={eps:.4f}") | |
| model = DBSCAN(eps=eps, min_samples=min_samples) | |
| labels = model.fit_predict(X_scaled) | |
| n_clusters = len(set(labels)) - (1 if -1 in labels else 0) | |
| return labels, model, n_clusters | |
| def cluster_hierarchical(self, X_scaled: np.ndarray, n_clusters: int, linkage: str = 'ward') -> Tuple[np.ndarray, Any]: | |
| """Run Hierarchical clustering.""" | |
| model = AgglomerativeClustering(n_clusters=n_clusters, linkage=linkage) | |
| labels = model.fit_predict(X_scaled) | |
| return labels, model | |
| def cluster_gmm(self, X_scaled: np.ndarray, n_clusters: int) -> Tuple[np.ndarray, Any]: | |
| """Run GMM clustering.""" | |
| model = GaussianMixture(n_components=n_clusters, random_state=42, n_init=5, max_iter=200) | |
| labels = model.fit_predict(X_scaled) | |
| return labels, model | |
| def cluster_spectral(self, X_scaled: np.ndarray, n_clusters: int) -> Tuple[np.ndarray, Any]: | |
| """Run Spectral clustering.""" | |
| max_samples = 5000 | |
| if len(X_scaled) > max_samples: | |
| idx = np.random.choice(len(X_scaled), max_samples, replace=False) | |
| X_sample = X_scaled[idx] | |
| else: | |
| X_sample = X_scaled | |
| idx = np.arange(len(X_scaled)) | |
| model = SpectralClustering(n_clusters=n_clusters, random_state=42, affinity='nearest_neighbors', n_neighbors=10) | |
| labels_sample = model.fit_predict(X_sample) | |
| if len(X_scaled) > max_samples: | |
| from sklearn.neighbors import KNeighborsClassifier | |
| knn = KNeighborsClassifier(n_neighbors=5) | |
| knn.fit(X_sample, labels_sample) | |
| labels = knn.predict(X_scaled) | |
| else: | |
| labels = labels_sample | |
| return labels, model | |
| def calculate_metrics(self, X: np.ndarray, labels: np.ndarray, data_type: str = 'numeric') -> Dict[str, float]: | |
| """Calculate clustering quality metrics.""" | |
| mask = labels != -1 | |
| unique_labels = set(labels[mask]) | |
| metrics = { | |
| 'silhouette_score': 0.0, | |
| 'calinski_harabasz_score': 0.0, | |
| 'davies_bouldin_score': float('inf'), | |
| 'n_clusters': len(unique_labels) | |
| } | |
| if len(unique_labels) <= 1 or mask.sum() < 2: | |
| return metrics | |
| # For mixed/categorical data, we need to encode for metrics | |
| if data_type in ['mixed', 'categorical']: | |
| # Encode categorical columns for metric calculation | |
| X_encoded = np.zeros((X.shape[0], X.shape[1])) | |
| for i in range(X.shape[1]): | |
| col = X[:, i] | |
| if isinstance(col[0], str): | |
| le = LabelEncoder() | |
| X_encoded[:, i] = le.fit_transform(col) | |
| else: | |
| X_encoded[:, i] = col.astype(float) | |
| X = X_encoded | |
| try: | |
| metrics['silhouette_score'] = float(silhouette_score(X[mask], labels[mask])) | |
| except Exception as e: | |
| logger.warning(f" Silhouette calculation failed: {e}") | |
| try: | |
| metrics['calinski_harabasz_score'] = float(calinski_harabasz_score(X[mask], labels[mask])) | |
| except Exception as e: | |
| logger.warning(f" Calinski-Harabasz calculation failed: {e}") | |
| try: | |
| metrics['davies_bouldin_score'] = float(davies_bouldin_score(X[mask], labels[mask])) | |
| except Exception as e: | |
| logger.warning(f" Davies-Bouldin calculation failed: {e}") | |
| return metrics | |
| def run_single_algorithm( | |
| self, | |
| df: pd.DataFrame, | |
| algorithm: str = 'kmeans', | |
| n_clusters: Optional[int] = None, | |
| exclude_columns: List[str] = None | |
| ) -> ClusteringResult: | |
| """ | |
| Run a single clustering algorithm with automatic data type detection. | |
| Automatically selects: | |
| - Standard algorithms (kmeans, etc.) for numeric data | |
| - K-Prototypes for mixed data | |
| - K-Modes for categorical data | |
| """ | |
| logger.info(f"π― Running {algorithm.upper()} clustering...") | |
| try: | |
| # Prepare data based on algorithm | |
| result = self.prepare_data(df, exclude_columns, algorithm) | |
| if len(result) == 4: | |
| X, feature_columns, data_type, cat_indices = result | |
| else: | |
| X, feature_columns, data_type = result | |
| cat_indices = [] | |
| if X is None or len(feature_columns) < 1: | |
| return ClusteringResult( | |
| success=False, algorithm=algorithm, n_clusters=0, | |
| silhouette_score=0.0, calinski_harabasz_score=0.0, | |
| davies_bouldin_score=0.0, inertia=None, labels=np.array([]), | |
| cluster_distribution={}, feature_columns=[], n_samples=0, | |
| n_features=0, pca_variance_explained=0.0, model=None, | |
| scaler=None, data_type=data_type, error="Not enough features" | |
| ) | |
| # Scale numeric data (for standard algorithms) | |
| if algorithm not in ['kprototypes', 'kmodes'] and data_type == 'numeric': | |
| self.scaler = StandardScaler() | |
| X_scaled = self.scaler.fit_transform(X) | |
| else: | |
| X_scaled = X | |
| self.scaler = None | |
| # Auto-detect optimal k if not provided | |
| if n_clusters is None and algorithm not in ['dbscan']: | |
| if algorithm in ['kprototypes', 'kmodes']: | |
| n_clusters = 4 # Default for mixed/categorical | |
| else: | |
| n_clusters, _ = self.find_optimal_k(X_scaled) | |
| # Run clustering | |
| inertia = None | |
| if algorithm == 'kmeans': | |
| labels, model, inertia = self.cluster_kmeans(X_scaled, n_clusters) | |
| elif algorithm == 'kprototypes': | |
| labels, model, inertia = self.cluster_kprototypes(X, n_clusters, cat_indices) | |
| elif algorithm == 'kmodes': | |
| labels, model, inertia = self.cluster_kmodes(X, n_clusters) | |
| elif algorithm == 'dbscan': | |
| labels, model, n_clusters = self.cluster_dbscan(X_scaled) | |
| elif algorithm == 'hierarchical': | |
| labels, model = self.cluster_hierarchical(X_scaled, n_clusters) | |
| elif algorithm == 'gmm': | |
| labels, model = self.cluster_gmm(X_scaled, n_clusters) | |
| elif algorithm == 'spectral': | |
| labels, model = self.cluster_spectral(X_scaled, n_clusters) | |
| else: | |
| return ClusteringResult( | |
| success=False, algorithm=algorithm, n_clusters=0, | |
| silhouette_score=0.0, calinski_harabasz_score=0.0, | |
| davies_bouldin_score=0.0, inertia=None, labels=np.array([]), | |
| cluster_distribution={}, feature_columns=[], n_samples=0, | |
| n_features=0, pca_variance_explained=0.0, model=None, | |
| scaler=None, data_type=data_type, error=f"Unknown algorithm: {algorithm}" | |
| ) | |
| # Calculate metrics | |
| metrics = self.calculate_metrics(X_scaled if self.scaler else X, labels, data_type) | |
| # Cluster distribution | |
| unique, counts = np.unique(labels, return_counts=True) | |
| distribution = { | |
| f"Cluster {int(k)}" if k != -1 else "Noise": int(v) | |
| for k, v in zip(unique, counts) | |
| } | |
| # PCA for visualization (only for numeric/scaled data) | |
| pca_variance = 0.0 | |
| if self.scaler is not None and X_scaled.shape[1] >= 2: | |
| self.pca = PCA(n_components=2) | |
| self.pca.fit(X_scaled) | |
| pca_variance = sum(self.pca.explained_variance_ratio_) | |
| logger.info(f"β {algorithm.upper()} complete:") | |
| logger.info(f" Data type: {data_type}") | |
| logger.info(f" Clusters: {n_clusters}") | |
| logger.info(f" Silhouette: {metrics['silhouette_score']:.4f}") | |
| return ClusteringResult( | |
| success=True, | |
| algorithm=algorithm, | |
| n_clusters=n_clusters, | |
| silhouette_score=metrics['silhouette_score'], | |
| calinski_harabasz_score=metrics['calinski_harabasz_score'], | |
| davies_bouldin_score=metrics['davies_bouldin_score'], | |
| inertia=inertia, | |
| labels=labels, | |
| cluster_distribution=distribution, | |
| feature_columns=feature_columns, | |
| n_samples=len(X), | |
| n_features=len(feature_columns), | |
| pca_variance_explained=pca_variance, | |
| model=model, | |
| scaler=self.scaler, | |
| data_type=data_type | |
| ) | |
| except Exception as e: | |
| logger.error(f"β Clustering failed: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return ClusteringResult( | |
| success=False, algorithm=algorithm, n_clusters=0, | |
| silhouette_score=0.0, calinski_harabasz_score=0.0, | |
| davies_bouldin_score=0.0, inertia=None, labels=np.array([]), | |
| cluster_distribution={}, feature_columns=[], n_samples=0, | |
| n_features=0, pca_variance_explained=0.0, model=None, | |
| scaler=None, data_type='unknown', error=str(e) | |
| ) | |
| def auto_select_algorithm(self, df: pd.DataFrame, exclude_columns: List[str] = None) -> str: | |
| """ | |
| Automatically select the best algorithm based on data type. | |
| - Numeric only β kmeans | |
| - Mixed data β kprototypes | |
| - Categorical only β kmodes | |
| """ | |
| exclude_columns = exclude_columns or [] | |
| df_clean = df.drop(columns=exclude_columns, errors='ignore') | |
| data_type = self.detect_data_type(df_clean) | |
| if data_type == 'mixed': | |
| logger.info("π Mixed data detected β Recommending K-Prototypes") | |
| return 'kprototypes' | |
| elif data_type == 'categorical': | |
| logger.info("π Categorical data detected β Recommending K-Modes") | |
| return 'kmodes' | |
| else: | |
| logger.info("π Numeric data detected β Recommending K-Means") | |
| return 'kmeans' | |
| # Global instance | |
| clustering_engine = ProductionClusteringEngine() | |