54a9ab70c7
Permite renderizar un SUBCONJUNTO de capítulos del informe AutomaticEDA (only_chapters=[...]) para iterar/testear un capítulo concreto sin generar el documento entero, garantizando que el capítulo pedido SIEMPRE llegue poblado. - Nuevo módulo automatic_eda/chapter_deps.py: mapa central CHAPTER_DEPS (fuente de verdad) que declara, por capítulo de CHAPTER_ORDER, qué flags de cómputo (run_models/run_series/run_llm) y qué piezas de ctx (raw_numeric, timeseries_raw, geo_points, head_rows, db_path/table) necesita para no salir degradado. Helpers puros: resolve_requirements, resolve_profile_flags, needs_render_ctx, resolve_ctx_data_keys, validate_chapter_ids. - build_document(profile, ctx, only=None): parámetro only opcional que restringe el cuerpo a esos capítulos (portada primera + glosario última siempre). Lee la clave reservada ctx['_only_chapters'] cuando only es None, para propagar la selección a través de los renderers sin modificarlos. Retrocompatible. - render_automatic_eda(..., only_chapters=None): valida los ids (error claro dict-no-throw), resuelve las dependencias activando el cómputo necesario aunque el caller no lo pidiera (un flag explícito siempre prima) y construyendo solo las piezas de ctx que los capítulos pedidos leen (salta build_eda_render_ctx entero si ninguno necesita datos crudos). only_chapters=None produce el documento completo idéntico al de hoy. - Tests: chapter_deps_test.py (resolución pura), build_document_only_test.py (filtro), render_automatic_eda_only_test.py (golden con DuckDB: outliers suelto con IsolationForest poblado por resolución; timeseries activa run_series; eficiencia geospatial sin modelos; edge cases). - .md del pipeline: documenta only_chapters + emit_md; version 1.1.0 -> 1.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
382 lines
20 KiB
Python
382 lines
20 KiB
Python
"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX + MD.
|
|
|
|
Pipeline impuro del grupo de capacidad `eda`. Dada UNA tabla DuckDB (o
|
|
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus tres formatos a la
|
|
vez (PDF móvil A5 + PPTX 16:9 + Markdown autocontenido para pegar a un LLM) con
|
|
los capítulos POBLADOS, en una sola llamada. Compone, sin reimplementar su
|
|
lógica, varias funciones del registry:
|
|
|
|
- profile_table : perfila la tabla end-to-end (TableProfile agregado),
|
|
opcionalmente con modelos baratos y análisis de serie.
|
|
- build_eda_render_ctx : construye el `ctx` con los DATOS CRUDOS que el
|
|
TableProfile agregado no incluye (raw_numeric para
|
|
modelos/geo, timeseries_raw para series, geo_points
|
|
para el mapa, db_path/table para la agregación
|
|
push-down). Sin él, esos capítulos degradan.
|
|
- render_automatic_eda_pdf : renderiza el documento por capítulos a PDF.
|
|
- render_automatic_eda_pptx : renderiza el mismo documento a PPTX.
|
|
- render_automatic_eda_markdown : serializa el mismo documento a Markdown
|
|
autocontenido (texto + tablas markdown, sin
|
|
binarios) para incorporar a un LLM.
|
|
|
|
El TableProfile agregado basta para portada/overview/distribuciones/calidad/
|
|
correlación, pero los capítulos `modelos`, `timeseries`, `geospatial` y
|
|
`agregacion` necesitan datos crudos (los clusters proyectados sobre el PCA, la
|
|
serie valor-vs-tiempo, los puntos lat/lon, las filas para el groupby/pivot).
|
|
`build_eda_render_ctx` los muestrea (LIMIT + push-down, sin traer la tabla
|
|
entera a RAM) y los entrega en `ctx`; este pipeline los pasa como `meta['ctx']`
|
|
a ambos renderers para que el informe salga completo.
|
|
|
|
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
|
|
degrada a `{"status": "error", "error": str}`.
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
from datascience import (
|
|
build_eda_render_ctx,
|
|
render_automatic_eda_markdown,
|
|
render_automatic_eda_pdf,
|
|
render_automatic_eda_pptx,
|
|
run_eda_models,
|
|
)
|
|
from pipelines.profile_table import profile_table
|
|
|
|
# Tokens de almacenamiento por backend (para la portada del informe).
|
|
_STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"}
|
|
|
|
# Presets de consumo CPU/LLM: cada profile_level fija SOLO los DEFAULTS de los
|
|
# flags que controlan el coste (un flag explícito del caller siempre prima sobre
|
|
# el preset). model_opts != None marca el camino "modelos baratos" (lite): los
|
|
# modelos NO los corre profile_table (que ejecutaría KMeans + IsolationForest),
|
|
# sino run_eda_models con esa granularidad, de modo que el coste CPU de los
|
|
# multivariantes nunca se paga. model_opts None => modelos completos como hasta
|
|
# ahora (profile_table los corre con todos los algoritmos).
|
|
_PROFILE_PRESETS = {
|
|
# Bajo consumo: sin LLM, sin serie, sample reducido y modelos limitados a
|
|
# PCA + normalidad (sin KMeans ni IsolationForest, lo caro en CPU). Vistazo
|
|
# rápido y barato de una tabla.
|
|
"lite": {
|
|
"run_models": True,
|
|
"run_series": False,
|
|
"run_llm": False,
|
|
"sample": 2000,
|
|
"model_opts": {"run_kmeans": False, "run_isolation": False},
|
|
},
|
|
# Default: idéntico al comportamiento histórico del pipeline (modelos
|
|
# completos, serie temporal, sin LLM, sample 5000).
|
|
"standard": {
|
|
"run_models": True,
|
|
"run_series": True,
|
|
"run_llm": False,
|
|
"sample": 5000,
|
|
"model_opts": None,
|
|
},
|
|
# Máximo: standard + narrativa LLM (interpretación del perfil y de los
|
|
# capítulos modelos/geospatial/agregacion). Es la única parte que gasta
|
|
# tokens del modelo.
|
|
"full": {
|
|
"run_models": True,
|
|
"run_series": True,
|
|
"run_llm": True,
|
|
"sample": 5000,
|
|
"model_opts": None,
|
|
},
|
|
}
|
|
|
|
|
|
def render_automatic_eda(
|
|
db_path: str,
|
|
table: str,
|
|
backend: str = "duckdb",
|
|
sample: int = None,
|
|
run_models: bool = None,
|
|
run_series: bool = None,
|
|
run_llm: bool = None,
|
|
profile_level: str = "standard",
|
|
out_dir: str = "reports",
|
|
basename: str = None,
|
|
ctx_extra: dict = None,
|
|
emit_md: bool = True,
|
|
only_chapters: list = None,
|
|
) -> dict:
|
|
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
|
|
|
|
Args:
|
|
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
|
|
table: nombre de la tabla a perfilar.
|
|
backend: "duckdb" (default) o "postgres".
|
|
sample: máximo de filas/valores muestreados por columna para el perfil
|
|
y para los datos crudos del ctx (LIMIT). Default None => lo fija el
|
|
preset de profile_level (lite=2000, standard/full=5000).
|
|
run_models: corre los modelos baratos
|
|
(PCA/KMeans/IsolationForest/normalidad). Necesario para que el
|
|
capítulo `modelos` pinte los clusters sobre el plano PCA. Default
|
|
None => lo fija el preset (True en los tres niveles); en `lite` los
|
|
modelos se limitan a PCA + normalidad (ver profile_level).
|
|
run_series: calcula el análisis de serie temporal por
|
|
columna numérica. Necesario para el análisis del capítulo
|
|
`timeseries` (la gráfica de evolución sale de los datos crudos del
|
|
ctx aunque run_series sea False). Default None => lo fija el preset
|
|
(standard/full=True, lite=False).
|
|
run_llm: hace la interpretación LLM del perfil y
|
|
ACTIVA además la narrativa LLM de los capítulos modelos/geospatial/
|
|
agregacion (títulos de segmento, descripción de la zona, selección de
|
|
agregaciones). Con False esos capítulos usan su derivación
|
|
cuantitativa (siguen completos, sin llamadas de red). Default None =>
|
|
lo fija el preset (full=True, lite/standard=False).
|
|
profile_level: preset de consumo CPU/LLM. Mapea a defaults de los flags
|
|
anteriores; un flag explícito SIEMPRE prima sobre el preset (el
|
|
preset solo fija el default cuando el flag se deja en None):
|
|
|
|
- "lite" bajo consumo: run_llm=False, run_series=False,
|
|
sample=2000 y modelos limitados a **PCA + normalidad** (SIN KMeans
|
|
ni IsolationForest, que es lo caro en CPU). Pensado para un vistazo
|
|
rápido y barato. El capítulo `modelos` sale con PCA + normalidad,
|
|
sin el scatter de clusters.
|
|
- "standard" (default): comportamiento histórico — modelos completos
|
|
(PCA/KMeans/IsolationForest/normalidad), serie temporal, sin LLM.
|
|
- "full" standard + narrativa LLM (run_llm=True).
|
|
|
|
Ejemplo de precedencia: profile_level="lite" con run_llm=True
|
|
explícito => el LLM SÍ se ejecuta (el flag explícito gana al preset).
|
|
out_dir: directorio de salida (se crea si no existe). Default "reports".
|
|
basename: nombre base de los archivos sin extensión. Default
|
|
"aeda_<table>_<timestamp>".
|
|
ctx_extra: dict opcional con claves de presentación/contexto extra que se
|
|
mezclan en el ctx (p.ej. dataset_name, description, source_origin).
|
|
No pisan las claves de datos calculadas por build_eda_render_ctx.
|
|
emit_md: además del PDF y el PPTX, emite un Markdown autocontenido del
|
|
MISMO documento por capítulos (texto plano + tablas markdown, sin
|
|
binarios), pensado para pegar a un LLM. Default True. La ruta sale en
|
|
la clave de retorno ``aeda_md_path``. No altera las demás salidas.
|
|
only_chapters: lista opcional de ids de capítulo a renderizar (un
|
|
SUBCONJUNTO de CHAPTER_ORDER) para iterar/testear un capítulo concreto
|
|
sin generar el documento entero. Default None => documento COMPLETO,
|
|
idéntico al de hoy (retrocompatible). Cuando se pasa una lista:
|
|
|
|
- Se VALIDA contra CHAPTER_ORDER; un id desconocido devuelve un error
|
|
claro listando los válidos (dict-no-throw, no lanza). Lista vacía
|
|
``[]`` también devuelve error (pasa al menos un capítulo o None).
|
|
- Se RESUELVEN las dependencias de cómputo de esos capítulos
|
|
(``automatic_eda.chapter_deps``): se activan los flags de coste que
|
|
necesiten (run_models / run_series / run_llm) AUNQUE el caller no
|
|
los pidiera, y se construyen SOLO las piezas de ``ctx`` que esos
|
|
capítulos leen. Así un capítulo suelto SIEMPRE llega poblado —
|
|
p.ej. ``only_chapters=['outliers']`` activa run_models y conserva
|
|
``ctx['raw_numeric']`` para que el bloque IsolationForest salga
|
|
completo— y a la vez no se malgasta CPU/LLM en lo que ningún
|
|
capítulo pedido usa (pedir solo ``geospatial`` no corre modelos).
|
|
- El documento (PDF/PPTX/MD) y su manifest contienen SOLO esos
|
|
capítulos, MÁS la portada (primera) y el glosario (última), que se
|
|
incluyen siempre para que el documento sea válido y los términos
|
|
clicables tengan destino.
|
|
- Un flag explícito del caller (run_models/run_series/run_llm != None)
|
|
SIEMPRE prima sobre lo que resuelvan las dependencias.
|
|
|
|
Returns:
|
|
dict (nunca lanza). En éxito::
|
|
|
|
{"status": "ok", "pdf_path": str, "pptx_path": str,
|
|
"aeda_md_path": str|None, "manifest_path": str|None,
|
|
"n_pages": int, "n_slides": int, "md_chars": int|None,
|
|
"pdf_note": str, "pptx_note": str, "md_note": str|None,
|
|
"profile": <TableProfile>}
|
|
|
|
En error: {"status": "error", "error": str}.
|
|
"""
|
|
try:
|
|
# 0) Resolución del preset: el profile_level fija los DEFAULTS de los
|
|
# flags de coste; cualquier flag que el caller haya pasado explícito
|
|
# (!= None) prima sobre el preset. Un profile_level desconocido cae a
|
|
# "standard" (comportamiento histórico), sin lanzar.
|
|
preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"])
|
|
sample = preset["sample"] if sample is None else sample
|
|
model_opts = preset["model_opts"]
|
|
|
|
# 0.bis) Modo "capítulos sueltos": valida la selección y RESUELVE sus
|
|
# dependencias de cómputo. Es lo que garantiza que un capítulo pedido
|
|
# llegue completo (activa lo que necesita) sin malgastar en lo que no.
|
|
# Cuando only_chapters es None se conserva el camino histórico (preset).
|
|
if only_chapters is not None:
|
|
from datascience.automatic_eda import CHAPTER_ORDER
|
|
from datascience.automatic_eda.chapter_deps import (
|
|
needs_render_ctx,
|
|
resolve_ctx_data_keys,
|
|
resolve_requirements,
|
|
validate_chapter_ids,
|
|
)
|
|
|
|
if not isinstance(only_chapters, (list, tuple)):
|
|
return {"status": "error",
|
|
"error": "only_chapters debe ser una lista de ids de "
|
|
"capítulo o None (documento completo)."}
|
|
only_chapters = [c for c in only_chapters]
|
|
if not only_chapters:
|
|
return {"status": "error",
|
|
"error": "only_chapters=[] está vacío. Pasa al menos un "
|
|
"capítulo, o None para el documento completo. "
|
|
"Capítulos válidos: " + ", ".join(CHAPTER_ORDER)}
|
|
checked = validate_chapter_ids(only_chapters, CHAPTER_ORDER)
|
|
if checked["unknown"]:
|
|
return {"status": "error",
|
|
"error": "only_chapters con ids desconocidos: "
|
|
+ ", ".join(checked["unknown"])
|
|
+ ". Capítulos válidos: "
|
|
+ ", ".join(CHAPTER_ORDER)}
|
|
only_chapters = checked["valid"]
|
|
|
|
# Las dependencias fijan el DEFAULT de cada flag de coste (eficiencia:
|
|
# lo que ningún capítulo pedido necesita queda en False); un flag
|
|
# explícito del caller (!= None) sigue primando.
|
|
dep_flags = resolve_requirements(only_chapters)["profile_flags"]
|
|
run_models = ("run_models" in dep_flags) if run_models is None else run_models
|
|
run_series = ("run_series" in dep_flags) if run_series is None else run_series
|
|
run_llm = ("run_llm" in dep_flags) if run_llm is None else run_llm
|
|
# En capítulos sueltos no se usa el camino "modelos baratos" (lite),
|
|
# que poda ctx['raw_numeric']: un capítulo como outliers lo necesita
|
|
# para su multivariante en vivo. El preset solo gobierna `sample`.
|
|
model_opts = None
|
|
else:
|
|
run_models = preset["run_models"] if run_models is None else run_models
|
|
run_series = preset["run_series"] if run_series is None else run_series
|
|
run_llm = preset["run_llm"] if run_llm is None else run_llm
|
|
|
|
# En el camino "modelos baratos" (lite) profile_table NO corre los
|
|
# modelos: los ejecuta este pipeline con run_eda_models y la granularidad
|
|
# del preset, evitando pagar el coste CPU de KMeans + IsolationForest.
|
|
# En standard/full profile_table los corre completos como siempre.
|
|
lite_models = bool(run_models) and model_opts is not None
|
|
pt_run_models = bool(run_models) and not lite_models
|
|
|
|
# 1) Perfil base + modelos/serie opcionales. No escribe report propio
|
|
# (write_report=False): este pipeline emite su propio par PDF/PPTX.
|
|
pres = profile_table(
|
|
db_path,
|
|
table,
|
|
backend=backend,
|
|
sample=sample,
|
|
run_models=pt_run_models,
|
|
run_llm=run_llm,
|
|
run_series=run_series,
|
|
emit_pdf=False,
|
|
write_report=False,
|
|
)
|
|
if pres.get("status") != "ok":
|
|
return {"status": "error",
|
|
"error": f"profile_table falló: {pres.get('error')}"}
|
|
prof = pres.get("profile") or {}
|
|
|
|
# 2) Contexto de presentación + datos crudos para los 4 capítulos que los
|
|
# necesitan. Las claves de presentación van en base_ctx; build_eda_render_ctx
|
|
# añade raw_numeric / timeseries_raw / geo_points / db_path / table.
|
|
base_ctx = {
|
|
"dataset_name": table,
|
|
"source_origin": db_path,
|
|
"storage": _STORAGE.get(backend, backend),
|
|
}
|
|
if run_llm:
|
|
# Activa la narrativa LLM de los capítulos que la soportan.
|
|
base_ctx.update({
|
|
"run_cluster_llm": True,
|
|
"run_geo_llm": True,
|
|
"run_agg_llm": True,
|
|
})
|
|
if ctx_extra:
|
|
base_ctx.update(ctx_extra)
|
|
|
|
# En modo capítulos sueltos, si NINGÚN capítulo pedido necesita datos
|
|
# crudos del ctx, se salta build_eda_render_ctx por completo (ahorro real
|
|
# de I/O): solo se conservan presentación + db_path/table. Si sí los
|
|
# necesita, se construye el ctx y luego se PODAN las piezas de datos que
|
|
# ningún capítulo pedido usa (db_path/table nunca se podan).
|
|
if only_chapters is not None and not needs_render_ctx(only_chapters):
|
|
ctx = dict(base_ctx)
|
|
ctx["db_path"] = db_path
|
|
ctx["table"] = table
|
|
else:
|
|
ctx = build_eda_render_ctx(
|
|
db_path, table, prof, backend=backend, sample=sample,
|
|
base_ctx=base_ctx,
|
|
)
|
|
if only_chapters is not None and isinstance(ctx, dict):
|
|
keep = resolve_ctx_data_keys(only_chapters)
|
|
for k in ("head_rows", "raw_numeric", "timeseries_raw", "geo_points"):
|
|
if k not in keep:
|
|
ctx.pop(k, None)
|
|
|
|
# 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
|
|
# IsolationForest). profile_table no corrió los modelos; aquí se corren
|
|
# con run_eda_models reusando la muestra numérica alineada por fila que
|
|
# build_eda_render_ctx ya trajo en ctx['raw_numeric'] (no se reimplementa
|
|
# la lógica de los modelos: se delega en run_eda_models con la
|
|
# granularidad del preset).
|
|
if lite_models:
|
|
raw_numeric = ctx.get("raw_numeric") if isinstance(ctx, dict) else None
|
|
if isinstance(raw_numeric, dict) and raw_numeric:
|
|
model_input = {
|
|
col: {"values": vals, "type": "numeric"}
|
|
for col, vals in raw_numeric.items()
|
|
}
|
|
prof["models"] = run_eda_models(model_input, **model_opts)
|
|
# Quita raw_numeric del ctx para que el capítulo `modelos` NO
|
|
# reproyecte clusters KMeans en vivo (project_clusters_2d ejecuta
|
|
# KMeans): en lite ese coste se evita. geo_points ya quedó derivado
|
|
# en ctx por build_eda_render_ctx, así que el capítulo geospatial no
|
|
# se ve afectado.
|
|
if isinstance(ctx, dict):
|
|
ctx.pop("raw_numeric", None)
|
|
|
|
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
|
|
# En modo capítulos sueltos, la selección viaja a build_document por una
|
|
# clave reservada del ctx (los renderers llaman build_document sin pasar
|
|
# `only`): build_document filtra el cuerpo a esos capítulos y siempre
|
|
# añade portada (primera) + glosario (última). build_document la consume
|
|
# y la quita, así que no llega a los capítulos.
|
|
if only_chapters is not None and isinstance(ctx, dict):
|
|
ctx["_only_chapters"] = list(only_chapters)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
base = basename or f"aeda_{table}_{ts}"
|
|
pdf_path = os.path.join(out_dir, base + ".pdf")
|
|
pptx_path = os.path.join(out_dir, base + ".pptx")
|
|
meta = {"title": f"EDA — {table}", "ctx": ctx}
|
|
|
|
rpdf = render_automatic_eda_pdf(prof, pdf_path, meta) or {}
|
|
rpptx = render_automatic_eda_pptx(prof, pptx_path, meta) or {}
|
|
|
|
# Salida Markdown autocontenida (mismo documento por capítulos) para
|
|
# pegar a un LLM. Aditiva: no afecta a PDF/PPTX/manifest. dict-no-throw.
|
|
rmd = {}
|
|
md_path = None
|
|
if emit_md:
|
|
md_path = os.path.join(out_dir, base + ".md")
|
|
# El Markdown es la salida MÁS completa: además del documento por
|
|
# capítulos (compartido con PDF/PPTX) volca un apéndice con TODOS los
|
|
# datos numéricos del perfil (matriz de asociación completa, describe
|
|
# con skew/kurtosis/percentiles, re-expresiones, scores_by_k de
|
|
# KMeans, estadísticos de normalidad). Se le pasa el `prof` vía
|
|
# meta['profile']; un meta propio evita alterar el de PDF/PPTX.
|
|
md_meta = dict(meta)
|
|
md_meta["profile"] = prof
|
|
rmd = render_automatic_eda_markdown(prof, md_path, md_meta) or {}
|
|
|
|
return {
|
|
"status": "ok",
|
|
"pdf_path": rpdf.get("path"),
|
|
"pptx_path": rpptx.get("path"),
|
|
"aeda_md_path": rmd.get("path"),
|
|
"manifest_path": rpdf.get("manifest_path"),
|
|
"n_pages": rpdf.get("n_pages"),
|
|
"n_slides": rpptx.get("n_slides"),
|
|
"md_chars": rmd.get("n_chars"),
|
|
"pdf_note": rpdf.get("note"),
|
|
"pptx_note": rpptx.get("note"),
|
|
"md_note": rmd.get("note"),
|
|
"only_chapters": only_chapters,
|
|
"profile": prof,
|
|
}
|
|
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
|
|
return {"status": "error", "error": str(e)}
|