feat(eda): series temporales + rigor anti-data-mining + PDF movil + /eda + benchmark issues
Bloque del grupo eda (sesion ausente EDA-benchmark): - 8 funciones nuevas: adf_kpss_stationarity, acf_pacf, stl_decompose, to_returns, fdr_correction, suggest_reexpression, exploratory_caveats, render_eda_pdf - integracion: profile_table (run_series, emit_pdf), association_matrix (FDR Benjamini-Hochberg), render_eda_markdown (secciones series/reexpresion/caveats) - slash commands /eda y /capitulos - issues 0173-0177: mejoras del /eda derivadas del benchmark sobre 12 datasets reales (outlier_pct x100, periodo estacional, FK inference, render models, tipos id-like) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault>/Libros/<Categoría>/
|
||||||
|
<Categoría> - MOC.md # MOC de categoría (crear o ACTUALIZAR, no sobrescribir)
|
||||||
|
<Libro>/
|
||||||
|
<Libro> - MOC.md # MOC del libro
|
||||||
|
01 - <Título capítulo>.md # una nota por capítulo, NN zero-padded a 2 dígitos
|
||||||
|
02 - <Título capítulo>.md
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Carpeta por libro, archivo por capítulo. Nombre de capítulo: `NN - <Título>.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 (`<Libro> - MOC.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Libro> - MOC
|
||||||
|
book: <Libro>
|
||||||
|
author: <Autor>
|
||||||
|
year: <Año>
|
||||||
|
type: book-moc
|
||||||
|
tags:
|
||||||
|
- <slug-libro>
|
||||||
|
- <tema-1>
|
||||||
|
- moc
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Libro> — Mapa de contenidos (MOC)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
- **Autor:** <Autor>
|
||||||
|
- **Año:** <Año> (<edición si aplica>)
|
||||||
|
- **Subtítulo:** *<subtítulo original>* (<traducción>)
|
||||||
|
- **Tema:** <de qué va en una frase>
|
||||||
|
- **Por qué importa:** <2-3 frases sobre qué problema resuelve y para quién>
|
||||||
|
|
||||||
|
## Resumen global
|
||||||
|
<Un párrafo denso (8-15 líneas) que sintetiza la tesis del libro y recorre el hilo de los
|
||||||
|
capítulos sin enumerarlos uno a uno: cuenta el argumento completo en prosa.>
|
||||||
|
|
||||||
|
## Capítulos
|
||||||
|
1. [[01 - <Título capítulo>]]
|
||||||
|
2. [[02 - <Título capítulo>]]
|
||||||
|
...
|
||||||
|
|
||||||
|
## Aplicación a mi caso (visión transversal)
|
||||||
|
<Párrafo que conecta el libro entero con el objetivo concreto del usuario (el enfoque
|
||||||
|
confirmado en las Decisiones): qué capítulos son los más relevantes y por qué.>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plantilla — nota de capítulo (`NN - <Título>.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Título capítulo>
|
||||||
|
book: <Libro>
|
||||||
|
author: <Autor>
|
||||||
|
chapter: <N>
|
||||||
|
type: chapter-summary
|
||||||
|
tags:
|
||||||
|
- <slug-libro>
|
||||||
|
- <tema>
|
||||||
|
---
|
||||||
|
|
||||||
|
# NN. <Título capítulo>
|
||||||
|
|
||||||
|
> Libro: [[<Libro> - MOC]]
|
||||||
|
|
||||||
|
## Idea central
|
||||||
|
<1-3 frases con la tesis del capítulo.>
|
||||||
|
|
||||||
|
## Puntos clave
|
||||||
|
- <bullet sustantivo, no genérico>
|
||||||
|
- <…>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## Ejemplos / citas
|
||||||
|
- <ejemplo concreto del capítulo o cita textual con su traducción si es en otro idioma>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## Aplicación a mi caso
|
||||||
|
<Párrafo concreto: cómo aplicar la idea del capítulo al caso del usuario.>
|
||||||
|
|
||||||
|
---
|
||||||
|
Anterior: [[NN-1 - <Título anterior>]] · Siguiente: [[NN+1 - <Título siguiente>]] · Índice: [[<Libro> - 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 (`<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: <Categoría> — MOC
|
||||||
|
type: moc
|
||||||
|
tags:
|
||||||
|
- libros
|
||||||
|
- <tema-categoría>
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Categoría> — Mapa de contenidos
|
||||||
|
|
||||||
|
<Frase que describe el tema común de los libros de esta categoría.>
|
||||||
|
|
||||||
|
Cada libro tiene su propia nota MOC con el índice de capítulos enlazados.
|
||||||
|
|
||||||
|
## <Sub-tema 1>
|
||||||
|
- [[<Libro A> - MOC]] — <Autor>. <una línea de qué aporta>.
|
||||||
|
- [[<Libro B> - MOC]] — <Autor>. <…>.
|
||||||
|
|
||||||
|
## Orden de lectura recomendado
|
||||||
|
1. **<Libro>** — <por qué primero>.
|
||||||
|
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/<vault>`).
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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 `<X>Id → <X>.<X>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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
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:
|
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).
|
- `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`.
|
> 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. |
|
| `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`. |
|
| `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
|
### Capa LLM y entrega
|
||||||
| ID | Pureza | Qué hace |
|
| 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. |
|
| `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. |
|
| `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)
|
### Orquestadores (pipelines)
|
||||||
| ID | Qué hace |
|
| 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. |
|
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
||||||
|
|
||||||
## Contrato de datos
|
## Contrato de datos
|
||||||
@@ -68,15 +84,26 @@ Orquestadores one-shot:
|
|||||||
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||||
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||||
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
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:{<col>: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,
|
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
||||||
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||||
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
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
|
# *_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}
|
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||||
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||||
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||||
@@ -91,11 +118,18 @@ import sys, os
|
|||||||
sys.path.insert(0, os.path.join("python", "functions"))
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
from pipelines.profile_table import profile_table
|
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"]
|
prof = r["profile"]
|
||||||
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
||||||
print(prof["correlations"]["strong"]) # pares correlacionados
|
print(r["pdf_path"]) # reports/eda_clientes_<ts>.pdf (móvil)
|
||||||
|
print(prof["correlations"]["strong"]) # pares fuertes Y significativos tras FDR
|
||||||
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
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
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Estado
|
||||||
|
|||||||
@@ -45,9 +45,25 @@ from .run_eda_models import run_eda_models
|
|||||||
from .eda_llm_insights import eda_llm_insights
|
from .eda_llm_insights import eda_llm_insights
|
||||||
from .build_eda_notebook import build_eda_notebook
|
from .build_eda_notebook import build_eda_notebook
|
||||||
from .decode_qr_image import decode_qr_image
|
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__ = [
|
__all__ = [
|
||||||
"decode_qr_image",
|
"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_duckdb",
|
||||||
"summarize_table_pg",
|
"summarize_table_pg",
|
||||||
"spearman_corr",
|
"spearman_corr",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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<alpha rechaza la hipotesis nula del test correspondiente."
|
||||||
|
output: "dict con 'adf' y 'kpss' (cada uno: stat, p_value, lags, stationary bool, conclusion), un 'verdict' de consenso ('stationary'|'non_stationary'|'inconclusive'), y 'warning' (texto sobre correlacion espuria si el veredicto no es stationary, si no None). Con <8 puntos validos: {'n', 'note': 'datos insuficientes', 'verdict': None}. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_random_walk_es_no_estacionario", "test_ruido_blanco_es_estacionario", "test_serie_con_tendencia_no_es_estacionaria", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_warning_presente_si_no_estacionaria", "test_estructura_basica_del_dict"]
|
||||||
|
test_file_path: "python/functions/datascience/adf_kpss_stationarity_test.py"
|
||||||
|
file_path: "python/functions/datascience/adf_kpss_stationarity.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import adf_kpss_stationarity
|
||||||
|
|
||||||
|
# Ruido blanco: estacionario (ADF rechaza raiz unitaria, KPSS no rechaza estacionariedad)
|
||||||
|
import numpy as np
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 300).tolist()
|
||||||
|
adf_kpss_stationarity(ruido)["verdict"] # -> "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<alpha` significa
|
||||||
|
estacionaria; en KPSS `p<alpha` significa NO estacionaria. La funcion ya
|
||||||
|
normaliza ambos a un campo `stationary` coherente — no inviertas tu la logica.
|
||||||
|
- KPSS interpola el p-valor sobre una tabla acotada `[0.01, 0.10]`: si el
|
||||||
|
estadistico cae fuera, statsmodels recorta el p-valor al extremo y lo marca en
|
||||||
|
`kpss.p_value_clipped = True`. Un p recortado a 0.01 o 0.10 es un limite, no un
|
||||||
|
valor exacto.
|
||||||
|
- El veredicto `"inconclusive"` suele indicar serie estacionaria-en-tendencia o
|
||||||
|
que necesita diferenciacion; no es un fallo, es informacion.
|
||||||
|
- Necesita al menos 8 puntos validos tras limpiar; con menos devuelve una nota.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests de estacionariedad de una serie temporal: ADF + KPSS (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que combina dos contrastes de estacionariedad con
|
||||||
|
hipotesis nulas opuestas y emite un veredicto de consenso. Motivada por la
|
||||||
|
necesidad (Hyndman "Forecasting", Hamilton "Time Series Analysis") de saber si
|
||||||
|
una serie es estacionaria ANTES de correlacionarla o modelarla: correlacionar
|
||||||
|
niveles no estacionarios produce correlacion espuria (Granger-Newbold 1974).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from statsmodels.tsa.stattools import adfuller, kpss
|
||||||
|
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
}
|
||||||
@@ -3,19 +3,23 @@ name: association_matrix
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
|
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. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
|
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]
|
tags: [eda, correlation, association, statistics, mixed-types, mutual-information, multiple-testing, p-value, fdr]
|
||||||
params:
|
params:
|
||||||
- name: columns
|
- 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."
|
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
|
- 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
|
- name: top_n
|
||||||
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
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:
|
uses_functions:
|
||||||
- pearson_py_datascience
|
- pearson_py_datascience
|
||||||
- spearman_corr_py_datascience
|
- spearman_corr_py_datascience
|
||||||
@@ -23,13 +27,14 @@ uses_functions:
|
|||||||
- theils_u_py_datascience
|
- theils_u_py_datascience
|
||||||
- correlation_ratio_py_datascience
|
- correlation_ratio_py_datascience
|
||||||
- mutual_info_columns_py_datascience
|
- mutual_info_columns_py_datascience
|
||||||
|
- fdr_correction_py_datascience
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: ""
|
error_type: ""
|
||||||
imports: []
|
imports: [scipy]
|
||||||
tested: true
|
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"
|
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
||||||
file_path: "python/functions/datascience/association_matrix.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.
|
categorica como primer argumento y la numerica como segundo.
|
||||||
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
||||||
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
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.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
|
||||||
|
|
||||||
from datascience import (
|
from datascience import (
|
||||||
correlation_ratio,
|
correlation_ratio,
|
||||||
@@ -19,6 +22,10 @@ from datascience import (
|
|||||||
theils_u,
|
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.
|
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
||||||
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
||||||
|
|
||||||
@@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
|
|||||||
return cx, cy
|
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(
|
def association_matrix(
|
||||||
columns: dict,
|
columns: dict,
|
||||||
strong_threshold: float = 0.5,
|
strong_threshold: float = 0.5,
|
||||||
top_n: int = 20,
|
top_n: int = 20,
|
||||||
|
alpha: float = 0.05,
|
||||||
|
fdr_method: str = "bh",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
"""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
|
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
||||||
columna (devuelve `pairs=[]`, `strong=[]`).
|
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:
|
Args:
|
||||||
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
||||||
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
||||||
Los tipos datetime/boolean/text se tratan como categoricos.
|
Los tipos datetime/boolean/text se tratan como categoricos.
|
||||||
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
|
strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser
|
||||||
abs(value) >= umbral o extra["mi"] >= umbral.
|
"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
|
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
||||||
relevancia (max(abs(value), mi)) descendente.
|
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:
|
Returns:
|
||||||
dict con claves:
|
dict con claves:
|
||||||
pairs: lista de todos los pares evaluados, cada uno
|
pairs: lista de todos los pares evaluados, cada uno
|
||||||
{a, b, a_type, b_type, method, value, extra}.
|
{a, b, a_type, b_type, method, value, extra, p_value,
|
||||||
strong: subconjunto de pairs por encima del umbral, ordenado por
|
p_value_adjusted, significant}. `p_value` es el del test del
|
||||||
relevancia descendente y truncado a top_n.
|
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}.
|
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 = {
|
legend = {
|
||||||
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
"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)
|
s = spearman_corr(a_vals, b_vals)
|
||||||
extra["pearson"] = p
|
extra["pearson"] = p
|
||||||
extra["spearman"] = s
|
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):
|
elif (not a_numeric) and (not b_numeric):
|
||||||
method = "cramers_v"
|
method = "cramers_v"
|
||||||
value = cramers_v(a_vals, b_vals)
|
value = cramers_v(a_vals, b_vals)
|
||||||
extra["u_ab"] = theils_u(a_vals, b_vals)
|
extra["u_ab"] = theils_u(a_vals, b_vals)
|
||||||
extra["u_ba"] = theils_u(b_vals, a_vals)
|
extra["u_ba"] = theils_u(b_vals, a_vals)
|
||||||
|
p_value = _chi2_pvalue(a_vals, b_vals)
|
||||||
else:
|
else:
|
||||||
method = "correlation_ratio"
|
method = "correlation_ratio"
|
||||||
if a_numeric:
|
if a_numeric:
|
||||||
# a numerica, b categorica.
|
# a numerica, b categorica.
|
||||||
value = correlation_ratio(b_vals, a_vals)
|
value = correlation_ratio(b_vals, a_vals)
|
||||||
|
p_value = _anova_pvalue(b_vals, a_vals)
|
||||||
else:
|
else:
|
||||||
# a categorica, b numerica.
|
# a categorica, b numerica.
|
||||||
value = correlation_ratio(a_vals, b_vals)
|
value = correlation_ratio(a_vals, b_vals)
|
||||||
|
p_value = _anova_pvalue(a_vals, b_vals)
|
||||||
|
|
||||||
pairs.append(
|
pairs.append(
|
||||||
{
|
{
|
||||||
@@ -192,19 +310,55 @@ def association_matrix(
|
|||||||
"method": method,
|
"method": method,
|
||||||
"value": value,
|
"value": value,
|
||||||
"extra": extra,
|
"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:
|
def _relevance(pair: dict) -> float:
|
||||||
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
||||||
|
|
||||||
strong = [
|
def _is_strong(pair: dict) -> bool:
|
||||||
pair
|
# Condicion 1: magnitud por encima del umbral (necesaria).
|
||||||
for pair in pairs
|
magnitude_ok = (
|
||||||
if abs(pair["value"]) >= strong_threshold
|
abs(pair["value"]) >= strong_threshold
|
||||||
or pair["extra"].get("mi", 0.0) >= 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.sort(key=_relevance, reverse=True)
|
||||||
strong = strong[:top_n]
|
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"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,79 @@ def test_single_column_returns_empty():
|
|||||||
result = association_matrix(columns)
|
result = association_matrix(columns)
|
||||||
assert result["pairs"] == []
|
assert result["pairs"] == []
|
||||||
assert result["strong"] == []
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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": ""}
|
||||||
@@ -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`.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -264,24 +264,129 @@ def render_eda_markdown(profile: dict) -> str:
|
|||||||
parts.append("## Calidad")
|
parts.append("## Calidad")
|
||||||
parts.append(_md_table(["column", "quality_score", "issues"], rows))
|
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")
|
correlations = profile.get("correlations")
|
||||||
if correlations:
|
if correlations:
|
||||||
pairs = correlations
|
strong = []
|
||||||
|
all_pairs = []
|
||||||
|
multiple_testing = None
|
||||||
if isinstance(correlations, dict):
|
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 = []
|
corr_rows = []
|
||||||
for pair in pairs or []:
|
for pair in shown or []:
|
||||||
if isinstance(pair, dict):
|
if not isinstance(pair, dict):
|
||||||
corr_rows.append([
|
continue
|
||||||
pair.get("a") or pair.get("col_a"),
|
padj = pair.get("p_value_adjusted")
|
||||||
pair.get("b") or pair.get("col_b"),
|
sig = pair.get("significant")
|
||||||
_fmt_num(pair.get("value") if pair.get("value") is not None
|
corr_rows.append([
|
||||||
else pair.get("corr")),
|
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:
|
if corr_rows:
|
||||||
parts.append("## Correlaciones")
|
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).
|
# 8. LLM analysis (tolerate None for now).
|
||||||
llm = profile.get("llm")
|
llm = profile.get("llm")
|
||||||
@@ -299,4 +404,24 @@ def render_eda_markdown(profile: dict) -> str:
|
|||||||
else:
|
else:
|
||||||
parts.append(str(llm))
|
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"
|
return "\n\n".join(parts) + "\n"
|
||||||
|
|||||||
@@ -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 — <table>'."
|
||||||
|
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.
|
||||||
@@ -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 — <table>"``.
|
||||||
|
|
||||||
|
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}
|
||||||
@@ -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).
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -5,17 +5,29 @@ lang: py
|
|||||||
domain: pipelines
|
domain: pipelines
|
||||||
purity: impure
|
purity: impure
|
||||||
version: "1.0.0"
|
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"
|
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 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."
|
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, profiling, data-quality, pipeline, dataops]
|
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- summarize_table_duckdb_py_datascience
|
- summarize_table_duckdb_py_datascience
|
||||||
|
- summarize_table_pg_py_datascience
|
||||||
- describe_numeric_py_datascience
|
- describe_numeric_py_datascience
|
||||||
- summarize_categorical_py_datascience
|
- summarize_categorical_py_datascience
|
||||||
- infer_semantic_type_py_datascience
|
- infer_semantic_type_py_datascience
|
||||||
- column_quality_score_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_markdown_py_datascience
|
||||||
|
- render_eda_pdf_py_datascience
|
||||||
- duckdb_query_readonly_py_infra
|
- duckdb_query_readonly_py_infra
|
||||||
|
- pg_query_py_infra
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
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"
|
file_path: "python/functions/pipelines/profile_table.py"
|
||||||
params:
|
params:
|
||||||
- name: db_path
|
- 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
|
- name: table
|
||||||
desc: "Nombre de la tabla a perfilar."
|
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
|
- name: sample
|
||||||
desc: "Maximo de valores no nulos muestreados por columna para el enriquecimiento (describe_numeric / summarize_categorical / infer_semantic_type). Default 5000."
|
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
|
- 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
|
- 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."
|
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:<TableProfile enriquecido con quality_score, key_candidates y type_breakdown recalculado>, report_md_path:str|None, report_json_path:str|None} o {status:'error', error:str} (dict-no-throw)."
|
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates, type_breakdown recalculado, correlaciones con FDR, reexpression por columna numerica, caveats, y (con run_series) series>, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None} o {status:'error', error:str} (dict-no-throw)."
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|||||||
@@ -29,16 +29,23 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from datascience import (
|
from datascience import (
|
||||||
|
acf_pacf,
|
||||||
|
adf_kpss_stationarity,
|
||||||
association_matrix,
|
association_matrix,
|
||||||
column_quality_score,
|
column_quality_score,
|
||||||
describe_numeric,
|
describe_numeric,
|
||||||
eda_llm_insights,
|
eda_llm_insights,
|
||||||
|
exploratory_caveats,
|
||||||
infer_semantic_type,
|
infer_semantic_type,
|
||||||
render_eda_markdown,
|
render_eda_markdown,
|
||||||
|
render_eda_pdf,
|
||||||
run_eda_models,
|
run_eda_models,
|
||||||
|
stl_decompose,
|
||||||
|
suggest_reexpression,
|
||||||
summarize_categorical,
|
summarize_categorical,
|
||||||
summarize_table_duckdb,
|
summarize_table_duckdb,
|
||||||
summarize_table_pg,
|
summarize_table_pg,
|
||||||
|
to_returns,
|
||||||
)
|
)
|
||||||
from infra import duckdb_query_readonly, pg_query
|
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", [])
|
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(
|
def profile_table(
|
||||||
db_path: str,
|
db_path: str,
|
||||||
table: str,
|
table: str,
|
||||||
@@ -122,6 +206,8 @@ def profile_table(
|
|||||||
sample: int = 5000,
|
sample: int = 5000,
|
||||||
run_models: bool = False,
|
run_models: bool = False,
|
||||||
run_llm: bool = False,
|
run_llm: bool = False,
|
||||||
|
run_series: bool = False,
|
||||||
|
emit_pdf: bool = False,
|
||||||
report_dir: str = "reports",
|
report_dir: str = "reports",
|
||||||
write_report: bool = True,
|
write_report: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -135,6 +221,20 @@ def profile_table(
|
|||||||
sample: maximo de valores no nulos muestreados por columna para el
|
sample: maximo de valores no nulos muestreados por columna para el
|
||||||
enriquecimiento (describe_numeric / summarize_categorical /
|
enriquecimiento (describe_numeric / summarize_categorical /
|
||||||
infer_semantic_type). Default 5000.
|
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.
|
report_dir: directorio donde escribir los reports si write_report.
|
||||||
Default "reports". Se crea si no existe.
|
Default "reports". Se crea si no existe.
|
||||||
write_report: si True (default), escribe un report markdown + un JSON
|
write_report: si True (default), escribe un report markdown + un JSON
|
||||||
@@ -143,8 +243,8 @@ def profile_table(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict. En exito: {status:'ok', profile: <TableProfile>,
|
dict. En exito: {status:'ok', profile: <TableProfile>,
|
||||||
report_md_path: str|None, report_json_path: str|None}. En error (sin
|
report_md_path: str|None, report_json_path: str|None, pdf_path: str|None}.
|
||||||
lanzar): {status:'error', error:str}.
|
En error (sin lanzar): {status:'error', error:str}.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 1) Perfil base por columna (push-down SQL) + lector read-only del
|
# 1) Perfil base por columna (push-down SQL) + lector read-only del
|
||||||
@@ -195,6 +295,9 @@ def profile_table(
|
|||||||
if inferred == "numeric":
|
if inferred == "numeric":
|
||||||
vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
|
vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
|
||||||
col["numeric"] = describe_numeric(vals_float)
|
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"):
|
elif inferred in ("categorical", "text"):
|
||||||
col["categorical"] = summarize_categorical(vals)
|
col["categorical"] = summarize_categorical(vals)
|
||||||
# Para columnas no promovidas que ya eran categorical/text y no
|
# Para columnas no promovidas que ya eran categorical/text y no
|
||||||
@@ -299,12 +402,53 @@ def profile_table(
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
prof["llm"] = None
|
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_md_path = None
|
||||||
report_json_path = None
|
report_json_path = None
|
||||||
|
pdf_path = None
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
if write_report:
|
if write_report:
|
||||||
os.makedirs(report_dir, exist_ok=True)
|
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_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")
|
report_md_path = os.path.join(report_dir, f"eda_{table}_{ts}.md")
|
||||||
with open(report_json_path, "w", encoding="utf-8") as fh:
|
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:
|
with open(report_md_path, "w", encoding="utf-8") as fh:
|
||||||
fh.write(render_eda_markdown(prof))
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"profile": prof,
|
"profile": prof,
|
||||||
"report_md_path": report_md_path,
|
"report_md_path": report_md_path,
|
||||||
"report_json_path": report_json_path,
|
"report_json_path": report_json_path,
|
||||||
|
"pdf_path": pdf_path,
|
||||||
}
|
}
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies = [
|
|||||||
"scipy>=1.17.1",
|
"scipy>=1.17.1",
|
||||||
"seaborn>=0.13.2",
|
"seaborn>=0.13.2",
|
||||||
"shapely>=2.1.2",
|
"shapely>=2.1.2",
|
||||||
|
"statsmodels>=0.14.6",
|
||||||
"trimesh>=4.12.2",
|
"trimesh>=4.12.2",
|
||||||
"xlrd>=2.0.2",
|
"xlrd>=2.0.2",
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+47
@@ -918,6 +918,7 @@ dependencies = [
|
|||||||
{ name = "scipy" },
|
{ name = "scipy" },
|
||||||
{ name = "seaborn" },
|
{ name = "seaborn" },
|
||||||
{ name = "shapely" },
|
{ name = "shapely" },
|
||||||
|
{ name = "statsmodels" },
|
||||||
{ name = "trimesh" },
|
{ name = "trimesh" },
|
||||||
{ name = "xlrd" },
|
{ name = "xlrd" },
|
||||||
]
|
]
|
||||||
@@ -977,6 +978,7 @@ requires-dist = [
|
|||||||
{ name = "scipy", specifier = ">=1.17.1" },
|
{ name = "scipy", specifier = ">=1.17.1" },
|
||||||
{ name = "seaborn", specifier = ">=0.13.2" },
|
{ name = "seaborn", specifier = ">=0.13.2" },
|
||||||
{ name = "shapely", specifier = ">=2.1.2" },
|
{ name = "shapely", specifier = ">=2.1.2" },
|
||||||
|
{ name = "statsmodels", specifier = ">=0.14.6" },
|
||||||
{ name = "trimesh", specifier = ">=4.12.2" },
|
{ name = "trimesh", specifier = ">=4.12.2" },
|
||||||
{ name = "xlrd", specifier = ">=2.0.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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pexpect"
|
name = "pexpect"
|
||||||
version = "4.9.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sympy"
|
name = "sympy"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user