"""Theil's U (uncertainty coefficient) direccional entre dos columnas categoricas. U(a|b) mide cuanta incertidumbre de `a` se elimina conociendo `b`, normalizado a [0, 1]. Es ASIMETRICO: theils_u(a, b) != theils_u(b, a) en general, lo que lo distingue de medidas simetricas como Cramer's V y permite detectar dependencias direccionales (p.ej. ciudad -> pais). """ import math from collections import Counter def _entropy(counts: list) -> float: """Entropia de Shannon (base natural) de una lista de conteos. Args: counts: conteos por categoria (enteros >= 0). Returns: entropia en nats; 0.0 si no hay observaciones. """ total = sum(counts) if total == 0: return 0.0 h = 0.0 for c in counts: if c > 0: p = c / total h -= p * math.log(p) return h def theils_u(a: list, b: list) -> float: """Theil's U direccional U(a|b): incertidumbre de `a` explicada por `b`. Calcula la fraccion de la entropia de la distribucion marginal de `a` que se elimina al condicionar sobre los valores de `b`. Es una medida de asociacion ASIMETRICA en [0, 1]: - U(a|b) = 1.0 -> conocer `b` determina por completo `a`. - U(a|b) = 0.0 -> `b` no aporta nada sobre `a` (independencia). Las entropias usan la misma base (logaritmo natural), por lo que la base se cancela en el cociente y el resultado es independiente de ella. Args: a: columna categorica objetivo (cuya incertidumbre se mide). b: columna categorica condicionante (el conocimiento que se aporta). Ambas listas se emparejan por indice; los pares con algun None se descartan antes de calcular. Returns: Theil's U(a|b) como float en [0.0, 1.0]. Devuelve 0.0 (nunca None ni excepcion) si hay menos de 2 pares validos o si H(a) == 0 (es decir, `a` ya es constante y no hay incertidumbre que eliminar). """ # Empareja por indice y descarta pares con algun None. pairs = [ (av, bv) for av, bv in zip(a, b) if av is not None and bv is not None ] if len(pairs) < 2: return 0.0 # H(a): entropia de la distribucion marginal de a. a_counts = Counter(av for av, _ in pairs) h_a = _entropy(list(a_counts.values())) if h_a == 0.0: return 0.0 # H(a|b) = suma_b p(b) * H(a | b=valor). by_b: dict = {} for av, bv in pairs: by_b.setdefault(bv, Counter())[av] += 1 total = len(pairs) h_a_given_b = 0.0 for bv, a_sub in by_b.items(): p_b = sum(a_sub.values()) / total h_a_given_b += p_b * _entropy(list(a_sub.values())) u = (h_a - h_a_given_b) / h_a # Clampa a [0, 1] para absorber errores de redondeo en coma flotante. if u < 0.0: return 0.0 if u > 1.0: return 1.0 return u