Files
fn_registry/python/functions/datascience/association_matrix.py
T
Egutierrez e142ef026d fix(eda): hallazgos de comportamiento del benchmark (H2,H3,H6,H7,H8,H10,H11)
Ronda 4 (verificada con re-corrida sobre los datasets afectados):
- H2: stl_decompose deriva periodo de la frecuencia del indice (seattle period=365
  seasonal_strength=0.84; fin del period=2 espurio)
- H3+H10: infer_fk por senal de nombre (<X>Id->X.<X>Id) + excluir no-clave -> chinook
  111->9 FK, todas reales, cero absurdas, 16-27x mas rapido; base intacta (flag off->111)
- H6: association no computa eta2 si cardinalidad~=n (Ticket-Fare espurio fuera)
- H7: id secuencial monotono excluido de correlacion y PCA/KMeans (PassengerId fuera)
- H8: correlacion de series no estacionarias marcada espuria / sobre retornos
- H11: distribution_type usa modos/cardinalidad/normalidad (quality->discrete)
- 66 tests verdes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:37:47 +02:00

380 lines
15 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 collections import Counter, defaultdict
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
from datascience import (
correlation_ratio,
cramers_v,
mutual_info_columns,
pearson,
spearman_corr,
theils_u,
)
# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su
# __init__ (lo integra el orquestador al cerrar el grupo eda).
from datascience.fdr_correction import fdr_correction
# 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 _safe_pvalue(value) -> float | None:
"""Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido."""
if value is None:
return None
try:
pv = float(value)
except (TypeError, ValueError):
return None
if math.isnan(pv) or math.isinf(pv):
return None
return pv
def _pearson_pvalue(cx: list, cy: list) -> float | None:
"""p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado."""
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
return None
try:
return _safe_pvalue(pearsonr(cx, cy).pvalue)
except Exception:
return None
def _spearman_pvalue(cx: list, cy: list) -> float | None:
"""p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado."""
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
return None
try:
return _safe_pvalue(spearmanr(cx, cy).pvalue)
except Exception:
return None
def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None:
"""p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado."""
pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None]
if len(pairs) < 2:
return None
rows = sorted({x for x, _ in pairs}, key=repr)
cols = sorted({y for _, y in pairs}, key=repr)
if len(rows) < 2 or len(cols) < 2:
return None
row_idx = {v: i for i, v in enumerate(rows)}
col_idx = {v: j for j, v in enumerate(cols)}
counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
table = [
[counts.get((i, j), 0) for j in range(len(cols))]
for i in range(len(rows))
]
try:
return _safe_pvalue(chi2_contingency(table).pvalue)
except Exception:
return None
def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None:
"""p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado."""
groups: dict = defaultdict(list)
for c, x in zip(cat_vals, num_vals):
if c is None or not _is_num(x):
continue
groups[c].append(float(x))
valid = [g for g in groups.values() if len(g) >= 2]
if len(valid) < 2:
return None
try:
return _safe_pvalue(f_oneway(*valid).pvalue)
except Exception:
return None
def association_matrix(
columns: dict,
strong_threshold: float = 0.5,
top_n: int = 20,
alpha: float = 0.05,
fdr_method: str = "bh",
) -> 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=[]`).
Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor
del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de
correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio:
ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan
todos los pares a la vez, esos p-valores se corrigen por comparaciones
multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el
subconjunto `strong` se basa en la **significancia corregida**, no solo en
superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado
> alpha NO entra en `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]. Condicion de magnitud para ser
"fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero
ya no suficiente (ver alpha).
top_n: numero maximo de pares fuertes a devolver, ordenados por
relevancia (max(abs(value), mi)) descendente.
alpha: nivel de significancia tras la correccion FDR (default 0.05). Un
par con p-valor disponible solo es fuerte si ademas su p-valor
ajustado <= alpha.
fdr_method: metodo de correccion de comparaciones multiples,
"bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER).
Returns:
dict con claves:
pairs: lista de todos los pares evaluados, cada uno
{a, b, a_type, b_type, method, value, extra, p_value,
p_value_adjusted, significant}. `p_value` es el del test del
metodo principal (None si no aplica / degenerado);
`p_value_adjusted` el p-valor tras FDR; `significant` True si
p_value_adjusted <= alpha.
strong: subconjunto de pairs que cumplen magnitud >= umbral Y son
significativos tras la correccion (los pares sin test disponible
se admiten por magnitud), ordenado por relevancia descendente y
truncado a top_n.
methods_legend: dict {metodo: descripcion}.
n_tests: numero total de pares evaluados (== len(pairs)).
multiple_testing: dict {method, alpha, n_tests, n_rejected} con el
resumen de la correccion (n_tests aqui = p-valores validos
corregidos, puede ser < len(pairs) si algun par no tiene test).
"""
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, datetime o cat casi-unica)."""
col = columns[name]
vals = col.get("values", [])
ctype = col.get("type", "categorical")
numeric = _is_numeric_type(ctype)
nvalid = _valid_count(vals, numeric)
if nvalid < 3:
return True
if numeric:
return False
# Datetime: indice temporal unico-ish por fila. Como categorica da
# correlation_ratio (eta) ~= 1 trivial frente a cualquier numerica (cada
# fecha es su propio grupo de un solo valor) y Cramer's V / MI inflados.
# La estacionalidad/tendencia se analizan en el bloque de series, no aqui.
if ctype == "datetime":
return True
# Grupos casi singleton: si el tamano medio de grupo (valores presentes /
# cardinalidad) es < 1.5, la varianza intra-grupo ~= 0 y correlation_ratio
# sale ~= 1 por artefacto determinista (no por azar: el FDR no protege).
# Cubre ids/free-text (Ticket: 681 distintos sobre 891) y categoricas
# dispersas con muchos nulos (Cabin: 147 distintos sobre 204 presentes).
# Se mide sobre valores PRESENTES, no sobre n_rows, para captar las dispersas.
card = _cardinality(vals)
if card >= 2 and (nvalid / card) < 1.5:
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
pearson_p = _pearson_pvalue(cx, cy)
spearman_p = _spearman_pvalue(cx, cy)
extra["pearson_p"] = pearson_p
extra["spearman_p"] = spearman_p
if abs(p) >= abs(s):
value = p
p_value = pearson_p
else:
value = s
p_value = spearman_p
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)
p_value = _chi2_pvalue(a_vals, b_vals)
else:
method = "correlation_ratio"
if a_numeric:
# a numerica, b categorica.
value = correlation_ratio(b_vals, a_vals)
p_value = _anova_pvalue(b_vals, a_vals)
else:
# a categorica, b numerica.
value = correlation_ratio(a_vals, b_vals)
p_value = _anova_pvalue(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,
"p_value": p_value,
}
)
# Correccion de comparaciones multiples sobre los p-valores disponibles.
# Se pasa la lista completa (incluidos los None de pares sin test): la
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
fdr = fdr_correction(
[pair["p_value"] for pair in pairs],
alpha=alpha,
method=fdr_method,
)
for pair, padj, rej in zip(
pairs, fdr["p_values_adjusted"], fdr["reject"]
):
pair["p_value_adjusted"] = padj
pair["significant"] = bool(rej)
def _relevance(pair: dict) -> float:
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
def _is_strong(pair: dict) -> bool:
# Condicion 1: magnitud por encima del umbral (necesaria).
magnitude_ok = (
abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
)
if not magnitude_ok:
return False
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
# admiten por magnitud, ya que no hay p-valor que corregir.
if pair["p_value"] is None:
return True
return pair["significant"]
strong = [pair for pair in pairs if _is_strong(pair)]
strong.sort(key=_relevance, reverse=True)
strong = strong[:top_n]
return {
"pairs": pairs,
"strong": strong,
"methods_legend": legend,
"n_tests": len(pairs),
"multiple_testing": {
"method": fdr_method,
"alpha": alpha,
"n_tests": fdr["n_tests"],
"n_rejected": fdr["n_rejected"],
},
}