Spaces:
Sleeping
Sleeping
| """Cluster characterization helpers (schema-driven).""" | |
| from __future__ import annotations | |
| from typing import Iterable | |
| import numpy as np | |
| import pandas as pd | |
| from .adapters.base import DatasetSchema | |
| _DEVIATION_THRESHOLD = 0.25 | |
| def canonical_cluster_order( | |
| X_scaled: np.ndarray, | |
| labels: np.ndarray, | |
| feature_columns: list[str], | |
| primary: str | None = None, | |
| secondary: str | None = None, | |
| ) -> dict[int, int]: | |
| """Return ``{raw_cluster_id: canonical_id}`` sorted deterministically. | |
| When schema-selected ordering columns are available, cluster 0 has the | |
| highest mean(primary), tie-broken by mean(secondary). Otherwise, cluster 0 | |
| is the largest cluster, tie-broken by centroid L2 norm. Noise label ``-1`` | |
| is preserved as-is. | |
| """ | |
| valid = labels != -1 | |
| clusters = sorted(set(labels[valid].tolist())) | |
| if not clusters: | |
| return {} | |
| if primary and primary in feature_columns: | |
| primary_idx = feature_columns.index(primary) | |
| secondary_idx = ( | |
| feature_columns.index(secondary) | |
| if secondary and secondary in feature_columns | |
| else primary_idx | |
| ) | |
| df = pd.DataFrame({ | |
| "cluster": labels[valid], | |
| "primary": X_scaled[valid, primary_idx], | |
| "secondary": X_scaled[valid, secondary_idx], | |
| }) | |
| means = df.groupby("cluster").agg({"primary": "mean", "secondary": "mean"}) | |
| ordered = means.sort_values( | |
| ["primary", "secondary"], ascending=[False, False], kind="stable", | |
| ).index | |
| else: | |
| rows = [] | |
| for cluster in clusters: | |
| mask = labels == cluster | |
| centroid = X_scaled[mask].mean(axis=0) | |
| rows.append( | |
| { | |
| "cluster": cluster, | |
| "size": int(mask.sum()), | |
| "centroid_norm": float(np.linalg.norm(centroid)), | |
| } | |
| ) | |
| ordered = ( | |
| pd.DataFrame(rows) | |
| .sort_values(["size", "centroid_norm", "cluster"], ascending=[False, False, True], kind="stable") | |
| ["cluster"] | |
| ) | |
| return {int(raw): canon for canon, raw in enumerate(ordered)} | |
| def apply_remap(labels: np.ndarray, remap: dict[int, int]) -> np.ndarray: | |
| """Apply a cluster-ID remap, preserving noise (``-1``) unchanged.""" | |
| return np.array([remap.get(int(l), int(l)) for l in labels]) | |
| def _role_mean( | |
| means: np.ndarray, columns: list[str], role_cols: Iterable[str | None] | |
| ) -> float | None: | |
| """Mean standardized deviation across a role's columns; None if no columns present.""" | |
| indices = [columns.index(c) for c in role_cols if c and c in columns] | |
| if not indices: | |
| return None | |
| return float(np.mean(means[indices])) | |
| def _interpret_label( | |
| means: np.ndarray, | |
| columns: list[str], | |
| schema: DatasetSchema | None, | |
| ) -> str: | |
| """Build a label from schema role columns; fall back to z-score description.""" | |
| if schema is None: | |
| return _generic_label(means) | |
| engagement_cols = [schema.engagement_col] if schema.engagement_col else [] | |
| performance_cols = [schema.performance_col] if schema.performance_col else [] | |
| engagement = _role_mean(means, columns, engagement_cols) | |
| performance = _role_mean(means, columns, performance_cols) | |
| parts: list[str] = [] | |
| if engagement is not None: | |
| parts.append(_describe(engagement, "engagement")) | |
| if performance is not None: | |
| parts.append(_describe(performance, "performance")) | |
| if not parts: | |
| return _generic_label(means) | |
| parts = [p for p in parts if p] | |
| if not parts: | |
| return "Mixed-profile learners" | |
| return ", ".join(parts) + " learners" | |
| def _describe(value: float, role: str) -> str: | |
| if value > _DEVIATION_THRESHOLD: | |
| return f"high-{role}" | |
| if value < -_DEVIATION_THRESHOLD: | |
| return f"low-{role}" | |
| return f"average-{role}" | |
| def _generic_label(means: np.ndarray) -> str: | |
| spread = float(np.max(np.abs(means))) if means.size else 0.0 | |
| if spread < _DEVIATION_THRESHOLD: | |
| return "Average-profile learners" | |
| return "Distinctive-profile learners" | |
| def characterize_clusters( | |
| X_scaled: np.ndarray, | |
| labels: np.ndarray, | |
| columns: list[str], | |
| top_n: int = 3, | |
| schema: DatasetSchema | None = None, | |
| ) -> pd.DataFrame: | |
| """Summarize each non-noise cluster by strongest standardized feature deviations. | |
| When a ``schema`` is provided, the interpretive label is derived from the | |
| cluster's mean deviation along schema role columns (engagement, performance). | |
| Without a schema, a generic z-score-based label is used. | |
| """ | |
| labels = np.asarray(labels) | |
| rows = [] | |
| for cluster in sorted(set(labels.tolist()) - {-1}): | |
| mask = labels == cluster | |
| means = X_scaled[mask].mean(axis=0) | |
| pos_idx = np.argsort(means)[::-1][:top_n] | |
| neg_idx = np.argsort(means)[:top_n] | |
| rows.append( | |
| { | |
| "cluster": int(cluster), | |
| "size": int(mask.sum()), | |
| "top_positive_features": ", ".join( | |
| f"{columns[i]} ({means[i]:+.2f}z)" for i in pos_idx | |
| ), | |
| "top_negative_features": ", ".join( | |
| f"{columns[i]} ({means[i]:+.2f}z)" for i in neg_idx | |
| ), | |
| "interpretive_label": _interpret_label(means, columns, schema), | |
| } | |
| ) | |
| return pd.DataFrame(rows) | |