4de071f2f9
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>
209 lines
8.5 KiB
Python
209 lines
8.5 KiB
Python
"""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)
|