feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
@@ -0,0 +1,210 @@
"""Matriz de asociacion unificada para una tabla con columnas de tipos mezclados.
Funcion pura del grupo eda. Para cada par de columnas elige la metrica de
asociacion adecuada al par de tipos (Pearson/Spearman para num-num, Cramer's V
para cat-cat, correlation ratio para num-cat) y, ademas, calcula informacion
mutua normalizada como medida comun no-lineal para todos los pares. Devuelve la
lista de pares evaluados, el subconjunto de pares fuertes y una leyenda de los
metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
"""
import math
from datascience import (
correlation_ratio,
cramers_v,
mutual_info_columns,
pearson,
spearman_corr,
theils_u,
)
# Tipos que, para efectos de asociacion, se tratan como categoricos.
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
def _is_num(v) -> bool:
"""True si v es un numero real (int/float) que no es bool ni NaN."""
return (
isinstance(v, (int, float))
and not isinstance(v, bool)
and not (isinstance(v, float) and math.isnan(v))
)
def _is_numeric_type(t: str) -> bool:
return t == "numeric"
def _valid_count(values: list, numeric: bool) -> int:
"""Numero de valores validos: numericos finitos si numeric, no-None si cat."""
if numeric:
return sum(1 for v in values if _is_num(v))
return sum(1 for v in values if v is not None)
def _cardinality(values: list) -> int:
"""Numero de valores distintos no-None."""
return len({v for v in values if v is not None})
def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
"""Empareja por indice y conserva solo pares con ambos lados numericos."""
cx: list[float] = []
cy: list[float] = []
for x, y in zip(xs, ys):
if _is_num(x) and _is_num(y):
cx.append(float(x))
cy.append(float(y))
return cx, cy
def association_matrix(
columns: dict,
strong_threshold: float = 0.5,
top_n: int = 20,
) -> dict:
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
Para cada par de columnas (i < j) selecciona la metrica adecuada al par de
tipos y calcula tambien informacion mutua normalizada como medida comun:
- num-num: `pearson` (lineal) y `spearman_corr` (monotonica). El `value`
principal es el de mayor valor absoluto; ambos se guardan en `extra`.
- cat-cat: `cramers_v` (simetrica) como `value`; `theils_u` en ambas
direcciones en `extra` (u_ab = U(a|b), u_ba = U(b|a)).
- num-cat: `correlation_ratio(categorias, valores)` como `value`.
- Todos los pares: `mutual_info_columns` normalizada en `extra["mi"]`.
Se saltan los pares donde alguna columna tenga menos de 3 valores validos o
sea de tipo `text` con cardinalidad cercana al numero de filas (ruido sin
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
columna (devuelve `pairs=[]`, `strong=[]`).
Args:
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
es uno de "numeric", "categorical", "datetime", "boolean", "text".
Los tipos datetime/boolean/text se tratan como categoricos.
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
abs(value) >= umbral o extra["mi"] >= umbral.
top_n: numero maximo de pares fuertes a devolver, ordenados por
relevancia (max(abs(value), mi)) descendente.
Returns:
dict con claves:
pairs: lista de todos los pares evaluados, cada uno
{a, b, a_type, b_type, method, value, extra}.
strong: subconjunto de pairs por encima del umbral, ordenado por
relevancia descendente y truncado a top_n.
methods_legend: dict {metodo: descripcion}.
"""
legend = {
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
"spearman": "num-num monotonica (Spearman rho), robusta a outliers, [-1, 1]",
"cramers_v": "cat-cat simetrica (Cramer's V, sesgo-corregido), [0, 1]",
"theils_u": "cat-cat direccional (Theil's U), incertidumbre explicada, [0, 1]",
"correlation_ratio": "num-cat (eta), varianza numerica explicada por la categoria, [0, 1]",
"mutual_info": "general no-lineal (NMI normalizada) para cualquier par de tipos, [0, 1]",
}
names = list(columns.keys())
if len(names) < 2:
return {"pairs": [], "strong": [], "methods_legend": legend}
n_rows = max(
(len(columns[name].get("values", [])) for name in names),
default=0,
)
def _skip(name: str) -> bool:
"""True si la columna no aporta asociacion util (pocos validos o text ruidoso)."""
col = columns[name]
vals = col.get("values", [])
ctype = col.get("type", "categorical")
numeric = _is_numeric_type(ctype)
if _valid_count(vals, numeric) < 3:
return True
# Texto de cardinalidad ~ n: identificadores/free-text, sin asociacion util.
if ctype == "text" and n_rows > 0 and _cardinality(vals) >= 0.9 * n_rows:
return True
return False
pairs: list[dict] = []
for i in range(len(names)):
a_name = names[i]
if _skip(a_name):
continue
a_col = columns[a_name]
a_vals = a_col.get("values", [])
a_type = a_col.get("type", "categorical")
a_numeric = _is_numeric_type(a_type)
for j in range(i + 1, len(names)):
b_name = names[j]
if _skip(b_name):
continue
b_col = columns[b_name]
b_vals = b_col.get("values", [])
b_type = b_col.get("type", "categorical")
b_numeric = _is_numeric_type(b_type)
extra: dict = {}
# Medida comun no-lineal para todos los pares.
mi = mutual_info_columns(
a_vals,
b_vals,
a_numeric=a_numeric,
b_numeric=b_numeric,
normalized=True,
)
extra["mi"] = mi
if a_numeric and b_numeric:
method = "pearson/spearman"
cx, cy = _clean_numeric_pairs(a_vals, b_vals)
p = pearson(cx, cy)
s = spearman_corr(a_vals, b_vals)
extra["pearson"] = p
extra["spearman"] = s
value = p if abs(p) >= abs(s) else s
elif (not a_numeric) and (not b_numeric):
method = "cramers_v"
value = cramers_v(a_vals, b_vals)
extra["u_ab"] = theils_u(a_vals, b_vals)
extra["u_ba"] = theils_u(b_vals, a_vals)
else:
method = "correlation_ratio"
if a_numeric:
# a numerica, b categorica.
value = correlation_ratio(b_vals, a_vals)
else:
# a categorica, b numerica.
value = correlation_ratio(a_vals, b_vals)
pairs.append(
{
"a": a_name,
"b": b_name,
"a_type": a_type,
"b_type": b_type,
"method": method,
"value": value,
"extra": extra,
}
)
def _relevance(pair: dict) -> float:
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
strong = [
pair
for pair in pairs
if abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
]
strong.sort(key=_relevance, reverse=True)
strong = strong[:top_n]
return {"pairs": pairs, "strong": strong, "methods_legend": legend}