diff --git a/.claude/commands/capitulos.md b/.claude/commands/capitulos.md new file mode 100644 index 00000000..cd3b9426 --- /dev/null +++ b/.claude/commands/capitulos.md @@ -0,0 +1,204 @@ +--- +description: Genera en un vault Obsidian un resumen capítulo a capítulo de uno o varios libros, siguiendo el formato de notas del vault captacion_clientes (MOC de libro + una nota por capítulo + MOC de categoría, todo enlazado con wikilinks). +--- + +# /capitulos — resumen de libros capítulo a capítulo en Obsidian + +Genera notas de estudio de un libro (o varios) en un vault Obsidian, replicando el formato +canónico del vault `captacion_clientes`: una nota MOC por libro, una nota por capítulo, y una +nota MOC de categoría que agrupa los libros. Todo enlazado con wikilinks `[[ ]]` para que +Obsidian construya el grafo. + +## Argumentos + +`$ARGUMENTS` contiene, en lenguaje natural, los libros a procesar y opcionalmente el destino. +Interpreta: + +- **Libros** — uno o varios títulos. Pueden venir con autor ("Forecasting de Hyndman"). Si el + usuario dice "los libros que me has dicho" o similar, usa los que se recomendaron en la + conversación previa. +- **Vault destino** — si no se especifica, **PREGUNTA** antes de escribir (ver Decisiones). + Vault por defecto de ejemplo de formato: `/home/enmanuel/Obsidian/captacion_clientes`. +- **Categoría** — la subcarpeta bajo `Libros/` que agrupa los libros (ej. "Marca y Mercado", + "Datos e Inversión"). Si no se da, propón una coherente con el tema de los libros y confírmala. +- **Profundidad** — `completo` (default, como The Mom Test: idea central + puntos clave + + citas + aplicación por capítulo) o `breve` (idea central + 3 bullets por capítulo). + +## Decisiones a confirmar antes de escribir (si faltan en los argumentos) + +Usa `AskUserQuestion` para resolver lo que cambie el trabajo, NO inventes: + +1. **Vault y categoría destino** — dónde se crean las notas. +2. **Alcance** — qué libros exactamente y cuántos (si la lista es grande, confirma si son + todos o un subconjunto; cada libro es trabajo no trivial). +3. **Enfoque de "Aplicación"** — el ángulo desde el que se escribe la sección "Aplicación a mi + negocio / a mi caso" de cada capítulo (ej. inversión cuantitativa, data-analyst, SaaS…). + El vault de captación lo orienta al negocio del usuario; mantén ese espíritu pero ajustado + al tema real de los libros. + +## Estructura de archivos a crear + +``` +/Libros// + - MOC.md # MOC de categoría (crear o ACTUALIZAR, no sobrescribir) + / + - MOC.md # MOC del libro + 01 - .md # una nota por capítulo, NN zero-padded a 2 dígitos + 02 - .md + ... +``` + +- Carpeta por libro, archivo por capítulo. Nombre de capítulo: `NN - .md` con `NN` + empezando en `01`. Si el capítulo tiene título original en otro idioma, puedes incluir la + traducción entre paréntesis como en el vault (`01 - The Mom Test (El test de la madre).md`). +- Nombres de archivo sin caracteres que rompan en Obsidian (evita `/`, `:`; los paréntesis y + acentos son válidos). + +## Determinar los capítulos de cada libro + +Para listar los capítulos reales de un libro: + +1. Usa tu conocimiento del libro si lo conoces con fiabilidad (índice real, no inventado). +2. Si no estás seguro del índice exacto, **búscalo en la web** (`WebSearch` / `WebFetch` sobre + la tabla de contenidos del libro) antes de escribir. No inventes capítulos. +3. Indica en el MOC del libro si el índice procede de una edición concreta. + +**Regla dura:** nunca te inventes el número o los títulos de los capítulos. Si no puedes +verificarlos, dilo y pregunta al usuario en vez de fabricar un índice plausible. + +## Plantilla — MOC del libro (` - MOC.md`) + +```markdown +--- +title: - MOC +book: +author: +year: +type: book-moc +tags: +- +- +- moc +--- + +# — Mapa de contenidos (MOC) + +## Metadata +- **Autor:** +- **Año:** () +- **Subtítulo:** ** () +- **Tema:** +- **Por qué importa:** <2-3 frases sobre qué problema resuelve y para quién> + +## Resumen global + + +## Capítulos +1. [[01 - ]] +2. [[02 - ]] +... + +## Aplicación a mi caso (visión transversal) + +``` + +## Plantilla — nota de capítulo (`NN - .md`) + +```markdown +--- +title: +book: +author: +chapter: +type: chapter-summary +tags: +- +- +--- + +# NN. + +> Libro: [[ - MOC]] + +## Idea central +<1-3 frases con la tesis del capítulo.> + +## Puntos clave +- +- <…> +- <…> + +## Ejemplos / citas +- +- <…> + +## Aplicación a mi caso + + +--- +Anterior: [[NN-1 - ]] · Siguiente: [[NN+1 - ]] · Índice: [[ - MOC]] +``` + +Notas de la plantilla: +- El primer capítulo: `Anterior: —`. El último: `Siguiente: —`. (Ver patrón en el vault.) +- La sección "Aplicación" es obligatoria y debe ser específica del caso del usuario, no un + consejo genérico. Es lo que da valor a estas notas frente a un resumen cualquiera. +- En profundidad `breve`, omite "Ejemplos / citas" y deja "Puntos clave" en 3 bullets. + +## Plantilla — MOC de categoría (` - MOC.md`) + +Si ya existe, **ACTUALÍZALO** añadiendo los libros nuevos a la sección que corresponda (no lo +reescribas perdiendo lo previo). Si no existe, créalo: + +```markdown +--- +title: — MOC +type: moc +tags: +- libros +- +--- + +# — Mapa de contenidos + + + +Cada libro tiene su propia nota MOC con el índice de capítulos enlazados. + +## +- [[ - MOC]] — . . +- [[ - MOC]] — . <…>. + +## Orden de lectura recomendado +1. **** — . +2. ... +``` + +## Flujo de ejecución + +1. Parsear `$ARGUMENTS`: libros, vault, categoría, profundidad, enfoque. +2. Resolver decisiones faltantes con `AskUserQuestion`. +3. Para cada libro: verificar el índice real de capítulos (conocimiento fiable o WebSearch). +4. Crear carpeta del libro. Escribir el MOC del libro y todas las notas de capítulo con + wikilinks y navegación correctos. +5. Crear o actualizar el MOC de categoría enlazando los libros nuevos. +6. **Paralelización:** si son varios libros, cada libro es independiente (carpetas disjuntas). + En modo orquestador, lanza un ejecutor por libro (o por lote de libros) escribiendo en + carpetas distintas del mismo vault. Cada ejecutor escribe SOLO su carpeta de libro; el MOC + de categoría lo actualiza UN único agente al final (o el orquestador) para evitar que dos + ejecutores editen el mismo archivo a la vez. +7. Reportar: lista de archivos creados (MOC + nº de capítulos por libro) y la ruta del vault + para abrirlo en Obsidian. + +## Gotchas + +- **El vault es artefacto local** (gitignored en fn_registry, symlink a `~/Obsidian/`). + Escribir notas NO toca el repo `fn_registry`. Si el vault es su propio repo git, NO commitees + desde varios ejecutores a la vez (race): deja el commit/sync al usuario o a un único paso final. +- **No sobrescribas** un MOC de categoría existente ni notas de capítulo ya escritas a mano sin + confirmarlo. Ante colisión de nombre, pregunta. +- **Índices inventados = bug.** Verifica los capítulos reales antes de escribir. +- **Wikilinks deben resolver:** el texto dentro de `[[ ]]` debe coincidir exactamente con el + nombre de archivo (sin extensión). Un typo rompe el enlace en Obsidian. diff --git a/.claude/commands/eda.md b/.claude/commands/eda.md new file mode 100644 index 00000000..8ce31ef8 --- /dev/null +++ b/.claude/commands/eda.md @@ -0,0 +1,95 @@ +--- +description: EDA (exploratory data analysis) de una tabla o de una base entera con el grupo `eda` del registry. Perfila, escribe el report (JSON + Markdown + PDF móvil) y monta un analysis Jupyter lanzado en el navegador colaborativo y ejecutado en vivo por Claude. +--- + +# /eda — Exploratory Data Analysis con el grupo `eda` + +Cuando Enmanuel pide un EDA ("hazme un EDA de X", "analiza esta tabla", "qué hay en estos datos"), **no escribas análisis inline**: usa el grupo de capacidad `eda` del registry, escribe los reports y monta el analysis Jupyter en su navegador colaborativo, ejecutando las celdas tú mismo en vivo. Respeta la memoria `eda-workflow-registry` y la regla `.claude/rules/notebook_collaboration.md`. + +Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar el cluster entero). + +## Uso + +``` +/eda /ruta/datos.duckdb tabla # EDA de una tabla DuckDB +/eda /ruta/datos.csv # CSV/Parquet → cargar a DuckDB y perfilar +/eda postgresql://user:pass@host:5432/db tabla # EDA de una tabla PostgreSQL (backend="postgres") +/eda /ruta/datos.duckdb --all # EDA de TODA la base (todas las tablas + FK + join graph) +/eda /ruta/datos.duckdb ventas --series --pdf # con análisis de serie temporal + PDF móvil +``` + +`$ARGUMENTS` lleva la fuente y, opcionalmente, la tabla y flags. Interpreta: +- **Fuente**: ruta a `.duckdb`/`.csv`/`.parquet`, o un DSN PostgreSQL (`postgresql://...` o `postgres://...`). +- **Tabla**: nombre de la tabla. Si no se da y la fuente es un único archivo CSV/Parquet, usa su nombre base. Si se pide "toda la base" / `--all`, usa `profile_database`. +- **Flags** (actívalos según lo que pida el usuario; pregunta solo si es ambiguo y costoso): + - `--models` → `run_models=True` (PCA/KMeans/IsolationForest/normalidad). + - `--llm` → `run_llm=True` (1 call LLM sobre el perfil agregado). + - `--series` → `run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica). + - `--pdf` → `emit_pdf=True` (PDF A5 vertical legible en móvil). + +Por defecto, para un EDA "completo" cuando el usuario no especifica, activa `run_models`, `run_series` y `emit_pdf`; deja `run_llm` para cuando lo pida o cuando interese la interpretación semántica (es la única parte que gasta tokens del modelo). + +## Reglas duras + +1. **Registry-first**: invoca las funciones del grupo `eda`, no reescribas lógica de perfilado ni de gráficos inline (regla `registry_first.md`). +2. **CSV/Parquet/Excel** entran cargándolos antes a DuckDB (`read_csv_auto`/`read_parquet`/`read_xlsx`) — DuckDB es el motor por defecto. No traigas la tabla entera a RAM. +3. **Secretos**: si la fuente es un DSN PostgreSQL con credenciales, NO las imprimas en los reports ni en el notebook; resuélvelas vía `resolve_pg_dsn`/`pass` cuando aplique. +4. **El report es un artefacto local**: vive en `reports/` (gitignored), no se sube a Gitea ni se versiona. Compartir = pasar la ruta (regla `reports.md`). +5. **Entrega las 4 salidas**: JSON sidecar + Markdown + **PDF móvil** + **notebook Jupyter colaborativo ejecutado en vivo**. + +## Paso 1 — Perfilar y escribir los reports + +Una tabla (caso normal): + +```bash +PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF' +from pipelines.profile_table import profile_table +r = profile_table( + "/ruta/datos.duckdb", "ventas", + run_models=True, run_series=True, emit_pdf=True, run_llm=False, +) +print("status:", r["status"]) +print("md: ", r["report_md_path"]) +print("json: ", r["report_json_path"]) +print("pdf: ", r["pdf_path"]) +PYEOF +``` + +Una base entera (todas las tablas + relaciones FK): + +```bash +PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF' +from pipelines.profile_database import profile_database +r = profile_database("/ruta/datos.duckdb") +print(r["db_profile"]["join_graph"]["mermaid"]) +PYEOF +``` + +Lee el Markdown resultante y resume a Enmanuel lo esencial: forma, calidad, correlaciones fuertes (ya corregidas por FDR), series no estacionarias, transformaciones sugeridas y avisos exploratorios. + +## Paso 2 — Notebook Jupyter colaborativo, ejecutado en vivo por Claude + +Sigue la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`: + +1. Genera el notebook con `build_eda_notebook` (mismo perfil de la tabla): + + ```bash + PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF' + from datascience import build_eda_notebook + build_eda_notebook("/ruta/datos.duckdb", "ventas", + "analysis/eda_ventas/notebooks/01_eda.ipynb", run_models=True) + PYEOF + ``` + + (o crea un analysis dedicado con `fn run init_jupyter_analysis eda_ventas duckdb` y escribe el notebook dentro de `notebooks/`). + +2. Confirma que hay Jupyter colaborativo activo con `jupyter_discover` (o lánzalo con el `run-jupyter-lab.sh` del analysis) y **ábrelo en el navegador colaborativo** para que Enmanuel lo vea en vivo. + +3. **Ejecuta tú las celdas** (no se las dejes para que las corra él): usa las funciones del dominio `notebook` (`jupyter_exec` append+execute / `jupyter_read`) descritas en `notebook_collaboration.md`, o el MCP `jupyter` si está conectado en la sesión del analysis. Ejecuta de arriba a abajo, comenta cada bloque relevante y deja el notebook navegable. + +## Notas + +- El `TableProfile` lleva ahora, además del perfilado base y las correlaciones con FDR: `series` (por columna numérica, con `run_series`), `reexpression` por columna numérica (escalera de Tukey) y `caveats` (siempre, avisos exploratorios). El Markdown y el PDF renderizan estas secciones automáticamente cuando están presentes. +- El PDF (`emit_pdf`) está pensado para leerse en el móvil (A5 vertical, tipografía grande, gráficos Tufte). Se escribe junto al Markdown en `reports/`. +- `run_series` ordena por la primera columna datetime si existe; si no, por el orden físico de filas. Necesita ≥8 puntos válidos por columna. +- Fuentes: DuckDB (CSV/Parquet/Excel cargados antes) y PostgreSQL (`backend="postgres"`). `profile_database` (multi-tabla + FK) es solo DuckDB por ahora. diff --git a/dev/issues/0173-eda-correctitud-estadistica-critica.md b/dev/issues/0173-eda-correctitud-estadistica-critica.md new file mode 100644 index 00000000..d92aa449 --- /dev/null +++ b/dev/issues/0173-eda-correctitud-estadistica-critica.md @@ -0,0 +1,87 @@ +--- +id: "0173" +title: "EDA: bugs críticos de correctitud estadística (outlier_pct ×100, distribution_type por-skew)" +status: pendiente +type: bugfix +domain: + - registry-quality +scope: registry-only +priority: alta +depends: [] +blocks: [] +related: ["0174", "0175", "0176", "0177", "0068"] +created: 2026-06-29 +updated: 2026-06-29 +tags: [eda, datascience, profile_table, render_eda_markdown, describe_numeric, benchmark] +--- +# 0173 — EDA: bugs críticos de correctitud estadística + +## Contexto + +Un benchmark adversarial del workflow `/eda` sobre 12 datasets reales (29/06/2026, +`temp/eda_benchmark/EVALUATION.md`) detectó que los estadísticos descriptivos base son +correctos, pero el **porcentaje de outliers que el report markdown muestra es imposible** +(supera el 100%, hasta 336%), engañando a un lector no experto con apariencia de autoridad. + +Hallazgos cubiertos por este issue: + +| Hallazgo | Severidad | Evidencia del benchmark | +|---|---|---| +| H1 — `outlier_pct` por-columna >100% en el report markdown | crítico | wine-red `chlorides` 193.87%, `density` 112.57% (skew 0.07); titanic `SibSp` 336.70%, `Fare` 224.47%; seattle `precipitation` 253.25% | +| H11 — `distribution_type` por-skew etiqueta mal discretas/ordinales/multimodales | bajo | wine `quality` (6 valores) → "normal-ish"; precios BTC multimodales → "normal-ish" (skew 0.45) | + +### Causa raíz de H1 (verificada en código, READ-ONLY) + +`EVALUATION.md` propuso "corregir la fórmula en `describe_numeric`". **Eso es incorrecto.** Al +leer el código: + +- `python/functions/datascience/describe_numeric.py:113` calcula + `outlier_pct = 100.0 * n_outliers / n` — ya en escala 0-100 y acotado a [0,100]. **Está bien.** +- `python/functions/datascience/render_eda_markdown.py:203-204` renderiza ese valor con + `_fmt_pct(val)`, y `_fmt_pct` (líneas 31-44) hace `num * 100` porque **asume que su input es + una fracción 0-1**. Resultado: **doble ×100** (un 1.94 real se muestra como 193.87%). +- El PDF (`render_eda_pdf.py:296`) usa `_fmt_num(outlier_pct, 1) + "%"` sin multiplicar — por eso + el PDF muestra el outlier_pct correcto y el markdown no. El bug es **exclusivo del renderer + markdown**. + +El factor "19-40×" que observó el evaluador se debe a que comparaba contra outliers IQR (3-10%), +mientras `describe_numeric` usa z-score (umbral 3.0, da menos outliers); pero el mecanismo del bug +es el doble ×100, no la fórmula. + +## Tareas + +1. **H1 (fix de 1 línea):** en `python/functions/datascience/render_eda_markdown.py:203-204`, + sustituir `_fmt_pct(val)` por un formateo que NO multiplique (p.ej. `f"{_fmt_num(val, 2)}%"`), + porque `numeric.outlier_pct` ya viene en escala 0-100. **No tocar** `describe_numeric.py` (su + fórmula es correcta). +2. Auditar el resto de `render_eda_markdown.py` por si otro campo en escala 0-100 pasa por + `_fmt_pct` (los `*_pct` del perfil base sí son fracciones 0-1 y deben seguir con `_fmt_pct`; + solo `numeric.outlier_pct` está en escala 0-100). Documentar en el docstring de `describe_numeric` + que `outlier_pct` está en 0-100 para evitar la confusión a futuro. +3. **H11:** en `python/functions/datascience/detect_distribution_type.py`, no etiquetar por skew + solamente: usar también nº de modos / cardinalidad y, cuando esté disponible, el test de + normalidad Jarque-Bera (`normality_tests.py`, ya expuesto en `models.normality` vía + `run_eda_models`). Una variable discreta/ordinal/multimodal no debe salir "normal-ish". +4. Añadir/extender tests unitarios: `describe_numeric_test.py` (outlier_pct en [0,100]), + `render_eda_markdown_test.py` (un perfil con `outlier_pct=7.0` renderiza `"7.00%"`, no `"700%"`), + y un test de `detect_distribution_type` (discreta de 6 valores no se etiqueta "normal-ish"). Nota: + hoy NO existe `detect_distribution_type_test.py` en `python/functions/datascience/` — hay que + crearlo (a confirmar el nombre canónico al implementar; el resto de tests citados sí existen). + +## Definition of Done + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: outlier_pct en rango | e2e | re-correr `profile_table` sobre `temp/eda_benchmark/datasets/.../wine-red` y leer el `.md` | `chlorides`/`density` muestran `outlier_pct` en [0,100]% (no 193.87% / 112.57%) | +| Edge: skew alto real | unit | `describe_numeric_test.py` con datos de cola fuerte | `outlier_pct` ≤ 100 y coherente con n_outliers/n | +| Edge: discreta ordinal | unit | `detect_distribution_type_test.py` con 6 valores discretos | NO etiqueta "normal-ish" | +| Error: input vacío/no numérico | unit | `describe_numeric([])` | claves None, sin crash (contrato actual preservado) | +| Mecánica | — | `./fn run describe_numeric_py_datascience`, `./fn run render_eda_markdown_py_datascience` | tests verdes; `fn index` limpio | + +Re-correr el benchmark sobre wine-red y titanic y confirmar que ningún `outlier_pct` supera 100%. + +## Notas + +Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md` (consolidación del benchmark). H1 es el fix de +mayor ratio impacto/esfuerzo del lote (una línea elimina los números imposibles que más minan la +confianza del report). Hermanos: 0174 (series), 0175 (relational), 0176 (render), 0177 (tipos). diff --git a/dev/issues/0174-eda-series-periodo-estacional-niveles.md b/dev/issues/0174-eda-series-periodo-estacional-niveles.md new file mode 100644 index 00000000..074efc19 --- /dev/null +++ b/dev/issues/0174-eda-series-periodo-estacional-niveles.md @@ -0,0 +1,86 @@ +--- +id: "0174" +title: "EDA series temporales: período estacional roto + correlación de niveles + to_returns ciego" +status: pendiente +type: bugfix +domain: + - registry-quality +scope: registry-only +priority: alta +depends: [] +blocks: [] +related: ["0173", "0175", "0176", "0177"] +created: 2026-06-29 +updated: 2026-06-29 +tags: [eda, datascience, stl_decompose, profile_table, to_returns, series, benchmark] +--- +# 0174 — EDA series temporales: período estacional + correlación de niveles + +## Contexto + +El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la +estacionariedad (ADF+KPSS), la autocorrelación (Ljung-Box) y el aviso de espuriedad +Granger-Newbold están **bien** (verificados a mano con `statsmodels`). Pero el **detector de +período estacional está roto**, lo que produce falsos negativos de estacionalidad, y la +correlación de precios se calcula sobre niveles (espuria para uso financiero). + +Hallazgos cubiertos: + +| Hallazgo | Severidad | Evidencia del benchmark | +|---|---|---| +| H2 — período estacional sale `2` casi siempre → `seasonal_strength=0` | crítico | seattle `temp_max` reporta "sin estacionalidad" (`period=2`); STL real con `period=365` da fuerza estacional **0.843**. UNRATE (mensual) debería usar 12, no 2 | +| H8 — correlación de precios sobre niveles marcada `sig=sí` | medio-alto | aapl/btc `Close–Open=0.998 sig=sí`: espuria por construcción (niveles autocorrelados no estacionarios) | +| H13 — `to_returns` sugerido ciegamente a temperatura (sin sentido físico) | bajo | seattle `temp_max`: "convertir a retornos"; debería ser "diferencias" | + +### Causa raíz H2 (verificada en código, READ-ONLY) + +`python/functions/datascience/stl_decompose.py:34-58` (`_infer_period`) busca el lag entre 2 y +`max_period` que maximiza la autocorrelación **cruda** de la serie. En cualquier serie con +tendencia (precios, temperatura), la autocorrelación decae monótonamente desde el lag mínimo, así +que **el lag 2 casi siempre gana** → `period=2` espurio y un STL con componente estacional que es +ruido (`seasonal_strength≈0`). Además, `python/functions/pipelines/profile_table.py:175` +(`_build_series_block`) llama `stl_decompose(series_vals)` **sin pasar el período**, pese a que el +pipeline ya conoce la columna de orden temporal (`order_col`) y podría derivar la frecuencia. + +## Tareas + +1. **H2 — arreglar la inferencia de período** en `stl_decompose.py:34-58`. Opciones (preferir la + robusta): (a) detrend antes de autocorrelar; (b) buscar picos en el periodograma/FFT en vez del + primer lag; (c) **derivar el período de la frecuencia del índice datetime** (mensual→12, + diario→7 y/o 365) — la señal más fiable. +2. **H2 — pasar el período desde el pipeline:** en `profile_table.py:_build_series_block`, cuando + exista `order_col` datetime, inferir la frecuencia del índice y pasar `period=` explícito a + `stl_decompose`. Si no se puede determinar un período fiable, que `stl_decompose` **no reporte + `seasonal_strength=0`** como conclusión: devolver `note` "período no determinado" (ya hay una + rama así en `:139-145`; extenderla a los casos que hoy caen en `period=2`). +3. **H8 — correlación sobre retornos para series no estacionarias:** en la sección de correlaciones + de `profile_table.py:346-384`, cuando una columna sea una serie no estacionaria de niveles + (verdict `non_stationary`/`inconclusive`, ya detectado), correlacionar sobre retornos/diferencias + (`to_returns`, ya importado) o marcar esos pares de niveles como "posible espuria" junto a la + tabla. El aviso global existe pero está lejos de los números. +4. **H13 — retornos vs diferencias por semántica:** en `profile_table.py:189` / `to_returns.py`, + elegir "retornos" (financiero, estrictamente positivo multiplicativo) vs "diferencias" (físico, + aditivo) según la naturaleza, o usar "diferencias" por defecto cuando no haya señal financiera. +5. Tests: `stl_decompose_test.py` (serie sintética mensual con estacionalidad anual → período + correcto y `seasonal_strength` alta; serie con tendencia sin estacionalidad → nota, no + `period=2`); cobertura de `_build_series_block` con `order_col` datetime. + +## Definition of Done + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: estacionalidad anual | e2e | re-correr `profile_table` con `run_series=True` sobre seattle `temp_max` | `seasonal_strength ≈ 0.84` con período ≈ 365 (NO "sin estacionalidad", NO `period=2`) | +| Edge: serie mensual | unit | `stl_decompose_test.py` serie mensual sintética con ciclo 12 | período inferido 12 y fuerza estacional alta | +| Edge: sin estacionalidad | unit | `stl_decompose_test.py` serie con solo tendencia | `note` "período no determinado", NO `seasonal_strength=0` como conclusión | +| Error: serie corta | unit | `stl_decompose([...]<2*period)` | nota "serie corta", sin crash (contrato actual) | +| H8 | e2e | re-correr `profile_table` sobre aapl/btc | pares de niveles no estacionarios marcados como posible espuria o correlación sobre retornos | +| Mecánica | — | `./fn run stl_decompose_py_datascience`; `fn index` | tests verdes; índice limpio | + +Re-correr el benchmark sobre seattle, fred-unrate, aapl y btc y confirmar que la estacionalidad se +detecta donde existe y no se inventa donde no. + +## Notas + +Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. H2 es el segundo bloqueante de fiabilidad: un +"sin estacionalidad" donde la hay es un falso negativo que un decisor creería. La estacionariedad ya +funciona — no tocarla. Hermanos: 0173, 0175, 0176, 0177. diff --git a/dev/issues/0175-eda-relational-fk-inference-views.md b/dev/issues/0175-eda-relational-fk-inference-views.md new file mode 100644 index 00000000..5d75fb41 --- /dev/null +++ b/dev/issues/0175-eda-relational-fk-inference-views.md @@ -0,0 +1,89 @@ +--- +id: "0175" +title: "EDA relational: precisión de FK inference (falsos positivos) + filtrar VIEWs + test ATTACH" +status: pendiente +type: bugfix +domain: + - registry-quality +scope: registry-only +priority: alta +depends: [] +blocks: [] +related: ["0173", "0174", "0176", "0177"] +created: 2026-06-29 +updated: 2026-06-29 +tags: [eda, datascience, infer_fk_containment_duckdb, build_join_graph, profile_database, duckdb, benchmark] +--- +# 0175 — EDA relational: precisión de FK inference + filtrar VIEWs + +## Contexto + +El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la inferencia de +claves foráneas a nivel de base es **inútil por falsos positivos masivos** y que las VISTAS se +perfilan como tablas base. El join graph resultante necesita filtrado manual para ser legible. + +Hallazgos cubiertos: + +| Hallazgo | Severidad | Evidencia del benchmark | +|---|---|---| +| H3 — FK inference por contención: 10-20× falsos positivos | crítico | chinook 111 candidatas vs ~11 reales; sakila 565 vs ~30. Casos absurdos: `InvoiceLine.Quantity→Album.AlbumId`, `Genre.GenreId→{Album,Artist,Customer,…}` | +| H5 — VIEWs perfiladas como tablas base | alto | sakila `n_tables=21` incluye 5 VISTAS (`customer_list`, `film_list` 5462 filas, `staff_list`, `sales_by_store`, `sales_by_film_category`) + `film_text` (FTS, 0 filas) | +| H10 — coste relacional gastado en computar FK falsas | medio | sakila 31.82s: la mayoría en INTERSECT de los 565 pares candidatos, casi todos falsos | +| H14 — bug `sqlite_master does not exist` tras ATTACH (ya parcheado, falta test) | bajo (resuelto) | `_run.log`: `profile_database` falló con `Catalog Error: src.sqlite_master`; re-run posterior `ok` | + +### Causa raíz (verificada en código, READ-ONLY) + +- `python/functions/datascience/infer_fk_containment_duckdb.py:217-285` emite una FK candidata si + `inclusion(A⊆B) ≥ min_inclusion` **y** B "parece clave" (unicidad ≥0.95). **No usa el nombre de + la columna**, que es la señal más fuerte de FK (`AlbumId→Album.AlbumId`), ni excluye columnas + no-clave (cantidades, importes) como ORIGEN. Enteros pequeños (`GenreId` 1..25) están contenidos + en casi todo → ruido. +- `python/functions/pipelines/profile_database.py:155-159` lista tablas con `duckdb_list_tables` + sin filtrar `table_type` → perfila VIEWs y tablas FTS como base (H5), lo que infla el universo de + pares y multiplica las FK falsas (relaciona H10). +- H10 es el **mismo cambio** que H3: filtrar candidatos por nombre **antes** del INTERSECT reduce + pares (más rápido) y falsos positivos (más preciso) a la vez. + +## Tareas + +1. **H3+H10 — señal de nombre en `infer_fk_containment_duckdb.py:217-285`:** antes de lanzar el + INTERSECT, exigir coincidencia/patrón de nombre entre origen y destino (`from_col` casa con + `to_table`/`to_col`, patrón `Id → .Id`; case-insensitive). Excluir como ORIGEN columnas + claramente no-clave (cantidades, importes, flags) por heurística de nombre/tipo. Esto poda el + O(tablas²×columnas²) y elimina la mayoría de los falsos positivos. Validar mejor la cardinalidad + (los `1:1` imposibles del benchmark). +2. **H5 — filtrar VIEWs** antes de perfilar e inferir FK: filtrar `table_type='BASE TABLE'` vía + `information_schema.tables` / `duckdb_tables()`. Decidir (a confirmar al implementar) si el filtro + va como flag nuevo en `duckdb_list_tables` (infra, reutilizable) o en `profile_database.py` tras + listar. Preferir el flag en `duckdb_list_tables` si no rompe consumidores. +3. **H3 — propagar al join graph:** verificar que `build_join_graph.py` recibe la lista ya filtrada + y que el diagrama Mermaid resultante es legible (sin nodos VIEW ni aristas espurias). +4. **H14 — test de regresión:** añadir test (en `profile_database_test.py` o + `infer_fk_containment_duckdb_test.py`) que haga `ATTACH` de una base SQLite pequeña en DuckDB y + perfile, confirmando que se usa `information_schema`/`duckdb_tables()` y nunca `sqlite_master`. + (A confirmar: localizar la función que hace el ATTACH —probablemente `summarize_table_duckdb.py` + o una primitiva infra `duckdb_*`— para cubrirla.) +5. Tests: casos sintéticos con tablas que tengan columnas tipo `XId` (FK real) y columnas de + cantidad contenidas en claves (falso positivo) → confirmar que solo emite las reales. + +## Definition of Done + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: FK reales sin ruido | e2e | re-correr `profile_database` sobre chinook | ~11 FK candidatas (no 111); incluyen `Album.ArtistId→Artist.ArtistId`, `Invoice.CustomerId→Customer.CustomerId`; NO incluyen `InvoiceLine.Quantity→Album.AlbumId` | +| Edge: VIEWs excluidas | e2e | re-correr `profile_database` sobre sakila | `n_tables` cuenta solo BASE TABLE (sin `customer_list`/`film_list`/…); FK candidatas ≪ 565 | +| Edge: cantidad vs clave | unit | `infer_fk_containment_duckdb_test.py` con columna `Quantity` contenida en una clave | NO emite FK desde `Quantity` | +| Error: ATTACH SQLite | unit | test de regresión ATTACH SQLite→DuckDB | perfila sin `sqlite_master does not exist`; usa information_schema | +| Rendimiento (H10) | e2e | medir duración de `profile_database` sobre sakila | menor que el baseline 31.82s (menos INTERSECT) | +| Mecánica | — | `./fn run infer_fk_containment_duckdb_py_datascience`, `./fn run profile_database_py_pipelines`; `fn index` | tests verdes; índice limpio | + +Re-correr el benchmark sobre chinook y sakila y confirmar que las FK reales son distinguibles del +ruido y que las VIEWs no se cuentan como tablas. + +## Notas + +Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. Tres síntomas (H3/H5/H10) con un núcleo común: +la capa de inferencia de relaciones inter-tabla. Atacarlos juntos en una rama; filtrar VIEWs reduce +el universo de pares y filtrar candidatos por nombre arregla precisión y velocidad a la vez. H14 ya +está parcheado en producción; este issue solo añade el test de regresión que faltaba. +Hermanos: 0173, 0174, 0176, 0177. diff --git a/dev/issues/0176-eda-render-models-series-caveats-pdf.md b/dev/issues/0176-eda-render-models-series-caveats-pdf.md new file mode 100644 index 00000000..a4ab6458 --- /dev/null +++ b/dev/issues/0176-eda-render-models-series-caveats-pdf.md @@ -0,0 +1,80 @@ +--- +id: "0176" +title: "EDA render: models/series/caveats en markdown+PDF + PDF para profile_database" +status: pendiente +type: feature +domain: + - registry-quality +scope: registry-only +priority: media +depends: [] +blocks: [] +related: ["0173", "0174", "0175", "0177"] +created: 2026-06-29 +updated: 2026-06-29 +tags: [eda, datascience, render_eda_markdown, render_eda_pdf, profile_database, pdf, benchmark] +--- +# 0176 — EDA render: models/series/caveats en markdown+PDF + PDF para profile_database + +## Contexto + +El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la información de +modelos (PCA/KMeans) está completa en el JSON pero **no llega legible a ningún formato**, y que el +análisis relacional no tiene salida móvil (PDF). El tercio final del PDF queda ilegible. + +Hallazgos cubiertos: + +| Hallazgo | Severidad | Evidencia del benchmark | +|---|---|---| +| H4 — `models` omitido en Markdown; `models`/`series`/`caveats` como dict crudo truncado en PDF | alto | wine-red `.md` (12 numéricas, PCA valioso) → cero menciones de models. PDF aapl: `- pca: {'n_components': 2, …` cortado a media línea | +| H9 — `profile_database` no genera PDF | medio | chinook y sakila con `pdf=null`; análisis relacional solo en Markdown | + +### Causa raíz (verificada en código, READ-ONLY) + +- `python/functions/datascience/render_eda_markdown.py`: tiene formatters para `series` (`:337`) y + `caveats` (`:407`), pero **no para `models`** → el bloque PCA/KMeans nunca se renderiza en MD. +- `python/functions/datascience/render_eda_pdf.py:50-55`: `_KNOWN_TOP_KEYS` **no incluye** `models`, + `series` ni `caveats`, así que caen en `_generic_pages` (`:479-495`) → `_wrap_value` → + `str(dict)` truncado a 60-64 chars. Por eso esas tres secciones salen como dict crudo en el PDF. +- `python/functions/pipelines/profile_database.py:205-218`: solo escribe MD+JSON, nunca invoca + `render_eda_pdf`; no tiene param `emit_pdf`. + +## Tareas + +1. **H4 — markdown:** añadir una sección `## Modelos` (PCA/KMeans/outliers/normalidad) a + `render_eda_markdown.py`, formateando `models.pca` (varianza explicada, top loadings, acumulada), + `models.kmeans` (best_k, silhouette, tamaños de cluster) y `models.outliers` como tablas legibles. +2. **H4 — PDF:** en `render_eda_pdf.py`, añadir builders dedicados para `models`, `series` y + `caveats` (tablas/listas, no `str(dict)`) y registrarlos en `_KNOWN_TOP_KEYS` + en la lista + `builders` (`:595-604`) para sacarlos del volcado genérico. Mantener el contrato dict-no-throw + (una sección que falle no aborta el PDF). +3. **Unificar renderers:** asegurar que MD y PDF cubren el mismo conjunto de secciones (`models`, + `series`, `caveats`) para que no diverjan otra vez. +4. **H9 — PDF relational:** añadir un renderer PDF DB-level (puede ser una variante en + `render_eda_pdf.py` o una función nueva) con: portada de la base, resumen de tablas, join graph + filtrado (tras 0175), y FK candidatas. Añadir param `emit_pdf` a `profile_database.py` que lo + invoque y devuelva `pdf_path`. +5. Tests: `render_eda_markdown_test.py` (perfil con `models` → aparece sección Modelos); + `render_eda_pdf_test.py` (perfil con `models`/`series`/`caveats` → NO aparecen como `str(dict)`; + `n_pages` incrementa); test de `profile_database(emit_pdf=True)` → `pdf_path` no nulo, PDF válido. + +## Definition of Done + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: models en MD | e2e | re-correr `profile_table(run_models=True)` sobre wine-red y leer el `.md` | sección `## Modelos` con PCA (varianza explicada) y KMeans (silhouette) legibles | +| Golden: PDF legible | e2e | re-correr sobre aapl y `pdftotext` del PDF | `models`/`series`/`caveats` como tablas, sin `{'n_components': 2, …` truncado | +| Edge: perfil sin models | unit | `render_eda_markdown_test.py`/`render_eda_pdf_test.py` con `models=None` | sección omitida limpiamente, sin crash | +| Edge: PDF relational | e2e | `profile_database(emit_pdf=True)` sobre chinook | `pdf_path` no nulo; PDF con resumen de tablas + join graph | +| Error: sección corrupta | unit | `render_eda_pdf` con una sección con tipo inesperado | esa sección se omite con nota; PDF sigue válido (≥1 página) | +| Mecánica | — | `./fn run render_eda_markdown_py_datascience`, `./fn run render_eda_pdf_py_datascience`; `fn index` | tests verdes; índice limpio | + +Re-correr el benchmark sobre un single-table con modelos (wine-red) y sobre un relational (chinook) +y confirmar que models llega al MD y al PDF, y que `profile_database` emite PDF. + +## Notas + +Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. Tipo `feature` porque, además de arreglar el +volcado crudo (H4, fix), añade un renderer PDF relational nuevo (H9). La información ya existe en el +JSON; este issue solo la hace legible en las dos salidas pensadas para humanos. Hermanos: 0173, 0174, +0175, 0177. diff --git a/dev/issues/0177-eda-tipos-id-secuencial-eta-cuadrado.md b/dev/issues/0177-eda-tipos-id-secuencial-eta-cuadrado.md new file mode 100644 index 00000000..c297c5e1 --- /dev/null +++ b/dev/issues/0177-eda-tipos-id-secuencial-eta-cuadrado.md @@ -0,0 +1,86 @@ +--- +id: "0177" +title: "EDA tipos: id secuencial fuera de correlación/PCA + η² espurio por cardinalidad + re-expresión no-continuas" +status: pendiente +type: bugfix +domain: + - registry-quality +scope: registry-only +priority: media +depends: [] +blocks: [] +related: ["0173", "0174", "0175", "0176"] +created: 2026-06-29 +updated: 2026-06-29 +tags: [eda, datascience, profile_table, association_matrix, correlation_ratio, run_eda_models, suggest_reexpression, benchmark] +--- +# 0177 — EDA tipos: id secuencial fuera de correlación/PCA + η² espurio + +## Contexto + +El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) **refutó** el riesgo temido +(que el EDA excluyera columnas financieras `Open/Close/High/Low/Volume` por marcarlas id-like: NO +ocurre, aparecen en todo). Pero detectó el **problema inverso**: el flag `possible_id` es cosmético +y no excluye lo que sí debería (índices secuenciales), y la razón de correlación η² da artefactos +≈1 por cardinalidad. + +Hallazgos cubiertos: + +| Hallazgo | Severidad | Evidencia del benchmark | +|---|---|---| +| H7 — `possible_id` no excluye id secuencial (`PassengerId`) de correlación ni de PCA/KMeans | medio-alto | titanic `PassengerId–Cabin` η²=0.897 `sig=sí`; `models.pca.n_features=7` incluye `PassengerId`, `Survived`, `Pclass` | +| H6 — `correlation_ratio` (η²) ≈1 espurio cuando la categórica tiene cardinalidad ≈ n | alto | titanic `Ticket–Fare=1 sig=sí` (`Ticket` 681 distintos/891); aapl/btc/seattle/fred `Date–* =1` | +| H12 — `suggest_reexpression` sugiere fila para binarias/ordinales/ids (aunque sea `none`) | bajo | titanic `Survived` (0/1), `Pclass` (ordinal), `PassengerId` (id) listadas | + +### Causa raíz (verificada en código, READ-ONLY) + +- `python/functions/pipelines/profile_table.py:356-361` (`_skip_for_assoc`) excluye de la matriz de + asociación las columnas id-like **categóricas/text** (`possible_id`/`high_cardinality`), pero **no** + excluye numéricas secuenciales (`PassengerId` es numérica con `possible_id`) ni columnas datetime. + El `assoc_input` resultante se pasa tal cual a `run_eda_models` (`:391`), así que el id secuencial, + el target binario y el ordinal entran como features de PCA/KMeans. +- H6: `correlation_ratio.py` calcula η² sin guard de cardinalidad; cuando cada grupo tiene ~1 + observación (categórica de cardinalidad ≈ n), la varianza intra-grupo ≈0 → η²≈1 trivialmente. El + FDR no protege (artefacto determinista, no azar). +- H12: `suggest_reexpression` (llamado en `profile_table.py:300` para toda numérica) no salta + binarias/ordinales/ids. + +## Tareas + +1. **H7 — distinguir id secuencial de float continuo:** en la detección de tipos + (`summarize_table_duckdb.py` / lógica de `possible_id`) o en `profile_table.py`, marcar + "índice entero secuencial/monótono" distinto de "float continuo de alta cardinalidad". El primero + se excluye de correlación y de PCA/KMeans; el segundo se mantiene (precios). **Nunca** excluir + floats continuos. +2. **H7 — excluir no-features de los modelos:** en `_skip_for_assoc` (y/o en `run_eda_models.py`) + excluir de PCA/KMeans los ids secuenciales, binarias, ordinales y el target evidente, además de + las categóricas id-like que ya se excluyen. +3. **H6 — guard de cardinalidad en η²:** en `correlation_ratio.py` (y/o al construir los pares en + `association_matrix.py`/`profile_table.py`), no computar η² si la categórica tiene cardinalidad + cercana a `n` o tamaño de grupo medio ≈1; excluir columnas datetime/id de los pares categóricos. +4. **H12 — saltar no-continuas en re-expresión:** en `suggest_reexpression.py` (o en la llamada de + `profile_table.py:300`), no emitir fila de re-expresión para binarias/ordinales/ids. +5. Tests: `correlation_ratio_test.py` (categórica cardinalidad≈n → no η²≈1 espurio); + `run_eda_models_test.py` (id secuencial/target/ordinal no entran como features); + `suggest_reexpression_test.py` (binaria/ordinal/id → sin sugerencia). + +## Definition of Done + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: id secuencial fuera | e2e | re-correr `profile_table(run_models=True)` sobre titanic | `PassengerId` NO aparece en correlaciones ni en `models.pca.features`; floats continuos (precios en aapl/btc) SÍ se conservan | +| Golden: η² sin artefacto | e2e | re-correr sobre titanic | `Ticket–Fare` y `Date–*` NO aparecen como par fuerte η²=1 | +| Edge: float continuo | unit | `correlation_ratio_test.py` / detección de tipos | columna float de alta cardinalidad (precio) se mantiene en correlación | +| Edge: re-expresión | unit | `suggest_reexpression_test.py` con binaria/ordinal/id | sin fila de re-expresión | +| Error: solo numéricas | unit | `run_eda_models` con assoc_input vacío tras filtrar | sin crash; bloque models coherente | +| Mecánica | — | `./fn run correlation_ratio_py_datascience`, `./fn run run_eda_models_py_datascience`, `./fn run suggest_reexpression_py_datascience`; `fn index` | tests verdes; índice limpio | + +Re-correr el benchmark sobre titanic (id secuencial + η² espurio) y sobre aapl/btc (confirmar que +los floats financieros NO se excluyen) y verificar ambos comportamientos. + +## Notas + +Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. El warning "grave" del benchmark (excluir +columnas financieras) quedó **refutado**: este issue arregla el problema inverso real (no excluir +ids secuenciales) sin tocar el tratamiento correcto de los floats continuos. Hermanos: 0173, 0174, +0175, 0176. diff --git a/docs/capabilities/eda.md b/docs/capabilities/eda.md index bf1b1bbd..946fec5f 100644 --- a/docs/capabilities/eda.md +++ b/docs/capabilities/eda.md @@ -3,7 +3,7 @@ Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos). Orquestadores one-shot: -- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM). +- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON (+ PDF móvil con `emit_pdf`). Flags `run_models` (modelos baratos), `run_llm` (interpretación LLM), `run_series` (análisis de serie temporal por columna numérica) y `emit_pdf` (PDF vertical legible en móvil). Re-expresión sugerida por columna y avisos exploratorios se añaden siempre. - `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid). > Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`. @@ -50,16 +50,32 @@ Orquestadores one-shot: | `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. | | `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. | +### Series temporales (flag `run_series`) +| ID | Pureza | Qué hace | +|---|---|---| +| `adf_kpss_stationarity_py_datascience` | pure | Estacionariedad por consenso ADF + KPSS (hipótesis nulas opuestas) → veredicto `stationary`/`non_stationary`/`inconclusive` + aviso de correlación espuria. | +| `acf_pacf_py_datascience` | pure | ACF + PACF con bandas de confianza + lags significativos + Ljung-Box (¿ruido blanco?). Detecta autocorrelación que infla los p-valores OLS. | +| `stl_decompose_py_datascience` | pure | Descomposición STL (tendencia/estacional/resto) + fuerza de tendencia y estacional de Hyndman. Auto-infiere el periodo por autocorrelación. | +| `to_returns_py_datascience` | pure | Convierte una serie de niveles (precios) a retornos log/simples. Los niveles no son estacionarios; los retornos sí (unidad correcta para correlacionar/modelar). | + +### Rigor y disciplina exploratoria +| ID | Pureza | Qué hace | +|---|---|---| +| `fdr_correction_py_datascience` | pure | Corrige p-valores por comparaciones múltiples (Benjamini-Hochberg FDR / Bonferroni FWER) → controla el data-mining bias. Ya integrada en `association_matrix`. | +| `suggest_reexpression_py_datascience` | pure | Escalera de potencias de Tukey: qué transformación (log/sqrt/Yeo-Johnson/...) simetriza mejor una columna numérica según su skew y dominio. No la ejecuta, la sugiere. | +| `exploratory_caveats_py_datascience` | pure | Genera las advertencias de que el EDA es exploratorio (correlación≠causalidad, overfitting in-sample, comparaciones múltiples, outliers, muestra pequeña, MNAR) según lo que el perfil realmente contiene. | + ### Capa LLM y entrega | ID | Pureza | Qué hace | |---|---|---| | `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. | | `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. | +| `render_eda_pdf_py_datascience` | impure | Renderiza el `TableProfile` a un PDF multipágina **vertical (A5), legible en móvil** (estilo Tufte: histogramas como small multiples, top-k, heatmap de asociación). 4ª salida del workflow, junto a JSON/Markdown/notebook. | ### Orquestadores (pipelines) | ID | Qué hace | |---|---| -| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. | +| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación con FDR + `run_models` + `run_llm` + `run_series` + re-expresión + caveats) → JSON + markdown (+ PDF móvil con `emit_pdf`). | | `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. | ## Contrato de datos @@ -68,15 +84,26 @@ Orquestadores one-shot: TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct, type_breakdown:{numeric,categorical,datetime,text,boolean}, - columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models} + columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models, + series:{:SeriesBlock}|None, # solo con run_series + caveats:{n, caveats:[{id,topic,message,reference}], note}} # siempre ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows, null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct, flags:[constant|possible_id|high_cardinality|mostly_null], quality_score, - numeric:{...}|None, categorical:{...}|None, datetime:{...}|None} + numeric:{...}|None, categorical:{...}|None, datetime:{...}|None, + reexpression:{recommended,ladder_power,reason,alternatives,skew}|None, # cols numéricas + series:SeriesBlock|None} # solo con run_series # *_pct son FRACCIONES 0-1; el render las muestra ×100 -correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend} +SeriesBlock = {order_col, ordered, n, stationarity:{adf,kpss,verdict,warning}, + acf_pacf:{acf,pacf,significant_acf_lags,ljung_box,is_autocorrelated}, + stl:{period,trend_strength,seasonal_strength,...}, + to_returns:{...}|absent, levels_suggested:bool} + +correlations = {pairs:[{a,b,a_type,b_type,method,value,extra,p_value, + p_value_adjusted,significant}], strong:[...], methods_legend, + multiple_testing:{method,alpha,n_tests,n_rejected}} # p-valores corregidos por FDR models = {n_numeric_cols, pca, kmeans, outliers, normality, note} llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}], pii:[{column,kind,severity}], cleaning:[str], analyses:[str]} @@ -91,11 +118,18 @@ import sys, os sys.path.insert(0, os.path.join("python", "functions")) from pipelines.profile_table import profile_table -r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True) +r = profile_table( + "/ruta/datos.duckdb", "clientes", + run_models=True, run_llm=True, run_series=True, emit_pdf=True, +) prof = r["profile"] print(r["report_md_path"]) # reports/eda_clientes_.md -print(prof["correlations"]["strong"]) # pares correlacionados +print(r["pdf_path"]) # reports/eda_clientes_.pdf (móvil) +print(prof["correlations"]["strong"]) # pares fuertes Y significativos tras FDR print(prof["models"]["kmeans"]["best_k"]) # segmentos +print(prof["series"]["precio"]["stationarity"]["verdict"]) # ¿serie estacionaria? +print(prof["columns"][0]["reexpression"]["recommended"]) # transformación sugerida +print(prof["caveats"]["caveats"][0]["message"]) # aviso exploratorio general print(prof["llm"]["row_meaning"]) # qué representa 1 fila ``` @@ -121,6 +155,9 @@ build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_model - **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta. - **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1. - **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude. +- **Series** (`run_series`) trata cada columna numérica como serie temporal: si hay una columna datetime se ordena por ella, si no por el orden físico de filas. Necesita ≥8 puntos válidos por columna; STL exige ≥2 periodos. La sugerencia de retornos (`to_returns`) solo aparece en columnas estrictamente positivas y no claramente estacionarias (series de niveles/precios). +- **PDF** (`emit_pdf`) genera un PDF A5 vertical legible en móvil junto al report markdown vía `render_eda_pdf` (matplotlib `PdfPages`, sin dependencias nuevas). +- **Correlaciones**: los p-valores de cada par se corrigen por comparaciones múltiples (FDR Benjamini-Hochberg) dentro de `association_matrix`; un par solo entra en `strong` si supera el umbral de magnitud Y es significativo tras la corrección. - **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora. ## Estado diff --git a/python/functions/datascience/__init__.py b/python/functions/datascience/__init__.py index 715fb43d..b8f0388f 100644 --- a/python/functions/datascience/__init__.py +++ b/python/functions/datascience/__init__.py @@ -45,9 +45,25 @@ from .run_eda_models import run_eda_models from .eda_llm_insights import eda_llm_insights from .build_eda_notebook import build_eda_notebook from .decode_qr_image import decode_qr_image +from .adf_kpss_stationarity import adf_kpss_stationarity +from .acf_pacf import acf_pacf +from .stl_decompose import stl_decompose +from .to_returns import to_returns +from .fdr_correction import fdr_correction +from .suggest_reexpression import suggest_reexpression +from .exploratory_caveats import exploratory_caveats +from .render_eda_pdf import render_eda_pdf __all__ = [ "decode_qr_image", + "adf_kpss_stationarity", + "acf_pacf", + "stl_decompose", + "to_returns", + "fdr_correction", + "suggest_reexpression", + "exploratory_caveats", + "render_eda_pdf", "summarize_table_duckdb", "summarize_table_pg", "spearman_corr", diff --git a/python/functions/datascience/acf_pacf.md b/python/functions/datascience/acf_pacf.md new file mode 100644 index 00000000..8e7b3cda --- /dev/null +++ b/python/functions/datascience/acf_pacf.md @@ -0,0 +1,73 @@ +--- +name: acf_pacf +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict" +description: "Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie temporal con sus bandas de confianza (statsmodels), mas el test Ljung-Box de autocorrelacion global. Devuelve listas acf/pacf, sus intervalos, los lags significativos y un flag is_autocorrelated. Clave: una serie autocorrelacionada viola IID, asi que los p-valores de una regresion OLS estandar sobre ella estan inflados (Lopez de Prado). Descarta None/NaN; <8 puntos validos -> nota." +tags: [statistics, timeseries, autocorrelation, acf, pacf, ljung-box, arima, eda, forecasting, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [math, numpy, statsmodels] +params: + - name: values + desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del calculo." + - name: nlags + desc: "numero maximo de retardos a calcular (default 40). Se recorta a los limites de statsmodels: n-1 para ACF, (n//2)-1 para PACF." + - name: alpha + desc: "nivel de significancia para las bandas de confianza y el test de Ljung-Box (default 0.05)." +output: "dict con 'acf' y 'pacf' (listas, indice 0 = lag 0), 'acf_confint'/'pacf_confint' (banda por lag), 'significant_acf_lags'/'significant_pacf_lags' (lags >=1 fuera de banda), 'ljung_box' (stat, p_value, lags) e 'is_autocorrelated' (bool: Ljung-Box rechaza independencia). Con <8 puntos: {'n', 'note', 'is_autocorrelated': None}. Nunca lanza excepcion." +tested: true +tests: ["test_ruido_blanco_no_autocorrelado", "test_ar1_es_autocorrelado", "test_lag1_significativo_en_ar1", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_recorta_nlags_a_limites", "test_acf_lag0_es_uno"] +test_file_path: "python/functions/datascience/acf_pacf_test.py" +file_path: "python/functions/datascience/acf_pacf.py" +--- + +## Ejemplo + +```python +from datascience import acf_pacf +import numpy as np + +# Ruido blanco: sin autocorrelacion (Ljung-Box no rechaza independencia) +rng = np.random.default_rng(0) +ruido = rng.normal(0, 1, 500).tolist() +acf_pacf(ruido)["is_autocorrelated"] # -> False + +# Proceso AR(1) fuerte: autocorrelado, lag 1 significativo en PACF +ar = [0.0] +for _ in range(500): + ar.append(0.8 * ar[-1] + rng.normal(0, 1)) +res = acf_pacf(ar) +res["is_autocorrelated"] # -> True +res["significant_pacf_lags"][:1] # -> [1] +``` + +## Cuando usarla + +Para diagnosticar la estructura de dependencia temporal de una serie: identificar +el orden de un modelo ARIMA (PACF corta en el orden AR, ACF corta en el orden MA), +o detectar estacionalidad (picos en lags estacionales). Y, critico para EDA: antes +de meter una variable temporal en una regresion, comprueba `is_autocorrelated`. Si +es `True`, la serie no es IID y los p-valores de OLS estandar estan inflados — hay +que usar errores estandar robustos (Newey-West) o modelar la dinamica +explicitamente (Lopez de Prado). + +## Gotchas + +- Es pura pero importa `statsmodels` y `numpy` (ambos en `python/.venv`). +- `acf[0]` y `pacf[0]` valen siempre 1.0 (autocorrelacion de la serie consigo + misma en lag 0). Los lags interesantes empiezan en el indice 1. +- `nlags` se recorta automaticamente: PACF exige `nlags < n/2`. Si pides 40 lags + sobre una serie de 30 puntos, `nlags` efectivo baja — mira el campo `nlags` + del resultado para saber cuantos se calcularon. +- Las bandas de confianza asumen ruido blanco bajo H0; en una serie con + tendencia muchos lags saldran "significativos" por la propia tendencia, no por + estructura ARMA. Estaciona primero (ver adf_kpss_stationarity / to_returns). +- Ljung-Box es un test global (todos los lags juntos); los lags individuales + significativos te dicen DONDE esta la autocorrelacion. diff --git a/python/functions/datascience/acf_pacf.py b/python/functions/datascience/acf_pacf.py new file mode 100644 index 00000000..8fbfb69a --- /dev/null +++ b/python/functions/datascience/acf_pacf.py @@ -0,0 +1,134 @@ +"""Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie (grupo eda). + +Funcion pura y determinista que calcula la funcion de autocorrelacion y la +parcial con sus bandas de confianza, mas el test de Ljung-Box de autocorrelacion +global. Motivada por Hyndman ("Forecasting") para identificar el orden de un +modelo ARIMA, y por Lopez de Prado ("Advances in Financial ML"): una serie +autocorrelacionada viola el supuesto IID, de modo que los p-valores de una +regresion OLS estandar sobre ella estan inflados (falsos positivos). +""" + +from __future__ import annotations + +import math + +import numpy as np +from statsmodels.stats.diagnostic import acorr_ljungbox +from statsmodels.tsa.stattools import acf, pacf + + +def _clean(values: list) -> list[float]: + """Conserva solo valores numericos finitos, descartando None/NaN/no-numericos. + + Los booleanos se excluyen explicitamente (en Python ``bool`` es subclase de + ``int``, pero no es un valor de serie temporal valido). + """ + out: list[float] = [] + for v in values: + if v is None or isinstance(v, bool): + continue + if not isinstance(v, (int, float)): + continue + x = float(v) + if math.isnan(x) or math.isinf(x): + continue + out.append(x) + return out + + +def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict: + """Calcula ACF, PACF y el test Ljung-Box de una serie temporal. + + Computa la funcion de autocorrelacion (ACF) y la autocorrelacion parcial + (PACF) hasta ``nlags`` retardos, con sus bandas de confianza al nivel + ``1 - alpha``, e identifica que retardos son significativos (cuyo intervalo + de confianza no contiene 0). Ademas corre el test de **Ljung-Box** sobre el + conjunto de retardos: H0 = "los datos son independientes" (sin + autocorrelacion); si ``p < alpha`` se rechaza -> la serie esta + autocorrelacionada. + + Funcion pura y determinista: no hace I/O, no muta los inputs. + + Args: + values: serie temporal de valores numericos en orden cronologico. + None/NaN/infinitos/no-numericos se descartan antes del calculo. + nlags: numero maximo de retardos a calcular (default 40). Se recorta + automaticamente a ``n // 2`` para PACF (statsmodels exige + ``nlags < n/2``) y a ``n - 1`` para ACF. + alpha: nivel de significancia para las bandas de confianza y para el + test de Ljung-Box (default 0.05). + + Returns: + Con menos de 8 puntos validos devuelve + ``{"n": n, "note": "datos insuficientes", "is_autocorrelated": None}``. + + En otro caso un dict con:: + + { + "n": int, + "nlags": int, # retardos efectivamente calculados + "acf": [float, ...], # incluye lag 0 (=1.0) en el indice 0 + "pacf": [float, ...], + "acf_confint": [[low, high], ...], # banda por lag + "pacf_confint": [[low, high], ...], + "significant_acf_lags": [int, ...], # lags (>=1) significativos + "significant_pacf_lags": [int, ...], + "ljung_box": {"stat": float, "p_value": float, "lags": int}, + "is_autocorrelated": bool, # Ljung-Box rechaza independencia + } + + ``is_autocorrelated = True`` significa que la serie NO es ruido blanco: + cuidado al aplicarle inferencia OLS clasica (p-valores inflados). + """ + clean = _clean(values) + n = len(clean) + + if n < 8: + return {"n": n, "note": "datos insuficientes", "is_autocorrelated": None} + + arr = np.asarray(clean, dtype=float) + + # Recorta nlags a los limites de statsmodels: ACF admite hasta n-1, PACF < n/2. + eff_lags = min(nlags, n - 1, (n // 2) - 1) + eff_lags = max(eff_lags, 1) + + acf_vals, acf_confint = acf(arr, nlags=eff_lags, alpha=alpha, fft=False) + pacf_vals, pacf_confint = pacf(arr, nlags=eff_lags, alpha=alpha) + + # Un lag es significativo si su banda de confianza (centrada en el valor) no + # contiene 0. statsmodels devuelve confint como intervalos centrados en el + # estimador, asi que comparamos el intervalo desplazado al origen. + def _significant(vals, confint) -> list[int]: + out: list[int] = [] + for lag in range(1, len(vals)): + low = confint[lag][0] - vals[lag] + high = confint[lag][1] - vals[lag] + if vals[lag] < low or vals[lag] > high: + out.append(lag) + return out + + significant_acf = _significant(acf_vals, acf_confint) + significant_pacf = _significant(pacf_vals, pacf_confint) + + # Ljung-Box sobre el maximo retardo calculado. + lb = acorr_ljungbox(arr, lags=[eff_lags], return_df=True) + lb_stat = float(lb["lb_stat"].iloc[0]) + lb_p = float(lb["lb_pvalue"].iloc[0]) + is_autocorrelated = bool(lb_p < alpha) + + return { + "n": n, + "nlags": int(eff_lags), + "acf": [float(v) for v in acf_vals], + "pacf": [float(v) for v in pacf_vals], + "acf_confint": [[float(lo), float(hi)] for lo, hi in acf_confint], + "pacf_confint": [[float(lo), float(hi)] for lo, hi in pacf_confint], + "significant_acf_lags": significant_acf, + "significant_pacf_lags": significant_pacf, + "ljung_box": { + "stat": lb_stat, + "p_value": lb_p, + "lags": int(eff_lags), + }, + "is_autocorrelated": is_autocorrelated, + } diff --git a/python/functions/datascience/adf_kpss_stationarity.md b/python/functions/datascience/adf_kpss_stationarity.md new file mode 100644 index 00000000..34b2868b --- /dev/null +++ b/python/functions/datascience/adf_kpss_stationarity.md @@ -0,0 +1,69 @@ +--- +name: adf_kpss_stationarity +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict" +description: "Test de estacionariedad de una serie temporal combinando ADF (H0=raiz unitaria/no estacionaria) y KPSS (H0=estacionaria) de statsmodels. Devuelve por test estadistico, p_value, lags y conclusion, mas un veredicto de consenso ('stationary'|'non_stationary'|'inconclusive'). Avisa de correlacion espuria (Granger-Newbold) cuando la serie no es estacionaria. Descarta None/NaN/infinitos; <8 puntos validos -> nota 'datos insuficientes'." +tags: [statistics, timeseries, stationarity, adf, kpss, unit-root, eda, forecasting, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [math, warnings, statsmodels] +params: + - name: values + desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del test." + - name: alpha + desc: "nivel de significancia para ambos contrastes (default 0.05). p "stationary" + +# Random walk (suma acumulada): NO estacionario +paseo = np.cumsum(rng.normal(0, 1, 300)).tolist() +res = adf_kpss_stationarity(paseo) +res["verdict"] # -> "non_stationary" +res["warning"] # -> aviso de correlacion espuria +``` + +## Cuando usarla + +Antes de correlacionar, regresionar o modelar (ARIMA, VAR) una serie temporal, +para saber si es estacionaria. Es el primer paso obligatorio del analisis de +series: una serie no estacionaria (con tendencia o raiz unitaria) rompe los +supuestos de la regresion OLS clasica y, si la correlacionas con otra serie no +estacionaria, obtienes una correlacion alta pero **espuria** (Granger-Newbold). +Si el veredicto no es `"stationary"`, diferencia la serie o pasala a retornos +(`to_returns`) y vuelve a testear. + +## Gotchas + +- Es pura pero importa `statsmodels.tsa.stattools` (instalado en `python/.venv`). +- ADF y KPSS tienen hipotesis nulas OPUESTAS: en ADF `p list[float]: + """Conserva solo valores numericos finitos, descartando None/NaN/no-numericos. + + Los booleanos se excluyen explicitamente: en Python ``bool`` es subclase de + ``int``, pero tratar True/False como numeros en una serie temporal es casi + siempre un error de tipado. + """ + out: list[float] = [] + for v in values: + if v is None or isinstance(v, bool): + continue + if not isinstance(v, (int, float)): + continue + x = float(v) + if math.isnan(x) or math.isinf(x): + continue + out.append(x) + return out + + +def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict: + """Evalua la estacionariedad de una serie combinando ADF y KPSS. + + Aplica dos contrastes con hipotesis nulas opuestas: + + - **ADF** (Augmented Dickey-Fuller): H0 = "la serie tiene raiz unitaria" + (es NO estacionaria). Si ``p < alpha`` se rechaza H0 -> evidencia de + estacionariedad. + - **KPSS** (Kwiatkowski-Phillips-Schmidt-Shin): H0 = "la serie es + estacionaria (en torno a una tendencia)". Si ``p < alpha`` se rechaza H0 + -> evidencia de NO estacionariedad. + + Combinar ambos da mas robustez que cualquiera por separado, porque sus + hipotesis nulas son contrarias. El veredicto de consenso sigue la + interpretacion estandar (Hyndman, "Forecasting: Principles and Practice"): + + - ADF rechaza H0 **y** KPSS no rechaza H0 -> ``"stationary"``. + - ADF no rechaza H0 **y** KPSS rechaza H0 -> ``"non_stationary"``. + - Ambos coinciden en lo contrario o se contradicen -> ``"inconclusive"`` + (a menudo indica serie diferenciable o estacionaria en tendencia). + + Funcion pura y determinista: no hace I/O, no muta los inputs. + + Args: + values: serie temporal de valores numericos en orden cronologico. + None/NaN/infinitos/no-numericos se descartan antes del test. + alpha: nivel de significancia para ambos contrastes (default 0.05). + + Returns: + Con menos de 8 puntos validos (muestra insuficiente para un test de + raiz unitaria fiable) devuelve + ``{"n": n, "note": "datos insuficientes", "verdict": None}``. + + En otro caso un dict con:: + + { + "n": int, + "alpha": float, + "adf": {"stat": float, "p_value": float, "lags": int, + "stationary": bool, # rechaza H0 de raiz unitaria + "conclusion": str}, + "kpss": {"stat": float, "p_value": float, "lags": int, + "stationary": bool, # NO rechaza H0 de estacionariedad + "conclusion": str, + "p_value_clipped": bool}, # p en limite de tabla KPSS + "verdict": "stationary" | "non_stationary" | "inconclusive", + "warning": str | None, # aviso de correlacion espuria si procede + } + + ``warning`` se rellena cuando el veredicto NO es ``"stationary"`` para + recordar que correlacionar/regresionar niveles no estacionarios produce + relaciones espurias; conviene pasar a retornos o diferencias. + """ + clean = _clean(values) + n = len(clean) + + if n < 8: + return {"n": n, "note": "datos insuficientes", "verdict": None} + + # ADF: H0 = raiz unitaria (no estacionaria). p < alpha => estacionaria. + adf_stat, adf_p, adf_lags, _adf_nobs, _adf_crit, _adf_icbest = adfuller( + clean, autolag="AIC" + ) + adf_stationary = bool(adf_p < alpha) + adf = { + "stat": float(adf_stat), + "p_value": float(adf_p), + "lags": int(adf_lags), + "stationary": adf_stationary, + "conclusion": ( + "rechaza H0 de raiz unitaria: evidencia de estacionariedad" + if adf_stationary + else "no rechaza H0 de raiz unitaria: posible no estacionaria" + ), + } + + # KPSS: H0 = estacionaria en torno a tendencia. p < alpha => NO estacionaria. + # statsmodels emite InterpolationWarning cuando el p-valor cae fuera de la + # tabla [0.01, 0.10]; lo capturamos para saber si quedo recortado. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + kpss_stat, kpss_p, kpss_lags, _kpss_crit = kpss( + clean, regression="c", nlags="auto" + ) + p_clipped = any("InterpolationWarning" in str(w.category) for w in caught) or any( + "p-value" in str(w.message).lower() for w in caught + ) + kpss_stationary = bool(kpss_p >= alpha) # NO rechaza H0 => estacionaria + kpss_result = { + "stat": float(kpss_stat), + "p_value": float(kpss_p), + "lags": int(kpss_lags), + "stationary": kpss_stationary, + "conclusion": ( + "no rechaza H0 de estacionariedad: evidencia de estacionariedad" + if kpss_stationary + else "rechaza H0 de estacionariedad: posible no estacionaria" + ), + "p_value_clipped": bool(p_clipped), + } + + # Consenso de los dos contrastes. + if adf_stationary and kpss_stationary: + verdict = "stationary" + elif (not adf_stationary) and (not kpss_stationary): + verdict = "non_stationary" + else: + verdict = "inconclusive" + + warning: str | None = None + if verdict != "stationary": + warning = ( + "serie no claramente estacionaria: correlacionar o regresionar sus " + "niveles puede dar relaciones espurias (Granger-Newbold). Considera " + "trabajar sobre retornos o diferencias (ver to_returns)." + ) + + return { + "n": n, + "alpha": float(alpha), + "adf": adf, + "kpss": kpss_result, + "verdict": verdict, + "warning": warning, + } diff --git a/python/functions/datascience/association_matrix.md b/python/functions/datascience/association_matrix.md index f303cfd8..f2b8a853 100644 --- a/python/functions/datascience/association_matrix.md +++ b/python/functions/datascience/association_matrix.md @@ -3,19 +3,23 @@ name: association_matrix kind: function lang: py domain: datascience -version: "1.0.0" +version: "1.1.0" purity: pure -signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict" -description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos." -tags: [eda, correlation, association, statistics, mixed-types, mutual-information] +signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20, alpha: float = 0.05, fdr_method: str = \"bh\") -> dict" +description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Cada par lleva su p-valor (test de correlacion / chi-cuadrado / ANOVA) y se corrige por comparaciones multiples (FDR) para combatir el sesgo de mineria de datos: el subconjunto fuerte se basa en la significancia corregida, no solo en superar el umbral de magnitud." +tags: [eda, correlation, association, statistics, mixed-types, mutual-information, multiple-testing, p-value, fdr] params: - name: columns desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido." - name: strong_threshold - desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5." + desc: "Umbral de magnitud en [0, 1]. Condicion necesaria (ya no suficiente) para ser fuerte: abs(value) >= umbral o extra.mi >= umbral. Default 0.5." - name: top_n desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20." -output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]." + - name: alpha + desc: "Nivel de significancia tras la correccion FDR (default 0.05). Un par con p-valor disponible solo es fuerte si ademas su p-valor ajustado <= alpha." + - name: fdr_method + desc: "Metodo de correccion de comparaciones multiples: 'bh' (Benjamini-Hochberg, FDR; default) o 'bonferroni' (FWER, mas conservador)." +output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra, p_value, p_value_adjusted, significant}; strong: subconjunto con magnitud >= umbral Y significativo tras FDR (pares sin test se admiten por magnitud), ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion; n_tests: nº total de pares evaluados (== len(pairs)); multiple_testing: {method, alpha, n_tests, n_rejected}}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]." uses_functions: - pearson_py_datascience - spearman_corr_py_datascience @@ -23,13 +27,14 @@ uses_functions: - theils_u_py_datascience - correlation_ratio_py_datascience - mutual_info_columns_py_datascience + - fdr_correction_py_datascience uses_types: [] returns: [] returns_optional: false error_type: "" -imports: [] +imports: [scipy] tested: true -tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"] +tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty", "test_pairs_carry_significance_fields", "test_result_reports_multiple_testing_summary", "test_strong_requires_corrected_significance", "test_bonferroni_method_is_accepted"] test_file_path: "python/functions/datascience/association_matrix_test.py" file_path: "python/functions/datascience/association_matrix.py" --- @@ -84,3 +89,36 @@ no-lineal a todos los pares. categorica como primer argumento y la numerica como segundo. - Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya cardinalidad sea >= 90% del numero de filas (identificadores / free-text). + +## Gotchas + +- **Ahora corrige multiple-testing (v1.1.0).** El subconjunto `strong` ya no + depende solo de la magnitud: un par con magnitud alta pero p-valor ajustado + > `alpha` NO entra en `strong`. Esto combate el sesgo de mineria de datos + (data-mining bias, Aronson cap. 6): al evaluar todos los pares a la vez, el + azar produce correlaciones espurias que el umbral de magnitud por si solo + dejaria pasar. +- Cada par lleva `p_value` (test del metodo principal: correlacion de + Pearson/Spearman, chi-cuadrado de independencia para Cramer's V, ANOVA de una + via para correlation ratio) y `p_value_adjusted` (tras `fdr_correction`). La + informacion mutua no tiene test asociado, por lo que un par cuyo metodo + principal sea degenerado puede tener `p_value = None`; esos pares se admiten en + `strong` por magnitud (no hay p-valor que corregir). +- `n_tests` (top-level) es el numero total de pares evaluados (`len(pairs)`), + mientras que `multiple_testing.n_tests` es el numero de p-valores **validos** + que entraron en la correccion (puede ser menor si algun par no tiene test). +- Sigue siendo pura, pero ahora importa `scipy.stats` (`pearsonr`, `spearmanr`, + `chi2_contingency`, `f_oneway`) para los p-valores; scipy ya vive en + `python/.venv`. +- Sube `alpha` o usa `fdr_method="bonferroni"` segun lo costoso que sea un falso + positivo: BH controla la tasa de falsos descubrimientos (mas potencia), + Bonferroni la probabilidad de cualquier falso positivo (mas cautela). + +## Capability growth log + +- v1.1.0 (28/06/2026) — anade p-valor por par (Pearson/Spearman, chi-cuadrado, + ANOVA) + correccion de comparaciones multiples via `fdr_correction` (BH / + Bonferroni). `strong` pasa a basarse en la significancia corregida, no solo en + el umbral de magnitud. Nuevos parametros `alpha` y `fdr_method`; nuevas claves + `p_value`/`p_value_adjusted`/`significant` por par y `n_tests`/ + `multiple_testing` en el resultado. Retrocompatible: no quita claves previas. diff --git a/python/functions/datascience/association_matrix.py b/python/functions/datascience/association_matrix.py index 2f284555..1b7f7250 100644 --- a/python/functions/datascience/association_matrix.py +++ b/python/functions/datascience/association_matrix.py @@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas. """ import math +from collections import Counter, defaultdict + +from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr from datascience import ( correlation_ratio, @@ -19,6 +22,10 @@ from datascience import ( theils_u, ) +# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su +# __init__ (lo integra el orquestador al cerrar el grupo eda). +from datascience.fdr_correction import fdr_correction + # Tipos que, para efectos de asociacion, se tratan como categoricos. _CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"} @@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]: return cx, cy +def _safe_pvalue(value) -> float | None: + """Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido.""" + if value is None: + return None + try: + pv = float(value) + except (TypeError, ValueError): + return None + if math.isnan(pv) or math.isinf(pv): + return None + return pv + + +def _pearson_pvalue(cx: list, cy: list) -> float | None: + """p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado.""" + if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2: + return None + try: + return _safe_pvalue(pearsonr(cx, cy).pvalue) + except Exception: + return None + + +def _spearman_pvalue(cx: list, cy: list) -> float | None: + """p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado.""" + if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2: + return None + try: + return _safe_pvalue(spearmanr(cx, cy).pvalue) + except Exception: + return None + + +def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None: + """p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado.""" + pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None] + if len(pairs) < 2: + return None + rows = sorted({x for x, _ in pairs}, key=repr) + cols = sorted({y for _, y in pairs}, key=repr) + if len(rows) < 2 or len(cols) < 2: + return None + row_idx = {v: i for i, v in enumerate(rows)} + col_idx = {v: j for j, v in enumerate(cols)} + counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs) + table = [ + [counts.get((i, j), 0) for j in range(len(cols))] + for i in range(len(rows)) + ] + try: + return _safe_pvalue(chi2_contingency(table).pvalue) + except Exception: + return None + + +def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None: + """p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado.""" + groups: dict = defaultdict(list) + for c, x in zip(cat_vals, num_vals): + if c is None or not _is_num(x): + continue + groups[c].append(float(x)) + valid = [g for g in groups.values() if len(g) >= 2] + if len(valid) < 2: + return None + try: + return _safe_pvalue(f_oneway(*valid).pvalue) + except Exception: + return None + + def association_matrix( columns: dict, strong_threshold: float = 0.5, top_n: int = 20, + alpha: float = 0.05, + fdr_method: str = "bh", ) -> dict: """Construye la matriz de asociacion de una tabla con tipos mezclados. @@ -81,22 +161,48 @@ def association_matrix( asociacion util). Es una funcion pura: no falla con dict vacio o una sola columna (devuelve `pairs=[]`, `strong=[]`). + Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor + del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de + correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio: + ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan + todos los pares a la vez, esos p-valores se corrigen por comparaciones + multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el + subconjunto `strong` se basa en la **significancia corregida**, no solo en + superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado + > alpha NO entra en `strong`. + Args: columns: dict {nombre_columna: {"values": list, "type": str}} donde type es uno de "numeric", "categorical", "datetime", "boolean", "text". Los tipos datetime/boolean/text se tratan como categoricos. - strong_threshold: umbral en [0, 1]. Un par es "fuerte" si - abs(value) >= umbral o extra["mi"] >= umbral. + strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser + "fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero + ya no suficiente (ver alpha). top_n: numero maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) descendente. + alpha: nivel de significancia tras la correccion FDR (default 0.05). Un + par con p-valor disponible solo es fuerte si ademas su p-valor + ajustado <= alpha. + fdr_method: metodo de correccion de comparaciones multiples, + "bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER). Returns: dict con claves: pairs: lista de todos los pares evaluados, cada uno - {a, b, a_type, b_type, method, value, extra}. - strong: subconjunto de pairs por encima del umbral, ordenado por - relevancia descendente y truncado a top_n. + {a, b, a_type, b_type, method, value, extra, p_value, + p_value_adjusted, significant}. `p_value` es el del test del + metodo principal (None si no aplica / degenerado); + `p_value_adjusted` el p-valor tras FDR; `significant` True si + p_value_adjusted <= alpha. + strong: subconjunto de pairs que cumplen magnitud >= umbral Y son + significativos tras la correccion (los pares sin test disponible + se admiten por magnitud), ordenado por relevancia descendente y + truncado a top_n. methods_legend: dict {metodo: descripcion}. + n_tests: numero total de pares evaluados (== len(pairs)). + multiple_testing: dict {method, alpha, n_tests, n_rejected} con el + resumen de la correccion (n_tests aqui = p-valores validos + corregidos, puede ser < len(pairs) si algun par no tiene test). """ legend = { "pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]", @@ -168,20 +274,32 @@ def association_matrix( s = spearman_corr(a_vals, b_vals) extra["pearson"] = p extra["spearman"] = s - value = p if abs(p) >= abs(s) else s + pearson_p = _pearson_pvalue(cx, cy) + spearman_p = _spearman_pvalue(cx, cy) + extra["pearson_p"] = pearson_p + extra["spearman_p"] = spearman_p + if abs(p) >= abs(s): + value = p + p_value = pearson_p + else: + value = s + p_value = spearman_p elif (not a_numeric) and (not b_numeric): method = "cramers_v" value = cramers_v(a_vals, b_vals) extra["u_ab"] = theils_u(a_vals, b_vals) extra["u_ba"] = theils_u(b_vals, a_vals) + p_value = _chi2_pvalue(a_vals, b_vals) else: method = "correlation_ratio" if a_numeric: # a numerica, b categorica. value = correlation_ratio(b_vals, a_vals) + p_value = _anova_pvalue(b_vals, a_vals) else: # a categorica, b numerica. value = correlation_ratio(a_vals, b_vals) + p_value = _anova_pvalue(a_vals, b_vals) pairs.append( { @@ -192,19 +310,55 @@ def association_matrix( "method": method, "value": value, "extra": extra, + "p_value": p_value, } ) + # Correccion de comparaciones multiples sobre los p-valores disponibles. + # Se pasa la lista completa (incluidos los None de pares sin test): la + # correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba. + fdr = fdr_correction( + [pair["p_value"] for pair in pairs], + alpha=alpha, + method=fdr_method, + ) + for pair, padj, rej in zip( + pairs, fdr["p_values_adjusted"], fdr["reject"] + ): + pair["p_value_adjusted"] = padj + pair["significant"] = bool(rej) + def _relevance(pair: dict) -> float: return max(abs(pair["value"]), pair["extra"].get("mi", 0.0)) - strong = [ - pair - for pair in pairs - if abs(pair["value"]) >= strong_threshold - or pair["extra"].get("mi", 0.0) >= strong_threshold - ] + def _is_strong(pair: dict) -> bool: + # Condicion 1: magnitud por encima del umbral (necesaria). + magnitude_ok = ( + abs(pair["value"]) >= strong_threshold + or pair["extra"].get("mi", 0.0) >= strong_threshold + ) + if not magnitude_ok: + return False + # Condicion 2: significancia tras la correccion FDR. Los pares sin test + # disponible (p_value None, p.ej. informacion mutua o caso degenerado) se + # admiten por magnitud, ya que no hay p-valor que corregir. + if pair["p_value"] is None: + return True + return pair["significant"] + + strong = [pair for pair in pairs if _is_strong(pair)] strong.sort(key=_relevance, reverse=True) strong = strong[:top_n] - return {"pairs": pairs, "strong": strong, "methods_legend": legend} + return { + "pairs": pairs, + "strong": strong, + "methods_legend": legend, + "n_tests": len(pairs), + "multiple_testing": { + "method": fdr_method, + "alpha": alpha, + "n_tests": fdr["n_tests"], + "n_rejected": fdr["n_rejected"], + }, + } diff --git a/python/functions/datascience/association_matrix_test.py b/python/functions/datascience/association_matrix_test.py index 7a51b9fc..0795b118 100644 --- a/python/functions/datascience/association_matrix_test.py +++ b/python/functions/datascience/association_matrix_test.py @@ -80,3 +80,79 @@ def test_single_column_returns_empty(): result = association_matrix(columns) assert result["pairs"] == [] assert result["strong"] == [] + + +def test_pairs_carry_significance_fields(): + # Tras la correccion FDR cada par evaluado lleva p_value, p_value_adjusted y + # significant. Un par num-num fuertemente correlado es significativo. + columns = { + "size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"}, + "price": { + "values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1], + "type": "numeric", + }, + } + result = association_matrix(columns, strong_threshold=0.5) + pair = _find_pair(result["pairs"], "size", "price") + assert "p_value" in pair and "p_value_adjusted" in pair and "significant" in pair + assert pair["p_value"] is not None and pair["p_value"] < 0.05 + assert pair["significant"] is True + # p ajustado nunca por debajo del crudo. + assert pair["p_value_adjusted"] >= pair["p_value"] - 1e-12 + + +def test_result_reports_multiple_testing_summary(): + columns = { + "size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"}, + "price": { + "values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1], + "type": "numeric", + }, + } + result = association_matrix(columns) + # n_tests = total de pares evaluados. + assert result["n_tests"] == len(result["pairs"]) + mt = result["multiple_testing"] + assert mt["method"] == "bh" + assert mt["alpha"] == 0.05 + assert mt["n_rejected"] >= 1 + assert mt["n_tests"] >= 1 + + +def test_strong_requires_corrected_significance(): + # Par num-num con magnitud alta pero p-valor no diminuto. Con alpha normal es + # fuerte; con un alpha mas estricto que su p-valor, deja de ser significativo + # y sale de strong AUNQUE la magnitud siga por encima del umbral. Esto prueba + # que strong se basa en la significancia corregida, no solo en el umbral. + columns = { + "a": {"values": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "type": "numeric"}, + "b": {"values": [2, 1, 3, 4, 6, 5, 7, 8, 10, 9, 11, 12], "type": "numeric"}, + } + relaxed = association_matrix(columns, strong_threshold=0.5, alpha=0.05) + pair = _find_pair(relaxed["pairs"], "a", "b") + assert pair["p_value"] is not None and pair["p_value"] < 0.05 + assert abs(pair["value"]) >= 0.5 + assert _find_pair(relaxed["strong"], "a", "b") is not None + + # alpha mas estricto que el p-valor del par -> ya no significativo. + strict = association_matrix( + columns, strong_threshold=0.5, alpha=pair["p_value"] / 10.0 + ) + sp = _find_pair(strict["pairs"], "a", "b") + assert abs(sp["value"]) >= 0.5 # magnitud intacta + assert sp["significant"] is False + assert _find_pair(strict["strong"], "a", "b") is None + + +def test_bonferroni_method_is_accepted(): + columns = { + "size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"}, + "price": { + "values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1], + "type": "numeric", + }, + } + result = association_matrix(columns, fdr_method="bonferroni") + assert result["multiple_testing"]["method"] == "bonferroni" + pair = _find_pair(result["pairs"], "size", "price") + assert pair["p_value_adjusted"] is not None diff --git a/python/functions/datascience/exploratory_caveats.md b/python/functions/datascience/exploratory_caveats.md new file mode 100644 index 00000000..219803f4 --- /dev/null +++ b/python/functions/datascience/exploratory_caveats.md @@ -0,0 +1,77 @@ +--- +name: exploratory_caveats +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def exploratory_caveats(profile: dict) -> dict" +description: "Genera las advertencias que recuerdan que un EDA es EXPLORATORIO (genera hipotesis), no confirmatorio. Inspecciona un TableProfile del grupo eda y devuelve solo los caveats que aplican a lo calculado: correlacion!=causalidad, overfitting in-sample, p-values no son confirmacion, comparaciones multiples, outliers!=errores, muestra pequena, datos faltantes. El caveat general va siempre. Pura." +tags: [eda, exploratory, caveats, hypotheses, overfitting, correlation-causation, p-values, tukey, lopez-de-prado, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: profile + desc: "TableProfile dict del grupo eda. Se leen defensivamente `correlations` (pares), `models` (pca/kmeans/outliers/normality), `columns` (sub-bloques `numeric` con n_outliers/outlier_pct y `trend` con p_value), `n_rows`, `null_cell_pct` y `all_null_cols`. Cualquier clave puede faltar." +output: "dict con `n` (numero de caveats), `caveats` (lista de {id, topic, message, reference} empezando por el general `exploratory_nature`) y `note` (vacio en caso normal; mensaje si el perfil esta vacio y solo se devuelve el caveat general). Nunca lanza excepcion." +tested: true +tests: ["test_perfil_vacio_solo_caveat_general", "test_none_no_lanza_y_da_general", "test_caveat_general_siempre_primero", "test_correlaciones_disparan_causalidad_y_overfitting", "test_dos_o_mas_pares_disparan_comparaciones_multiples", "test_modelos_disparan_overfitting_y_pvalues", "test_outliers_por_columna_disparan_caveat", "test_outliers_multivariantes_disparan_caveat", "test_trend_pvalue_dispara_caveat_pvalues", "test_muestra_pequena_dispara_caveat", "test_muestra_grande_no_dispara_small_sample", "test_muchos_faltantes_disparan_missing_data", "test_columnas_all_null_disparan_missing_data", "test_pocos_faltantes_no_disparan_missing_data", "test_estructura_de_cada_caveat"] +test_file_path: "python/functions/datascience/exploratory_caveats_test.py" +file_path: "python/functions/datascience/exploratory_caveats.py" +--- + +## Ejemplo + +```python +from datascience import exploratory_caveats + +profile = { + "n_rows": 5000, + "correlations": {"pairs": [ + {"a": "precio", "b": "ventas", "value": 0.82}, + {"a": "precio", "b": "margen", "value": -0.61}, + ]}, + "models": {"pca": {"explained": [0.6, 0.3]}, "normality": {"precio": {"is_normal": False}}}, + "columns": [{"name": "precio", "numeric": {"n_outliers": 4, "outlier_pct": 0.8}}], +} +out = exploratory_caveats(profile) +out["n"] # -> 6 +[c["id"] for c in out["caveats"]] +# -> ['exploratory_nature', 'correlation_not_causation', 'in_sample_overfitting', +# 'p_values_not_confirmation', 'multiple_comparisons', 'outliers_not_errors'] + +# Perfil vacio -> solo la advertencia general. +exploratory_caveats({})["caveats"][0]["id"] # -> "exploratory_nature" +``` + +## Cuando usarla + +Al cerrar un EDA, antes de entregar el reporte o de tomar decisiones sobre lo que +muestra. Convierte la disciplina exploratoria (Tukey: el EDA da hipotesis, no +conclusiones) en una lista accionable de advertencias adaptada a lo que realmente se +calculo en ese perfil. Pensada para inyectar una seccion "Advertencias / esto es +exploratorio" en el markdown de un reporte EDA, o para que un agente recuerde no +tratar una correlacion o una "significancia" como confirmacion. NO la uses para +calcular estadisticos: solo razona sobre el contenido de un TableProfile ya hecho. + +## Gotchas + +- Es **pura**: no recalcula nada, solo decide que advertencias aplican a partir de + las claves presentes en el `profile`. Si una fase del EDA no se corrio (p.ej. sin + `models`), su caveat no aparece — es deliberado. +- El caveat `exploratory_nature` (general) va SIEMPRE, incluso con perfil vacio o + `None` (en ese caso `note` lo avisa). No lanza excepcion ante entradas raras. +- `correlations` se tolera como lista de pares o como dict con `pairs`/`strongest` + (mismo shape que consume `render_eda_markdown`). Un solo par dispara + `correlation_not_causation` + `in_sample_overfitting`; >=2 anaden ademas + `multiple_comparisons`. +- Umbrales: muestra pequena si `n_rows < 30`; faltantes notables si + `null_cell_pct > 0.2` (fraccion) o si hay `all_null_cols`. Son convenciones + prudentes, ajustables si el caller lo necesita (recomputando sobre el mismo + profile). +- `null_cell_pct` se asume fraccion 0-1 (como en el resto del grupo eda). Si tu + pipeline lo guarda como porcentaje 0-100, el umbral se dispara casi siempre. diff --git a/python/functions/datascience/exploratory_caveats.py b/python/functions/datascience/exploratory_caveats.py new file mode 100644 index 00000000..77116ce3 --- /dev/null +++ b/python/functions/datascience/exploratory_caveats.py @@ -0,0 +1,246 @@ +"""Genera las advertencias que recuerdan que un EDA es EXPLORATORIO, no confirmatorio. + +Funcion pura y determinista: dict (TableProfile del grupo ``eda``) entra, dict con +una lista de caveats sale. No hace I/O, no muta el input, no lanza excepciones. + +Doctrina (Tukey, *EDA* 1977; Aronson; López de Prado 2018): el análisis exploratorio +sirve para GENERAR hipótesis, no para confirmarlas. Lo que se ve mirando todo el +dataset a la vez —correlaciones, clusters, "significancias", outliers— es un punto de +partida, no una conclusión: hay que validarlo fuera de muestra con un análisis dirigido. +Esta función inspecciona qué contiene el perfil y devuelve solo las advertencias que +aplican a lo que realmente se ha calculado (si hay correlaciones → caveat de +causalidad; si hay modelos → caveat de overfitting; etc.), además de una advertencia +general que siempre acompaña a un EDA. +""" + +from __future__ import annotations + +# Umbrales para disparar caveats dependientes de magnitud. +_SMALL_SAMPLE_ROWS = 30 # n_rows por debajo de esto -> baja potencia. +_HIGH_MISSING_FRACTION = 0.2 # null_cell_pct (fracción) por encima -> sesgo MNAR. + + +def _to_float(v): + """Parsea a float; None si es None/bool/no parseable (NaN incluido).""" + if v is None or isinstance(v, bool): + return None + try: + f = float(v) + except (TypeError, ValueError): + return None + if f != f: # NaN + return None + return f + + +def _correlation_pairs(profile: dict) -> list: + """Extrae la lista de pares de correlación del perfil, tolerando varios shapes. + + ``correlations`` puede ser una lista de pares o un dict con ``pairs`` / + ``strongest``. Devuelve siempre una lista (vacía si no hay nada usable). + """ + correlations = profile.get("correlations") + if not correlations: + return [] + if isinstance(correlations, dict): + pairs = correlations.get("pairs") or correlations.get("strongest") or [] + else: + pairs = correlations + return list(pairs) if isinstance(pairs, (list, tuple)) else [] + + +def _has_models(profile: dict) -> bool: + """True si el perfil contiene un bloque de modelos multivariantes ajustados.""" + models = profile.get("models") + if not isinstance(models, dict): + return False + return any(models.get(k) for k in ("pca", "kmeans", "outliers")) + + +def _has_pvalues(profile: dict) -> bool: + """True si el perfil contiene p-values (tests de normalidad o de tendencia).""" + models = profile.get("models") + if isinstance(models, dict) and models.get("normality"): + return True + # Tests de tendencia adjuntados por columna (trend_slope) también traen p-value. + for col in profile.get("columns") or []: + if isinstance(col, dict) and isinstance(col.get("trend"), dict): + if col["trend"].get("p_value") is not None: + return True + return False + + +def _has_outliers(profile: dict) -> bool: + """True si se han detectado outliers (multivariantes o por columna numérica).""" + models = profile.get("models") + if isinstance(models, dict) and models.get("outliers"): + return True + for col in profile.get("columns") or []: + if not isinstance(col, dict): + continue + num = col.get("numeric") + if isinstance(num, dict): + n_out = _to_float(num.get("n_outliers")) + opct = _to_float(num.get("outlier_pct")) + if (n_out is not None and n_out > 0) or (opct is not None and opct > 0): + return True + return False + + +def exploratory_caveats(profile: dict) -> dict: + """Devuelve las advertencias de que el EDA es exploratorio según lo que contiene. + + Inspecciona un TableProfile (dict del grupo ``eda``) y arma la lista de caveats + relevantes. Una advertencia general (la naturaleza exploratoria del EDA) se + incluye SIEMPRE; el resto solo se añaden cuando el perfil contiene aquello a lo + que aplican: + + - correlaciones presentes -> correlación ≠ causalidad. + - modelos / correlaciones -> riesgo de overfitting in-sample (validar OOS). + - p-values (normalidad/tendencia) -> no son confirmación sin corregir / IID. + - ≥2 pares de correlación -> comparaciones múltiples (falsos positivos). + - outliers detectados -> no implican errores. + - n_rows pequeño -> baja potencia, estimaciones inestables. + - muchos faltantes -> posible sesgo si no son aleatorios (MNAR). + + Es pura, determinista y no lanza excepciones. Un perfil vacío o ``None`` devuelve + solo el caveat general con una nota. + + Args: + profile: TableProfile dict del grupo ``eda``. Se lee todo defensivamente con + ``.get(...)`` porque casi cualquier fase puede faltar. + + Returns: + dict con: + - ``n``: número de caveats devueltos (int). + - ``caveats``: lista de dicts ``{"id", "topic", "message", "reference"}``, + empezando por el general ``exploratory_nature``. + - ``note``: cadena vacía en el caso normal; mensaje cuando el perfil está + vacío y solo se devuelve la advertencia general. + """ + if not isinstance(profile, dict): + profile = {} + + caveats: list = [] + + # Caveat general: SIEMPRE presente. El EDA genera hipótesis, no conclusiones. + caveats.append({ + "id": "exploratory_nature", + "topic": "naturaleza exploratoria", + "message": ( + "El EDA genera HIPÓTESIS, no conclusiones. Cada patrón que veas aquí es un " + "punto de partida para confirmarlo con un análisis dirigido sobre datos " + "nuevos, no una verdad ya establecida." + ), + "reference": "Tukey (1977), Exploratory Data Analysis; Aronson", + }) + + if not profile: + return { + "n": len(caveats), + "caveats": caveats, + "note": "perfil vacío: solo se devuelve la advertencia general", + } + + corr_pairs = _correlation_pairs(profile) + has_corr = len(corr_pairs) > 0 + has_models = _has_models(profile) + + # Correlación ≠ causalidad. + if has_corr: + caveats.append({ + "id": "correlation_not_causation", + "topic": "correlación vs causalidad", + "message": ( + "Las correlaciones son asociaciones, no relaciones causales. Una " + "correlación fuerte puede venir de una variable de confusión o del " + "azar; valídala out-of-sample o con un diseño experimental antes de " + "actuar sobre ella." + ), + "reference": "Tukey (1977), EDA", + }) + + # Overfitting in-sample: cualquier patrón ajustado sobre todo el dataset. + if has_models or has_corr: + caveats.append({ + "id": "in_sample_overfitting", + "topic": "overfitting in-sample", + "message": ( + "Los patrones (modelos, clusters, correlaciones) se han extraído sobre " + "TODO el dataset. Lo aprendido in-sample puede no replicar fuera de " + "muestra (overfitting / selección por backtest). Valida con holdout o " + "walk-forward antes de confiar en ellos." + ), + "reference": "López de Prado (2018), Advances in Financial Machine Learning", + }) + + # p-values: no son confirmación sin corregir multiplicidad / sobre datos no-IID. + if _has_pvalues(profile): + caveats.append({ + "id": "p_values_not_confirmation", + "topic": "p-values", + "message": ( + "Los p-values sin corregir por comparaciones múltiples, o calculados " + "sobre datos no-IID (series temporales, datos agrupados), no son " + "confirmación. Trata cualquier 'significancia' vista en exploración " + "como provisional." + ), + "reference": "Tukey (1977), EDA", + }) + + # Comparaciones múltiples: cuantos más pares/columnas miras, más falsos positivos. + if len(corr_pairs) >= 2: + caveats.append({ + "id": "multiple_comparisons", + "topic": "comparaciones múltiples", + "message": ( + "Al examinar muchos pares/columnas a la vez, algunos parecerán " + "'significativos' solo por azar (problema de comparaciones múltiples). " + "Cuantas más combinaciones miras, más falsos positivos esperas." + ), + "reference": "López de Prado (2018), AFML", + }) + + # Outliers detectados no implican errores. + if _has_outliers(profile): + caveats.append({ + "id": "outliers_not_errors", + "topic": "outliers", + "message": ( + "Los outliers detectados son puntos estadísticamente atípicos, NO " + "necesariamente errores. Pueden ser el dato más interesante (fraude, " + "evento raro). Investígalos antes de eliminarlos." + ), + "reference": "Tukey (1977), EDA", + }) + + # Muestra pequeña: baja potencia, estimaciones inestables. + n_rows = _to_float(profile.get("n_rows")) + if n_rows is not None and n_rows < _SMALL_SAMPLE_ROWS: + caveats.append({ + "id": "small_sample", + "topic": "muestra pequeña", + "message": ( + f"Pocas filas (n={int(n_rows)}): la potencia estadística es baja y las " + "estimaciones (media, correlación, forma de la distribución) son " + "inestables. Los patrones pueden cambiar con más datos." + ), + "reference": "Tukey (1977), EDA", + }) + + # Datos faltantes: posible sesgo si no son aleatorios (MNAR). + null_frac = _to_float(profile.get("null_cell_pct")) + all_null_cols = profile.get("all_null_cols") or [] + if (null_frac is not None and null_frac > _HIGH_MISSING_FRACTION) or all_null_cols: + caveats.append({ + "id": "missing_data_bias", + "topic": "datos faltantes", + "message": ( + "Hay un volumen notable de datos faltantes. Si los ausentes no son " + "aleatorios (MNAR), los estadísticos calculados sobre lo presente " + "están sesgados; no extrapoles sin entender por qué faltan." + ), + "reference": "Tukey (1977), EDA", + }) + + return {"n": len(caveats), "caveats": caveats, "note": ""} diff --git a/python/functions/datascience/fdr_correction.md b/python/functions/datascience/fdr_correction.md new file mode 100644 index 00000000..32fd4635 --- /dev/null +++ b/python/functions/datascience/fdr_correction.md @@ -0,0 +1,83 @@ +--- +name: fdr_correction +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict" +description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh') o Bonferroni (FWER, 'bonferroni'). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)." +tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, p-value, data-mining-bias, python] +params: + - name: pvalues + desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)." + - name: alpha + desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)." + - name: method + desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador). Cualquier otro valor devuelve un dict con note." +output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [math] +tested: true +tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos"] +test_file_path: "python/functions/datascience/fdr_correction_test.py" +file_path: "python/functions/datascience/fdr_correction.py" +--- + +## Ejemplo + +```python +from datascience import fdr_correction + +# Tres pruebas: dos muy significativas, una claramente no. +pvalues = [0.01, 0.02, 0.5] + +bh = fdr_correction(pvalues, alpha=0.05, method="bh") +print(bh["reject"]) # -> [True, True, False] +print(bh["n_rejected"]) # -> 2 + +# Bonferroni es mas conservador: solo sobrevive la mas fuerte. +bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni") +print(bon["reject"]) # -> [True, False, False] +print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0] + +# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la +# lista completa de pares y recuperar el mapeo 1:1. +mix = fdr_correction([0.001, None, 0.9]) +print(mix["reject"]) # -> [True, False, False] +print(mix["n_tests"]) # -> 2 (el None no cuenta como prueba) +``` + +## Cuando usarla + +Cuando evalues **muchas hipotesis a la vez** y vayas a declarar "significativos" +los resultados por debajo de un umbral de p-valor: matriz de asociacion entre +todas las columnas, barrido de reglas/senales, cualquier busqueda que pruebe N +combinaciones y se quede con las que "pasan". Sin corregir, con N pruebas y +alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas +correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la +familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"` +por defecto (mejor potencia); `"bonferroni"` cuando un falso positivo sea muy +costoso y prefieras maxima cautela. + +## Gotchas + +- Pura y sin dependencias externas (solo `math` de la stdlib). +- Corrige **dentro de una familia de pruebas**: pasa de una vez todos los + p-valores que compiten, no los corrijas por separado o pierdes el control del + sesgo. +- La salida esta **alineada 1:1** con la entrada. Las posiciones invalidas + (`None`, `NaN`, fuera de `[0, 1]`, no numericas) se devuelven como + `p_values_adjusted=None` y `reject=False`, y no cuentan en `n_tests` (m). Por + eso puedes pasar la lista completa de pares aunque algunos no tengan test. +- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que + `len(pvalues)` si hay `None`. +- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos + descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso + positivo (FWER). No son intercambiables; elige segun el coste de equivocarte. +- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict + con `note`. diff --git a/python/functions/datascience/fdr_correction.py b/python/functions/datascience/fdr_correction.py new file mode 100644 index 00000000..25b0d58b --- /dev/null +++ b/python/functions/datascience/fdr_correction.py @@ -0,0 +1,158 @@ +"""Correccion de comparaciones multiples (multiple-testing) para una lista de p-valores. + +Funcion pura del grupo eda. Cuando se evaluan muchas hipotesis a la vez (p.ej. +todos los pares de una matriz de asociacion), la probabilidad de obtener al menos +un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria +de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical +Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo +mediante dos metodos clasicos: + +- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos + (False Discovery Rate, FDR). Menos conservador, mas potencia estadistica. +- Bonferroni (``"bonferroni"``): controla la tasa de error por familia + (Family-Wise Error Rate, FWER). Mas conservador. + +No usa dependencias externas: aritmetica de la libreria estandar. +""" + +from __future__ import annotations + +import math + + +def _is_valid_p(v) -> bool: + """True si v es un p-valor numerico finito dentro de [0, 1].""" + if v is None or isinstance(v, bool): + return False + if not isinstance(v, (int, float)): + return False + x = float(v) + if math.isnan(x) or math.isinf(x): + return False + return 0.0 <= x <= 1.0 + + +def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict: + """Corrige una lista de p-valores por comparaciones multiples. + + Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y + devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y + si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las + posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de + ``[0, 1]`` o no numerico) se conservan en la salida como ``None`` / + ``False`` y se excluyen del conteo de pruebas ``m``; asi el llamador puede + pasar la lista completa (incluidos pares sin test disponible) y recuperar un + mapeo 1:1. + + Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza + excepcion ante datos vacios o invalidos; en su lugar devuelve un dict con la + clave ``note`` explicando el caso degenerado. + + Args: + pvalues: lista de p-valores (floats en [0, 1]). Se admiten ``None`` u + otros valores no validos en posiciones sin test disponible; se + propagan como ``None`` en la salida y no cuentan como prueba. + alpha: nivel de significancia objetivo tras la correccion (default 0.05). + Para BH es el umbral del FDR; para Bonferroni, del FWER. + method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER). + + Returns: + dict con las claves: + p_values_adjusted: lista alineada con ``pvalues``. Cada entrada es el + p-valor ajustado (float en [0, 1]) o ``None`` si la posicion no + era un p-valor valido. + reject: lista de booleanos alineada con ``pvalues``. ``True`` si la + hipotesis se rechaza al nivel ``alpha`` tras la correccion + (es significativa); ``False`` en caso contrario o si la posicion + no era valida. + n_tests: numero de p-valores validos usados en la correccion (m). + n_rejected: numero de hipotesis rechazadas (significativas). + alpha: nivel de significancia aplicado (float). + method: metodo aplicado (``"bh"`` o ``"bonferroni"``). + + Casos degenerados (lista vacia, sin p-valores validos o metodo + desconocido) anaden ademas una clave ``note`` y devuelven listas + coherentes (``reject`` todo ``False``, ``p_values_adjusted`` con ``None`` + en las posiciones invalidas). + """ + method_norm = (method or "").strip().lower() + if method_norm not in {"bh", "bonferroni"}: + n = len(pvalues) + return { + "p_values_adjusted": [None] * n, + "reject": [False] * n, + "n_tests": 0, + "n_rejected": 0, + "alpha": float(alpha), + "method": method, + "note": ( + f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) " + "o 'bonferroni'" + ), + } + + n = len(pvalues) + if n == 0: + return { + "p_values_adjusted": [], + "reject": [], + "n_tests": 0, + "n_rejected": 0, + "alpha": float(alpha), + "method": method_norm, + "note": "lista de p-valores vacia", + } + + # Posiciones validas: (indice_original, p). Las invalidas se propagan como None. + valid = [(i, float(p)) for i, p in enumerate(pvalues) if _is_valid_p(p)] + m = len(valid) + + adjusted: list = [None] * n + reject: list = [False] * n + + if m == 0: + return { + "p_values_adjusted": adjusted, + "reject": reject, + "n_tests": 0, + "n_rejected": 0, + "alpha": float(alpha), + "method": method_norm, + "note": "ningun p-valor valido en la entrada", + } + + a = float(alpha) + + if method_norm == "bonferroni": + # p ajustado = min(1, p * m); rechaza si p_ajustado <= alpha. + for orig_idx, p in valid: + padj = min(1.0, p * m) + adjusted[orig_idx] = padj + reject[orig_idx] = padj <= a + else: + # Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores + # con la monotonicidad acumulada de derecha a izquierda. + order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc + q_sorted = [0.0] * m + prev = 1.0 + for rank in range(m, 0, -1): + orig_idx, p = order[rank - 1] + val = p * m / rank + prev = min(prev, val) + q_sorted[rank - 1] = min(prev, 1.0) + for k in range(m): + orig_idx, _p = order[k] + q = q_sorted[k] + adjusted[orig_idx] = q + reject[orig_idx] = q <= a + + n_rejected = sum(1 for r in reject if r) + + return { + "p_values_adjusted": adjusted, + "reject": reject, + "n_tests": m, + "n_rejected": n_rejected, + "alpha": a, + "method": method_norm, + } diff --git a/python/functions/datascience/render_eda_markdown.py b/python/functions/datascience/render_eda_markdown.py index e8330dba..8a062209 100644 --- a/python/functions/datascience/render_eda_markdown.py +++ b/python/functions/datascience/render_eda_markdown.py @@ -264,24 +264,129 @@ def render_eda_markdown(profile: dict) -> str: parts.append("## Calidad") parts.append(_md_table(["column", "quality_score", "issues"], rows)) - # 7. Correlations (tolerate None for now). + # 7. Correlaciones / asociación. `association_matrix` ya corrige los p-valores + # por comparaciones múltiples (FDR Benjamini-Hochberg / Bonferroni); aquí solo + # se renderizan los campos que produjo (value, p_value_adjusted, significant), + # sin recalcular nada. Se prefieren los pares `strong` (magnitud alta Y + # significativos tras la corrección); si no hay, se muestran todos. correlations = profile.get("correlations") if correlations: - pairs = correlations + strong = [] + all_pairs = [] + multiple_testing = None if isinstance(correlations, dict): - pairs = correlations.get("pairs") or correlations.get("strongest") or [] + strong = correlations.get("strong") or correlations.get("strongest") or [] + all_pairs = correlations.get("pairs") or [] + multiple_testing = correlations.get("multiple_testing") + else: + all_pairs = correlations + shown = strong or all_pairs corr_rows = [] - for pair in pairs or []: - if isinstance(pair, dict): - corr_rows.append([ - pair.get("a") or pair.get("col_a"), - pair.get("b") or pair.get("col_b"), - _fmt_num(pair.get("value") if pair.get("value") is not None - else pair.get("corr")), - ]) + for pair in shown or []: + if not isinstance(pair, dict): + continue + padj = pair.get("p_value_adjusted") + sig = pair.get("significant") + corr_rows.append([ + pair.get("a") or pair.get("col_a"), + pair.get("b") or pair.get("col_b"), + pair.get("method", ""), + _fmt_num(pair.get("value") if pair.get("value") is not None + else pair.get("corr")), + _fmt_num(padj) if padj is not None else "", + "sí" if sig else ("no" if sig is not None else ""), + ]) if corr_rows: parts.append("## Correlaciones") - parts.append(_md_table(["a", "b", "corr"], corr_rows)) + if isinstance(multiple_testing, dict): + parts.append( + "Corrección de comparaciones múltiples: " + f"{multiple_testing.get('method')} " + f"(α={multiple_testing.get('alpha')}); " + f"{multiple_testing.get('n_rejected')} de " + f"{multiple_testing.get('n_tests')} pares significativos tras la " + "corrección. Mostrando " + f"{'solo pares fuertes' if strong else 'todos los pares evaluados'}." + ) + parts.append(_md_table( + ["a", "b", "method", "value", "p_adj (FDR)", "sig"], corr_rows)) + + # 7b. Re-expresión sugerida (escalera de potencias de Tukey) por columna + # numérica. `suggest_reexpression` decide la transformación que más simetriza; + # aquí solo se rinde su recomendación y razón. + reexp_rows = [] + for col in columns: + if not isinstance(col, dict): + continue + rx = col.get("reexpression") + if not isinstance(rx, dict) or rx.get("recommended") is None: + continue + ladder = rx.get("ladder_power") + reexp_rows.append([ + col.get("name"), + _fmt_num(rx.get("skew")), + rx.get("recommended"), + _fmt_num(ladder) if ladder is not None else "", + rx.get("reason", ""), + ]) + if reexp_rows: + parts.append("## Re-expresión sugerida") + parts.append(_md_table( + ["column", "skew", "transform", "ladder_power", "reason"], reexp_rows)) + + # 7c. Series temporales. Bloque por columna numérica cuando el pipeline corrió + # con run_series: estacionariedad (ADF+KPSS), autocorrelación (ACF/PACF + + # Ljung-Box), descomposición STL y, si es una serie de niveles, sugerencia de + # retornos. + series_blocks = [] + for col in columns: + if not isinstance(col, dict): + continue + s = col.get("series") + if not isinstance(s, dict): + continue + name = col.get("name") or "(col)" + block = [f"### {name}"] + rows = [] + stat = s.get("stationarity") or {} + if stat.get("verdict") is not None: + rows.append(["estacionariedad (ADF+KPSS)", stat.get("verdict")]) + acf = s.get("acf_pacf") or {} + if acf.get("is_autocorrelated") is not None: + rows.append([ + "autocorrelada (Ljung-Box)", + "sí" if acf.get("is_autocorrelated") else "no", + ]) + sig_lags = acf.get("significant_acf_lags") + if sig_lags: + rows.append([ + "lags ACF significativos", + ", ".join(str(lag) for lag in sig_lags[:12]), + ]) + stl = s.get("stl") or {} + if stl.get("trend_strength") is not None: + rows.append(["fuerza de tendencia (STL)", _fmt_num(stl.get("trend_strength"))]) + if stl.get("seasonal_strength") is not None: + rows.append(["fuerza estacional (STL)", _fmt_num(stl.get("seasonal_strength"))]) + if stl.get("period") is not None: + rows.append(["periodo estacional", stl.get("period")]) + elif stl.get("note"): + rows.append(["STL", stl.get("note")]) + if s.get("levels_suggested"): + rows.append(["sugerencia", "convertir a retornos (serie de niveles)"]) + tr = s.get("to_returns") or {} + if tr.get("mean") is not None: + rows.append(["retorno medio (log)", _fmt_num(tr.get("mean"))]) + if tr.get("std") is not None: + rows.append(["volatilidad retornos (σ)", _fmt_num(tr.get("std"))]) + if rows: + block.append(_md_table(["aspecto", "valor"], rows)) + if stat.get("warning"): + block.append(f"> {stat.get('warning')}") + series_blocks.append("\n\n".join(block)) + if series_blocks: + parts.append("## Series temporales") + parts.extend(series_blocks) # 8. LLM analysis (tolerate None for now). llm = profile.get("llm") @@ -299,4 +404,24 @@ def render_eda_markdown(profile: dict) -> str: else: parts.append(str(llm)) + # 9. Avisos exploratorios. `exploratory_caveats` recuerda que el EDA genera + # hipótesis, no conclusiones; se renderiza la lista de advertencias que aplican + # a lo que realmente se calculó. + caveats = profile.get("caveats") + cav_list = [] + if isinstance(caveats, dict): + cav_list = caveats.get("caveats") or [] + elif isinstance(caveats, list): + cav_list = caveats + cav_lines = [] + for cav in cav_list: + if not isinstance(cav, dict): + continue + topic = cav.get("topic") or cav.get("id") or "" + msg = cav.get("message") or "" + cav_lines.append(f"- **{topic}**: {msg}") + if cav_lines: + parts.append("## Avisos exploratorios") + parts.append("\n".join(cav_lines)) + return "\n\n".join(parts) + "\n" diff --git a/python/functions/datascience/render_eda_pdf.md b/python/functions/datascience/render_eda_pdf.md new file mode 100644 index 00000000..b010df96 --- /dev/null +++ b/python/functions/datascience/render_eda_pdf.md @@ -0,0 +1,114 @@ +--- +name: render_eda_pdf +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: impure +signature: "def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict" +description: "Renderiza un TableProfile del grupo eda en un PDF multipágina portátil pensado para LEER Y EXPLORAR EN EL MÓVIL. Páginas A5 retrato, una columna, tipografía grande; diseño Tufte (alto data-ink ratio, histogramas reales como small multiples, barras top-k, heatmap de asociación, integridad de ejes desde 0). Lee todo el profile defensivamente con .get y sólo renderiza las secciones presentes; bloques nuevos del profile (models, caveats, ...) se vuelcan genéricamente (forward-compatible). dict-no-throw: nunca lanza, devuelve {pdf_path, n_pages, note}. Motor matplotlib PdfPages, cero dependencias nuevas." +tags: [eda, pdf, render, report, mobile, tufte, visualization, matplotlib, profiling, datascience, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [os, textwrap, datetime, matplotlib, numpy] +params: + - name: profile + desc: "TableProfile dict del grupo de capacidad eda (el dict que profile_table devuelve bajo la clave 'profile'). Puede tener muchas claves ausentes o None; un profile None/vacío genera igualmente un PDF de 1 página. Claves consumidas: table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows/_pct, null_cell_pct, quality_score, type_breakdown, constant_cols, all_null_cols, key_candidates, columns[] (con numeric.histogram [{lo,hi,count}], categorical.top [{value,count,pct}], quality_score, flags/issues), correlations.pairs [{a,b,value}], llm. Cualquier otra clave de nivel superior se vuelca en una página forward-compat." + - name: out_path + desc: "ruta del archivo PDF de salida. Los directorios padre se crean si faltan." + - name: title + desc: "título opcional para la portada. Por defecto 'EDA — '." +output: "dict (nunca lanza): {pdf_path: str, n_pages: int, note: str}. En éxito pdf_path es la ruta escrita, n_pages el número de páginas generadas y note un resumen ('N páginas', con detalle de las secciones omitidas si alguna falló). En error fatal de escritura pdf_path es None y note explica la causa." +tested: true +tests: ["test_golden_genera_pdf_multipagina", "test_edge_profile_vacio_no_revienta", "test_edge_profile_none_no_revienta", "test_edge_solo_numericas", "test_forward_compat_seccion_desconocida"] +test_file_path: "python/functions/datascience/render_eda_pdf_test.py" +file_path: "python/functions/datascience/render_eda_pdf.py" +--- + +## Ejemplo + +```python +from datascience import render_eda_pdf + +# TableProfile mínimo (en la práctica viene de profile_table(...)["profile"]). +profile = { + "table": "ventas", + "source": "data/ventas.csv", + "n_rows": 1000, + "n_cols": 2, + "null_cell_pct": 0.02, + "quality_score": 92.5, + "type_breakdown": {"numeric": 1, "categorical": 1}, + "columns": [ + { + "name": "precio", + "inferred_type": "numeric", + "quality_score": 95.0, + "numeric": { + "min": 1.0, "max": 100.0, "median": 40.0, "mean": 42.5, + "std": 12.3, "outlier_pct": 1.2, + "histogram": [ + {"lo": 0.0, "hi": 25.0, "count": 100}, + {"lo": 25.0, "hi": 50.0, "count": 500}, + {"lo": 50.0, "hi": 75.0, "count": 300}, + {"lo": 75.0, "hi": 100.0, "count": 50}, + ], + }, + }, + { + "name": "categoria", + "inferred_type": "categorical", + "quality_score": 99.0, + "categorical": { + "entropy": 1.05, + "top": [ + {"value": "neumaticos", "count": 500, "pct": 0.5}, + {"value": "aceite", "count": 300, "pct": 0.3}, + {"value": "filtros", "count": 200, "pct": 0.2}, + ], + }, + }, + ], +} + +res = render_eda_pdf(profile, "reports/eda_ventas.pdf", title="EDA — ventas") +print(res) # -> {'pdf_path': 'reports/eda_ventas.pdf', 'n_pages': 5, 'note': '5 páginas'} +``` + +## Cuando usarla + +Cuando quieras una **4ª salida portátil del EDA para revisar en el teléfono**: +después de `profile_table(...)`, pásale el `profile` resultante para emitir un PDF +que el usuario recibe y explora desde el móvil, sin abrir notebooks ni markdown. +Úsala como capa de presentación del grupo `eda` (junto al report markdown, el JSON +sidecar y el notebook Jupyter): histogramas reales en small multiples, barras top-k +de las categóricas, heatmap de correlaciones y una portada con el score de calidad, +todo maquetado para pantalla pequeña con criterios de Tufte (alto data-ink ratio, +ejes honestos desde 0). No recalcula nada del perfil — sólo lo dibuja. + +## Gotchas + +- **Impura**: escribe un archivo en `out_path` (crea los directorios padre). Usa el + backend headless `Agg` de matplotlib, así que corre en agentes/CI sin display. +- **Nunca lanza** (dict-no-throw): cada sección se construye aislada; si una falla, + se omite y se anota en `note`, pero el PDF se genera igual. Un profile `None`/`{}` + produce un PDF de 1 página válido. +- **Forward-compatible**: sólo conoce un conjunto fijo de claves de nivel superior; + cualquier bloque nuevo del profile (p.ej. `models`, `caveats`, series temporales + que añadan otras funciones del grupo) se vuelca en una página genérica "Otras + secciones" en vez de perderse o romper. No asume claves que quizá no existan. +- **Registro en el package**: el `## Ejemplo` usa `from datascience import render_eda_pdf`, + que requiere que la función esté añadida al `__init__.py` del paquete (lo hace `fn + index` + la integración del orquestador). El test importa el módulo directo + (`from render_eda_pdf import render_eda_pdf`) para no depender de ese registro. +- **Histograma real, no ASCII**: necesita `numeric.histogram` como lista de bins + `{lo, hi, count}` (el formato que emite `describe_numeric`). Si una columna numérica + no trae histograma, esa columna se salta en la página de distribuciones. +- **Heatmap de correlaciones**: reconstruye la matriz simétrica desde + `correlations.pairs` (`{a, b, value}`); anota los valores en celda sólo si hay ≤8 + columnas para no saturar la pantalla del móvil. +- **PDF con texto seleccionable** (`pdf.fonttype=42`, TrueType embebido), legible y + buscable en visores móviles. diff --git a/python/functions/datascience/render_eda_pdf.py b/python/functions/datascience/render_eda_pdf.py new file mode 100644 index 00000000..b2a1bf50 --- /dev/null +++ b/python/functions/datascience/render_eda_pdf.py @@ -0,0 +1,626 @@ +"""render_eda_pdf — Portable, mobile-readable PDF report of a TableProfile (eda group). + +Impure function (writes a file): takes a TableProfile dict from the `eda` +capability group and renders a MULTI-PAGE PDF designed to be read and explored +on a phone screen. It is the 4th output of the eda workflow, next to the +markdown report, the JSON sidecar and the executed Jupyter notebook. + +Design follows Edward Tufte, "The Visual Display of Quantitative Information": +high data-ink ratio (no chartjunk, despined axes, light grids), small multiples +for per-column histograms, and graphical integrity (y-axes start at 0, no +misleading truncation). Pages are A5 portrait, single column, with a large, +legible typeface so the report stays readable on a small display. + +Every key of the profile is read defensively with ``.get(...)`` and only the +sections actually present are rendered. The function is forward-compatible: if +the profile carries blocks this renderer does not know about (e.g. ``models``, +time series, ``caveats`` added by sibling functions), they are dumped generically +on a final page instead of being ignored or crashing the render. + +dict-no-throw contract of the eda group: it NEVER raises. Any failure of a single +section is caught and noted; the function always returns a dict with the path, +the page count and a human note. + +Engine: matplotlib ``PdfPages`` (already in ``python/.venv``) — zero new deps. +""" + +import os +import textwrap +from datetime import datetime, timezone + +import matplotlib + +# Headless backend: this runs in agents/CI without a display. +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +from matplotlib.backends.backend_pdf import PdfPages # noqa: E402 + +# A5 portrait in inches (148 x 210 mm). Single column, tall, phone-friendly. +_A5_PORTRAIT = (5.83, 8.27) + +# Number of per-column small multiples stacked vertically on one page. +_NUMERIC_PER_PAGE = 3 +_CATEGORICAL_PER_PAGE = 3 + +# Top-of-profile keys this renderer handles explicitly. Anything else found at +# the top level of the profile is dumped on the forward-compat "Otros" page so +# new sections added by sibling functions still reach the reader. +_KNOWN_TOP_KEYS = { + "table", "source", "profiled_at", "n_rows", "n_cols", "size_bytes", + "duplicate_rows", "duplicate_pct", "null_cell_pct", "constant_cols", + "all_null_cols", "quality_score", "type_breakdown", "key_candidates", + "columns", "correlations", "llm", +} + +# Restrained, high-contrast palette: a single accent reads cleanly on a phone. +_INK = "#1b1b1b" +_ACCENT = "#2a6f97" +_MUTED = "#8a8a8a" + + +# --------------------------------------------------------------------------- # +# Small formatting + Tufte helpers +# --------------------------------------------------------------------------- # +def _fmt_num(value, decimals: int = 3) -> str: + """Format a number compactly; fall back to str for non-numerics/None.""" + if value is None: + return "—" + if isinstance(value, bool): + return str(value) + if isinstance(value, int): + return f"{value:,}" + if isinstance(value, float): + if value != value: # NaN + return "NaN" + if value in (float("inf"), float("-inf")): + return str(value) + text = f"{value:.{decimals}f}".rstrip("0").rstrip(".") + return text if text else "0" + return str(value) + + +def _fmt_pct(value, decimals: int = 1) -> str: + """Format a fraction (0-1) as 'NN.N%'. Returns '—' for None.""" + if value is None: + return "—" + try: + num = float(value) + except (TypeError, ValueError): + return str(value) + return f"{num * 100:.{decimals}f}%" + + +def _despine(ax) -> None: + """Strip top/right spines and soften the rest — raise the data-ink ratio.""" + for side in ("top", "right"): + ax.spines[side].set_visible(False) + for side in ("left", "bottom"): + ax.spines[side].set_color(_MUTED) + ax.spines[side].set_linewidth(0.6) + ax.tick_params(colors=_MUTED, labelsize=7, length=2) + ax.title.set_color(_INK) + + +def _truncate(text, width: int = 22) -> str: + """Clip an arbitrary value to a short label for tight phone layouts.""" + s = str(text) if text is not None else "—" + return s if len(s) <= width else s[: width - 1] + "…" + + +def _text_page(pdf, title: str, lines: list, subtitle: str = None) -> int: + """Render one text page (monospace body) and return 1 (pages written).""" + fig = plt.figure(figsize=_A5_PORTRAIT) + fig.text(0.08, 0.94, title, fontsize=16, fontweight="bold", color=_INK) + if subtitle: + fig.text(0.08, 0.905, subtitle, fontsize=9, color=_MUTED) + body = "\n".join(lines) + fig.text( + 0.08, 0.88, body, fontsize=9.5, color=_INK, family="monospace", + va="top", ha="left", linespacing=1.5, + ) + pdf.savefig(fig) + plt.close(fig) + return 1 + + +def _kv_lines(rows: list, key_width: int = 18) -> list: + """Format [label, value] rows as aligned 'label : value' monospace lines.""" + out = [] + for label, value in rows: + out.append(f"{str(label):<{key_width}}: {value}") + return out + + +# --------------------------------------------------------------------------- # +# Page builders (each fully defensive, each returns the number of pages it made) +# --------------------------------------------------------------------------- # +def _cover_page(pdf, profile: dict, title: str) -> int: + """Cover: table name, date, shape and an oversized quality score.""" + fig = plt.figure(figsize=_A5_PORTRAIT) + + table = profile.get("table") or "(tabla sin nombre)" + heading = title or f"EDA — {table}" + fig.text(0.08, 0.82, heading, fontsize=22, fontweight="bold", color=_INK, + wrap=True) + + sub = [] + src = profile.get("source") + if src: + sub.append(f"fuente: {_truncate(src, 40)}") + when = profile.get("profiled_at") or datetime.now(timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + sub.append(f"generado: {when}") + fig.text(0.08, 0.76, "\n".join(sub), fontsize=10, color=_MUTED, va="top") + + n_rows = profile.get("n_rows") + n_cols = profile.get("n_cols") + shape = (f"{_fmt_num(n_rows)} filas × {_fmt_num(n_cols)} columnas") + fig.text(0.08, 0.60, shape, fontsize=15, color=_ACCENT, fontweight="bold") + + score = profile.get("quality_score") + if score is not None: + fig.text(0.08, 0.42, "calidad", fontsize=12, color=_MUTED) + fig.text(0.08, 0.31, _fmt_num(score), fontsize=60, fontweight="bold", + color=_INK) + fig.text(0.08, 0.25, "sobre 100", fontsize=12, color=_MUTED) + + fig.text(0.08, 0.06, "Tufte · alta densidad de datos · lectura en móvil", + fontsize=8, color=_MUTED, style="italic") + pdf.savefig(fig) + plt.close(fig) + return 1 + + +def _overview_page(pdf, profile: dict) -> int: + """Overview key/value page: types, duplicates, nulls, constants, keys.""" + rows = [] + if profile.get("n_rows") is not None: + rows.append(["Filas", _fmt_num(profile.get("n_rows"))]) + if profile.get("n_cols") is not None: + rows.append(["Columnas", _fmt_num(profile.get("n_cols"))]) + if profile.get("size_bytes") is not None: + rows.append(["Tamaño (bytes)", _fmt_num(profile.get("size_bytes"))]) + if profile.get("duplicate_rows") is not None: + dup = _fmt_num(profile.get("duplicate_rows")) + if profile.get("duplicate_pct") is not None: + dup += f" ({_fmt_pct(profile.get('duplicate_pct'))})" + rows.append(["Filas duplicadas", dup]) + if profile.get("null_cell_pct") is not None: + rows.append(["Celdas nulas", _fmt_pct(profile.get("null_cell_pct"))]) + if profile.get("quality_score") is not None: + rows.append(["Calidad", _fmt_num(profile.get("quality_score"))]) + + type_breakdown = profile.get("type_breakdown") or {} + tb = ", ".join( + f"{k}: {v}" for k, v in type_breakdown.items() if v + ) + if tb: + rows.append(["Tipos", tb]) + + constant_cols = profile.get("constant_cols") or [] + if constant_cols: + rows.append(["Columnas constantes", _truncate(", ".join(constant_cols), 40)]) + all_null_cols = profile.get("all_null_cols") or [] + if all_null_cols: + rows.append(["Columnas all-null", _truncate(", ".join(all_null_cols), 40)]) + key_candidates = profile.get("key_candidates") or [] + if key_candidates: + rows.append(["Candidatos a clave", _truncate(", ".join(key_candidates), 40)]) + + if not rows: + rows.append(["(sin métricas de overview)", ""]) + + return _text_page(pdf, "Overview", _kv_lines(rows, key_width=20)) + + +def _numeric_pages(pdf, columns: list) -> int: + """Small multiples: a real histogram per numeric column, several per page.""" + numeric_cols = [ + c for c in columns + if isinstance(c, dict) and c.get("numeric") and c["numeric"].get("histogram") + ] + if not numeric_cols: + return 0 + + pages = 0 + for start in range(0, len(numeric_cols), _NUMERIC_PER_PAGE): + chunk = numeric_cols[start:start + _NUMERIC_PER_PAGE] + fig, axes = plt.subplots( + len(chunk), 1, figsize=_A5_PORTRAIT, squeeze=False, + ) + fig.suptitle("Distribuciones numéricas", fontsize=14, fontweight="bold", + color=_INK, x=0.08, ha="left", y=0.98) + for ax, col in zip(axes[:, 0], chunk): + _draw_histogram(ax, col) + # Hide unused axes if the chunk is short (keeps spacing even). + for ax in axes[len(chunk):, 0]: + ax.axis("off") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + pdf.savefig(fig) + plt.close(fig) + pages += 1 + return pages + + +def _draw_histogram(ax, col: dict) -> None: + """Draw one column's real histogram from its {lo, hi, count} bins.""" + num = col.get("numeric") or {} + hist = num.get("histogram") or [] + lefts, widths, counts = [], [], [] + for b in hist: + if not isinstance(b, dict): + continue + lo = b.get("lo") + hi = b.get("hi") + cnt = b.get("count") or 0 + if lo is None or hi is None: + continue + w = hi - lo + if w <= 0: + w = max(abs(lo) * 1e-6, 1e-6) + lefts.append(lo) + widths.append(w) + counts.append(cnt) + + name = col.get("name") or "(col)" + if not counts: + ax.axis("off") + ax.text(0.5, 0.5, f"{name}: sin datos numéricos", ha="center", + va="center", fontsize=8, color=_MUTED, transform=ax.transAxes) + return + + ax.bar(lefts, counts, width=widths, align="edge", color=_ACCENT, + edgecolor="white", linewidth=0.3) + # Graphical integrity: count axis starts at 0, never truncated. + ax.set_ylim(bottom=0) + _despine(ax) + ax.set_title(_truncate(name, 28), fontsize=10, loc="left", pad=4) + ax.grid(axis="y", color=_MUTED, alpha=0.15, linewidth=0.5) + ax.set_axisbelow(True) + + # Median reference line (a single light marker, no chartjunk). + median = num.get("median") + if isinstance(median, (int, float)) and not isinstance(median, bool): + ax.axvline(median, color=_INK, linewidth=0.8, alpha=0.5) + + # One compact annotation line: mean / std / outliers. + bits = [] + if num.get("mean") is not None: + bits.append(f"μ={_fmt_num(num.get('mean'))}") + if num.get("std") is not None: + bits.append(f"σ={_fmt_num(num.get('std'))}") + if num.get("outlier_pct") is not None: + bits.append(f"outliers={_fmt_num(num.get('outlier_pct'), 1)}%") + if bits: + ax.text(0.99, 0.92, " ".join(bits), transform=ax.transAxes, + ha="right", va="top", fontsize=7, color=_MUTED) + + +def _categorical_pages(pdf, columns: list) -> int: + """Top-k horizontal bars per categorical column, several per page.""" + cat_cols = [ + c for c in columns + if isinstance(c, dict) and c.get("categorical") + and (c["categorical"].get("top")) + ] + if not cat_cols: + return 0 + + pages = 0 + for start in range(0, len(cat_cols), _CATEGORICAL_PER_PAGE): + chunk = cat_cols[start:start + _CATEGORICAL_PER_PAGE] + fig, axes = plt.subplots( + len(chunk), 1, figsize=_A5_PORTRAIT, squeeze=False, + ) + fig.suptitle("Categóricas (top-k)", fontsize=14, fontweight="bold", + color=_INK, x=0.08, ha="left", y=0.98) + for ax, col in zip(axes[:, 0], chunk): + _draw_topk_bars(ax, col) + for ax in axes[len(chunk):, 0]: + ax.axis("off") + fig.tight_layout(rect=[0, 0, 1, 0.95]) + pdf.savefig(fig) + plt.close(fig) + pages += 1 + return pages + + +def _draw_topk_bars(ax, col: dict) -> None: + """Draw top-k counts for one categorical column as horizontal bars.""" + cat = col.get("categorical") or {} + top = cat.get("top") or [] + labels, values = [], [] + for item in top[:10]: + if not isinstance(item, dict): + continue + labels.append(_truncate(item.get("value"), 20)) + values.append(item.get("count") or 0) + + name = col.get("name") or "(col)" + if not values: + ax.axis("off") + ax.text(0.5, 0.5, f"{name}: sin categorías", ha="center", va="center", + fontsize=8, color=_MUTED, transform=ax.transAxes) + return + + # Largest on top: reverse so barh reads naturally top-to-bottom. + labels = labels[::-1] + values = values[::-1] + y = np.arange(len(values)) + ax.barh(y, values, color=_ACCENT, edgecolor="white", linewidth=0.3) + ax.set_yticks(y) + ax.set_yticklabels(labels, fontsize=7) + ax.set_xlim(left=0) # bars start at 0 — honest length encoding. + _despine(ax) + ax.set_title(_truncate(name, 28), fontsize=10, loc="left", pad=4) + ax.grid(axis="x", color=_MUTED, alpha=0.15, linewidth=0.5) + ax.set_axisbelow(True) + if cat.get("entropy") is not None: + ax.text(0.99, 1.02, f"entropía={_fmt_num(cat.get('entropy'))}", + transform=ax.transAxes, ha="right", va="bottom", fontsize=7, + color=_MUTED) + + +def _quality_page(pdf, columns: list) -> int: + """Worst-quality columns first, with their issues/flags.""" + scored = [ + c for c in columns + if isinstance(c, dict) and c.get("quality_score") is not None + ] + if not scored: + return 0 + scored = sorted(scored, key=lambda c: c.get("quality_score")) + + lines = [f"{'columna':<20} {'score':>6} problemas", "-" * 52] + for col in scored: + issues = col.get("issues") or col.get("flags") or [] + issues_s = ", ".join(issues) if isinstance(issues, list) else str(issues) + lines.append( + f"{_truncate(col.get('name'), 20):<20} " + f"{_fmt_num(col.get('quality_score'), 1):>6} {_truncate(issues_s, 24)}" + ) + return _text_page(pdf, "Calidad", lines, + subtitle="ordenado de peor a mejor calidad") + + +def _correlations_page(pdf, correlations) -> int: + """Heatmap of the association matrix reconstructed from the pairs list.""" + if not correlations: + return 0 + pairs = correlations + if isinstance(correlations, dict): + pairs = correlations.get("pairs") or correlations.get("strong") or [] + if not pairs: + return 0 + + # Build the symmetric label set and a value matrix from the pairs. + labels = [] + for p in pairs: + if not isinstance(p, dict): + continue + for key in ("a", "col_a", "b", "col_b"): + v = p.get(key) + if v is not None and v not in labels: + labels.append(v) + if len(labels) < 2: + return 0 + idx = {lab: i for i, lab in enumerate(labels)} + n = len(labels) + mat = np.full((n, n), np.nan) + for i in range(n): + mat[i, i] = 1.0 + for p in pairs: + if not isinstance(p, dict): + continue + a = p.get("a") or p.get("col_a") + b = p.get("b") or p.get("col_b") + val = p.get("value") + if val is None: + val = p.get("corr") + if a in idx and b in idx and val is not None: + try: + fv = float(val) + except (TypeError, ValueError): + continue + mat[idx[a], idx[b]] = fv + mat[idx[b], idx[a]] = fv + + fig, ax = plt.subplots(figsize=_A5_PORTRAIT) + fig.suptitle("Correlaciones / asociación", fontsize=14, fontweight="bold", + color=_INK, x=0.08, ha="left", y=0.97) + im = ax.imshow(mat, cmap="RdBu_r", vmin=-1, vmax=1, aspect="auto") + ax.set_xticks(np.arange(n)) + ax.set_yticks(np.arange(n)) + ax.set_xticklabels([_truncate(lab, 12) for lab in labels], rotation=60, + ha="right", fontsize=7, color=_INK) + ax.set_yticklabels([_truncate(lab, 14) for lab in labels], fontsize=7, + color=_INK) + ax.tick_params(length=0) + for side in ("top", "right", "left", "bottom"): + ax.spines[side].set_visible(False) + # Annotate cells only when few columns (keeps it legible on a phone). + if n <= 8: + for i in range(n): + for j in range(n): + if not np.isnan(mat[i, j]): + ax.text(j, i, _fmt_num(mat[i, j], 2), ha="center", + va="center", fontsize=6, + color=_INK if abs(mat[i, j]) < 0.6 else "white") + cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + cbar.ax.tick_params(labelsize=7) + fig.tight_layout(rect=[0, 0, 1, 0.94]) + pdf.savefig(fig) + plt.close(fig) + return 1 + + +def _llm_pages(pdf, llm) -> int: + """Render the LLM block (data dictionary / summary) as wrapped text pages.""" + if not llm: + return 0 + lines = [] + if isinstance(llm, dict): + for key, value in llm.items(): + if value is None: + continue + lines.append(f"## {key}") + lines.extend(_wrap_value(value)) + lines.append("") + else: + lines.extend(_wrap_value(llm)) + if not lines: + return 0 + return _paginate_text(pdf, "Análisis LLM", lines) + + +def _generic_pages(pdf, profile: dict) -> int: + """Forward-compat: dump unknown top-level sections so they still reach the reader.""" + extras = { + k: v for k, v in profile.items() + if k not in _KNOWN_TOP_KEYS and v is not None + } + if not extras: + return 0 + lines = [] + for key, value in extras.items(): + lines.append(f"## {key}") + lines.extend(_wrap_value(value)) + lines.append("") + if not lines: + return 0 + return _paginate_text(pdf, "Otras secciones", lines, + subtitle="bloques nuevos del profile (forward-compat)") + + +def _wrap_value(value, width: int = 78) -> list: + """Flatten an arbitrary value into wrapped, readable text lines.""" + out = [] + if isinstance(value, dict): + for k, v in value.items(): + out.append(f"- {k}: {_truncate(_scalar(v), 64)}") + elif isinstance(value, (list, tuple)): + for item in value: + if isinstance(item, dict): + out.append("- " + _truncate( + ", ".join(f"{k}={_scalar(v)}" for k, v in item.items()), 70)) + else: + out.append(f"- {_truncate(_scalar(item), 72)}") + else: + for line in textwrap.wrap(str(value), width=width) or [""]: + out.append(line) + return out + + +def _scalar(v) -> str: + """Compact one-line representation of a scalar/nested value.""" + if isinstance(v, float): + return _fmt_num(v) + if isinstance(v, (dict, list, tuple)): + return _truncate(str(v), 60) + return str(v) + + +def _paginate_text(pdf, title: str, lines: list, subtitle: str = None, + per_page: int = 34) -> int: + """Split a long list of text lines across several text pages.""" + pages = 0 + for start in range(0, len(lines), per_page): + chunk = lines[start:start + per_page] + page_title = title if pages == 0 else f"{title} (cont.)" + pages += _text_page(pdf, page_title, chunk, + subtitle=subtitle if pages == 0 else None) + return pages + + +# --------------------------------------------------------------------------- # +# Public entry point +# --------------------------------------------------------------------------- # +def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict: + """Render a TableProfile dict into a portable, mobile-readable multi-page PDF. + + The report is laid out for reading on a phone: A5 portrait pages, single + column, large type, Tufte-style high data-ink charts (real histograms as + small multiples, top-k bars, an association heatmap). Every profile key is + read defensively and only present sections are rendered; unknown top-level + blocks are dumped on a forward-compat page rather than dropped. + + Args: + profile: TableProfile dict from the `eda` capability group (the dict + returned by ``profile_table`` under ``profile``). May have many keys + absent or None; a None/empty profile still yields a 1-page PDF. + out_path: filesystem path where the PDF is written. Parent directories + are created if missing. + title: optional report title for the cover. Defaults to + ``"EDA —
"``. + + Returns: + dict (never raises): {"pdf_path": str, "n_pages": int, "note": str}. + On a fatal write error, ``pdf_path`` is None and ``note`` explains why. + """ + if profile is None: + profile = {} + if not isinstance(profile, dict): + return {"pdf_path": None, "n_pages": 0, + "note": f"profile no es dict: {type(profile).__name__}"} + + columns = profile.get("columns") or [] + if not isinstance(columns, list): + columns = [] + + notes = [] + n_pages = 0 + + try: + parent = os.path.dirname(os.path.abspath(out_path)) + os.makedirs(parent, exist_ok=True) + except OSError as e: + return {"pdf_path": None, "n_pages": 0, + "note": f"no se pudo crear el directorio destino: {e}"} + + # Tufte-ish defaults scoped to this render only. + rc = { + "font.size": 10, + "font.family": "sans-serif", + "axes.titlesize": 11, + "axes.edgecolor": _MUTED, + "figure.facecolor": "white", + "savefig.facecolor": "white", + "pdf.fonttype": 42, # embed TrueType so text stays selectable on mobile. + } + + # Each section is isolated: a failure in one never aborts the whole PDF. + builders = [ + ("cover", lambda p: _cover_page(p, profile, title)), + ("overview", lambda p: _overview_page(p, profile)), + ("numeric", lambda p: _numeric_pages(p, columns)), + ("categorical", lambda p: _categorical_pages(p, columns)), + ("quality", lambda p: _quality_page(p, columns)), + ("correlations", lambda p: _correlations_page(p, profile.get("correlations"))), + ("llm", lambda p: _llm_pages(p, profile.get("llm"))), + ("generic", lambda p: _generic_pages(p, profile)), + ] + + try: + with plt.rc_context(rc): + with PdfPages(out_path) as pdf: + for name, build in builders: + try: + n_pages += build(pdf) or 0 + except Exception as e: # noqa: BLE001 — one bad section never aborts. + notes.append(f"sección '{name}' omitida: {e}") + # Guarantee at least one page so the PDF is always valid. + if n_pages == 0: + n_pages += _text_page( + pdf, title or "EDA", ["(perfil vacío — sin secciones)"] + ) + except Exception as e: # noqa: BLE001 + return {"pdf_path": None, "n_pages": 0, + "note": f"fallo al escribir el PDF: {e}"} + + note = f"{n_pages} páginas" + if notes: + note += " · " + "; ".join(notes) + return {"pdf_path": out_path, "n_pages": n_pages, "note": note} diff --git a/python/functions/datascience/stl_decompose.md b/python/functions/datascience/stl_decompose.md new file mode 100644 index 00000000..d8f8f6c3 --- /dev/null +++ b/python/functions/datascience/stl_decompose.md @@ -0,0 +1,72 @@ +--- +name: stl_decompose +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict" +description: "Descomposicion STL (Seasonal-Trend using Loess, statsmodels) de una serie temporal en tendencia, estacional y resto. Si period es None lo infiere por autocorrelacion. Devuelve las 3 componentes (o estadisticos si son largas), mas la fuerza de tendencia y de estacionalidad de Hyndman (1 - Var(resto)/Var(resto+componente)). Descarta None/NaN; serie corta (<2*period) -> nota." +tags: [statistics, timeseries, decomposition, stl, seasonality, trend, eda, forecasting, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [math, numpy, statsmodels] +params: + - name: values + desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes de descomponer." + - name: period + desc: "periodo estacional (observaciones por ciclo, p.ej. 12 para mensual con estacionalidad anual). Si None se infiere por autocorrelacion; si no hay periodo claro devuelve nota." + - name: robust + desc: "si True (default) usa el ajuste robusto de STL, que reduce el efecto de outliers sobre tendencia y estacionalidad." +output: "dict con 'period' usado, 'period_inferred' (bool), 'trend'/'seasonal'/'resid' (cada uno min/max/mean/std + values si la serie es corta, si no None), 'trend_strength' y 'seasonal_strength' (medidas de Hyndman en [0,1]). Serie insuficiente o sin periodo inferible: dict con 'note' y strengths en None. Nunca lanza excepcion." +tested: true +tests: ["test_serie_con_tendencia_y_estacionalidad", "test_fuerza_estacional_alta_con_estacionalidad_fuerte", "test_infiere_periodo_si_none", "test_serie_corta_devuelve_nota", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_serie_larga_resume_sin_values"] +test_file_path: "python/functions/datascience/stl_decompose_test.py" +file_path: "python/functions/datascience/stl_decompose.py" +--- + +## Ejemplo + +```python +from datascience import stl_decompose +import numpy as np + +# Serie mensual = tendencia lineal + ciclo estacional anual (periodo 12) + ruido +rng = np.random.default_rng(0) +n = 120 +serie = [0.3 * i + 10 * np.sin(2 * np.pi * i / 12) + rng.normal(0, 1) for i in range(n)] + +res = stl_decompose(serie, period=12) +res["trend_strength"] # -> ~0.99 (tendencia clara) +res["seasonal_strength"] # -> ~0.98 (estacionalidad clara) +res["seasonal"]["values"][:3] # primeras 3 muestras de la componente estacional + +# Sin pasar periodo: lo infiere por autocorrelacion +stl_decompose(serie)["period_inferred"] # -> True +``` + +## Cuando usarla + +Cuando quieres separar una serie temporal en sus partes para entenderla o +prepararla para modelar: cuanta de su variacion es tendencia de fondo, cuanta es +ciclo estacional repetitivo y cuanta es ruido. Util en EDA para decidir si merece +la pena desestacionalizar antes de comparar periodos, para detectar un cambio de +tendencia, o para extraer features (las fuerzas de tendencia/estacionalidad de +Hyndman resumen la serie en dos numeros comparables entre series). + +## Gotchas + +- Es pura pero importa `statsmodels.tsa.seasonal.STL` y `numpy` (en `python/.venv`). +- STL exige al menos **dos ciclos completos**: con `n < 2*period` devuelve una + nota en vez de descomponer. Para datos mensuales con estacionalidad anual + (period=12) necesitas >= 24 meses. +- La inferencia automatica de `period` busca el pico de autocorrelacion; es + heuristica. Si conoces el periodo real (12 mensual, 7 diario-semanal, 24 + horario-diario), pasalo explicito: es mas fiable. +- Las componentes largas (> 200 puntos) se resumen en estadisticos y `values` + queda en `None` para no inflar el payload; las cortas vienen completas. +- Las fuerzas estan en `[0,1]` por construccion (se recortan a 0 si la varianza + del resto supera la de resto+componente, lo que indica componente inexistente). diff --git a/python/functions/datascience/stl_decompose.py b/python/functions/datascience/stl_decompose.py new file mode 100644 index 00000000..bda18b33 --- /dev/null +++ b/python/functions/datascience/stl_decompose.py @@ -0,0 +1,195 @@ +"""Descomposicion STL de una serie temporal en tendencia/estacional/resto (grupo eda). + +Funcion pura y determinista que aplica STL (Seasonal-Trend decomposition using +Loess, Cleveland et al. 1990) via statsmodels y reporta las tres componentes mas +las medidas de fuerza de tendencia y de estacionalidad de Hyndman ("Forecasting: +Principles and Practice", seccion de feature extraction). Util en EDA para +entender que parte de la variacion de una serie es tendencia, ciclo estacional o +ruido antes de modelar o desestacionalizar. +""" + +from __future__ import annotations + +import math + +import numpy as np +from statsmodels.tsa.seasonal import STL + + +def _clean(values: list) -> list[float]: + """Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.""" + out: list[float] = [] + for v in values: + if v is None or isinstance(v, bool): + continue + if not isinstance(v, (int, float)): + continue + x = float(v) + if math.isnan(x) or math.isinf(x): + continue + out.append(x) + return out + + +def _infer_period(arr: np.ndarray, max_period: int) -> int | None: + """Infiere el periodo estacional dominante via autocorrelacion. + + Busca el retardo (entre 2 y ``max_period``) que maximiza la autocorrelacion + de la serie. Devuelve None si no encuentra un pico claro (autocorrelacion + maxima por debajo de un umbral pequeno). + """ + n = len(arr) + if n < 6: + return None + x = arr - arr.mean() + denom = float(np.dot(x, x)) + if denom == 0.0: + return None + best_lag = None + best_corr = 0.0 + upper = min(max_period, n // 2) + for lag in range(2, upper + 1): + corr = float(np.dot(x[:-lag], x[lag:]) / denom) + if corr > best_corr: + best_corr = corr + best_lag = lag + if best_lag is None or best_corr < 0.2: + return None + return best_lag + + +def _summarize(component: list[float], max_inline: int = 200) -> dict: + """Resume una componente: la incluye entera si es corta, si no estadisticos.""" + arr = np.asarray(component, dtype=float) + summary = { + "min": float(arr.min()), + "max": float(arr.max()), + "mean": float(arr.mean()), + "std": float(arr.std(ddof=0)), + } + if len(component) <= max_inline: + summary["values"] = [float(v) for v in component] + else: + summary["values"] = None + summary["note"] = f"serie larga ({len(component)} puntos): solo estadisticos" + return summary + + +def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict: + """Descompone una serie temporal en tendencia, estacional y resto via STL. + + Aplica STL (Seasonal-Trend decomposition using Loess) sobre ``values`` y + devuelve las tres componentes (resumidas si la serie es larga) junto a la + fuerza de tendencia y la fuerza estacional de Hyndman:: + + F_trend = max(0, 1 - Var(resto) / Var(resto + tendencia)) + F_seasonal = max(0, 1 - Var(resto) / Var(resto + estacional)) + + Ambas en ``[0, 1]``: cercano a 1 indica una componente fuerte y bien + definida; cercano a 0 indica que esa componente apenas existe. + + Funcion pura y determinista: no hace I/O, no muta los inputs. + + Args: + values: serie temporal de valores numericos en orden cronologico. + None/NaN/infinitos/no-numericos se descartan antes de descomponer. + period: periodo estacional (numero de observaciones por ciclo, p.ej. 12 + para datos mensuales con estacionalidad anual). Si es ``None`` se + intenta inferir por autocorrelacion; si no se halla un periodo + claro, se devuelve una nota. + robust: si ``True`` (default) usa el ajuste robusto de STL, que reduce el + efecto de outliers sobre tendencia y estacionalidad. + + Returns: + Con menos de ``2 * period`` puntos (o <8 si no hay periodo) devuelve un + dict con ``note`` explicando por que no se pudo descomponer y + ``trend_strength``/``seasonal_strength`` en ``None``. + + En otro caso un dict con:: + + { + "n": int, + "period": int, # periodo usado (inferido o dado) + "period_inferred": bool, # True si se infirio automaticamente + "robust": bool, + "trend": {min,max,mean,std, values|note}, + "seasonal": {...}, + "resid": {...}, + "trend_strength": float, # F_trend de Hyndman en [0,1] + "seasonal_strength": float, # F_seasonal de Hyndman en [0,1] + } + """ + clean = _clean(values) + n = len(clean) + + if n < 8: + return { + "n": n, + "note": "datos insuficientes", + "trend_strength": None, + "seasonal_strength": None, + } + + arr = np.asarray(clean, dtype=float) + + inferred = False + if period is None: + period = _infer_period(arr, max_period=max(2, n // 2)) + inferred = True + if period is None: + return { + "n": n, + "note": "no se pudo inferir un periodo estacional; pasa period explicito", + "trend_strength": None, + "seasonal_strength": None, + } + + period = int(period) + if period < 2: + return { + "n": n, + "note": "period debe ser >= 2", + "trend_strength": None, + "seasonal_strength": None, + } + + # STL exige al menos dos ciclos completos. + if n < 2 * period: + return { + "n": n, + "period": period, + "note": f"serie corta: STL necesita >= 2*period ({2 * period}) puntos", + "trend_strength": None, + "seasonal_strength": None, + } + + result = STL(arr, period=period, robust=robust).fit() + trend = np.asarray(result.trend, dtype=float) + seasonal = np.asarray(result.seasonal, dtype=float) + resid = np.asarray(result.resid, dtype=float) + + # Fuerza de tendencia y estacional (Hyndman). Var con ddof=0. + var_resid = float(np.var(resid, ddof=0)) + var_resid_trend = float(np.var(resid + trend, ddof=0)) + var_resid_seasonal = float(np.var(resid + seasonal, ddof=0)) + + trend_strength = ( + max(0.0, 1.0 - var_resid / var_resid_trend) if var_resid_trend > 0 else 0.0 + ) + seasonal_strength = ( + max(0.0, 1.0 - var_resid / var_resid_seasonal) + if var_resid_seasonal > 0 + else 0.0 + ) + + return { + "n": n, + "period": period, + "period_inferred": bool(inferred), + "robust": bool(robust), + "trend": _summarize(trend.tolist()), + "seasonal": _summarize(seasonal.tolist()), + "resid": _summarize(resid.tolist()), + "trend_strength": float(trend_strength), + "seasonal_strength": float(seasonal_strength), + } diff --git a/python/functions/datascience/suggest_reexpression.md b/python/functions/datascience/suggest_reexpression.md new file mode 100644 index 00000000..a0920b72 --- /dev/null +++ b/python/functions/datascience/suggest_reexpression.md @@ -0,0 +1,73 @@ +--- +name: suggest_reexpression +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def suggest_reexpression(stats: dict) -> dict" +description: "Sugiere la re-expresion de la escalera de potencias de Tukey (none/log/log1p/sqrt/square/cube/box-cox/yeo-johnson) que mas simetriza una columna numerica, a partir de su skew y su dominio (ceros/negativos). Pura: razona por reglas, NO ejecuta la transformacion. Devuelve recomendacion + razon legible + alternativas ordenadas." +tags: [statistics, eda, reexpression, transform, skew, tukey, ladder-of-powers, box-cox, yeo-johnson, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: stats + desc: "dict con los estadisticos de una columna numerica (sub-bloque `numeric` de un ColumnProfile del grupo eda, o el ColumnProfile completo). Usa `skew` (obligatorio), y `min`/`zero_pct`/`negative_pct` cuando esten para determinar el dominio. Si recibe un ColumnProfile entero, baja a su clave `numeric`." +output: "dict con `recommended` (nombre de la transformacion o None si falta skew), `ladder_power` (exponente conceptual de la escalera de Tukey: 1.0 raw, 0.5 sqrt, 0.0 log, None para data-driven), `reason` (explicacion legible), `alternatives` (lista ordenada de {transform, ladder_power, reason}), `skew` (el usado) y `note` (vacio en caso normal; mensaje si la entrada es incompleta o el dominio es desconocido). Nunca lanza excepcion." +tested: true +tests: ["test_aproximadamente_simetrica_recomienda_none", "test_positiva_fuerte_todo_positivo_recomienda_log", "test_positiva_moderada_todo_positivo_recomienda_sqrt", "test_positiva_con_ceros_fuerte_recomienda_log1p", "test_positiva_con_negativos_recomienda_yeo_johnson", "test_negativa_fuerte_todo_positivo_recomienda_cube", "test_negativa_moderada_todo_positivo_recomienda_square", "test_dominio_desconocido_recomienda_yeo_johnson_con_nota", "test_acepta_columnprofile_completo_con_numeric_anidado", "test_skew_ausente_devuelve_nota", "test_stats_vacio_devuelve_nota", "test_no_dict_no_lanza", "test_skew_no_numerico_devuelve_nota"] +test_file_path: "python/functions/datascience/suggest_reexpression_test.py" +file_path: "python/functions/datascience/suggest_reexpression.py" +--- + +## Ejemplo + +```python +from datascience import suggest_reexpression + +# Columna estrictamente positiva con cola derecha larga -> log. +stats = {"skew": 2.3, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0} +out = suggest_reexpression(stats) +out["recommended"] # -> "log" +out["ladder_power"] # -> 0.0 (escalon p=0 de la escalera de Tukey) +out["reason"] # -> "skew = 2.3 (cola derecha..., fuerte) y todos los valores > 0: log comprime..." +[a["transform"] for a in out["alternatives"]] # -> ["box-cox", "sqrt"] + +# Con valores negativos, log/Box-Cox no valen -> Yeo-Johnson. +suggest_reexpression({"skew": 1.8, "min": -4.0, "negative_pct": 20.0})["recommended"] # -> "yeo-johnson" + +# Funciona directo sobre el sub-bloque `numeric` de describe_numeric: +# col["numeric"] = {"skew": ..., "min": ..., "zero_pct": ..., "negative_pct": ...} +suggest_reexpression(col["numeric"]) +``` + +## Cuando usarla + +Cuando un EDA ya detecto que una columna numerica esta sesgada (|skew| alto en el +bloque `numeric` de `describe_numeric` / `detect_distribution_type`) y quieres el +siguiente paso de Tukey: que transformacion la simetriza. Cierra el gap entre +"detecto skew" y "sugiere la re-expresion". Util antes de modelar (muchos modelos +asumen ~normalidad o varianza estable) y para enriquecer un reporte EDA con una +recomendacion accionable por columna. NO la uses si solo quieres el valor del skew +(eso ya lo da `describe_numeric`). + +## Gotchas + +- Es **pura**: NO ejecuta la transformacion, solo decide cual sugerir. Aplicarla es + trabajo del caller (numpy/scipy/sklearn) si decide seguir la recomendacion. +- Necesita `skew`. Sin el devuelve `recommended=None` + `note` (no lanza). +- El dominio (ceros/negativos) se infiere de `min`, `zero_pct` y `negative_pct`. Si + ninguno esta presente, el dominio es desconocido y sugiere `yeo-johnson` (opcion + segura para cualquier rango) con una nota; pasale al menos `min` para una decision + mas fina (log vs sqrt vs Box-Cox). +- `zero_pct`/`negative_pct` se interpretan como ">0 = hay ceros/negativos"; la escala + (fraccion 0-1 o porcentaje 0-100) es indiferente para la decision. +- Umbrales: |skew|<0.5 -> `none`; 0.5-1.0 -> moderada; >=1.0 -> fuerte. Son la + convencion habitual, no una verdad absoluta — un caller puede recomputar con el + `skew` que se devuelve. +- `log`/`Box-Cox` exigen datos estrictamente positivos; con ceros usa `log1p`; con + negativos o ceros, `Yeo-Johnson`. La funcion ya aplica estas reglas por ti. diff --git a/python/functions/datascience/suggest_reexpression.py b/python/functions/datascience/suggest_reexpression.py new file mode 100644 index 00000000..8a694482 --- /dev/null +++ b/python/functions/datascience/suggest_reexpression.py @@ -0,0 +1,267 @@ +"""Sugiere la re-expresión (escalera de potencias de Tukey) que más simetriza una columna. + +Funcion pura y determinista: no hace I/O, no ejecuta la transformación, no muta el +input. Solo razona por reglas sobre un bloque de estadísticos de una columna numérica +(el sub-bloque ``numeric`` de un ColumnProfile del grupo ``eda``: ``describe_numeric``) +y devuelve la transformación de la "escalera de potencias" de Tukey que se espera que +reduzca mejor la asimetría, junto a su razón legible y alternativas ordenadas. + +Trasfondo (Tukey, *EDA* 1977, cap. 3-4 "re-expression"): la escalera de potencias +ordena las transformaciones por su exponente ``p``:: + + ... x^3 x^2 x sqrt(x) log(x) -1/sqrt(x) -1/x ... + p=3 p=2 p=1 p=0.5 p=0 p=-0.5 p=-1 + +Bajar por la escalera (``p`` menor) comprime la cola derecha → corrige asimetría +POSITIVA. Subir por la escalera (``p`` mayor) corrige asimetría NEGATIVA. El log +(``p=0``) es el escalón más usado para colas derechas largas, pero exige datos +estrictamente positivos. Con ceros se usa ``log1p`` (= ``log(1+x)``); con negativos +o ceros, la generalización moderna es ``Yeo-Johnson`` (y ``Box-Cox`` para datos +estrictamente positivos), que estiman el exponente óptimo a partir de los datos. + +Esta función NO ejecuta la transformación: decide cuál sugerir. Es el caller quien la +aplica (p.ej. con ``numpy``/``scipy``/``sklearn``) si decide seguir la recomendación. +""" + +from __future__ import annotations + +# Umbrales sobre |skew| (convención habitual en EDA): +# |skew| < 0.5 -> aproximadamente simétrica, no hace falta re-expresar. +# 0.5 <= |skew| < 1.0 -> asimetría moderada. +# |skew| >= 1.0 -> asimetría fuerte (cola larga). +_SYMMETRIC_THRESHOLD = 0.5 +_STRONG_THRESHOLD = 1.0 + +# Exponente conceptual de la escalera de Tukey por transformación (didáctico). +_LADDER_POWER = { + "cube": 3.0, + "square": 2.0, + "none": 1.0, + "sqrt": 0.5, + "log": 0.0, + "log1p": 0.0, + "reciprocal": -1.0, + "box-cox": None, # data-driven (lambda estimado) + "yeo-johnson": None, # data-driven (lambda estimado) +} + + +def _to_float(v): + """Parsea a float; None si es None/bool/no parseable (NaN incluido).""" + if v is None or isinstance(v, bool): + return None + try: + f = float(v) + except (TypeError, ValueError): + return None + if f != f: # NaN + return None + return f + + +def _alt(name: str, reason: str) -> dict: + """Construye una entrada de alternativa con su exponente de la escalera.""" + return {"transform": name, "ladder_power": _LADDER_POWER.get(name), "reason": reason} + + +def suggest_reexpression(stats: dict) -> dict: + """Sugiere la transformación de la escalera de potencias de Tukey que más simetriza. + + Razona por reglas (no ejecuta la transformación) a partir de un bloque de + estadísticos de una columna numérica. Acepta tanto el sub-bloque ``numeric`` de + un ColumnProfile (claves ``skew``, ``min``, ``kurtosis``, ``zero_pct``, + ``negative_pct``...) como el ColumnProfile completo (en cuyo caso usa su clave + ``numeric``). La decisión combina la magnitud y el signo de ``skew`` con el + dominio de los datos (si hay ceros y/o negativos), porque ``log``/``Box-Cox`` + solo admiten valores estrictamente positivos. + + Reglas: + - ``|skew| < 0.5`` -> ``none`` (ya es ~simétrica). + - ``skew`` positivo (cola derecha): + - hay negativos -> ``yeo-johnson``. + - hay ceros (sin negativos) -> ``log1p`` (fuerte) / ``sqrt`` (moderado). + - estrictamente positivos -> ``log`` (fuerte) / ``sqrt`` (moderado). + - ``skew`` negativo (cola izquierda): + - hay negativos o ceros -> ``yeo-johnson``. + - estrictamente positivos -> ``cube`` (fuerte) / ``square`` (moderado). + - dominio desconocido (sin ``min``/``zero_pct``/``negative_pct``) y + ``skew`` apreciable -> ``yeo-johnson`` (opción segura que admite cualquier + dominio) más una nota. + + Es pura, determinista y no lanza excepciones: entradas vacías o sin ``skew`` + devuelven ``recommended = None`` y una ``note`` explicativa. + + Args: + stats: dict con los estadísticos de la columna. Espera al menos ``skew``. + Usa además ``min``, ``zero_pct`` y ``negative_pct`` (cuando estén) para + determinar el dominio. Si recibe un ColumnProfile completo, lee su + sub-bloque ``numeric``. + + Returns: + dict con: + - ``recommended``: nombre de la transformación sugerida (``"none"``, + ``"log"``, ``"log1p"``, ``"sqrt"``, ``"square"``, ``"cube"``, + ``"reciprocal"``, ``"box-cox"``, ``"yeo-johnson"``) o ``None`` si no + se puede decidir (falta ``skew``). + - ``ladder_power``: exponente conceptual de la escalera de Tukey de la + transformación recomendada (``1.0`` raw, ``0.5`` sqrt, ``0.0`` log, + ``None`` para las data-driven), o ``None`` si no hay recomendación. + - ``reason``: explicación legible de por qué se sugiere. + - ``alternatives``: lista ordenada de otras transformaciones razonables, + cada una ``{"transform", "ladder_power", "reason"}``. + - ``skew``: el skew usado en la decisión (float) o ``None``. + - ``note``: cadena vacía en el caso normal; mensaje cuando la entrada es + incompleta (sin ``skew``, dominio desconocido, etc.). + """ + if not isinstance(stats, dict) or not stats: + return { + "recommended": None, + "ladder_power": None, + "reason": "", + "alternatives": [], + "skew": None, + "note": "stats vacío o no es un dict: nada que sugerir", + } + + # Aceptar un ColumnProfile completo: bajar a su sub-bloque numeric. + if "skew" not in stats and isinstance(stats.get("numeric"), dict): + stats = stats["numeric"] + + skew = _to_float(stats.get("skew")) + if skew is None: + return { + "recommended": None, + "ladder_power": None, + "reason": "", + "alternatives": [], + "skew": None, + "note": "skew ausente o no numérico: no se puede sugerir re-expresión", + } + + minimum = _to_float(stats.get("min")) + zero_pct = _to_float(stats.get("zero_pct")) + negative_pct = _to_float(stats.get("negative_pct")) + + # Determinar el dominio de los datos a partir de lo disponible. + domain_known = ( + minimum is not None or zero_pct is not None or negative_pct is not None + ) + has_negative = (negative_pct is not None and negative_pct > 0) or ( + minimum is not None and minimum < 0 + ) + has_zero = (zero_pct is not None and zero_pct > 0) or ( + minimum is not None and minimum == 0 + ) + strictly_positive = domain_known and not has_negative and not has_zero + + abs_skew = abs(skew) + strong = abs_skew >= _STRONG_THRESHOLD + magnitude = "fuerte" if strong else "moderada" + side = "cola derecha (asimetría positiva)" if skew > 0 else "cola izquierda (asimetría negativa)" + note = "" + + # 1. Aproximadamente simétrica -> no re-expresar. + if abs_skew < _SYMMETRIC_THRESHOLD: + return { + "recommended": "none", + "ladder_power": _LADDER_POWER["none"], + "reason": ( + f"skew = {skew:.3g} (|skew| < {_SYMMETRIC_THRESHOLD}): la columna ya es " + "aproximadamente simétrica, no necesita re-expresión" + ), + "alternatives": [], + "skew": skew, + "note": "", + } + + alternatives: list = [] + + # 2. Asimetría positiva (cola derecha): bajar por la escalera de Tukey. + if skew > 0: + if has_negative: + recommended = "yeo-johnson" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) y hay valores negativos: " + "Yeo-Johnson estima el exponente óptimo y admite negativos y ceros " + "(log/Box-Cox no)" + ) + elif has_zero: + recommended = "log1p" if strong else "sqrt" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) con ceros presentes: " + + ("log1p = log(1+x) comprime la cola sin romper en x=0" + if strong else + "sqrt simetriza una cola moderada y admite el cero") + ) + alternatives.append(_alt( + "yeo-johnson", + "estima el exponente óptimo y admite ceros; alternativa data-driven", + )) + alternatives.append(_alt( + "sqrt" if strong else "log1p", + "otro escalón cercano de la escalera para ceros", + )) + elif strictly_positive: + recommended = "log" if strong else "sqrt" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: " + + ("log comprime con fuerza la cola derecha larga (escalón p=0)" + if strong else + "sqrt corrige una cola derecha moderada (escalón p=0.5)") + ) + alternatives.append(_alt( + "box-cox", + "estima el exponente óptimo sobre datos estrictamente positivos", + )) + alternatives.append(_alt( + "sqrt" if strong else "log", + "escalón vecino de la escalera de Tukey", + )) + else: + recommended = "yeo-johnson" + note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: " + "Yeo-Johnson funciona con cualquier rango (positivos, ceros, negativos)" + ) + + # 3. Asimetría negativa (cola izquierda): subir por la escalera de Tukey. + else: + if has_negative or has_zero: + recommended = "yeo-johnson" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) con ceros/negativos: " + "Yeo-Johnson sube por la escalera y admite cualquier dominio" + ) + elif strictly_positive: + recommended = "cube" if strong else "square" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: " + + ("x^3 alarga la cola izquierda corta (escalón p=3)" + if strong else + "x^2 corrige una cola izquierda moderada (escalón p=2)") + ) + alternatives.append(_alt( + "box-cox", + "estima un exponente > 1 óptimo sobre datos positivos", + )) + alternatives.append(_alt( + "square" if strong else "cube", + "escalón vecino hacia arriba de la escalera de Tukey", + )) + else: + recommended = "yeo-johnson" + note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura" + reason = ( + f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: " + "Yeo-Johnson funciona con cualquier rango" + ) + + return { + "recommended": recommended, + "ladder_power": _LADDER_POWER.get(recommended), + "reason": reason, + "alternatives": alternatives, + "skew": skew, + "note": note, + } diff --git a/python/functions/datascience/to_returns.md b/python/functions/datascience/to_returns.md new file mode 100644 index 00000000..7e76b17e --- /dev/null +++ b/python/functions/datascience/to_returns.md @@ -0,0 +1,70 @@ +--- +name: to_returns +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def to_returns(values: list, method: str = 'log') -> dict" +description: "Convierte una serie de niveles (precios) a retornos: 'log' (ln(p_t/p_{t-1})) o 'simple' (p_t/p_{t-1}-1). Para correlacionar/modelar series financieras sobre retornos (aprox.) estacionarios en vez de niveles no estacionarios, evitando la regresion espuria (Granger-Newbold, Lopez de Prado). Devuelve la serie de retornos mas stats basicas. Maneja ceros/negativos en log marcando el paso invalido. Descarta None/NaN; <2 puntos validos -> nota." +tags: [timeseries, returns, finance, stationarity, log-returns, eda, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [math] +params: + - name: values + desc: "serie de niveles (precios) en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes de calcular." + - name: method + desc: "'log' (default) para retornos logaritmicos ln(p_t/p_{t-1}), o 'simple' para retornos aritmeticos p_t/p_{t-1}-1." +output: "dict con 'returns' (lista, un retorno por par consecutivo; None si el paso es invalido), 'method', 'n_levels', 'n_returns', 'n_skipped', y stats 'mean'/'std'/'min'/'max' de los retornos validos (None si todos invalidos). method invalido o <2 puntos: dict con 'note' y 'returns': []. Nunca lanza excepcion." +tested: true +tests: ["test_log_returns_valores_conocidos", "test_simple_returns_valores_conocidos", "test_log_marca_no_positivo_como_invalido", "test_simple_admite_negativos", "test_method_invalido_devuelve_nota", "test_un_solo_punto_devuelve_nota", "test_descarta_none_y_nan", "test_stats_de_retornos"] +test_file_path: "python/functions/datascience/to_returns_test.py" +file_path: "python/functions/datascience/to_returns.py" +--- + +## Ejemplo + +```python +from datascience import to_returns + +# Retornos logaritmicos de una serie de precios +precios = [100.0, 105.0, 103.0, 108.0] +res = to_returns(precios, method="log") +res["returns"] # -> [0.0488, -0.0192, 0.0474] (ln(105/100), ln(103/105), ...) +res["n_returns"] # -> 3 + +# Retornos simples (porcentuales) +to_returns(precios, method="simple")["returns"] # -> [0.05, -0.0190, 0.0485] + +# Un precio <= 0 invalida ese paso en log (no peta) +to_returns([100.0, 0.0, 50.0], method="log")["n_skipped"] # -> 2 +``` + +## Cuando usarla + +Antes de correlacionar, medir volatilidad o modelar una serie financiera de +precios. Los precios son no estacionarios (tienen raiz unitaria): correlacionar +dos series de precios da correlaciones altas pero espurias. Los retornos son +(aproximadamente) estacionarios, asi que son la unidad correcta. Encadena con +`adf_kpss_stationarity` para confirmar que los retornos ya son estacionarios, y +luego con `spearman_corr`/`pearson` o un modelo. Usa `log` para modelar (aditivo +en el tiempo) y `simple` cuando necesites interpretar el retorno como porcentaje. + +## Gotchas + +- Es pura (solo `math`, sin dependencias externas). +- `method="log"` exige precios estrictamente positivos: un valor <= 0 invalida + ese paso (queda `None` en `returns` y suma a `n_skipped`) en lugar de lanzar + `ValueError`. Revisa `n_skipped` si tu serie puede tener ceros/negativos. +- La serie de retornos tiene **un elemento menos** que la de niveles (no hay + retorno para el primer punto). +- Los huecos (None/NaN) se eliminan ANTES de emparejar, asi que el retorno se + calcula entre puntos validos consecutivos en el tiempo-indice original, no + rellenando el hueco. Si necesitas tratar huecos como saltos reales, limpia tu + la serie antes. +- `simple` solo invalida el paso cuando el precio previo es exactamente 0 + (division por cero); admite precios y retornos negativos. diff --git a/python/functions/datascience/to_returns.py b/python/functions/datascience/to_returns.py new file mode 100644 index 00000000..80eec5e0 --- /dev/null +++ b/python/functions/datascience/to_returns.py @@ -0,0 +1,127 @@ +"""Convierte una serie de niveles (precios) a retornos (grupo eda). + +Funcion pura y determinista que transforma una serie de niveles en una serie de +retornos, simples o logaritmicos. Motivada por Lopez de Prado ("Advances in +Financial ML") y Hamilton ("Time Series Analysis"): las series de precios son no +estacionarias (raiz unitaria), de modo que correlacionarlas o modelarlas sobre +sus niveles produce regresion espuria (Granger-Newbold). Los retornos son +(aproximadamente) estacionarios y son la unidad correcta para correlacionar, +medir volatilidad o ajustar modelos. +""" + +from __future__ import annotations + +import math + + +def _clean(values: list) -> list[float]: + """Conserva solo valores numericos finitos, descartando None/NaN/no-numericos. + + A diferencia de otras funciones del grupo, aqui el ORDEN importa (es una + serie temporal), pero un hueco intermedio rompe el calculo de retorno + consecutivo; por eso se descartan los no-validos y el retorno se calcula + sobre los puntos validos restantes en su orden original. + """ + out: list[float] = [] + for v in values: + if v is None or isinstance(v, bool): + continue + if not isinstance(v, (int, float)): + continue + x = float(v) + if math.isnan(x) or math.isinf(x): + continue + out.append(x) + return out + + +def to_returns(values: list, method: str = "log") -> dict: + """Convierte una serie de niveles (precios) a retornos. + + Calcula el retorno entre observaciones consecutivas de la serie limpia: + + - ``method="log"``: ``r_t = ln(p_t / p_{t-1})`` (retorno logaritmico). + Aditivo en el tiempo y simetrico; es el preferido para modelar. Requiere + precios estrictamente positivos: si aparece un valor <= 0 ese paso se + marca como invalido (``None`` en la serie) y se cuenta en ``n_skipped``. + - ``method="simple"``: ``r_t = p_t / p_{t-1} - 1`` (retorno aritmetico). + Admite valores negativos; solo se invalida el paso si ``p_{t-1} == 0`` + (division por cero). + + Funcion pura y determinista: no hace I/O, no muta los inputs. + + Args: + values: serie de niveles (precios) en orden cronologico. None/NaN/ + infinitos/no-numericos se descartan antes de calcular. + method: ``"log"`` (default) para retornos logaritmicos o ``"simple"`` + para retornos aritmeticos. + + Returns: + Con menos de 2 puntos validos (no hay ningun par consecutivo) devuelve + ``{"n": n, "note": "datos insuficientes", "returns": []}``. + + Si ``method`` no es ``"log"`` ni ``"simple"`` devuelve + ``{"note": "method debe ser 'log' o 'simple'", "returns": []}``. + + En otro caso un dict con:: + + { + "method": str, + "n_levels": int, # niveles validos de entrada + "returns": [float|None],# un retorno por par consecutivo (None si invalido) + "n_returns": int, # retornos validos (no None) + "n_skipped": int, # pasos invalidados (log de no-positivo, div/0) + "mean": float, # media de los retornos validos + "std": float, # desviacion tipica (ddof=0) de los validos + "min": float, + "max": float, + } + + Si todos los pasos resultan invalidos, ``mean/std/min/max`` son ``None``. + """ + if method not in ("log", "simple"): + return {"note": "method debe ser 'log' o 'simple'", "returns": []} + + clean = _clean(values) + n = len(clean) + + if n < 2: + return {"n": n, "note": "datos insuficientes", "returns": []} + + returns: list[float | None] = [] + n_skipped = 0 + for prev, cur in zip(clean[:-1], clean[1:]): + if method == "log": + if prev <= 0.0 or cur <= 0.0: + returns.append(None) + n_skipped += 1 + continue + returns.append(math.log(cur / prev)) + else: # simple + if prev == 0.0: + returns.append(None) + n_skipped += 1 + continue + returns.append(cur / prev - 1.0) + + valid = [r for r in returns if r is not None] + if valid: + mean = sum(valid) / len(valid) + var = sum((r - mean) ** 2 for r in valid) / len(valid) + std = math.sqrt(var) + vmin = min(valid) + vmax = max(valid) + else: + mean = std = vmin = vmax = None + + return { + "method": method, + "n_levels": n, + "returns": returns, + "n_returns": len(valid), + "n_skipped": n_skipped, + "mean": mean if mean is None else float(mean), + "std": std if std is None else float(std), + "min": vmin if vmin is None else float(vmin), + "max": vmax if vmax is None else float(vmax), + } diff --git a/python/functions/pipelines/profile_table.md b/python/functions/pipelines/profile_table.md index 4b741af3..eb8ce6d2 100644 --- a/python/functions/pipelines/profile_table.md +++ b/python/functions/pipelines/profile_table.md @@ -5,17 +5,29 @@ lang: py domain: pipelines purity: impure version: "1.0.0" -signature: "def profile_table(db_path: str, table: str, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True) -> dict" -description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla DuckDB end-to-end componiendo las 7 funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + render markdown) y emite el TableProfile completo mas (opcional) un report markdown y un JSON sidecar. Es la composicion canonica para hazme un EDA de esta tabla." -tags: [eda, duckdb, profiling, data-quality, pipeline, dataops] +signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", 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" +description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla (DuckDB o PostgreSQL) end-to-end componiendo las funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + correlaciones con correccion FDR + re-expresion de Tukey + avisos exploratorios) y, opcional, modelos baratos (run_models), interpretacion LLM (run_llm) y analisis de serie temporal por columna (run_series: estacionariedad ADF+KPSS, ACF/PACF, STL, retornos). Emite el TableProfile completo mas (opcional) report markdown + JSON sidecar + PDF movil (emit_pdf). Es la composicion canonica para hazme un EDA de esta tabla." +tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries] uses_functions: - summarize_table_duckdb_py_datascience + - summarize_table_pg_py_datascience - describe_numeric_py_datascience - summarize_categorical_py_datascience - infer_semantic_type_py_datascience - column_quality_score_py_datascience + - association_matrix_py_datascience + - run_eda_models_py_datascience + - eda_llm_insights_py_datascience + - adf_kpss_stationarity_py_datascience + - acf_pacf_py_datascience + - stl_decompose_py_datascience + - to_returns_py_datascience + - suggest_reexpression_py_datascience + - exploratory_caveats_py_datascience - render_eda_markdown_py_datascience + - render_eda_pdf_py_datascience - duckdb_query_readonly_py_infra + - pg_query_py_infra uses_types: [] returns: [] returns_optional: false @@ -28,16 +40,26 @@ test_file_path: "python/functions/pipelines/profile_table_test.py" file_path: "python/functions/pipelines/profile_table.py" params: - name: db_path - desc: "Ruta al archivo DuckDB (read-only, debe existir; no se crea)." + desc: "Ruta al archivo DuckDB (read-only, debe existir; no se crea) o DSN PostgreSQL si backend='postgres'." - name: table desc: "Nombre de la tabla a perfilar." + - name: backend + desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado base (summarize) y de muestreo read-only." - name: sample desc: "Maximo de valores no nulos muestreados por columna para el enriquecimiento (describe_numeric / summarize_categorical / infer_semantic_type). Default 5000." + - name: run_models + desc: "Si True (default False) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad) y guarda el bloque en prof['models']." + - name: run_llm + desc: "Si True (default False) hace 1 llamada LLM sobre el perfil agregado y guarda el resultado en prof['llm']." + - name: run_series + desc: "Si True (default False) calcula por columna numerica un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF, STL y, si parece de niveles, retornos). Ordena por la primera columna datetime si existe; si no, por el orden fisico. Guardado en col['series'] y agregado en prof['series']." + - name: emit_pdf + desc: "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." - name: report_dir - desc: "Directorio donde escribir los reports si write_report. Default 'reports'. Se crea si no existe." + desc: "Directorio donde escribir los reports si write_report (y el PDF si emit_pdf). Default 'reports'. Se crea si no existe." - name: write_report - desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths del retorno son None." -output: "dict {status:'ok', profile:, report_md_path:str|None, report_json_path:str|None} o {status:'error', error:str} (dict-no-throw)." + desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths markdown/json del retorno son None (emit_pdf es independiente)." +output: "dict {status:'ok', profile:, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None} o {status:'error', error:str} (dict-no-throw)." --- ## Ejemplo diff --git a/python/functions/pipelines/profile_table.py b/python/functions/pipelines/profile_table.py index 7f62bb31..19b57a25 100644 --- a/python/functions/pipelines/profile_table.py +++ b/python/functions/pipelines/profile_table.py @@ -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: , - 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)} diff --git a/python/pyproject.toml b/python/pyproject.toml index 05887de7..f75f2afa 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "scipy>=1.17.1", "seaborn>=0.13.2", "shapely>=2.1.2", + "statsmodels>=0.14.6", "trimesh>=4.12.2", "xlrd>=2.0.2", ] diff --git a/python/uv.lock b/python/uv.lock index db26ef2f..d46df6d9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -918,6 +918,7 @@ dependencies = [ { name = "scipy" }, { name = "seaborn" }, { name = "shapely" }, + { name = "statsmodels" }, { name = "trimesh" }, { name = "xlrd" }, ] @@ -977,6 +978,7 @@ requires-dist = [ { name = "scipy", specifier = ">=1.17.1" }, { name = "seaborn", specifier = ">=0.13.2" }, { name = "shapely", specifier = ">=2.1.2" }, + { name = "statsmodels", specifier = ">=0.14.6" }, { name = "trimesh", specifier = ">=4.12.2" }, { name = "xlrd", specifier = ">=2.0.2" }, ] @@ -3099,6 +3101,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] +[[package]] +name = "patsy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -4863,6 +4877,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] +[[package]] +name = "statsmodels" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, + { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, + { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, +] + [[package]] name = "sympy" version = "1.14.0"