"""Informacion mutua entre dos columnas pareadas (relaciones lineales y no lineales). Funcion pura del grupo eda. Mide la dependencia estadistica general entre dos columnas (numericas, categoricas o mezcla), capturando relaciones de cualquier forma -- no solo lineales como Pearson. Es la metrica "general" de la matriz de asociacion: complementa a `pearson` (solo lineal num-num) y `cramers_v` (solo cat-cat). """ import math from collections import Counter import numpy as np from sklearn.metrics import mutual_info_score def _discretize(values: list, numeric: bool, bins: int) -> list: """Discretiza una columna a etiquetas enteras. Columnas numericas -> `bins` cubos por cuantiles (np.digitize sobre los bordes de cuantil). Columnas categoricas -> factorizacion valor->id. """ if numeric: arr = np.asarray(values, dtype=float) # Bordes interiores por cuantiles (excluye 0 y 1 para usar digitize). qs = np.linspace(0.0, 1.0, bins + 1)[1:-1] if qs.size == 0: # bins <= 1 -> todo cae en un unico cubo. return [0] * len(arr) edges = np.quantile(arr, qs) # Bordes unicos: cuantiles repetidos (columnas con poca variacion) # colapsan en menos cubos, lo cual es correcto (menos entropia). edges = np.unique(edges) return list(np.digitize(arr, edges)) # Categorica: mapa valor -> id entero, en orden de aparicion. ids: dict = {} out = [] for v in values: if v not in ids: ids[v] = len(ids) out.append(ids[v]) return out def _entropy(labels: list) -> float: """Entropia de Shannon (nats) de una secuencia de etiquetas.""" n = len(labels) if n == 0: return 0.0 h = 0.0 for c in Counter(labels).values(): p = c / n h -= p * math.log(p) return h def mutual_info_columns( a: list, b: list, a_numeric: bool = False, b_numeric: bool = False, bins: int = 10, normalized: bool = True, ) -> float: """Informacion mutua entre dos columnas pareadas posicion a posicion. Empareja `a` y `b`, descarta los pares donde cualquiera de los dos sea None, discretiza cada columna (numericas por cuantiles, categoricas por factorizacion) y calcula la informacion mutua. Captura relaciones de cualquier forma (lineal o no, num-num, cat-cat, num-cat). Args: a: lista de valores de la primera columna (None se descarta). b: lista de valores pareada con `a` (mismo criterio). a_numeric: si True, `a` se discretiza en `bins` cuantiles; si False se factoriza como categorica. b_numeric: idem para `b`. bins: numero de cubos por cuantiles para columnas numericas. normalized: si True devuelve la NMI = MI / sqrt(H(a)*H(b)) en [0, 1] (1 = dependencia total). Si False devuelve la MI cruda en nats. Returns: float. NMI en [0, 1] si normalized; MI en nats (>= 0) si no. Devuelve 0.0 si hay menos de 2 pares validos o si alguna columna discretizada tiene entropia 0 (constante) bajo normalized. Nunca None ni excepcion. """ pairs = [ (x, y) for x, y in zip(a, b) if x is not None and y is not None ] if len(pairs) < 2: return 0.0 a_vals = [x for x, _ in pairs] b_vals = [y for _, y in pairs] a_disc = _discretize(a_vals, a_numeric, bins) b_disc = _discretize(b_vals, b_numeric, bins) mi = float(mutual_info_score(a_disc, b_disc)) if not normalized: return max(0.0, mi) ha = _entropy(a_disc) hb = _entropy(b_disc) if ha <= 0.0 or hb <= 0.0: # Alguna columna es constante -> no hay informacion compartida medible. return 0.0 nmi = mi / math.sqrt(ha * hb) # Clampa a [0, 1] por seguridad numerica. return max(0.0, min(1.0, nmi))