feat(eda): project_clusters_2d + describe_clusters_llm para el capitulo MODELOS
project_clusters_2d (pura): PCA(2)+KMeans sobre el MISMO subset estandarizado, devolviendo proyeccion 2D y labels alineados por fila + centroides en espacio PCA + perfiles de cluster desestandarizados. Es la pieza que garantiza la alineacion points<->labels que pca_explained y kmeans_segments no cubren (estandarizan por separado y kmeans descarta los labels). Habilita el scatter PCA coloreado por cluster (MUST-8.1). describe_clusters_llm (impura): micro-analisis LLM de los clusters en una sola llamada a ask_llm (grupo claude-direct), devuelve titulo + descripcion por cluster con degradacion dict-no-throw a titulos genericos si el LLM no responde (MUST-8.2). Ambas re-exportadas en datascience/__init__.py. Tests: 6/6 y 9/9 (sin red). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""Proyeccion PCA-2D + KMeans sobre el mismo subset, con puntos y labels alineados.
|
||||
|
||||
Estandariza una sola vez las columnas numericas (z-score), proyecta a 2D con PCA
|
||||
y clusteriza con KMeans sobre EXACTAMENTE la misma matriz escalada, de modo que
|
||||
la proyeccion 2D (`points`) y la etiqueta de cluster (`labels`) quedan alineadas
|
||||
fila a fila. Es la pieza que `pca_explained` + `kmeans_segments` no cubren: esas
|
||||
dos estandarizan por separado y descartan los labels, asi que sus salidas no se
|
||||
pueden cruzar para pintar un scatter PCA coloreado por cluster. Determinista.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from sklearn.cluster import KMeans
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.metrics import silhouette_score
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
|
||||
def project_clusters_2d(
|
||||
columns: dict,
|
||||
k_min: int = 2,
|
||||
k_max: int = 8,
|
||||
max_points: int = 2000,
|
||||
) -> dict:
|
||||
"""Proyecta a 2D (PCA) y clusteriza (KMeans) el mismo subset estandarizado.
|
||||
|
||||
PCA a 2D y KMeans se ajustan sobre la MISMA matriz estandarizada, por lo que
|
||||
`points` (proyeccion 2D) y `labels` (cluster por fila) quedan alineados por
|
||||
indice. El k se elige automaticamente por silhouette en el rango
|
||||
[k_min, min(k_max, n_rows-1)], igual criterio que `kmeans_segments`.
|
||||
Determinista: StandardScaler + PCA(random_state=0) + KMeans(random_state=0,
|
||||
n_init=10).
|
||||
|
||||
Args:
|
||||
columns: mapa {nombre_columna: [valores numericos]}. Listas alineadas por
|
||||
fila (misma longitud). Columnas no numericas o con menos de 2 valores
|
||||
distintos se descartan. None/NaN marcan filas a descartar listwise
|
||||
(una fila se elimina si cualquier feature falta).
|
||||
k_min: numero minimo de clusters a probar (default 2).
|
||||
k_max: numero maximo de clusters a probar (default 8). Se acota a
|
||||
min(k_max, n_rows_validas-1).
|
||||
max_points: tope de puntos devueltos en `points`/`labels`. Si las filas
|
||||
usadas superan este tope, se submuestrea points y labels CONJUNTAMENTE
|
||||
con paso determinista para mantenerlos alineados. El fit (best_k,
|
||||
silhouette, centroides, perfiles) usa SIEMPRE todas las filas.
|
||||
|
||||
Returns:
|
||||
dict con points (proyeccion 2D, posiblemente submuestreada a max_points),
|
||||
labels (cluster de cada point, alineado con points), centers_2d
|
||||
(centroides en espacio PCA, len == best_k), best_k, silhouette,
|
||||
explained_2d (varianza de PC1 y PC2), cluster_sizes (sobre n_used total),
|
||||
cluster_profiles (ver abajo), feature_names, n_used (filas del fit antes
|
||||
de muestreo) y note ("" si ok). Cada entrada de cluster_profiles:
|
||||
{cluster, size, pct, centroid_original (medias en escala original),
|
||||
centroid_z (z del centroide), distinctive (top 3 features por |z|)}.
|
||||
Con <2 columnas numericas o <max(3, k_min*2) filas validas devuelve
|
||||
best_k=0 y note "datos insuficientes" sin lanzar excepcion.
|
||||
"""
|
||||
feature_names: list[str] = []
|
||||
|
||||
def insufficient(names: list[str], n_used: int) -> dict:
|
||||
return {
|
||||
"best_k": 0,
|
||||
"points": [],
|
||||
"labels": [],
|
||||
"centers_2d": [],
|
||||
"cluster_profiles": [],
|
||||
"feature_names": names,
|
||||
"n_used": int(n_used),
|
||||
"note": "datos insuficientes",
|
||||
}
|
||||
|
||||
try:
|
||||
if not isinstance(columns, dict) or not columns:
|
||||
return insufficient([], 0)
|
||||
|
||||
# 1. Coerce a numerico, descartando columnas no parseables o constantes.
|
||||
numeric_cols: dict[str, list] = {}
|
||||
for name, values in columns.items():
|
||||
if not isinstance(values, (list, tuple)):
|
||||
continue
|
||||
coerced: list[float] = []
|
||||
usable = True
|
||||
for v in values:
|
||||
if v is None:
|
||||
coerced.append(math.nan)
|
||||
continue
|
||||
try:
|
||||
coerced.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
usable = False
|
||||
break
|
||||
if not usable:
|
||||
continue
|
||||
# Menos de 2 valores distintos no aporta varianza -> descartar.
|
||||
distinct = {x for x in coerced if not math.isnan(x)}
|
||||
if len(distinct) < 2:
|
||||
continue
|
||||
numeric_cols[name] = coerced
|
||||
|
||||
feature_names = list(numeric_cols.keys())
|
||||
if len(feature_names) < 2:
|
||||
return insufficient(feature_names, 0)
|
||||
|
||||
# 2. Matriz alineada por fila + listwise deletion (cualquier NaN -> fuera).
|
||||
matrix = np.array(
|
||||
[numeric_cols[n] for n in feature_names], dtype=float
|
||||
).T
|
||||
valid_mask = ~np.isnan(matrix).any(axis=1)
|
||||
data = matrix[valid_mask]
|
||||
|
||||
n_used = int(data.shape[0])
|
||||
min_rows = max(3, k_min * 2)
|
||||
if n_used < min_rows:
|
||||
return insufficient(feature_names, n_used)
|
||||
|
||||
# 3. Estandarizar UNA sola vez (guardamos el scaler para desestandarizar).
|
||||
scaler = StandardScaler()
|
||||
X_scaled = scaler.fit_transform(data)
|
||||
|
||||
# 4. PCA a 2D sobre la matriz escalada.
|
||||
pca = PCA(n_components=2, random_state=0)
|
||||
pca.fit(X_scaled)
|
||||
proj = pca.transform(X_scaled)
|
||||
|
||||
# 5. KMeans con seleccion automatica de k por silhouette (mismo X_scaled).
|
||||
upper_k = min(k_max, n_used - 1)
|
||||
if upper_k < k_min:
|
||||
return insufficient(feature_names, n_used)
|
||||
|
||||
best = None # (silhouette, k, model, labels)
|
||||
for k in range(k_min, upper_k + 1):
|
||||
model = KMeans(n_clusters=k, n_init=10, random_state=0)
|
||||
labels_k = model.fit_predict(X_scaled)
|
||||
if len(set(labels_k)) < 2:
|
||||
sil = -1.0
|
||||
else:
|
||||
sil = float(silhouette_score(X_scaled, labels_k))
|
||||
if best is None or sil > best[0]:
|
||||
best = (sil, k, model, labels_k)
|
||||
|
||||
best_sil, best_k, best_model, labels = best
|
||||
|
||||
# 6. Centroides KMeans (espacio escalado) proyectados al espacio PCA.
|
||||
centers_2d = pca.transform(best_model.cluster_centers_)
|
||||
|
||||
# 7. Perfiles por cluster sobre TODAS las filas usadas.
|
||||
centroids_original = scaler.inverse_transform(best_model.cluster_centers_)
|
||||
cluster_sizes: list[int] = []
|
||||
cluster_profiles: list[dict] = []
|
||||
for c in range(best_k):
|
||||
size = int(np.sum(labels == c))
|
||||
cluster_sizes.append(size)
|
||||
z_vec = best_model.cluster_centers_[c]
|
||||
orig_vec = centroids_original[c]
|
||||
centroid_z = {
|
||||
feature_names[j]: float(z_vec[j]) for j in range(len(feature_names))
|
||||
}
|
||||
centroid_original = {
|
||||
feature_names[j]: float(orig_vec[j])
|
||||
for j in range(len(feature_names))
|
||||
}
|
||||
order = np.argsort(np.abs(z_vec))[::-1]
|
||||
distinctive = [feature_names[int(j)] for j in order[:3]]
|
||||
cluster_profiles.append(
|
||||
{
|
||||
"cluster": int(c),
|
||||
"size": size,
|
||||
"pct": float(size / n_used) if n_used else 0.0,
|
||||
"centroid_original": centroid_original,
|
||||
"distinctive": distinctive,
|
||||
"centroid_z": centroid_z,
|
||||
}
|
||||
)
|
||||
|
||||
# 8. Muestreo determinista CONJUNTO de points + labels (mantiene alineacion).
|
||||
note = ""
|
||||
if n_used > max_points and max_points > 0:
|
||||
step = math.ceil(n_used / max_points)
|
||||
proj_out = proj[::step]
|
||||
labels_out = labels[::step]
|
||||
note = f"submuestreado a {len(proj_out)} de {n_used} puntos para visualizacion"
|
||||
else:
|
||||
proj_out = proj
|
||||
labels_out = labels
|
||||
|
||||
points = [[float(row[0]), float(row[1])] for row in proj_out]
|
||||
labels_list = [int(v) for v in labels_out]
|
||||
centers_list = [[float(row[0]), float(row[1])] for row in centers_2d]
|
||||
explained_2d = [float(x) for x in pca.explained_variance_ratio_]
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"labels": labels_list,
|
||||
"centers_2d": centers_list,
|
||||
"best_k": int(best_k),
|
||||
"silhouette": float(best_sil),
|
||||
"explained_2d": explained_2d,
|
||||
"cluster_sizes": cluster_sizes,
|
||||
"cluster_profiles": cluster_profiles,
|
||||
"feature_names": feature_names,
|
||||
"n_used": n_used,
|
||||
"note": note,
|
||||
}
|
||||
except Exception:
|
||||
# Lectura defensiva: nunca propagar excepciones al caller del EDA.
|
||||
return insufficient(feature_names, 0)
|
||||
Reference in New Issue
Block a user