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).
|
||||
|
||||
@@ -14,7 +14,9 @@ import tempfile
|
||||
import duckdb
|
||||
|
||||
from pipelines.profile_table import (
|
||||
_infer_period_from_dates,
|
||||
_is_continuous_for_reexpr,
|
||||
_is_sequential_id,
|
||||
_looks_financial,
|
||||
profile_table,
|
||||
)
|
||||
@@ -121,6 +123,142 @@ def test_series_no_financiera_sugiere_diferencias():
|
||||
assert "to_returns" not in s
|
||||
|
||||
|
||||
# --- H2: periodo estacional derivado de la frecuencia del indice datetime ---
|
||||
|
||||
def test_infer_period_from_dates_mensual_y_diario():
|
||||
from datetime import date as _date, timedelta
|
||||
|
||||
# Mensual (delta ~30 dias) con 72 puntos -> periodo 12.
|
||||
mensual = [_date(2000 + i // 12, i % 12 + 1, 1) for i in range(72)]
|
||||
assert _infer_period_from_dates(mensual, n_series=72) == 12
|
||||
|
||||
# Diario con >= 2 anios de datos -> estacionalidad anual (365).
|
||||
diario = [_date(2010, 1, 1) + timedelta(days=i) for i in range(800)]
|
||||
assert _infer_period_from_dates(diario, n_series=800) == 365
|
||||
|
||||
# Diario corto (< 2 anios) -> cae a semanal (7).
|
||||
diario_corto = [_date(2010, 1, 1) + timedelta(days=i) for i in range(100)]
|
||||
assert _infer_period_from_dates(diario_corto, n_series=100) == 7
|
||||
|
||||
# Sin fechas validas -> None (stl_decompose infiere o avisa).
|
||||
assert _infer_period_from_dates(["x", None, 3], n_series=50) is None
|
||||
|
||||
|
||||
def test_h2_periodo_de_frecuencia_datetime_end_to_end():
|
||||
import math
|
||||
from datetime import date as _date
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="h2_period_test_")
|
||||
db_path = os.path.join(tmp_dir, "m.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute("CREATE TABLE m (d DATE, v DOUBLE)")
|
||||
rows = []
|
||||
for i in range(72): # 6 anios mensual con estacionalidad de periodo 12
|
||||
dt = _date(2000 + i // 12, i % 12 + 1, 1)
|
||||
v = 10.0 + 0.1 * i + 5.0 * math.sin(2 * math.pi * (i % 12) / 12)
|
||||
rows.append((dt, v))
|
||||
con.executemany("INSERT INTO m VALUES (?, ?)", rows)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "m", run_series=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
s = _col(r["profile"], "v").get("series") or {}
|
||||
assert s.get("period_source") == "datetime_freq"
|
||||
stl = s.get("stl") or {}
|
||||
assert stl.get("period") == 12
|
||||
# Estacionalidad sinusoidal clara -> fuerza estacional alta (antes salia ~0).
|
||||
assert (stl.get("seasonal_strength") or 0) > 0.3
|
||||
|
||||
|
||||
# --- H7: id entero secuencial fuera de correlacion y de PCA/KMeans -----------
|
||||
|
||||
def test_is_sequential_id_distingue_id_de_precio():
|
||||
# Id entero secuencial denso (1..n): True.
|
||||
idcol = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": ["possible_id"],
|
||||
"distinct_count": 300,
|
||||
"numeric": {"min": 1.0, "max": 300.0},
|
||||
}
|
||||
assert _is_sequential_id(idcol) is True
|
||||
# Float continuo de alta cardinalidad (precios): min/max con decimales -> False.
|
||||
precio = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": ["possible_id"],
|
||||
"distinct_count": 300,
|
||||
"numeric": {"min": 24.35, "max": 189.7},
|
||||
}
|
||||
assert _is_sequential_id(precio) is False
|
||||
# Entero disperso (anios): no es indice denso -> False.
|
||||
disperso = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": ["possible_id"],
|
||||
"distinct_count": 3,
|
||||
"numeric": {"min": 1990.0, "max": 2010.0},
|
||||
}
|
||||
assert _is_sequential_id(disperso) is False
|
||||
# Sin flag possible_id -> nunca id secuencial.
|
||||
sin_flag = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": [],
|
||||
"distinct_count": 300,
|
||||
"numeric": {"min": 1.0, "max": 300.0},
|
||||
}
|
||||
assert _is_sequential_id(sin_flag) is False
|
||||
|
||||
|
||||
def test_h7_id_secuencial_fuera_de_correlacion_y_modelos():
|
||||
tmp_dir = tempfile.mkdtemp(prefix="h7_id_test_")
|
||||
db_path = os.path.join(tmp_dir, "t.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute("CREATE TABLE t (rid INTEGER, age DOUBLE, fare DOUBLE)")
|
||||
# rid 0..299: indice de fila (id secuencial). age/fare: floats continuos.
|
||||
con.execute(
|
||||
"INSERT INTO t SELECT i, ((i*0.13)%80)+1.5, ((i*1.7)%50)+0.3 "
|
||||
"FROM range(300) tbl(i)"
|
||||
)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "t", run_models=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
prof = r["profile"]
|
||||
|
||||
# rid (id secuencial) no entra en correlaciones fuertes.
|
||||
strong = (prof.get("correlations") or {}).get("strong", [])
|
||||
assert not any("rid" in (p["a"], p["b"]) for p in strong)
|
||||
|
||||
# rid no entra como feature de los modelos (normality solo sobre continuas).
|
||||
norm = (prof.get("models") or {}).get("normality") or {}
|
||||
assert "rid" not in norm
|
||||
# age/fare (continuas) SI se mantienen como features.
|
||||
assert "age" in norm and "fare" in norm
|
||||
|
||||
|
||||
# --- H8: correlacion sobre niveles no estacionarios marcada espuria ----------
|
||||
|
||||
def test_h8_correlacion_niveles_marcada_posible_espuria():
|
||||
tmp_dir = tempfile.mkdtemp(prefix="h8_levels_test_")
|
||||
db_path = os.path.join(tmp_dir, "s.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute('CREATE TABLE s (ts INTEGER, "close" DOUBLE, "open" DOUBLE)')
|
||||
rows = []
|
||||
level = 100.0
|
||||
for t in range(90): # niveles crecientes (no estacionarios), close~open
|
||||
level += 1.0 + (t % 5) * 0.4
|
||||
rows.append((t, level, level - 0.5))
|
||||
con.executemany("INSERT INTO s VALUES (?, ?, ?)", rows)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "s", run_series=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
corr = r["profile"].get("correlations") or {}
|
||||
co = [p for p in corr.get("pairs", []) if {p["a"], p["b"]} == {"close", "open"}]
|
||||
assert co, "par close-open no encontrado"
|
||||
# Ambas son series financieras de niveles no estacionarias -> par marcado.
|
||||
assert co[0].get("levels_possible_spurious") is True
|
||||
assert "levels_caveat" in corr
|
||||
|
||||
|
||||
def _make_db() -> str:
|
||||
"""Crea una DuckDB temporal con la tabla de prueba y devuelve su path."""
|
||||
tmp_dir = tempfile.mkdtemp(prefix="profile_table_test_")
|
||||
|
||||
Reference in New Issue
Block a user