763e06c127
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
7.6 KiB
Python
211 lines
7.6 KiB
Python
"""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}
|