feat(eda): series temporales + rigor anti-data-mining + PDF movil + /eda + benchmark issues
Bloque del grupo eda (sesion ausente EDA-benchmark): - 8 funciones nuevas: adf_kpss_stationarity, acf_pacf, stl_decompose, to_returns, fdr_correction, suggest_reexpression, exploratory_caveats, render_eda_pdf - integracion: profile_table (run_series, emit_pdf), association_matrix (FDR Benjamini-Hochberg), render_eda_markdown (secciones series/reexpresion/caveats) - slash commands /eda y /capitulos - issues 0173-0177: mejoras del /eda derivadas del benchmark sobre 12 datasets reales (outlier_pct x100, periodo estacional, FK inference, render models, tipos id-like) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,16 +29,23 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datascience import (
|
||||
acf_pacf,
|
||||
adf_kpss_stationarity,
|
||||
association_matrix,
|
||||
column_quality_score,
|
||||
describe_numeric,
|
||||
eda_llm_insights,
|
||||
exploratory_caveats,
|
||||
infer_semantic_type,
|
||||
render_eda_markdown,
|
||||
render_eda_pdf,
|
||||
run_eda_models,
|
||||
stl_decompose,
|
||||
suggest_reexpression,
|
||||
summarize_categorical,
|
||||
summarize_table_duckdb,
|
||||
summarize_table_pg,
|
||||
to_returns,
|
||||
)
|
||||
from infra import duckdb_query_readonly, pg_query
|
||||
|
||||
@@ -115,6 +122,83 @@ def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
|
||||
return q.get("rows", [])
|
||||
|
||||
|
||||
def _sample_series(query_fn, table: str, value_col: str, order_col, sample: int) -> list:
|
||||
"""Trae hasta `sample` valores no nulos de una columna en orden de serie temporal.
|
||||
|
||||
A diferencia de _sample_values, cuando hay una columna de orden temporal
|
||||
(`order_col`, normalmente la primera columna datetime de la tabla) se ordena
|
||||
ascendentemente por ella para que la secuencia recuperada respete el orden
|
||||
cronologico, requisito de los contrastes de serie temporal (ADF/KPSS, ACF/PACF,
|
||||
STL). Si `order_col` es None se cae al orden fisico de inserciones (columna
|
||||
numerica secuencial). query_fn es el lector read-only del backend activo.
|
||||
"""
|
||||
base = (
|
||||
f'SELECT "{value_col}" AS v FROM "{table}" '
|
||||
f'WHERE "{value_col}" IS NOT NULL'
|
||||
)
|
||||
if order_col:
|
||||
base += f' ORDER BY "{order_col}"'
|
||||
base += f" LIMIT {int(sample)}"
|
||||
q = query_fn(base)
|
||||
if q.get("status") != "ok":
|
||||
return []
|
||||
return [row.get("v") for row in q.get("rows", [])]
|
||||
|
||||
|
||||
def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int) -> dict:
|
||||
"""Construye el bloque `series` de una columna numerica (estilo dict-no-throw).
|
||||
|
||||
Compone los contrastes de serie temporal del grupo `eda` sobre la secuencia
|
||||
ordenada de la columna: estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF +
|
||||
Ljung-Box) y descomposicion STL (tendencia/estacional/resto). Cuando la columna
|
||||
parece de NIVELES (precios: estrictamente positiva y no claramente estacionaria)
|
||||
anade ademas la conversion a retornos (`to_returns`) como sugerencia, ya que
|
||||
correlacionar/modelar niveles no estacionarios produce relaciones espurias
|
||||
(Granger-Newbold).
|
||||
|
||||
Devuelve None si no hay suficientes puntos validos (<8) para ningun contraste.
|
||||
"""
|
||||
name = col.get("name")
|
||||
raw = _sample_series(query_fn, table, name, order_col, sample)
|
||||
series_vals = [f for f in (_to_float(v) for v in raw) if f is not None]
|
||||
if len(series_vals) < 8:
|
||||
return None
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
# Sugerencia de retornos solo si la columna parece de niveles: estrictamente
|
||||
# positiva y con veredicto de estacionariedad NO confirmado.
|
||||
nb = col.get("numeric") or {}
|
||||
minimum = nb.get("min")
|
||||
verdict = (block["stationarity"] or {}).get("verdict")
|
||||
if (
|
||||
isinstance(minimum, (int, float))
|
||||
and not isinstance(minimum, bool)
|
||||
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)."
|
||||
)
|
||||
else:
|
||||
block["levels_suggested"] = False
|
||||
|
||||
return block
|
||||
|
||||
|
||||
def profile_table(
|
||||
db_path: str,
|
||||
table: str,
|
||||
@@ -122,6 +206,8 @@ def profile_table(
|
||||
sample: int = 5000,
|
||||
run_models: bool = False,
|
||||
run_llm: bool = False,
|
||||
run_series: bool = False,
|
||||
emit_pdf: bool = False,
|
||||
report_dir: str = "reports",
|
||||
write_report: bool = True,
|
||||
) -> dict:
|
||||
@@ -135,6 +221,20 @@ def profile_table(
|
||||
sample: maximo de valores no nulos muestreados por columna para el
|
||||
enriquecimiento (describe_numeric / summarize_categorical /
|
||||
infer_semantic_type). Default 5000.
|
||||
run_models: si True (default False) corre los modelos baratos
|
||||
(PCA/KMeans/IsolationForest/normalidad) sobre las numericas y guarda
|
||||
el bloque en prof["models"].
|
||||
run_llm: si True (default False) hace 1 llamada LLM sobre el perfil
|
||||
agregado y guarda el resultado en prof["llm"].
|
||||
run_series: si True (default False) calcula, para cada columna numerica,
|
||||
un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF,
|
||||
descomposicion STL y, si parece de niveles, conversion a retornos).
|
||||
Si hay una columna datetime se usa como orden cronologico; si no, se
|
||||
usa el orden fisico de filas (columna numerica secuencial). Los bloques
|
||||
se guardan por columna en col["series"] y agregados en prof["series"].
|
||||
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
|
||||
(legible en movil) del perfil junto al report markdown y devuelve su
|
||||
ruta en pdf_path.
|
||||
report_dir: directorio donde escribir los reports si write_report.
|
||||
Default "reports". Se crea si no existe.
|
||||
write_report: si True (default), escribe un report markdown + un JSON
|
||||
@@ -143,8 +243,8 @@ def profile_table(
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', profile: <TableProfile>,
|
||||
report_md_path: str|None, report_json_path: str|None}. En error (sin
|
||||
lanzar): {status:'error', error:str}.
|
||||
report_md_path: str|None, report_json_path: str|None, pdf_path: str|None}.
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
# 1) Perfil base por columna (push-down SQL) + lector read-only del
|
||||
@@ -195,6 +295,9 @@ def profile_table(
|
||||
if inferred == "numeric":
|
||||
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"])
|
||||
elif inferred in ("categorical", "text"):
|
||||
col["categorical"] = summarize_categorical(vals)
|
||||
# Para columnas no promovidas que ya eran categorical/text y no
|
||||
@@ -299,12 +402,53 @@ def profile_table(
|
||||
except Exception: # noqa: BLE001
|
||||
prof["llm"] = None
|
||||
|
||||
# 9) Reports opcionales.
|
||||
# 8.7) Analisis de serie temporal opt-in. Para cada columna numerica se
|
||||
# calcula estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF) y
|
||||
# descomposicion STL sobre la secuencia ordenada; si parece de niveles se
|
||||
# anade la conversion a retornos. Si hay una columna datetime se usa como
|
||||
# orden cronologico; si no, el orden fisico (columna numerica secuencial).
|
||||
if run_series:
|
||||
try:
|
||||
order_col = next(
|
||||
(
|
||||
c.get("name")
|
||||
for c in cols
|
||||
if c.get("inferred_type") == "datetime"
|
||||
),
|
||||
None,
|
||||
)
|
||||
series_map: dict = {}
|
||||
for col in cols:
|
||||
if col.get("inferred_type") != "numeric":
|
||||
continue
|
||||
try:
|
||||
sblock = _build_series_block(
|
||||
_q, table, col, order_col, sample
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
sblock = None
|
||||
if sblock is not None:
|
||||
col["series"] = sblock
|
||||
series_map[col["name"]] = sblock
|
||||
prof["series"] = series_map or None
|
||||
except Exception: # noqa: BLE001
|
||||
prof["series"] = None
|
||||
|
||||
# 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).
|
||||
try:
|
||||
prof["caveats"] = exploratory_caveats(prof)
|
||||
except Exception: # noqa: BLE001
|
||||
prof["caveats"] = None
|
||||
|
||||
# 9) Reports opcionales (markdown + JSON sidecar + PDF movil).
|
||||
report_md_path = None
|
||||
report_json_path = None
|
||||
pdf_path = None
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
if write_report:
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
report_json_path = os.path.join(report_dir, f"eda_{table}_{ts}.json")
|
||||
report_md_path = os.path.join(report_dir, f"eda_{table}_{ts}.md")
|
||||
with open(report_json_path, "w", encoding="utf-8") as fh:
|
||||
@@ -312,11 +456,22 @@ def profile_table(
|
||||
with open(report_md_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(render_eda_markdown(prof))
|
||||
|
||||
# PDF multipagina vertical (legible en movil), junto al report markdown.
|
||||
if emit_pdf:
|
||||
try:
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
pdf_target = os.path.join(report_dir, f"eda_{table}_{ts}.pdf")
|
||||
pres = render_eda_pdf(prof, pdf_target)
|
||||
pdf_path = pres.get("pdf_path")
|
||||
except Exception: # noqa: BLE001
|
||||
pdf_path = None
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"profile": prof,
|
||||
"report_md_path": report_md_path,
|
||||
"report_json_path": report_json_path,
|
||||
"pdf_path": pdf_path,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
Reference in New Issue
Block a user