"""PCA rapido sobre columnas numericas para revelar estructura latente. Estandariza las columnas (z-score), descarta filas con valores faltantes y ajusta un PCA determinista para ver cuanta varianza concentran pocos componentes. Pensado para exploracion de datos (EDA) barata. """ import math def pca_explained(columns: dict, n_components: int = 2) -> dict: """Ejecuta PCA sobre columnas numericas y resume la varianza explicada. Args: columns: mapa {nombre_columna: [valores numericos]}. Las listas estan alineadas por fila (misma longitud). Las columnas no numericas o con menos de dos valores distintos se descartan. n_components: numero maximo de componentes principales a calcular. Se acota a min(n_features, n_filas_validas). Returns: dict con: n_components: numero de componentes realmente calculados. n_rows_used: filas validas usadas (sin None/NaN). n_features: columnas numericas usadas. explained_variance_ratio: varianza explicada por componente. cumulative: varianza acumulada componente a componente. top_loadings: cargas mas grandes (en valor absoluto) por componente. projection: proyeccion de las filas (cap a 1000 filas). Si hay menos de 2 columnas numericas o menos de 3 filas validas, devuelve {n_components: 0, explained_variance_ratio: [], note: "datos insuficientes"} sin lanzar excepcion. """ import numpy as np from sklearn.decomposition import PCA from sklearn.preprocessing import StandardScaler insufficient = { "n_components": 0, "explained_variance_ratio": [], "note": "datos insuficientes", } if not isinstance(columns, dict) or not columns: return insufficient # Quedarnos solo con columnas que se puedan interpretar como numericas. numeric_cols: dict[str, list] = {} for name, values in columns.items(): if not isinstance(values, (list, tuple)): continue coerced = [] usable = True for v in values: if v is None: coerced.append(math.nan) continue try: f = float(v) except (TypeError, ValueError): usable = False break coerced.append(f) if usable: numeric_cols[name] = coerced if len(numeric_cols) < 2: return insufficient feature_names = list(numeric_cols.keys()) matrix = np.array([numeric_cols[n] for n in feature_names], dtype=float).T # Descartar filas con cualquier NaN (incluye los None convertidos). valid_mask = ~np.isnan(matrix).any(axis=1) data = matrix[valid_mask] if data.shape[0] < 3: return insufficient n_rows_used = int(data.shape[0]) n_features = int(data.shape[1]) k = min(n_components, n_features, n_rows_used) if k < 1: return insufficient scaled = StandardScaler().fit_transform(data) pca = PCA(n_components=k, random_state=0) proj = pca.fit_transform(scaled) evr = [float(x) for x in pca.explained_variance_ratio_] cumulative = [] running = 0.0 for x in evr: running += x cumulative.append(float(running)) # Cargas: una fila por componente, una columna por feature. top_loadings = [] for comp_idx, comp in enumerate(pca.components_): order = np.argsort(np.abs(comp))[::-1] for feat_idx in order: top_loadings.append( { "component": int(comp_idx), "feature": feature_names[int(feat_idx)], "loading": float(comp[int(feat_idx)]), } ) projection = [[float(v) for v in row] for row in proj[:1000]] return { "n_components": int(k), "n_rows_used": n_rows_used, "n_features": n_features, "explained_variance_ratio": evr, "cumulative": cumulative, "top_loadings": top_loadings, "projection": projection, }