fix(eda): bugs de bajo riesgo del benchmark (H1,H5,H12,H13,H14) + tests faltantes

- H1: render_eda_markdown ya no aplica doble x100 a outlier_pct (336% -> real)
- H5: profile_database filtra base_tables_only (excluye VIEWs; sakila 21->16)
- H12: suggest_reexpression salta columnas no-continuas
- H13: to_returns/profile_table elige retornos (financiera) vs diferencias (fisica)
- H14: test de regresion ATTACH sqlite via information_schema
- +8 tests de las funciones eda nuevas (acf_pacf, adf_kpss, ...). 77 tests verdes
- L/M (H2,H3,H4,H6,H7,H8,H9,H10,H11) quedan en issues 0174-0177 para revision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-06-29 03:51:11 +02:00
parent 7ac69ab4fb
commit caf8c25d99
17 changed files with 1145 additions and 31 deletions
@@ -151,9 +151,11 @@ def profile_database(
}
"""
try:
# 1) Resolver lista de tablas.
# 1) Resolver lista de tablas. Solo BASE TABLE: las VIEWs no son tablas
# reales — perfilarlas infla n_tables y multiplica las FK falsas (sus
# columnas son copias de las de las tablas base, con contención perfecta).
if tables is None:
lst = duckdb_list_tables(db_path)
lst = duckdb_list_tables(db_path, base_tables_only=True)
if lst.get("status") != "ok":
return {"status": "error", "error": lst.get("error", "list failed")}
tables = lst.get("tables", [])
@@ -78,6 +78,77 @@ def test_profile_database_two_related_tables():
assert res["report_json_path"] is None
def test_profile_database_excluye_views(tmp_path):
# Regresión H5: una VIEW no es una tabla real. profile_database debe perfilar
# solo las BASE TABLE y no contar las VIEWs (inflan n_tables y multiplican FK
# falsas, al ser copias de columnas de las tablas base).
db_path = os.path.join(str(tmp_path), "withviews.duckdb")
_build_related_db(db_path)
con = duckdb.connect(db_path)
con.execute("CREATE VIEW customers_v AS SELECT id, name FROM customers")
con.execute("CREATE VIEW orders_v AS SELECT order_id, total FROM orders")
con.close()
res = profile_database(db_path, write_report=False)
assert res["status"] == "ok", res
prof = res["db_profile"]
# Solo las 2 tablas base; las 2 views quedan fuera.
assert prof["n_tables"] == 2
profiled = {tp["table"] for tp in prof["table_profiles"]}
assert profiled == {"customers", "orders"}
assert "customers_v" not in profiled
assert "orders_v" not in profiled
def test_profile_database_attach_sqlite_no_usa_sqlite_master(tmp_path):
# Regresión H14: materializar una base SQLite vía ATTACH (information_schema,
# no sqlite_master) y perfilarla con profile_database sin que falle. Blinda el
# bug original 'sqlite_master does not exist'.
import sqlite3
sqlite_path = os.path.join(str(tmp_path), "shop.sqlite")
sconn = sqlite3.connect(sqlite_path)
sconn.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)")
sconn.execute("INSERT INTO customers VALUES (1,'Ana'),(2,'Luis'),(3,'Marta')")
sconn.execute(
"CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, total REAL)"
)
sconn.execute(
"INSERT INTO orders VALUES (10,1,99.5),(11,2,12.0),(12,3,7.25),(13,1,5.0)"
)
sconn.execute("CREATE VIEW big_orders AS SELECT * FROM orders WHERE total > 10")
sconn.commit()
sconn.close()
ddb_path = os.path.join(str(tmp_path), "shop_mat.duckdb")
con = duckdb.connect(ddb_path)
con.execute("INSTALL sqlite")
con.execute("LOAD sqlite")
con.execute(f"ATTACH '{sqlite_path}' AS src (TYPE sqlite)")
rows = con.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_catalog='src' AND table_type='BASE TABLE' "
"AND table_name NOT LIKE 'sqlite_%'"
).fetchall()
for (name,) in rows:
con.execute(f'CREATE TABLE "{name}" AS SELECT * FROM src."{name}"')
con.execute("DETACH src")
con.close()
res = profile_database(ddb_path, write_report=False)
assert res["status"] == "ok", res
prof = res["db_profile"]
# Solo las 2 tablas base materializadas (la VIEW no se materializó).
profiled = {tp["table"] for tp in prof["table_profiles"]}
assert profiled == {"customers", "orders"}
# FK orders.customer_id -> customers.id detectable.
assert any(
fk.get("from_table") == "orders" and fk.get("to_table") == "customers"
for fk in prof["fk_candidates"]
), prof["fk_candidates"]
def test_profile_database_writes_report(tmp_path):
db_path = os.path.join(str(tmp_path), "shop2.duckdb")
_build_related_db(db_path)
+77 -10
View File
@@ -57,6 +57,57 @@ _DATETIME_SEMANTIC = ("datetime_iso", "date_eu")
# promocion a numeric (evita promocionar columnas mayormente no parseables).
_PROMOTE_MIN_PARSE = 0.8
# Cardinalidad maxima (distinct_count) por debajo de la cual una columna numerica
# se trata como NO continua (binaria / ordinal de pocos niveles) y, por tanto, no
# es candidata a re-expresion de Tukey (la escalera de potencias no aplica a una
# variable con pocos niveles discretos).
_REEXPR_MIN_DISTINCT = 12
# Tokens en el nombre (o semantic_type currency) que sugieren que una serie de
# niveles es FINANCIERA (precios/volumen): en ese caso la transformacion adecuada
# son los retornos. Para magnitudes fisicas (temperatura, caudal) la transformacion
# correcta son las diferencias, no los retornos.
_FINANCIAL_TOKENS = (
"price", "close", "open", "high", "low", "volume", "adj", "vwap",
"bid", "ask", "return", "precio", "cierre", "apertura", "cotiz", "retorno",
)
def _is_continuous_for_reexpr(col: dict, vals_float: list) -> bool:
"""True si la columna numerica es continua y justifica sugerir re-expresion.
Se saltan (devuelve False):
- binarias / ordinales de baja cardinalidad (``distinct_count`` <= umbral):
la escalera de potencias de Tukey no tiene sentido sobre pocos niveles
discretos (p.ej. ``Survived`` 0/1, ``Pclass`` 1/2/3).
- identificadores enteros (flag ``possible_id`` y todos los valores enteros):
re-expresar un id (p.ej. ``PassengerId`` 1..n) no aporta nada.
Los floats continuos de alta cardinalidad (precios, medidas) NO se saltan
aunque lleven ``possible_id``, porque tienen parte decimal (no son enteros).
"""
dc = col.get("distinct_count")
if isinstance(dc, int) and not isinstance(dc, bool) and dc <= _REEXPR_MIN_DISTINCT:
return False
flags = col.get("flags") or []
if "possible_id" in flags and vals_float and all(
float(f).is_integer() for f in vals_float
):
return False
return True
def _looks_financial(col: dict) -> bool:
"""True si la columna parece una serie financiera (precio/volumen/divisa).
Heuristica por nombre (tokens OHLCV típicos) o ``semantic_type == currency``.
Decide si una serie de niveles se debe transformar a retornos (financiera) o a
diferencias (no financiera, p.ej. temperatura).
"""
name = (col.get("name") or "").lower()
if any(tok in name for tok in _FINANCIAL_TOKENS):
return True
return (col.get("semantic_type") or "").lower() == "currency"
def _to_float(value):
"""Parsea un valor a float limpiando simbolos de moneda y separadores.
@@ -175,8 +226,12 @@ def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int)
"stl": stl_decompose(series_vals),
}
# Sugerencia de retornos solo si la columna parece de niveles: estrictamente
# positiva y con veredicto de estacionariedad NO confirmado.
# Sugerencia de transformacion solo si la columna parece de niveles:
# estrictamente positiva y con veredicto de estacionariedad NO confirmado.
# La transformacion adecuada depende de la SEMANTICA: retornos para series
# financieras (precios/volumen), diferencias para magnitudes fisicas
# (temperatura, caudal). Aplicar "retornos" a una temperatura no tiene sentido
# fisico; la primera diferencia si la estaciona.
nb = col.get("numeric") or {}
minimum = nb.get("min")
verdict = (block["stationarity"] or {}).get("verdict")
@@ -186,13 +241,22 @@ def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int)
and minimum > 0
and verdict in ("non_stationary", "inconclusive")
):
block["to_returns"] = to_returns(series_vals, method="log")
block["levels_suggested"] = True
block["levels_reason"] = (
"columna estrictamente positiva y no claramente estacionaria: parece una "
"serie de niveles (precios); trabajar sobre retornos evita correlacion "
"espuria (Granger-Newbold)."
)
if _looks_financial(col):
block["levels_kind"] = "returns"
block["to_returns"] = to_returns(series_vals, method="log")
block["levels_reason"] = (
"columna financiera estrictamente positiva y no claramente "
"estacionaria (serie de niveles/precios): trabajar sobre retornos "
"evita correlacion espuria (Granger-Newbold)."
)
else:
block["levels_kind"] = "differences"
block["levels_reason"] = (
"serie de niveles no financiera y no claramente estacionaria: la "
"primera diferencia la estaciona; los retornos no tienen sentido en "
"magnitudes fisicas (p.ej. temperatura)."
)
else:
block["levels_suggested"] = False
@@ -296,8 +360,11 @@ def profile_table(
vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
col["numeric"] = describe_numeric(vals_float)
# Re-expresion sugerida (escalera de Tukey): que transformacion
# simetriza mejor la columna a partir de su skew/dominio.
col["reexpression"] = suggest_reexpression(col["numeric"])
# simetriza mejor la columna a partir de su skew/dominio. Solo para
# columnas CONTINUAS: no aplica a binarias/ordinales de baja
# cardinalidad ni a identificadores enteros (la fila seria ruido).
if _is_continuous_for_reexpr(col, vals_float):
col["reexpression"] = suggest_reexpression(col["numeric"])
elif inferred in ("categorical", "text"):
col["categorical"] = summarize_categorical(vals)
# Para columnas no promovidas que ya eran categorical/text y no
@@ -13,7 +13,112 @@ import tempfile
import duckdb
from pipelines.profile_table import profile_table
from pipelines.profile_table import (
_is_continuous_for_reexpr,
_looks_financial,
profile_table,
)
# --- H12: re-expresión solo para columnas continuas -------------------------
def test_is_continuous_for_reexpr_baja_cardinalidad():
# Binaria (2 niveles) y ordinal de baja cardinalidad (3 niveles): NO continuas.
binaria = {"distinct_count": 2, "flags": []}
ordinal = {"distinct_count": 3, "flags": []}
assert _is_continuous_for_reexpr(binaria, [0.0, 1.0, 0.0, 1.0]) is False
assert _is_continuous_for_reexpr(ordinal, [1.0, 2.0, 3.0, 2.0]) is False
def test_is_continuous_for_reexpr_id_entero():
# Identificador entero (possible_id + todos enteros): NO continua.
idcol = {"distinct_count": 200, "flags": ["possible_id"]}
vals = [float(i) for i in range(1, 201)]
assert _is_continuous_for_reexpr(idcol, vals) is False
def test_is_continuous_for_reexpr_float_continuo():
# Float continuo de alta cardinalidad, aunque lleve possible_id, SÍ es continuo
# (tiene parte decimal, no es un id entero).
precio = {"distinct_count": 200, "flags": ["possible_id"]}
vals = [i * 1.7 for i in range(200)]
assert _is_continuous_for_reexpr(precio, vals) is True
def test_reexpression_solo_para_columnas_continuas():
# En una tabla con binaria/ordinal/id/continua, solo la continua trae el bloque
# reexpression en su ColumnProfile.
tmp_dir = tempfile.mkdtemp(prefix="reexpr_test_")
db_path = os.path.join(tmp_dir, "t.duckdb")
con = duckdb.connect(db_path)
con.execute(
"CREATE TABLE t (pid INTEGER, surv INTEGER, pclass INTEGER, fare DOUBLE)"
)
con.execute(
"INSERT INTO t SELECT i, i%2, (i%3)+1, ((i*1.7)%50)+0.3 "
"FROM range(300) tbl(i)"
)
con.close()
r = profile_table(db_path, "t", write_report=False)
assert r["status"] == "ok", r
prof = r["profile"]
assert _col(prof, "pid").get("reexpression") is None # id entero
assert _col(prof, "surv").get("reexpression") is None # binaria
assert _col(prof, "pclass").get("reexpression") is None # ordinal baja card
assert _col(prof, "fare").get("reexpression") is not None # continua
# --- H13: retornos (financiera) vs diferencias (física) ---------------------
def test_looks_financial_por_nombre_y_semantic():
assert _looks_financial({"name": "Close"}) is True
assert _looks_financial({"name": "Adj Close"}) is True
assert _looks_financial({"name": "Volume"}) is True
assert _looks_financial({"name": "precio_cierre"}) is True
assert _looks_financial({"name": "temp_max"}) is False
assert _looks_financial({"name": "precipitation"}) is False
assert _looks_financial({"name": "caudal", "semantic_type": "currency"}) is True
def _make_series_db(value_col: str) -> str:
"""DuckDB con una serie de niveles no estacionaria (random walk creciente)."""
tmp_dir = tempfile.mkdtemp(prefix="series_test_")
db_path = os.path.join(tmp_dir, "s.duckdb")
con = duckdb.connect(db_path)
con.execute(f'CREATE TABLE s (ts INTEGER, "{value_col}" DOUBLE)')
# Niveles estrictamente positivos con tendencia creciente (no estacionaria).
level = 100.0
rows = []
for t in range(80):
level += 1.0 + (t % 7) * 0.3 # incrementos positivos deterministas
rows.append((t, level))
con.executemany(f'INSERT INTO s VALUES (?, ?)', rows)
con.close()
return db_path
def test_series_financiera_sugiere_retornos():
db_path = _make_series_db("close")
r = profile_table(db_path, "s", run_series=True, write_report=False)
assert r["status"] == "ok", r
s = _col(r["profile"], "close").get("series")
assert s is not None
if s.get("levels_suggested"):
assert s.get("levels_kind") == "returns"
def test_series_no_financiera_sugiere_diferencias():
db_path = _make_series_db("temp_max")
r = profile_table(db_path, "s", run_series=True, write_report=False)
assert r["status"] == "ok", r
s = _col(r["profile"], "temp_max").get("series")
assert s is not None
if s.get("levels_suggested"):
assert s.get("levels_kind") == "differences"
# Para diferencias no se computa el bloque de retornos.
assert "to_returns" not in s
def _make_db() -> str: