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>
This commit is contained in:
Egutierrez
2026-06-29 06:37:47 +02:00
parent c4cff5ed5b
commit e142ef026d
12 changed files with 1028 additions and 36 deletions
+201 -7
View File
@@ -26,7 +26,7 @@ Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
import json
import os
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from datascience import (
acf_pacf,
@@ -109,6 +109,104 @@ def _looks_financial(col: dict) -> bool:
return (col.get("semantic_type") or "").lower() == "currency"
def _to_ordinal_days(value) -> float | None:
"""Convierte un valor fecha/datetime/ISO-string a dias ordinales (float), o None.
Soporta los tipos que devuelve DuckDB para columnas DATE/TIMESTAMP
(``datetime.date`` / ``datetime.datetime``) y strings ISO. Devuelve None para
cualquier cosa que no parsee a una fecha (numeros sueltos, basura).
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, datetime):
return value.toordinal() + value.hour / 24.0 + value.minute / 1440.0
if isinstance(value, date):
return float(value.toordinal())
if isinstance(value, (int, float)):
return None # entero/float suelto: no es una fecha fiable
s = str(value).strip()
if not s:
return None
try:
return float(datetime.fromisoformat(s).toordinal())
except (TypeError, ValueError):
pass
try:
return float(date.fromisoformat(s[:10]).toordinal())
except (TypeError, ValueError):
return None
def _infer_period_from_dates(dates: list, n_series: int) -> int | None:
"""Deriva el periodo estacional de la FRECUENCIA del indice datetime.
En vez de adivinar el periodo por autocorrelacion cruda (que en series con
tendencia degenera a 2 y produce un falso negativo de estacionalidad), se mide
el delta mediano en dias entre observaciones consecutivas ordenadas y se mapea
a su periodo estacional dominante:
- diario (delta ~= 1 dia) -> 365 si hay >= 2 anios de datos; si no, 7.
- semanal (delta ~= 7 dias) -> 52.
- mensual (delta ~= 30 dias)-> 12.
- trimestral (delta ~= 91) -> 4.
Devuelve None si no hay fechas suficientes, si la frecuencia no encaja en un
patron conocido, o si la serie es demasiado corta para dos ciclos del periodo.
"""
ords = [d for d in (_to_ordinal_days(v) for v in dates) if d is not None]
if len(ords) < 3:
return None
ords.sort()
deltas = sorted(b - a for a, b in zip(ords[:-1], ords[1:]) if b - a > 0)
if not deltas:
return None
med = deltas[len(deltas) // 2] # delta mediano en dias
if med <= 2.0: # diario
if n_series >= 730: # >= 2 anios: estacionalidad anual
return 365
return 7 if n_series >= 14 else None
if 5.0 <= med <= 10.0: # semanal
return 52 if n_series >= 104 else None
if 25.0 <= med <= 35.0: # mensual
return 12 if n_series >= 24 else None
if 85.0 <= med <= 100.0: # trimestral
return 4 if n_series >= 8 else None
return None
def _is_sequential_id(col: dict) -> bool:
"""True si la columna numerica es un id ENTERO secuencial (indice de fila).
Distingue ``PassengerId`` (1..n, enteros densos, monotono) de un float continuo
de alta cardinalidad (precios): el id no debe entrar en correlacion ni en
PCA/KMeans (es ruido que infla pares espurios y distorsiona componentes); el
precio si. Criterio: flag ``possible_id`` + min/max enteros + rango denso (casi
todos los enteros del intervalo presentes). Un precio tiene parte decimal en
min/max, asi que NUNCA lo marca.
"""
if col.get("inferred_type") != "numeric":
return False
if "possible_id" not in (col.get("flags") or []):
return False
nb = col.get("numeric") or {}
mn, mx = nb.get("min"), nb.get("max")
if not (
isinstance(mn, (int, float))
and not isinstance(mn, bool)
and isinstance(mx, (int, float))
and not isinstance(mx, bool)
):
return False
if not (float(mn).is_integer() and float(mx).is_integer()):
return False # float continuo (precios): mantener
dc = col.get("distinct_count")
if isinstance(dc, int) and not isinstance(dc, bool) and dc > 1:
span = float(mx) - float(mn) + 1.0
if span > 0 and (dc / span) >= 0.95:
return True
return False
def _to_float(value):
"""Parsea un valor a float limpiando simbolos de moneda y separadores.
@@ -215,15 +313,30 @@ def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int)
if len(series_vals) < 8:
return None
# Periodo estacional derivado de la FRECUENCIA del indice datetime (mensual->12,
# diario->365/7), que es fiable, en vez de dejar que stl_decompose lo adivine por
# autocorrelacion (que en series con tendencia degenera a period=2 -> falso
# negativo de estacionalidad). Si no hay order_col datetime o la frecuencia no
# encaja, period queda None y stl_decompose lo infiere (ya con detrend) o avisa.
period = None
period_source = "autocorr"
if order_col:
raw_dates = _sample_series(query_fn, table, order_col, order_col, sample)
period = _infer_period_from_dates(raw_dates, len(series_vals))
if period is not None:
period_source = "datetime_freq"
block: dict = {
"order_col": order_col,
"ordered": bool(order_col),
"n": len(series_vals),
"stationarity": adf_kpss_stationarity(series_vals),
"acf_pacf": acf_pacf(series_vals),
# stl_decompose auto-infiere el periodo; si no hay estacionalidad detectable
# devuelve una nota y strengths None (se incluye igual, es informativo).
"stl": stl_decompose(series_vals),
# Periodo de la frecuencia del indice si se pudo derivar; si no, stl_decompose
# lo infiere por autocorrelacion del residuo detrended o devuelve una nota
# ("periodo no determinado") sin reportar seasonal_strength=0 como conclusion.
"period_source": period_source,
"stl": stl_decompose(series_vals, period=period),
}
# Sugerencia de transformacion solo si la columna parece de niveles:
@@ -423,9 +536,16 @@ def profile_table(
def _skip_for_assoc(c):
it = c.get("inferred_type")
flags = c.get("flags") or []
return it in ("categorical", "text") and (
# Categoricas/text id-like por cardinalidad ~ n.
if it in ("categorical", "text") and (
"possible_id" in flags or "high_cardinality" in flags
)
):
return True
# Id ENTERO secuencial numerico (PassengerId 1..n): indice de fila,
# genera pares espurios (correlation_ratio ~0.9 sig=si) y entra como
# feature ruidosa en PCA/KMeans. Los floats continuos (precios) NO
# se saltan aunque lleven possible_id.
return _is_sequential_id(c)
assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
rows = _sample_rows(
@@ -455,7 +575,30 @@ def profile_table(
# reales: un try/except compartido ponia ambos campos a None).
if run_models:
try:
prof["models"] = run_eda_models(assoc_input)
# Subconjunto de features para PCA/KMeans: solo numericas CONTINUAS.
# Se excluyen binarias/ordinales/target de baja cardinalidad (Survived
# 0/1, Pclass 1/2/3): como dimensiones del PCA/clustering anaden ruido
# y distorsionan componentes y centroides. Los id secuenciales ya
# quedaron fuera de assoc_input via _skip_for_assoc.
def _is_model_feature(cname):
c = next(
(x for x in assoc_cols if x.get("name") == cname), None
)
if c is None or c.get("inferred_type") != "numeric":
return False
dc = c.get("distinct_count")
if (
isinstance(dc, int)
and not isinstance(dc, bool)
and dc <= _REEXPR_MIN_DISTINCT
):
return False
return True
models_input = {
n: v for n, v in assoc_input.items() if _is_model_feature(n)
}
prof["models"] = run_eda_models(models_input)
except Exception: # noqa: BLE001
prof["models"] = None
@@ -501,6 +644,57 @@ def profile_table(
except Exception: # noqa: BLE001
prof["series"] = None
# 8.75) Marcar las correlaciones num-num calculadas sobre NIVELES de series
# no estacionarias (autocorreladas) como posible espuria (Granger-Newbold):
# Close-Open=0.998 es un artefacto de la raiz unitaria, no una relacion util.
# Para uso financiero lo correcto es correlacionar retornos (col["series"]
# ["to_returns"]). Si corrio run_series se usa el veredicto ADF/KPSS por
# columna; si no, una heuristica financiera por nombre + dominio positivo.
try:
corr = prof.get("correlations")
if isinstance(corr, dict):
levels_cols: set = set()
series_map = prof.get("series") or {}
if series_map:
for cname, sb in series_map.items():
if not isinstance(sb, dict):
continue
verdict = (sb.get("stationarity") or {}).get("verdict")
if sb.get("levels_suggested") or verdict in (
"non_stationary",
"inconclusive",
):
levels_cols.add(cname)
else:
for c in cols:
if c.get("inferred_type") == "numeric" and _looks_financial(c):
mn = (c.get("numeric") or {}).get("min")
if (
isinstance(mn, (int, float))
and not isinstance(mn, bool)
and mn > 0
):
levels_cols.add(c.get("name"))
n_marked = 0
for pair in corr.get("pairs", []):
if (
pair.get("method") == "pearson/spearman"
and pair.get("a") in levels_cols
and pair.get("b") in levels_cols
):
pair["levels_possible_spurious"] = True
n_marked += 1
if n_marked:
corr["levels_caveat"] = (
f"{n_marked} par(es) de correlacion se calculan sobre NIVELES "
"de series no estacionarias (autocorreladas): la correlacion "
"puede ser espuria (Granger-Newbold). Para uso financiero, "
"correlacionar sobre retornos/diferencias (ver el bloque "
"series de cada columna)."
)
except Exception: # noqa: BLE001
pass
# 8.8) Avisos exploratorios: recuerdan que el EDA genera hipotesis, no
# conclusiones. Se calculan sobre el perfil ya completo (correlaciones,
# modelos, outliers, faltantes determinan que advertencias aplican).