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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user