"""Orquesta los modelos baratos del grupo `eda` en un solo bloque. Compone las funciones puras de modelado del registry (PCA, KMeans, Isolation Forest, tests de normalidad) sobre el subconjunto de columnas numericas de un perfil de tabla y devuelve el bloque "models" canonico que consume el flag ``--models`` de ``profile_table``. No reescribe logica: delega en cada funcion del registry. Es pura y determinista (todas las dependencias lo son). """ from datascience import ( isolation_forest_outliers, kmeans_segments, normality_tests, pca_explained, ) def _pf(v): """Parsea un valor a float; devuelve None si es None/bool/no parseable.""" if v is None or isinstance(v, bool): return None try: return float(v) except (TypeError, ValueError): return None def _to_numeric_subset(columns: dict) -> dict: """Extrae las columnas numericas alineadas por fila (listwise deletion). Solo se quedan las columnas con ``type == "numeric"``. CLAVE: la alineacion por fila se preserva. Pasos: 1. Descarta columnas numericas con menos del 50% de valores parseables (evita que una columna casi-toda-nula tire todas las filas en el paso 3). 2. Sobre las columnas buenas, conserva SOLO las filas en las que TODAS tienen un valor numerico (listwise deletion). El resultado es un mapa {nombre: [float, ...]} donde todas las listas tienen la MISMA longitud (filas completas) — requisito de PCA/KMeans/IsolationForest (matriz rectangular sin NaN). El bug previo descartaba None por columna, dejando longitudes desiguales y reventando sklearn con ValueError. Args: columns: mapa {nombre_columna: {"values": list, "type": str}}; las listas llegan alineadas por fila (misma longitud, None donde no hay dato). Returns: dict {nombre_columna: [float, ...]} con columnas numericas de igual longitud. Vacio si no hay columnas numericas validas. """ if not isinstance(columns, dict): return {} raw: dict[str, list] = {} for name, meta in columns.items(): if not isinstance(meta, dict): continue if meta.get("type") != "numeric": continue values = meta.get("values") if isinstance(values, (list, tuple)): raw[name] = list(values) if not raw: return {} # Longitud comun (min, defensivo si llegaran desalineadas). n = min(len(v) for v in raw.values()) if n == 0: return {} # 1) Parsea por celda y descarta columnas con <50% de valores parseables. good: dict[str, list] = {} for name, values in raw.items(): parsed = [_pf(values[i]) for i in range(n)] if sum(1 for x in parsed if x is not None) >= 0.5 * n: good[name] = parsed if not good: return {} # 2) Listwise: conserva solo filas donde TODAS las columnas tienen valor. names = list(good.keys()) numeric: dict[str, list] = {name: [] for name in names} for i in range(n): if all(good[name][i] is not None for name in names): for name in names: numeric[name].append(good[name][i]) return numeric def run_eda_models( columns: dict, run_pca: bool = True, run_kmeans: bool = True, run_isolation: bool = True, run_normality: bool = True, ) -> dict: """Ejecuta los modelos baratos del grupo `eda` sobre las columnas numericas. Composicion canonica para el flag ``--models`` de ``profile_table``. Toma el mapa de columnas con el mismo shape que recibe ``association_matrix`` (cada columna con ``values`` y ``type``), extrae el subconjunto numerico, y corre los modelos pedidos sobre el. No reescribe ninguno: compone las funciones puras ``pca_explained``, ``kmeans_segments``, ``isolation_forest_outliers`` y ``normality_tests`` del registry. Los tests de normalidad se corren por columna numerica individual (basta 1 columna). PCA, KMeans e Isolation Forest son multivariantes y necesitan al menos 2 columnas numericas; con menos, sus claves quedan en None y se devuelve una ``note`` explicativa. No lanza excepciones. ``trend_slope`` NO se ejecuta aqui: requiere un orden temporal explicito y queda disponible suelto en el registry. Args: columns: mapa {nombre_columna: {"values": list, "type": str}}, mismo shape que recibe ``association_matrix``; listas alineadas por fila. run_pca: si True, ejecuta PCA sobre el subconjunto numerico. run_kmeans: si True, ejecuta KMeans con seleccion automatica de k. run_isolation: si True, ejecuta Isolation Forest multivariante. run_normality: si True, ejecuta tests de normalidad por columna. Returns: dict con: n_numeric_cols: numero de columnas numericas detectadas. pca: salida de pca_explained o None (si run_pca False / <2 cols). kmeans: salida de kmeans_segments o None (si run_kmeans False / <2). outliers: salida de isolation_forest_outliers o None. normality: {col: salida de normality_tests} o None (si run_normality False o no hay columnas numericas). note: descripcion de por que faltan los multivariantes, si aplica. Con menos de 2 columnas numericas devuelve los multivariantes en None y una ``note``; ``normality`` sigue poblandose si run_normality True y hay al menos 1 columna numerica. """ numeric = _to_numeric_subset(columns) n_numeric_cols = len(numeric) # normality es univariante: basta una columna numerica. normality = None if run_normality and n_numeric_cols >= 1: normality = {name: normality_tests(values) for name, values in numeric.items()} if n_numeric_cols < 2: return { "n_numeric_cols": n_numeric_cols, "pca": None, "kmeans": None, "outliers": None, "normality": normality, "note": "insuficientes columnas numericas para modelos multivariantes", } pca = pca_explained(numeric) if run_pca else None kmeans = kmeans_segments(numeric) if run_kmeans else None outliers = isolation_forest_outliers(numeric) if run_isolation else None return { "n_numeric_cols": n_numeric_cols, "pca": pca, "kmeans": kmeans, "outliers": outliers, "normality": normality, "note": "", }