Compare commits
5 Commits
orq/walk-cycle
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c4cff5ed5b | |||
| caf8c25d99 | |||
| 7ac69ab4fb | |||
| 02301aaed3 | |||
| 2729629f0a |
@@ -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).
|
||||
|
||||
Orquestadores one-shot:
|
||||
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM).
|
||||
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON (+ PDF móvil con `emit_pdf`). Flags `run_models` (modelos baratos), `run_llm` (interpretación LLM), `run_series` (análisis de serie temporal por columna numérica) y `emit_pdf` (PDF vertical legible en móvil). Re-expresión sugerida por columna y avisos exploratorios se añaden siempre.
|
||||
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
||||
|
||||
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
||||
@@ -50,16 +50,32 @@ Orquestadores one-shot:
|
||||
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
||||
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
||||
|
||||
### Series temporales (flag `run_series`)
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `adf_kpss_stationarity_py_datascience` | pure | Estacionariedad por consenso ADF + KPSS (hipótesis nulas opuestas) → veredicto `stationary`/`non_stationary`/`inconclusive` + aviso de correlación espuria. |
|
||||
| `acf_pacf_py_datascience` | pure | ACF + PACF con bandas de confianza + lags significativos + Ljung-Box (¿ruido blanco?). Detecta autocorrelación que infla los p-valores OLS. |
|
||||
| `stl_decompose_py_datascience` | pure | Descomposición STL (tendencia/estacional/resto) + fuerza de tendencia y estacional de Hyndman. Auto-infiere el periodo por autocorrelación. |
|
||||
| `to_returns_py_datascience` | pure | Convierte una serie de niveles (precios) a retornos log/simples. Los niveles no son estacionarios; los retornos sí (unidad correcta para correlacionar/modelar). |
|
||||
|
||||
### Rigor y disciplina exploratoria
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `fdr_correction_py_datascience` | pure | Corrige p-valores por comparaciones múltiples (Benjamini-Hochberg FDR / Bonferroni FWER) → controla el data-mining bias. Ya integrada en `association_matrix`. |
|
||||
| `suggest_reexpression_py_datascience` | pure | Escalera de potencias de Tukey: qué transformación (log/sqrt/Yeo-Johnson/...) simetriza mejor una columna numérica según su skew y dominio. No la ejecuta, la sugiere. |
|
||||
| `exploratory_caveats_py_datascience` | pure | Genera las advertencias de que el EDA es exploratorio (correlación≠causalidad, overfitting in-sample, comparaciones múltiples, outliers, muestra pequeña, MNAR) según lo que el perfil realmente contiene. |
|
||||
|
||||
### Capa LLM y entrega
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
||||
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
||||
| `render_eda_pdf_py_datascience` | impure | Renderiza el `TableProfile` a un PDF multipágina **vertical (A5), legible en móvil** (estilo Tufte: histogramas como small multiples, top-k, heatmap de asociación). 4ª salida del workflow, junto a JSON/Markdown/notebook. |
|
||||
|
||||
### Orquestadores (pipelines)
|
||||
| ID | Qué hace |
|
||||
|---|---|
|
||||
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. |
|
||||
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación con FDR + `run_models` + `run_llm` + `run_series` + re-expresión + caveats) → JSON + markdown (+ PDF móvil con `emit_pdf`). |
|
||||
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
||||
|
||||
## Contrato de datos
|
||||
@@ -68,15 +84,26 @@ Orquestadores one-shot:
|
||||
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
|
||||
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models,
|
||||
series:{<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,
|
||||
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
||||
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
|
||||
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None,
|
||||
reexpression:{recommended,ladder_power,reason,alternatives,skew}|None, # cols numéricas
|
||||
series:SeriesBlock|None} # solo con run_series
|
||||
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||
|
||||
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
|
||||
SeriesBlock = {order_col, ordered, n, stationarity:{adf,kpss,verdict,warning},
|
||||
acf_pacf:{acf,pacf,significant_acf_lags,ljung_box,is_autocorrelated},
|
||||
stl:{period,trend_strength,seasonal_strength,...},
|
||||
to_returns:{...}|absent, levels_suggested:bool}
|
||||
|
||||
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra,p_value,
|
||||
p_value_adjusted,significant}], strong:[...], methods_legend,
|
||||
multiple_testing:{method,alpha,n_tests,n_rejected}} # p-valores corregidos por FDR
|
||||
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||
@@ -91,11 +118,18 @@ import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.profile_table import profile_table
|
||||
|
||||
r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
|
||||
r = profile_table(
|
||||
"/ruta/datos.duckdb", "clientes",
|
||||
run_models=True, run_llm=True, run_series=True, emit_pdf=True,
|
||||
)
|
||||
prof = r["profile"]
|
||||
print(r["report_md_path"]) # reports/eda_clientes_<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["series"]["precio"]["stationarity"]["verdict"]) # ¿serie estacionaria?
|
||||
print(prof["columns"][0]["reexpression"]["recommended"]) # transformación sugerida
|
||||
print(prof["caveats"]["caveats"][0]["message"]) # aviso exploratorio general
|
||||
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
||||
```
|
||||
|
||||
@@ -121,6 +155,9 @@ build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_model
|
||||
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
||||
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
||||
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
||||
- **Series** (`run_series`) trata cada columna numérica como serie temporal: si hay una columna datetime se ordena por ella, si no por el orden físico de filas. Necesita ≥8 puntos válidos por columna; STL exige ≥2 periodos. La sugerencia de retornos (`to_returns`) solo aparece en columnas estrictamente positivas y no claramente estacionarias (series de niveles/precios).
|
||||
- **PDF** (`emit_pdf`) genera un PDF A5 vertical legible en móvil junto al report markdown vía `render_eda_pdf` (matplotlib `PdfPages`, sin dependencias nuevas).
|
||||
- **Correlaciones**: los p-valores de cada par se corrigen por comparaciones múltiples (FDR Benjamini-Hochberg) dentro de `association_matrix`; un par solo entra en `strong` si supera el umbral de magnitud Y es significativo tras la corrección.
|
||||
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||
|
||||
## Estado
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -44,8 +44,27 @@ from .trend_slope import trend_slope
|
||||
from .run_eda_models import run_eda_models
|
||||
from .eda_llm_insights import eda_llm_insights
|
||||
from .build_eda_notebook import build_eda_notebook
|
||||
from .decode_qr_image import decode_qr_image
|
||||
from .adf_kpss_stationarity import adf_kpss_stationarity
|
||||
from .acf_pacf import acf_pacf
|
||||
from .stl_decompose import stl_decompose
|
||||
from .to_returns import to_returns
|
||||
from .fdr_correction import fdr_correction
|
||||
from .suggest_reexpression import suggest_reexpression
|
||||
from .exploratory_caveats import exploratory_caveats
|
||||
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
||||
|
||||
__all__ = [
|
||||
"decode_qr_image",
|
||||
"adf_kpss_stationarity",
|
||||
"acf_pacf",
|
||||
"stl_decompose",
|
||||
"to_returns",
|
||||
"fdr_correction",
|
||||
"suggest_reexpression",
|
||||
"exploratory_caveats",
|
||||
"render_eda_pdf",
|
||||
"render_eda_pdf_relational",
|
||||
"summarize_table_duckdb",
|
||||
"summarize_table_pg",
|
||||
"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,71 @@
|
||||
"""Tests para acf_pacf."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from acf_pacf import acf_pacf
|
||||
|
||||
|
||||
def _ar1(phi: float, n: int, seed: int) -> list:
|
||||
rng = np.random.default_rng(seed)
|
||||
series = [0.0]
|
||||
for _ in range(n):
|
||||
series.append(phi * series[-1] + rng.normal(0, 1))
|
||||
return series
|
||||
|
||||
|
||||
def test_ruido_blanco_no_autocorrelado():
|
||||
rng = np.random.default_rng(0)
|
||||
ruido = rng.normal(0, 1, 500).tolist()
|
||||
res = acf_pacf(ruido)
|
||||
assert res["is_autocorrelated"] is False
|
||||
|
||||
|
||||
def test_ar1_es_autocorrelado():
|
||||
ar = _ar1(0.8, 500, seed=1)
|
||||
res = acf_pacf(ar)
|
||||
assert res["is_autocorrelated"] is True
|
||||
|
||||
|
||||
def test_lag1_significativo_en_ar1():
|
||||
# En un AR(1) la PACF corta tras el lag 1: lag 1 debe ser significativo.
|
||||
ar = _ar1(0.8, 500, seed=2)
|
||||
res = acf_pacf(ar)
|
||||
assert 1 in res["significant_pacf_lags"]
|
||||
assert 1 in res["significant_acf_lags"]
|
||||
|
||||
|
||||
def test_muestra_insuficiente_devuelve_nota():
|
||||
res = acf_pacf([1, 2, 3, 4, 5])
|
||||
assert res["n"] == 5
|
||||
assert res["note"] == "datos insuficientes"
|
||||
assert res["is_autocorrelated"] is None
|
||||
|
||||
|
||||
def test_descarta_none_y_nan():
|
||||
rng = np.random.default_rng(3)
|
||||
base = rng.normal(0, 1, 200).tolist()
|
||||
sucio = []
|
||||
for i, v in enumerate(base):
|
||||
sucio.append(v)
|
||||
if i % 25 == 0:
|
||||
sucio.append(None)
|
||||
sucio.append(float("nan"))
|
||||
res = acf_pacf(sucio)
|
||||
assert res["n"] == 200
|
||||
|
||||
|
||||
def test_recorta_nlags_a_limites():
|
||||
# Serie de 20 puntos con nlags=40: debe recortar a < n/2.
|
||||
rng = np.random.default_rng(4)
|
||||
serie = rng.normal(0, 1, 20).tolist()
|
||||
res = acf_pacf(serie, nlags=40)
|
||||
assert res["nlags"] < 20 // 2
|
||||
assert len(res["acf"]) == res["nlags"] + 1
|
||||
|
||||
|
||||
def test_acf_lag0_es_uno():
|
||||
rng = np.random.default_rng(5)
|
||||
serie = rng.normal(0, 1, 100).tolist()
|
||||
res = acf_pacf(serie)
|
||||
assert abs(res["acf"][0] - 1.0) < 1e-9
|
||||
assert abs(res["pacf"][0] - 1.0) < 1e-9
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests para adf_kpss_stationarity."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from adf_kpss_stationarity import adf_kpss_stationarity
|
||||
|
||||
|
||||
def test_random_walk_es_no_estacionario():
|
||||
# Random walk = suma acumulada de ruido: tiene raiz unitaria.
|
||||
rng = np.random.default_rng(123)
|
||||
paseo = np.cumsum(rng.normal(0.0, 1.0, 400)).tolist()
|
||||
res = adf_kpss_stationarity(paseo)
|
||||
assert res["verdict"] == "non_stationary"
|
||||
assert res["adf"]["stationary"] is False
|
||||
assert res["kpss"]["stationary"] is False
|
||||
|
||||
|
||||
def test_ruido_blanco_es_estacionario():
|
||||
# Ruido blanco gaussiano: estacionario por construccion.
|
||||
rng = np.random.default_rng(42)
|
||||
ruido = rng.normal(0.0, 1.0, 400).tolist()
|
||||
res = adf_kpss_stationarity(ruido)
|
||||
assert res["verdict"] == "stationary"
|
||||
assert res["adf"]["stationary"] is True
|
||||
assert res["kpss"]["stationary"] is True
|
||||
assert res["warning"] is None
|
||||
|
||||
|
||||
def test_serie_con_tendencia_no_es_estacionaria():
|
||||
# Tendencia lineal determinista + ruido pequeno: KPSS la marca no estacionaria.
|
||||
rng = np.random.default_rng(7)
|
||||
serie = [0.1 * i + rng.normal(0, 0.5) for i in range(300)]
|
||||
res = adf_kpss_stationarity(serie)
|
||||
assert res["verdict"] != "stationary"
|
||||
assert res["warning"] is not None
|
||||
|
||||
|
||||
def test_muestra_insuficiente_devuelve_nota():
|
||||
res = adf_kpss_stationarity([1, 2, 3, 4, 5])
|
||||
assert res["n"] == 5
|
||||
assert res["note"] == "datos insuficientes"
|
||||
assert res["verdict"] is None
|
||||
|
||||
|
||||
def test_descarta_none_y_nan():
|
||||
rng = np.random.default_rng(1)
|
||||
base = rng.normal(0, 1, 200).tolist()
|
||||
sucio = []
|
||||
for i, v in enumerate(base):
|
||||
sucio.append(v)
|
||||
if i % 20 == 0:
|
||||
sucio.append(None)
|
||||
sucio.append(float("nan"))
|
||||
res = adf_kpss_stationarity(sucio)
|
||||
assert res["n"] == 200 # las None/NaN no cuentan
|
||||
|
||||
|
||||
def test_warning_presente_si_no_estacionaria():
|
||||
# Tendencia lineal fuerte: garantiza no estacionariedad (verdict != stationary).
|
||||
rng = np.random.default_rng(99)
|
||||
serie = [0.5 * i + rng.normal(0, 0.3) for i in range(300)]
|
||||
res = adf_kpss_stationarity(serie)
|
||||
assert res["verdict"] != "stationary"
|
||||
assert res["warning"] is not None
|
||||
assert "espuria" in res["warning"].lower()
|
||||
|
||||
|
||||
def test_estructura_basica_del_dict():
|
||||
rng = np.random.default_rng(5)
|
||||
ruido = rng.normal(0, 1, 100).tolist()
|
||||
res = adf_kpss_stationarity(ruido)
|
||||
for key in ("n", "alpha", "adf", "kpss", "verdict"):
|
||||
assert key in res
|
||||
for sub in ("stat", "p_value", "lags", "stationary", "conclusion"):
|
||||
assert sub in res["adf"]
|
||||
assert sub in res["kpss"]
|
||||
@@ -3,19 +3,23 @@ name: association_matrix
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
|
||||
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
|
||||
tags: [eda, correlation, association, statistics, mixed-types, mutual-information]
|
||||
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20, alpha: float = 0.05, fdr_method: str = \"bh\") -> dict"
|
||||
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Cada par lleva su p-valor (test de correlacion / chi-cuadrado / ANOVA) y se corrige por comparaciones multiples (FDR) para combatir el sesgo de mineria de datos: el subconjunto fuerte se basa en la significancia corregida, no solo en superar el umbral de magnitud."
|
||||
tags: [eda, correlation, association, statistics, mixed-types, mutual-information, multiple-testing, p-value, fdr]
|
||||
params:
|
||||
- name: columns
|
||||
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
|
||||
- name: strong_threshold
|
||||
desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
||||
desc: "Umbral de magnitud en [0, 1]. Condicion necesaria (ya no suficiente) para ser fuerte: abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
||||
- name: top_n
|
||||
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
||||
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
||||
- name: alpha
|
||||
desc: "Nivel de significancia tras la correccion FDR (default 0.05). Un par con p-valor disponible solo es fuerte si ademas su p-valor ajustado <= alpha."
|
||||
- name: fdr_method
|
||||
desc: "Metodo de correccion de comparaciones multiples: 'bh' (Benjamini-Hochberg, FDR; default) o 'bonferroni' (FWER, mas conservador)."
|
||||
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra, p_value, p_value_adjusted, significant}; strong: subconjunto con magnitud >= umbral Y significativo tras FDR (pares sin test se admiten por magnitud), ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion; n_tests: nº total de pares evaluados (== len(pairs)); multiple_testing: {method, alpha, n_tests, n_rejected}}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
||||
uses_functions:
|
||||
- pearson_py_datascience
|
||||
- spearman_corr_py_datascience
|
||||
@@ -23,13 +27,14 @@ uses_functions:
|
||||
- theils_u_py_datascience
|
||||
- correlation_ratio_py_datascience
|
||||
- mutual_info_columns_py_datascience
|
||||
- fdr_correction_py_datascience
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
imports: [scipy]
|
||||
tested: true
|
||||
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"]
|
||||
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty", "test_pairs_carry_significance_fields", "test_result_reports_multiple_testing_summary", "test_strong_requires_corrected_significance", "test_bonferroni_method_is_accepted"]
|
||||
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
||||
file_path: "python/functions/datascience/association_matrix.py"
|
||||
---
|
||||
@@ -84,3 +89,36 @@ no-lineal a todos los pares.
|
||||
categorica como primer argumento y la numerica como segundo.
|
||||
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
||||
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Ahora corrige multiple-testing (v1.1.0).** El subconjunto `strong` ya no
|
||||
depende solo de la magnitud: un par con magnitud alta pero p-valor ajustado
|
||||
> `alpha` NO entra en `strong`. Esto combate el sesgo de mineria de datos
|
||||
(data-mining bias, Aronson cap. 6): al evaluar todos los pares a la vez, el
|
||||
azar produce correlaciones espurias que el umbral de magnitud por si solo
|
||||
dejaria pasar.
|
||||
- Cada par lleva `p_value` (test del metodo principal: correlacion de
|
||||
Pearson/Spearman, chi-cuadrado de independencia para Cramer's V, ANOVA de una
|
||||
via para correlation ratio) y `p_value_adjusted` (tras `fdr_correction`). La
|
||||
informacion mutua no tiene test asociado, por lo que un par cuyo metodo
|
||||
principal sea degenerado puede tener `p_value = None`; esos pares se admiten en
|
||||
`strong` por magnitud (no hay p-valor que corregir).
|
||||
- `n_tests` (top-level) es el numero total de pares evaluados (`len(pairs)`),
|
||||
mientras que `multiple_testing.n_tests` es el numero de p-valores **validos**
|
||||
que entraron en la correccion (puede ser menor si algun par no tiene test).
|
||||
- Sigue siendo pura, pero ahora importa `scipy.stats` (`pearsonr`, `spearmanr`,
|
||||
`chi2_contingency`, `f_oneway`) para los p-valores; scipy ya vive en
|
||||
`python/.venv`.
|
||||
- Sube `alpha` o usa `fdr_method="bonferroni"` segun lo costoso que sea un falso
|
||||
positivo: BH controla la tasa de falsos descubrimientos (mas potencia),
|
||||
Bonferroni la probabilidad de cualquier falso positivo (mas cautela).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (28/06/2026) — anade p-valor por par (Pearson/Spearman, chi-cuadrado,
|
||||
ANOVA) + correccion de comparaciones multiples via `fdr_correction` (BH /
|
||||
Bonferroni). `strong` pasa a basarse en la significancia corregida, no solo en
|
||||
el umbral de magnitud. Nuevos parametros `alpha` y `fdr_method`; nuevas claves
|
||||
`p_value`/`p_value_adjusted`/`significant` por par y `n_tests`/
|
||||
`multiple_testing` en el resultado. Retrocompatible: no quita claves previas.
|
||||
|
||||
@@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
|
||||
|
||||
from datascience import (
|
||||
correlation_ratio,
|
||||
@@ -19,6 +22,10 @@ from datascience import (
|
||||
theils_u,
|
||||
)
|
||||
|
||||
# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su
|
||||
# __init__ (lo integra el orquestador al cerrar el grupo eda).
|
||||
from datascience.fdr_correction import fdr_correction
|
||||
|
||||
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
||||
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
||||
|
||||
@@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
|
||||
return cx, cy
|
||||
|
||||
|
||||
def _safe_pvalue(value) -> float | None:
|
||||
"""Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
pv = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if math.isnan(pv) or math.isinf(pv):
|
||||
return None
|
||||
return pv
|
||||
|
||||
|
||||
def _pearson_pvalue(cx: list, cy: list) -> float | None:
|
||||
"""p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado."""
|
||||
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
|
||||
return None
|
||||
try:
|
||||
return _safe_pvalue(pearsonr(cx, cy).pvalue)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _spearman_pvalue(cx: list, cy: list) -> float | None:
|
||||
"""p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado."""
|
||||
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
|
||||
return None
|
||||
try:
|
||||
return _safe_pvalue(spearmanr(cx, cy).pvalue)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None:
|
||||
"""p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado."""
|
||||
pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None]
|
||||
if len(pairs) < 2:
|
||||
return None
|
||||
rows = sorted({x for x, _ in pairs}, key=repr)
|
||||
cols = sorted({y for _, y in pairs}, key=repr)
|
||||
if len(rows) < 2 or len(cols) < 2:
|
||||
return None
|
||||
row_idx = {v: i for i, v in enumerate(rows)}
|
||||
col_idx = {v: j for j, v in enumerate(cols)}
|
||||
counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
|
||||
table = [
|
||||
[counts.get((i, j), 0) for j in range(len(cols))]
|
||||
for i in range(len(rows))
|
||||
]
|
||||
try:
|
||||
return _safe_pvalue(chi2_contingency(table).pvalue)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None:
|
||||
"""p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado."""
|
||||
groups: dict = defaultdict(list)
|
||||
for c, x in zip(cat_vals, num_vals):
|
||||
if c is None or not _is_num(x):
|
||||
continue
|
||||
groups[c].append(float(x))
|
||||
valid = [g for g in groups.values() if len(g) >= 2]
|
||||
if len(valid) < 2:
|
||||
return None
|
||||
try:
|
||||
return _safe_pvalue(f_oneway(*valid).pvalue)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def association_matrix(
|
||||
columns: dict,
|
||||
strong_threshold: float = 0.5,
|
||||
top_n: int = 20,
|
||||
alpha: float = 0.05,
|
||||
fdr_method: str = "bh",
|
||||
) -> dict:
|
||||
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
||||
|
||||
@@ -81,22 +161,48 @@ def association_matrix(
|
||||
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
||||
columna (devuelve `pairs=[]`, `strong=[]`).
|
||||
|
||||
Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor
|
||||
del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de
|
||||
correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio:
|
||||
ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan
|
||||
todos los pares a la vez, esos p-valores se corrigen por comparaciones
|
||||
multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el
|
||||
subconjunto `strong` se basa en la **significancia corregida**, no solo en
|
||||
superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado
|
||||
> alpha NO entra en `strong`.
|
||||
|
||||
Args:
|
||||
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
||||
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
||||
Los tipos datetime/boolean/text se tratan como categoricos.
|
||||
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
|
||||
abs(value) >= umbral o extra["mi"] >= umbral.
|
||||
strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser
|
||||
"fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero
|
||||
ya no suficiente (ver alpha).
|
||||
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
||||
relevancia (max(abs(value), mi)) descendente.
|
||||
alpha: nivel de significancia tras la correccion FDR (default 0.05). Un
|
||||
par con p-valor disponible solo es fuerte si ademas su p-valor
|
||||
ajustado <= alpha.
|
||||
fdr_method: metodo de correccion de comparaciones multiples,
|
||||
"bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER).
|
||||
|
||||
Returns:
|
||||
dict con claves:
|
||||
pairs: lista de todos los pares evaluados, cada uno
|
||||
{a, b, a_type, b_type, method, value, extra}.
|
||||
strong: subconjunto de pairs por encima del umbral, ordenado por
|
||||
relevancia descendente y truncado a top_n.
|
||||
{a, b, a_type, b_type, method, value, extra, p_value,
|
||||
p_value_adjusted, significant}. `p_value` es el del test del
|
||||
metodo principal (None si no aplica / degenerado);
|
||||
`p_value_adjusted` el p-valor tras FDR; `significant` True si
|
||||
p_value_adjusted <= alpha.
|
||||
strong: subconjunto de pairs que cumplen magnitud >= umbral Y son
|
||||
significativos tras la correccion (los pares sin test disponible
|
||||
se admiten por magnitud), ordenado por relevancia descendente y
|
||||
truncado a top_n.
|
||||
methods_legend: dict {metodo: descripcion}.
|
||||
n_tests: numero total de pares evaluados (== len(pairs)).
|
||||
multiple_testing: dict {method, alpha, n_tests, n_rejected} con el
|
||||
resumen de la correccion (n_tests aqui = p-valores validos
|
||||
corregidos, puede ser < len(pairs) si algun par no tiene test).
|
||||
"""
|
||||
legend = {
|
||||
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
||||
@@ -168,20 +274,32 @@ def association_matrix(
|
||||
s = spearman_corr(a_vals, b_vals)
|
||||
extra["pearson"] = p
|
||||
extra["spearman"] = s
|
||||
value = p if abs(p) >= abs(s) else s
|
||||
pearson_p = _pearson_pvalue(cx, cy)
|
||||
spearman_p = _spearman_pvalue(cx, cy)
|
||||
extra["pearson_p"] = pearson_p
|
||||
extra["spearman_p"] = spearman_p
|
||||
if abs(p) >= abs(s):
|
||||
value = p
|
||||
p_value = pearson_p
|
||||
else:
|
||||
value = s
|
||||
p_value = spearman_p
|
||||
elif (not a_numeric) and (not b_numeric):
|
||||
method = "cramers_v"
|
||||
value = cramers_v(a_vals, b_vals)
|
||||
extra["u_ab"] = theils_u(a_vals, b_vals)
|
||||
extra["u_ba"] = theils_u(b_vals, a_vals)
|
||||
p_value = _chi2_pvalue(a_vals, b_vals)
|
||||
else:
|
||||
method = "correlation_ratio"
|
||||
if a_numeric:
|
||||
# a numerica, b categorica.
|
||||
value = correlation_ratio(b_vals, a_vals)
|
||||
p_value = _anova_pvalue(b_vals, a_vals)
|
||||
else:
|
||||
# a categorica, b numerica.
|
||||
value = correlation_ratio(a_vals, b_vals)
|
||||
p_value = _anova_pvalue(a_vals, b_vals)
|
||||
|
||||
pairs.append(
|
||||
{
|
||||
@@ -192,19 +310,55 @@ def association_matrix(
|
||||
"method": method,
|
||||
"value": value,
|
||||
"extra": extra,
|
||||
"p_value": p_value,
|
||||
}
|
||||
)
|
||||
|
||||
# Correccion de comparaciones multiples sobre los p-valores disponibles.
|
||||
# Se pasa la lista completa (incluidos los None de pares sin test): la
|
||||
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
|
||||
fdr = fdr_correction(
|
||||
[pair["p_value"] for pair in pairs],
|
||||
alpha=alpha,
|
||||
method=fdr_method,
|
||||
)
|
||||
for pair, padj, rej in zip(
|
||||
pairs, fdr["p_values_adjusted"], fdr["reject"]
|
||||
):
|
||||
pair["p_value_adjusted"] = padj
|
||||
pair["significant"] = bool(rej)
|
||||
|
||||
def _relevance(pair: dict) -> float:
|
||||
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
||||
|
||||
strong = [
|
||||
pair
|
||||
for pair in pairs
|
||||
if abs(pair["value"]) >= strong_threshold
|
||||
def _is_strong(pair: dict) -> bool:
|
||||
# Condicion 1: magnitud por encima del umbral (necesaria).
|
||||
magnitude_ok = (
|
||||
abs(pair["value"]) >= strong_threshold
|
||||
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
||||
]
|
||||
)
|
||||
if not magnitude_ok:
|
||||
return False
|
||||
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
|
||||
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
|
||||
# admiten por magnitud, ya que no hay p-valor que corregir.
|
||||
if pair["p_value"] is None:
|
||||
return True
|
||||
return pair["significant"]
|
||||
|
||||
strong = [pair for pair in pairs if _is_strong(pair)]
|
||||
strong.sort(key=_relevance, reverse=True)
|
||||
strong = strong[:top_n]
|
||||
|
||||
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"strong": strong,
|
||||
"methods_legend": legend,
|
||||
"n_tests": len(pairs),
|
||||
"multiple_testing": {
|
||||
"method": fdr_method,
|
||||
"alpha": alpha,
|
||||
"n_tests": fdr["n_tests"],
|
||||
"n_rejected": fdr["n_rejected"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -80,3 +80,79 @@ def test_single_column_returns_empty():
|
||||
result = association_matrix(columns)
|
||||
assert result["pairs"] == []
|
||||
assert result["strong"] == []
|
||||
|
||||
|
||||
def test_pairs_carry_significance_fields():
|
||||
# Tras la correccion FDR cada par evaluado lleva p_value, p_value_adjusted y
|
||||
# significant. Un par num-num fuertemente correlado es significativo.
|
||||
columns = {
|
||||
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||
"price": {
|
||||
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||
"type": "numeric",
|
||||
},
|
||||
}
|
||||
result = association_matrix(columns, strong_threshold=0.5)
|
||||
pair = _find_pair(result["pairs"], "size", "price")
|
||||
assert "p_value" in pair and "p_value_adjusted" in pair and "significant" in pair
|
||||
assert pair["p_value"] is not None and pair["p_value"] < 0.05
|
||||
assert pair["significant"] is True
|
||||
# p ajustado nunca por debajo del crudo.
|
||||
assert pair["p_value_adjusted"] >= pair["p_value"] - 1e-12
|
||||
|
||||
|
||||
def test_result_reports_multiple_testing_summary():
|
||||
columns = {
|
||||
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||
"price": {
|
||||
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||
"type": "numeric",
|
||||
},
|
||||
}
|
||||
result = association_matrix(columns)
|
||||
# n_tests = total de pares evaluados.
|
||||
assert result["n_tests"] == len(result["pairs"])
|
||||
mt = result["multiple_testing"]
|
||||
assert mt["method"] == "bh"
|
||||
assert mt["alpha"] == 0.05
|
||||
assert mt["n_rejected"] >= 1
|
||||
assert mt["n_tests"] >= 1
|
||||
|
||||
|
||||
def test_strong_requires_corrected_significance():
|
||||
# Par num-num con magnitud alta pero p-valor no diminuto. Con alpha normal es
|
||||
# fuerte; con un alpha mas estricto que su p-valor, deja de ser significativo
|
||||
# y sale de strong AUNQUE la magnitud siga por encima del umbral. Esto prueba
|
||||
# que strong se basa en la significancia corregida, no solo en el umbral.
|
||||
columns = {
|
||||
"a": {"values": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "type": "numeric"},
|
||||
"b": {"values": [2, 1, 3, 4, 6, 5, 7, 8, 10, 9, 11, 12], "type": "numeric"},
|
||||
}
|
||||
relaxed = association_matrix(columns, strong_threshold=0.5, alpha=0.05)
|
||||
pair = _find_pair(relaxed["pairs"], "a", "b")
|
||||
assert pair["p_value"] is not None and pair["p_value"] < 0.05
|
||||
assert abs(pair["value"]) >= 0.5
|
||||
assert _find_pair(relaxed["strong"], "a", "b") is not None
|
||||
|
||||
# alpha mas estricto que el p-valor del par -> ya no significativo.
|
||||
strict = association_matrix(
|
||||
columns, strong_threshold=0.5, alpha=pair["p_value"] / 10.0
|
||||
)
|
||||
sp = _find_pair(strict["pairs"], "a", "b")
|
||||
assert abs(sp["value"]) >= 0.5 # magnitud intacta
|
||||
assert sp["significant"] is False
|
||||
assert _find_pair(strict["strong"], "a", "b") is None
|
||||
|
||||
|
||||
def test_bonferroni_method_is_accepted():
|
||||
columns = {
|
||||
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||
"price": {
|
||||
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||
"type": "numeric",
|
||||
},
|
||||
}
|
||||
result = association_matrix(columns, fdr_method="bonferroni")
|
||||
assert result["multiple_testing"]["method"] == "bonferroni"
|
||||
pair = _find_pair(result["pairs"], "size", "price")
|
||||
assert pair["p_value_adjusted"] is not None
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Decodificación robusta de códigos QR desde una imagen en disco.
|
||||
|
||||
Función del registry (grupo de capacidad `qr`, dominio `datascience`). Pensada para el caso real
|
||||
en el que un lector básico (pyzbar, `cv2.QRCodeDetector` sobre la imagen cruda) NO capta el QR:
|
||||
screenshots de pantalla con QR pálidos (bajo contraste) o pequeños. En vez de un único intento,
|
||||
genera varias variantes preprocesadas de la imagen y prueba cada detector disponible sobre cada
|
||||
variante, parando al primer acierto.
|
||||
|
||||
Impura: lee un archivo de disco y depende de OpenCV (`opencv-contrib-python-headless`). Degrada
|
||||
limpio (devuelve `[]`) si la imagen no se puede leer o si ningún QR se decodifica; no lanza.
|
||||
|
||||
Detectores (se usan los que estén instalados; el import se envuelve en try/except para degradar):
|
||||
- `cv2.QRCodeDetectorAruco` (preferido — OpenCV puro, sin libs de sistema)
|
||||
- `cv2.QRCodeDetector` (fallback OpenCV puro)
|
||||
- `cv2.wechat_qrcode.WeChatQRCode` (excelente con bajo contraste; SOLO si los modelos cargan)
|
||||
- `pyzbar` (bonus opcional; requiere la lib de sistema `libzbar0`)
|
||||
|
||||
Cero dependencias de sistema obligatorias: con solo OpenCV la función ya funciona.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _make_opencv_runner(detector):
|
||||
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
|
||||
|
||||
def run(img):
|
||||
out: list[str] = []
|
||||
# detectAndDecodeMulti: capta varios QR en la misma imagen.
|
||||
try:
|
||||
ok, decoded, _points, _ = detector.detectAndDecodeMulti(img)
|
||||
if ok and decoded:
|
||||
out = [s for s in decoded if s]
|
||||
except cv2.error:
|
||||
pass
|
||||
if not out:
|
||||
# Fallback al decodificador de un solo QR.
|
||||
try:
|
||||
s, _pts, _ = detector.detectAndDecode(img)
|
||||
if s:
|
||||
out = [s]
|
||||
except cv2.error:
|
||||
pass
|
||||
return out
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _make_wechat_runner(wd):
|
||||
"""Envuelve un cv2.wechat_qrcode.WeChatQRCode en run(img) -> list[str]."""
|
||||
|
||||
def run(img):
|
||||
try:
|
||||
texts, _points = wd.detectAndDecode(img)
|
||||
return [t for t in texts if t]
|
||||
except Exception:
|
||||
# Si los modelos no están cargados o el detector falla, degradar sin romper.
|
||||
return []
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _make_pyzbar_runner(zbar_decode):
|
||||
"""Envuelve pyzbar.decode en run(img) -> list[str]."""
|
||||
|
||||
def run(img):
|
||||
out: list[str] = []
|
||||
try:
|
||||
for sym in zbar_decode(img):
|
||||
try:
|
||||
out.append(sym.data.decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return []
|
||||
return out
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _build_detectors(debug=False):
|
||||
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||
detectors = []
|
||||
|
||||
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||
if hasattr(cv2, "QRCodeDetectorAruco"):
|
||||
try:
|
||||
detectors.append(("opencv_aruco", _make_opencv_runner(cv2.QRCodeDetectorAruco())))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# OpenCV clásico (fallback puro).
|
||||
if hasattr(cv2, "QRCodeDetector"):
|
||||
try:
|
||||
detectors.append(("opencv", _make_opencv_runner(cv2.QRCodeDetector())))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# WeChat QR (excelente con bajo contraste) — SOLO si los modelos cargan; opcional.
|
||||
if hasattr(cv2, "wechat_qrcode"):
|
||||
try:
|
||||
wd = cv2.wechat_qrcode.WeChatQRCode()
|
||||
detectors.append(("wechat", _make_wechat_runner(wd)))
|
||||
except Exception:
|
||||
# Modelos no presentes / build sin soporte → saltar sin romper.
|
||||
pass
|
||||
|
||||
# pyzbar (bonus): requiere libzbar0 (lib de sistema). Degrada si falta.
|
||||
try:
|
||||
from pyzbar.pyzbar import decode as _zbar_decode # type: ignore
|
||||
|
||||
detectors.append(("pyzbar", _make_pyzbar_runner(_zbar_decode)))
|
||||
except (ImportError, OSError, Exception): # noqa: B014 - OSError = libzbar0 ausente
|
||||
pass
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[decode_qr_image] detectores disponibles: {[n for n, _ in detectors]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return detectors
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Variantes preprocesadas de la imagen. Orden = prioridad; se para en el primer acierto.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _load_bgr(image_path):
|
||||
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||
if bgr is not None:
|
||||
return bgr
|
||||
# Fallback PIL para formatos que cv2.imread no maneja en esta build.
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
pil = Image.open(image_path).convert("RGB")
|
||||
return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_variants(image_path, upscale):
|
||||
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||
bgr = _load_bgr(image_path)
|
||||
if bgr is None:
|
||||
return []
|
||||
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Contrast stretch (NORM_MINMAX): clave para QR de bajo contraste (gris sobre gris).
|
||||
stretch = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||
|
||||
# CLAHE: realce de contraste local.
|
||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
|
||||
|
||||
# Upscale del stretch: QR pequeño es la causa #1 de fallo.
|
||||
if upscale and upscale > 1:
|
||||
up = cv2.resize(stretch, None, fx=upscale, fy=upscale, interpolation=cv2.INTER_CUBIC)
|
||||
else:
|
||||
up = stretch
|
||||
|
||||
# Binarizaciones sobre el stretch (mejor base que el gris crudo).
|
||||
_, otsu = cv2.threshold(stretch, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
adaptive = cv2.adaptiveThreshold(
|
||||
stretch, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 5
|
||||
)
|
||||
|
||||
variants = [
|
||||
("original", bgr),
|
||||
("gray", gray),
|
||||
("contrast_stretch", stretch),
|
||||
("clahe", clahe),
|
||||
("upscale", up),
|
||||
("otsu", otsu),
|
||||
("adaptive_gaussian", adaptive),
|
||||
]
|
||||
|
||||
# Rotaciones sobre la mejor variante binarizada (Otsu).
|
||||
for name, rot in (
|
||||
("rot90", cv2.ROTATE_90_CLOCKWISE),
|
||||
("rot180", cv2.ROTATE_180),
|
||||
("rot270", cv2.ROTATE_90_COUNTERCLOCKWISE),
|
||||
):
|
||||
variants.append((f"otsu_{name}", cv2.rotate(otsu, rot)))
|
||||
|
||||
return variants
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# API pública.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def decode_qr_image(image_path: str, upscale: int = 2, debug: bool = False) -> list[str]:
|
||||
"""Decodifica los códigos QR de una imagen, robusto a bajo contraste y QR pequeños.
|
||||
|
||||
Genera varias variantes preprocesadas de la imagen (escala de grises, contrast stretch,
|
||||
CLAHE, upscale, binarización Otsu/adaptativa, rotaciones) y prueba cada detector disponible
|
||||
(OpenCV Aruco/clásico, WeChat si hay modelos, pyzbar si hay libzbar0) sobre cada variante,
|
||||
parando al primer acierto.
|
||||
|
||||
Parámetros (`upscale` y `debug` pensados como opciones keyword):
|
||||
image_path: ruta del archivo de imagen a leer (png/jpg/...).
|
||||
upscale: factor de ampliación (INTER_CUBIC) aplicado a la variante de contraste estirado
|
||||
para rescatar QR pequeños. Default 2. <=1 desactiva el upscale.
|
||||
debug: si True, imprime a stderr qué variante/detector acertó (o que no se detectó nada).
|
||||
|
||||
Returns:
|
||||
Lista de payloads de texto de los QR detectados (deduplicada, preservando orden). Lista
|
||||
vacía si no se detecta ninguno o si la imagen no se puede leer. No lanza.
|
||||
"""
|
||||
try:
|
||||
variants = _build_variants(image_path, upscale)
|
||||
except Exception as exc: # pragma: no cover - defensa ante imágenes corruptas
|
||||
if debug:
|
||||
print(f"[decode_qr_image] fallo construyendo variantes: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
if not variants:
|
||||
if debug:
|
||||
print(f"[decode_qr_image] no se pudo leer la imagen: {image_path}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
detectors = _build_detectors(debug=debug)
|
||||
if not detectors:
|
||||
if debug:
|
||||
print("[decode_qr_image] ningún detector QR disponible", file=sys.stderr)
|
||||
return []
|
||||
|
||||
for vname, vimg in variants:
|
||||
for dname, drun in detectors:
|
||||
payloads = drun(vimg)
|
||||
uniq = list(dict.fromkeys(p for p in payloads if p))
|
||||
if uniq:
|
||||
if debug:
|
||||
print(
|
||||
f"[decode_qr_image] acierto variante={vname} detector={dname} "
|
||||
f"n={len(uniq)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return uniq
|
||||
|
||||
if debug:
|
||||
print("[decode_qr_image] ningún QR decodificado en ninguna variante", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo CLI para `python3 decode_qr_image.py <image_path> [upscale] [debug]`.
|
||||
# (fn run usa su propio runner generado; este bloque es para invocación manual directa.)
|
||||
import json
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"error": "uso: <image_path> [upscale] [debug]"}))
|
||||
sys.exit(1)
|
||||
|
||||
_path = sys.argv[1]
|
||||
_upscale = int(sys.argv[2]) if len(sys.argv) > 2 else 2
|
||||
_debug = (sys.argv[3].lower() in ("1", "true", "yes")) if len(sys.argv) > 3 else False
|
||||
|
||||
_result = decode_qr_image(_path, upscale=_upscale, debug=_debug)
|
||||
print(json.dumps(_result))
|
||||
@@ -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,112 @@
|
||||
"""Tests para exploratory_caveats."""
|
||||
|
||||
from exploratory_caveats import exploratory_caveats
|
||||
|
||||
|
||||
def _ids(out):
|
||||
return {c["id"] for c in out["caveats"]}
|
||||
|
||||
|
||||
def test_perfil_vacio_solo_caveat_general():
|
||||
out = exploratory_caveats({})
|
||||
assert out["n"] == 1
|
||||
assert _ids(out) == {"exploratory_nature"}
|
||||
assert out["note"]
|
||||
|
||||
|
||||
def test_none_no_lanza_y_da_general():
|
||||
out = exploratory_caveats(None)
|
||||
assert _ids(out) == {"exploratory_nature"}
|
||||
|
||||
|
||||
def test_caveat_general_siempre_primero():
|
||||
out = exploratory_caveats({"n_rows": 1000, "columns": []})
|
||||
assert out["caveats"][0]["id"] == "exploratory_nature"
|
||||
|
||||
|
||||
def test_correlaciones_disparan_causalidad_y_overfitting():
|
||||
profile = {
|
||||
"n_rows": 5000,
|
||||
"correlations": {"pairs": [{"a": "x", "b": "y", "value": 0.8}]},
|
||||
}
|
||||
ids = _ids(exploratory_caveats(profile))
|
||||
assert "correlation_not_causation" in ids
|
||||
assert "in_sample_overfitting" in ids
|
||||
# un solo par -> NO dispara comparaciones múltiples
|
||||
assert "multiple_comparisons" not in ids
|
||||
|
||||
|
||||
def test_dos_o_mas_pares_disparan_comparaciones_multiples():
|
||||
profile = {
|
||||
"correlations": [
|
||||
{"a": "x", "b": "y", "value": 0.8},
|
||||
{"a": "x", "b": "z", "value": -0.6},
|
||||
],
|
||||
}
|
||||
assert "multiple_comparisons" in _ids(exploratory_caveats(profile))
|
||||
|
||||
|
||||
def test_modelos_disparan_overfitting_y_pvalues():
|
||||
profile = {
|
||||
"models": {
|
||||
"pca": {"explained": [0.6, 0.3]},
|
||||
"normality": {"col_a": {"is_normal": False}},
|
||||
},
|
||||
}
|
||||
ids = _ids(exploratory_caveats(profile))
|
||||
assert "in_sample_overfitting" in ids
|
||||
assert "p_values_not_confirmation" in ids
|
||||
|
||||
|
||||
def test_outliers_por_columna_disparan_caveat():
|
||||
profile = {
|
||||
"columns": [
|
||||
{"name": "precio", "numeric": {"n_outliers": 3, "outlier_pct": 1.5}},
|
||||
],
|
||||
}
|
||||
assert "outliers_not_errors" in _ids(exploratory_caveats(profile))
|
||||
|
||||
|
||||
def test_outliers_multivariantes_disparan_caveat():
|
||||
profile = {"models": {"outliers": {"flags": [True, False, True]}}}
|
||||
assert "outliers_not_errors" in _ids(exploratory_caveats(profile))
|
||||
|
||||
|
||||
def test_trend_pvalue_dispara_caveat_pvalues():
|
||||
profile = {
|
||||
"columns": [
|
||||
{"name": "ventas", "trend": {"direction": "up", "p_value": 0.01}},
|
||||
],
|
||||
}
|
||||
assert "p_values_not_confirmation" in _ids(exploratory_caveats(profile))
|
||||
|
||||
|
||||
def test_muestra_pequena_dispara_caveat():
|
||||
out = exploratory_caveats({"n_rows": 12})
|
||||
assert "small_sample" in _ids(out)
|
||||
msg = next(c["message"] for c in out["caveats"] if c["id"] == "small_sample")
|
||||
assert "12" in msg
|
||||
|
||||
|
||||
def test_muestra_grande_no_dispara_small_sample():
|
||||
assert "small_sample" not in _ids(exploratory_caveats({"n_rows": 5000}))
|
||||
|
||||
|
||||
def test_muchos_faltantes_disparan_missing_data():
|
||||
assert "missing_data_bias" in _ids(exploratory_caveats({"null_cell_pct": 0.35}))
|
||||
|
||||
|
||||
def test_columnas_all_null_disparan_missing_data():
|
||||
assert "missing_data_bias" in _ids(exploratory_caveats({"all_null_cols": ["x"]}))
|
||||
|
||||
|
||||
def test_pocos_faltantes_no_disparan_missing_data():
|
||||
assert "missing_data_bias" not in _ids(exploratory_caveats({"null_cell_pct": 0.05}))
|
||||
|
||||
|
||||
def test_estructura_de_cada_caveat():
|
||||
out = exploratory_caveats({"correlations": [{"a": "x", "b": "y", "value": 0.9}]})
|
||||
for c in out["caveats"]:
|
||||
assert set(c.keys()) == {"id", "topic", "message", "reference"}
|
||||
assert all(isinstance(c[k], str) and c[k] for k in c)
|
||||
assert out["n"] == len(out["caveats"])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests para fdr_correction (correccion de comparaciones multiples).
|
||||
|
||||
Importa el modulo hoja directamente (`datascience.fdr_correction`) para no
|
||||
depender 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
|
||||
|
||||
|
||||
def test_bh_golden_rechaza_dos_de_tres():
|
||||
# Dos p-valores fuertes y uno claramente no significativo.
|
||||
# BH (step-up) sobre [0.01, 0.02, 0.5], m=3, alpha=0.05:
|
||||
# q3 = 0.5*3/3 = 0.50
|
||||
# q2 = min(0.50, 0.02*3/2=0.03) = 0.03
|
||||
# q1 = min(0.03, 0.01*3/1=0.03) = 0.03
|
||||
# reject = [q<=0.05] -> [True, True, False]
|
||||
out = fdr_correction([0.01, 0.02, 0.5], alpha=0.05, method="bh")
|
||||
assert out["reject"] == [True, True, False]
|
||||
assert out["n_rejected"] == 2
|
||||
assert out["n_tests"] == 3
|
||||
assert out["method"] == "bh"
|
||||
# q-valores esperados.
|
||||
adj = out["p_values_adjusted"]
|
||||
assert abs(adj[0] - 0.03) < 1e-9
|
||||
assert abs(adj[1] - 0.03) < 1e-9
|
||||
assert abs(adj[2] - 0.50) < 1e-9
|
||||
|
||||
|
||||
def test_bonferroni_mas_conservador_que_bh():
|
||||
pvalues = [0.01, 0.02, 0.5]
|
||||
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||
# Bonferroni nunca rechaza mas que BH.
|
||||
assert bon["n_rejected"] <= bh["n_rejected"]
|
||||
# p ajustado = min(1, p*m): [0.03, 0.06, 1.0] -> solo el primero pasa.
|
||||
assert bon["reject"] == [True, False, False]
|
||||
assert abs(bon["p_values_adjusted"][0] - 0.03) < 1e-9
|
||||
assert abs(bon["p_values_adjusted"][1] - 0.06) < 1e-9
|
||||
assert bon["p_values_adjusted"][2] == 1.0
|
||||
|
||||
|
||||
def test_p_values_adjusted_alineados_y_en_rango():
|
||||
pvalues = [0.001, 0.2, 0.04, 0.6, 0.9]
|
||||
out = fdr_correction(pvalues, method="bh")
|
||||
assert len(out["p_values_adjusted"]) == len(pvalues)
|
||||
assert len(out["reject"]) == len(pvalues)
|
||||
for q in out["p_values_adjusted"]:
|
||||
assert q is not None and 0.0 <= q <= 1.0
|
||||
# El p-valor ajustado nunca es menor que el crudo (la correccion solo sube).
|
||||
for p, q in zip(pvalues, out["p_values_adjusted"]):
|
||||
assert q >= p - 1e-12
|
||||
|
||||
|
||||
def test_none_se_propaga_alineado():
|
||||
# Posicion central sin test disponible: se propaga como None / False y no
|
||||
# cuenta como prueba (m=2, no 3).
|
||||
out = fdr_correction([0.001, None, 0.9], alpha=0.05, method="bh")
|
||||
assert out["n_tests"] == 2
|
||||
assert out["p_values_adjusted"][1] is None
|
||||
assert out["reject"][1] is False
|
||||
assert out["reject"][0] is True
|
||||
assert len(out["reject"]) == 3
|
||||
|
||||
|
||||
def test_lista_vacia_devuelve_note():
|
||||
out = fdr_correction([])
|
||||
assert out["p_values_adjusted"] == []
|
||||
assert out["reject"] == []
|
||||
assert out["n_tests"] == 0
|
||||
assert out["n_rejected"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_solo_none_devuelve_note():
|
||||
out = fdr_correction([None, None, float("nan")])
|
||||
assert out["n_tests"] == 0
|
||||
assert out["n_rejected"] == 0
|
||||
assert out["reject"] == [False, False, False]
|
||||
assert out["p_values_adjusted"] == [None, None, None]
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_metodo_desconocido_devuelve_note():
|
||||
out = fdr_correction([0.01, 0.02], method="holm")
|
||||
assert "note" in out
|
||||
assert out["n_rejected"] == 0
|
||||
assert out["reject"] == [False, False]
|
||||
|
||||
|
||||
def test_todos_significativos():
|
||||
# Todos los p-valores diminutos -> todos rechazados con ambos metodos.
|
||||
pvalues = [1e-6, 1e-5, 1e-4]
|
||||
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||
assert bh["n_rejected"] == 3
|
||||
assert bon["n_rejected"] == 3
|
||||
assert all(bh["reject"])
|
||||
assert all(bon["reject"])
|
||||
@@ -201,7 +201,10 @@ def render_eda_markdown(profile: dict) -> str:
|
||||
if val is None:
|
||||
continue
|
||||
if key == "outlier_pct":
|
||||
stat_rows.append([label, _fmt_pct(val)])
|
||||
# outlier_pct ya viene en escala 0-100 desde describe_numeric
|
||||
# (100 * n_outliers / n). NO usar _fmt_pct (multiplica x100 otra
|
||||
# vez y produce porcentajes imposibles, p.ej. 7% -> 700%).
|
||||
stat_rows.append([label, _fmt_num(val, 2) + "%"])
|
||||
elif key == "distribution_type":
|
||||
stat_rows.append([label, str(val)])
|
||||
else:
|
||||
@@ -264,24 +267,247 @@ def render_eda_markdown(profile: dict) -> str:
|
||||
parts.append("## Calidad")
|
||||
parts.append(_md_table(["column", "quality_score", "issues"], rows))
|
||||
|
||||
# 7. Correlations (tolerate None for now).
|
||||
# 7. Correlaciones / asociación. `association_matrix` ya corrige los p-valores
|
||||
# por comparaciones múltiples (FDR Benjamini-Hochberg / Bonferroni); aquí solo
|
||||
# se renderizan los campos que produjo (value, p_value_adjusted, significant),
|
||||
# sin recalcular nada. Se prefieren los pares `strong` (magnitud alta Y
|
||||
# significativos tras la corrección); si no hay, se muestran todos.
|
||||
correlations = profile.get("correlations")
|
||||
if correlations:
|
||||
pairs = correlations
|
||||
strong = []
|
||||
all_pairs = []
|
||||
multiple_testing = None
|
||||
if isinstance(correlations, dict):
|
||||
pairs = correlations.get("pairs") or correlations.get("strongest") or []
|
||||
strong = correlations.get("strong") or correlations.get("strongest") or []
|
||||
all_pairs = correlations.get("pairs") or []
|
||||
multiple_testing = correlations.get("multiple_testing")
|
||||
else:
|
||||
all_pairs = correlations
|
||||
shown = strong or all_pairs
|
||||
corr_rows = []
|
||||
for pair in pairs or []:
|
||||
if isinstance(pair, dict):
|
||||
for pair in shown or []:
|
||||
if not isinstance(pair, dict):
|
||||
continue
|
||||
padj = pair.get("p_value_adjusted")
|
||||
sig = pair.get("significant")
|
||||
corr_rows.append([
|
||||
pair.get("a") or pair.get("col_a"),
|
||||
pair.get("b") or pair.get("col_b"),
|
||||
pair.get("method", ""),
|
||||
_fmt_num(pair.get("value") if pair.get("value") is not None
|
||||
else pair.get("corr")),
|
||||
_fmt_num(padj) if padj is not None else "",
|
||||
"sí" if sig else ("no" if sig is not None else ""),
|
||||
])
|
||||
if corr_rows:
|
||||
parts.append("## Correlaciones")
|
||||
parts.append(_md_table(["a", "b", "corr"], corr_rows))
|
||||
if isinstance(multiple_testing, dict):
|
||||
parts.append(
|
||||
"Corrección de comparaciones múltiples: "
|
||||
f"{multiple_testing.get('method')} "
|
||||
f"(α={multiple_testing.get('alpha')}); "
|
||||
f"{multiple_testing.get('n_rejected')} de "
|
||||
f"{multiple_testing.get('n_tests')} pares significativos tras la "
|
||||
"corrección. Mostrando "
|
||||
f"{'solo pares fuertes' if strong else 'todos los pares evaluados'}."
|
||||
)
|
||||
parts.append(_md_table(
|
||||
["a", "b", "method", "value", "p_adj (FDR)", "sig"], corr_rows))
|
||||
|
||||
# 7b. Re-expresión sugerida (escalera de potencias de Tukey) por columna
|
||||
# numérica. `suggest_reexpression` decide la transformación que más simetriza;
|
||||
# aquí solo se rinde su recomendación y razón.
|
||||
reexp_rows = []
|
||||
for col in columns:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
rx = col.get("reexpression")
|
||||
if not isinstance(rx, dict) or rx.get("recommended") is None:
|
||||
continue
|
||||
ladder = rx.get("ladder_power")
|
||||
reexp_rows.append([
|
||||
col.get("name"),
|
||||
_fmt_num(rx.get("skew")),
|
||||
rx.get("recommended"),
|
||||
_fmt_num(ladder) if ladder is not None else "",
|
||||
rx.get("reason", ""),
|
||||
])
|
||||
if reexp_rows:
|
||||
parts.append("## Re-expresión sugerida")
|
||||
parts.append(_md_table(
|
||||
["column", "skew", "transform", "ladder_power", "reason"], reexp_rows))
|
||||
|
||||
# 7c. Series temporales. Bloque por columna numérica cuando el pipeline corrió
|
||||
# con run_series: estacionariedad (ADF+KPSS), autocorrelación (ACF/PACF +
|
||||
# Ljung-Box), descomposición STL y, si es una serie de niveles, sugerencia de
|
||||
# retornos.
|
||||
series_blocks = []
|
||||
for col in columns:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
s = col.get("series")
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
name = col.get("name") or "(col)"
|
||||
block = [f"### {name}"]
|
||||
rows = []
|
||||
stat = s.get("stationarity") or {}
|
||||
if stat.get("verdict") is not None:
|
||||
rows.append(["estacionariedad (ADF+KPSS)", stat.get("verdict")])
|
||||
acf = s.get("acf_pacf") or {}
|
||||
if acf.get("is_autocorrelated") is not None:
|
||||
rows.append([
|
||||
"autocorrelada (Ljung-Box)",
|
||||
"sí" if acf.get("is_autocorrelated") else "no",
|
||||
])
|
||||
sig_lags = acf.get("significant_acf_lags")
|
||||
if sig_lags:
|
||||
rows.append([
|
||||
"lags ACF significativos",
|
||||
", ".join(str(lag) for lag in sig_lags[:12]),
|
||||
])
|
||||
stl = s.get("stl") or {}
|
||||
if stl.get("trend_strength") is not None:
|
||||
rows.append(["fuerza de tendencia (STL)", _fmt_num(stl.get("trend_strength"))])
|
||||
if stl.get("seasonal_strength") is not None:
|
||||
rows.append(["fuerza estacional (STL)", _fmt_num(stl.get("seasonal_strength"))])
|
||||
if stl.get("period") is not None:
|
||||
rows.append(["periodo estacional", stl.get("period")])
|
||||
elif stl.get("note"):
|
||||
rows.append(["STL", stl.get("note")])
|
||||
if s.get("levels_suggested"):
|
||||
# La transformación recomendada depende de la semántica: retornos para
|
||||
# series financieras (precio/volumen), diferencias para magnitudes
|
||||
# físicas (temperatura, caudal). Aplicar "retornos" a temperatura no
|
||||
# tiene sentido físico; las diferencias sí.
|
||||
kind = s.get("levels_kind")
|
||||
if kind == "returns":
|
||||
label = "convertir a retornos (serie de niveles financiera)"
|
||||
elif kind == "differences":
|
||||
label = "trabajar sobre diferencias (serie de niveles no financiera)"
|
||||
else:
|
||||
label = "convertir a retornos o diferencias (serie de niveles)"
|
||||
rows.append(["sugerencia", label])
|
||||
# Las métricas de retorno (media/volatilidad) solo se muestran cuando la
|
||||
# transformación recomendada son retornos; para diferencias no aplican.
|
||||
if kind != "differences":
|
||||
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)
|
||||
|
||||
# 7d. Modelos baratos (PCA, KMeans, outliers multivariantes, normalidad). El
|
||||
# pipeline corre `run_eda_models` cuando se pide con run_models; el bloque está
|
||||
# completo en el JSON pero antes no tenía formatter en markdown y se omitía. Se
|
||||
# lee todo defensivo con .get y cada submodelo se renderiza solo si está presente.
|
||||
models = profile.get("models")
|
||||
if isinstance(models, dict):
|
||||
model_parts: list[str] = []
|
||||
|
||||
pca = models.get("pca")
|
||||
if isinstance(pca, dict):
|
||||
evr = pca.get("explained_variance_ratio") or []
|
||||
cum = pca.get("cumulative") or []
|
||||
pca_rows = []
|
||||
for i, var in enumerate(evr):
|
||||
acc = cum[i] if i < len(cum) else None
|
||||
pca_rows.append([f"PC{i + 1}", _fmt_pct(var), _fmt_pct(acc)])
|
||||
sub = ["### PCA"]
|
||||
n_feat = pca.get("n_features")
|
||||
n_used = pca.get("n_rows_used")
|
||||
if n_feat is not None or n_used is not None:
|
||||
sub.append(
|
||||
f"{pca.get('n_components')} componentes sobre "
|
||||
f"{n_used if n_used is not None else '?'} filas, "
|
||||
f"{n_feat if n_feat is not None else '?'} features."
|
||||
)
|
||||
if pca_rows:
|
||||
sub.append(_md_table(
|
||||
["componente", "var. explicada", "acumulada"], pca_rows))
|
||||
loadings = pca.get("top_loadings") or []
|
||||
load_rows = []
|
||||
for ld in loadings[:12]:
|
||||
if not isinstance(ld, dict):
|
||||
continue
|
||||
comp = ld.get("component")
|
||||
comp_label = f"PC{comp + 1}" if isinstance(comp, int) else str(comp)
|
||||
load_rows.append([comp_label, ld.get("feature"),
|
||||
_fmt_num(ld.get("loading"), 3)])
|
||||
if load_rows:
|
||||
sub.append("Cargas principales:")
|
||||
sub.append(_md_table(["componente", "feature", "carga"], load_rows))
|
||||
model_parts.append("\n\n".join(sub))
|
||||
|
||||
km = models.get("kmeans")
|
||||
if isinstance(km, dict):
|
||||
sub = ["### KMeans"]
|
||||
best_k = km.get("best_k")
|
||||
sil = km.get("silhouette")
|
||||
sizes = km.get("cluster_sizes") or []
|
||||
head = f"mejor k = {_fmt_num(best_k)}"
|
||||
if sil is not None:
|
||||
head += f" (silhouette {_fmt_num(sil, 3)})"
|
||||
if sizes:
|
||||
head += ". Tamaños de cluster: " + ", ".join(
|
||||
_fmt_num(s) for s in sizes)
|
||||
sub.append(head + ".")
|
||||
score_rows = []
|
||||
for sc in km.get("scores_by_k") or []:
|
||||
if not isinstance(sc, dict):
|
||||
continue
|
||||
score_rows.append([sc.get("k"), _fmt_num(sc.get("silhouette"), 3),
|
||||
_fmt_num(sc.get("inertia"), 2)])
|
||||
if score_rows:
|
||||
sub.append(_md_table(["k", "silhouette", "inertia"], score_rows))
|
||||
model_parts.append("\n\n".join(sub))
|
||||
|
||||
out = models.get("outliers")
|
||||
if isinstance(out, dict):
|
||||
# outlier_pct del modelo multivariante ya viene en escala 0-100.
|
||||
n_out = out.get("n_outliers")
|
||||
pct = out.get("outlier_pct")
|
||||
thr = out.get("threshold")
|
||||
line = f"{_fmt_num(n_out)} filas marcadas como outlier"
|
||||
if pct is not None:
|
||||
line += f" ({_fmt_num(pct, 2)}%)"
|
||||
if thr is not None:
|
||||
line += f"; umbral de score {_fmt_num(thr, 3)}"
|
||||
model_parts.append("### Outliers multivariante (Isolation Forest)\n\n"
|
||||
+ line + ".")
|
||||
|
||||
normality = models.get("normality")
|
||||
if isinstance(normality, dict):
|
||||
norm_rows = []
|
||||
for col_name, res in normality.items():
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
jb = res.get("jarque_bera") or {}
|
||||
norm_rows.append([
|
||||
col_name,
|
||||
"sí" if res.get("is_normal") else "no",
|
||||
_fmt_num(jb.get("p")) if jb.get("p") is not None else "",
|
||||
])
|
||||
if norm_rows:
|
||||
model_parts.append(
|
||||
"### Normalidad\n\n"
|
||||
+ _md_table(["columna", "normal", "Jarque-Bera p"], norm_rows))
|
||||
|
||||
note = models.get("note")
|
||||
if note:
|
||||
model_parts.append(f"> {note}")
|
||||
|
||||
if model_parts:
|
||||
parts.append("## Modelos")
|
||||
parts.extend(model_parts)
|
||||
|
||||
# 8. LLM analysis (tolerate None for now).
|
||||
llm = profile.get("llm")
|
||||
@@ -299,4 +525,24 @@ def render_eda_markdown(profile: dict) -> str:
|
||||
else:
|
||||
parts.append(str(llm))
|
||||
|
||||
# 9. Avisos exploratorios. `exploratory_caveats` recuerda que el EDA genera
|
||||
# hipótesis, no conclusiones; se renderiza la lista de advertencias que aplican
|
||||
# a lo que realmente se calculó.
|
||||
caveats = profile.get("caveats")
|
||||
cav_list = []
|
||||
if isinstance(caveats, dict):
|
||||
cav_list = caveats.get("caveats") or []
|
||||
elif isinstance(caveats, list):
|
||||
cav_list = caveats
|
||||
cav_lines = []
|
||||
for cav in cav_list:
|
||||
if not isinstance(cav, dict):
|
||||
continue
|
||||
topic = cav.get("topic") or cav.get("id") or ""
|
||||
msg = cav.get("message") or ""
|
||||
cav_lines.append(f"- **{topic}**: {msg}")
|
||||
if cav_lines:
|
||||
parts.append("## Avisos exploratorios")
|
||||
parts.append("\n".join(cav_lines))
|
||||
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
@@ -53,7 +53,9 @@ def _sample_profile(correlations=None, llm=None):
|
||||
"p99": 95.0,
|
||||
"skew": 0.4,
|
||||
"kurtosis": 2.1,
|
||||
"outlier_pct": 0.012,
|
||||
# outlier_pct ya viene en escala 0-100 desde describe_numeric
|
||||
# (100 * n_outliers / n), NO en fracción 0-1.
|
||||
"outlier_pct": 3.5,
|
||||
"distribution_type": "right-skewed",
|
||||
"histogram": [
|
||||
{"lo": 0, "hi": 25, "count": 100},
|
||||
@@ -126,8 +128,15 @@ def test_pct_fields_scaled_by_100():
|
||||
assert "0.86%" not in md
|
||||
# categorical top pct=0.5 -> "50.0%".
|
||||
assert "50.0" in md
|
||||
# outlier_pct=0.012 -> "1.20%".
|
||||
assert "1.20%" in md
|
||||
|
||||
|
||||
def test_outlier_pct_not_double_scaled():
|
||||
# outlier_pct ya viene en escala 0-100 (describe_numeric): el render lo muestra
|
||||
# tal cual + '%', SIN multiplicar otra vez por 100. outlier_pct=3.5 -> "3.5%",
|
||||
# nunca "350%" (el bug del doble ×100).
|
||||
md = render_eda_markdown(_sample_profile())
|
||||
assert "3.5%" in md
|
||||
assert "350" not in md
|
||||
|
||||
|
||||
def test_pct_handles_none_as_blank():
|
||||
@@ -164,3 +173,62 @@ def test_tolerates_empty_profile():
|
||||
def test_tolerates_none_profile():
|
||||
md = render_eda_markdown(None)
|
||||
assert "# EDA — (unnamed)" in md
|
||||
|
||||
|
||||
def _sample_models():
|
||||
"""Bloque `models` como el que produce run_eda_models (PCA/KMeans/...)."""
|
||||
return {
|
||||
"n_numeric_cols": 3,
|
||||
"pca": {
|
||||
"n_components": 2,
|
||||
"n_rows_used": 1000,
|
||||
"n_features": 3,
|
||||
"explained_variance_ratio": [0.62, 0.21],
|
||||
"cumulative": [0.62, 0.83],
|
||||
"top_loadings": [
|
||||
{"component": 0, "feature": "price", "loading": 0.71},
|
||||
{"component": 1, "feature": "qty", "loading": -0.55},
|
||||
],
|
||||
},
|
||||
"kmeans": {
|
||||
"best_k": 3,
|
||||
"silhouette": 0.48,
|
||||
"cluster_sizes": [500, 300, 200],
|
||||
"scores_by_k": [
|
||||
{"k": 2, "silhouette": 0.41, "inertia": 1200.0},
|
||||
{"k": 3, "silhouette": 0.48, "inertia": 900.0},
|
||||
],
|
||||
},
|
||||
"outliers": {
|
||||
"n_outliers": 35,
|
||||
"outlier_pct": 3.5,
|
||||
"threshold": -0.51,
|
||||
},
|
||||
"normality": {
|
||||
"price": {"jarque_bera": {"p": 0.0001}, "is_normal": False},
|
||||
},
|
||||
"note": "",
|
||||
}
|
||||
|
||||
|
||||
def test_models_section_rendered():
|
||||
# H4: el bloque models antes se omitía en markdown; ahora tiene formatter.
|
||||
profile = _sample_profile()
|
||||
profile["models"] = _sample_models()
|
||||
md = render_eda_markdown(profile)
|
||||
assert "## Modelos" in md
|
||||
assert "### PCA" in md
|
||||
assert "### KMeans" in md
|
||||
assert "### Outliers multivariante (Isolation Forest)" in md
|
||||
assert "### Normalidad" in md
|
||||
# Datos reales del PCA renderizados (varianza explicada ×100) y KMeans.
|
||||
assert "62.0" in md # explained_variance_ratio 0.62 -> 62.00%
|
||||
assert "mejor k = 3" in md
|
||||
# outlier_pct del modelo ya viene en escala 0-100: 3.5 -> "3.5%", no "350".
|
||||
assert "3.5%" in md
|
||||
|
||||
|
||||
def test_models_absent_when_none():
|
||||
# Edge: profile sin models (None) no produce sección Modelos ni rompe.
|
||||
md = render_eda_markdown(_sample_profile()) # models=None en el sample
|
||||
assert "## Modelos" not in md
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: render_eda_pdf
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict"
|
||||
description: "Renderiza un TableProfile del grupo eda en un PDF multipágina portátil pensado para LEER Y EXPLORAR EN EL MÓVIL. Páginas A5 retrato, una columna, tipografía grande; diseño Tufte (alto data-ink ratio, histogramas reales como small multiples, barras top-k, heatmap de asociación, integridad de ejes desde 0). Lee todo el profile defensivamente con .get y sólo renderiza las secciones presentes; bloques nuevos del profile (models, caveats, ...) se vuelcan genéricamente (forward-compatible). dict-no-throw: nunca lanza, devuelve {pdf_path, n_pages, note}. Motor matplotlib PdfPages, cero dependencias nuevas."
|
||||
tags: [eda, pdf, render, report, mobile, tufte, visualization, matplotlib, profiling, datascience, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, textwrap, datetime, matplotlib, numpy]
|
||||
params:
|
||||
- name: profile
|
||||
desc: "TableProfile dict del grupo de capacidad eda (el dict que profile_table devuelve bajo la clave 'profile'). Puede tener muchas claves ausentes o None; un profile None/vacío genera igualmente un PDF de 1 página. Claves consumidas: table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows/_pct, null_cell_pct, quality_score, type_breakdown, constant_cols, all_null_cols, key_candidates, columns[] (con numeric.histogram [{lo,hi,count}], categorical.top [{value,count,pct}], quality_score, flags/issues), correlations.pairs [{a,b,value}], llm. Cualquier otra clave de nivel superior se vuelca en una página forward-compat."
|
||||
- name: out_path
|
||||
desc: "ruta del archivo PDF de salida. Los directorios padre se crean si faltan."
|
||||
- name: title
|
||||
desc: "título opcional para la portada. Por defecto 'EDA — <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,942 @@
|
||||
"""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",
|
||||
# Bloques con builder dedicado (no caen al volcado genérico str(dict)).
|
||||
"models", "series", "caveats",
|
||||
}
|
||||
|
||||
# Restrained, high-contrast palette: a single accent reads cleanly on a phone.
|
||||
_INK = "#1b1b1b"
|
||||
_ACCENT = "#2a6f97"
|
||||
_MUTED = "#8a8a8a"
|
||||
|
||||
# Tufte-ish render defaults shared by both public entry points.
|
||||
_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.
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dedicated builders for forward-compat blocks (models / series / caveats).
|
||||
# Before these existed, ``models``/``series``/``caveats`` fell to the generic
|
||||
# dump and were rendered as truncated ``str(dict)``. Each builder is fully
|
||||
# defensive, reads with ``.get`` and returns the number of pages it produced.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _models_pages(pdf, models) -> int:
|
||||
"""Render the cheap-models block (PCA / KMeans / outliers / normality)."""
|
||||
if not isinstance(models, dict):
|
||||
return 0
|
||||
lines = []
|
||||
|
||||
pca = models.get("pca")
|
||||
if isinstance(pca, dict):
|
||||
lines.append("## PCA")
|
||||
n_used = pca.get("n_rows_used")
|
||||
n_feat = pca.get("n_features")
|
||||
if n_used is not None or n_feat is not None:
|
||||
lines.append(
|
||||
f" {pca.get('n_components')} comp · "
|
||||
f"{_fmt_num(n_used)} filas · {_fmt_num(n_feat)} features"
|
||||
)
|
||||
evr = pca.get("explained_variance_ratio") or []
|
||||
cum = pca.get("cumulative") or []
|
||||
for i, var in enumerate(evr):
|
||||
acc = cum[i] if i < len(cum) else None
|
||||
lines.append(f" PC{i + 1}: var {_fmt_pct(var)} acum {_fmt_pct(acc)}")
|
||||
loadings = pca.get("top_loadings") or []
|
||||
if loadings:
|
||||
lines.append(" cargas principales:")
|
||||
for ld in loadings[:8]:
|
||||
if not isinstance(ld, dict):
|
||||
continue
|
||||
comp = ld.get("component")
|
||||
comp_label = f"PC{comp + 1}" if isinstance(comp, int) else str(comp)
|
||||
lines.append(
|
||||
f" {comp_label} {_truncate(ld.get('feature'), 18)}: "
|
||||
f"{_fmt_num(ld.get('loading'), 3)}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
km = models.get("kmeans")
|
||||
if isinstance(km, dict):
|
||||
lines.append("## KMeans")
|
||||
head = f" mejor k = {_fmt_num(km.get('best_k'))}"
|
||||
if km.get("silhouette") is not None:
|
||||
head += f" silhouette {_fmt_num(km.get('silhouette'), 3)}"
|
||||
lines.append(head)
|
||||
sizes = km.get("cluster_sizes") or []
|
||||
if sizes:
|
||||
lines.append(" tamaños cluster: " + ", ".join(
|
||||
_fmt_num(s) for s in sizes))
|
||||
for sc in km.get("scores_by_k") or []:
|
||||
if not isinstance(sc, dict):
|
||||
continue
|
||||
lines.append(
|
||||
f" k={sc.get('k')}: silhouette {_fmt_num(sc.get('silhouette'), 3)}"
|
||||
f" inertia {_fmt_num(sc.get('inertia'), 1)}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
out = models.get("outliers")
|
||||
if isinstance(out, dict):
|
||||
lines.append("## Outliers multivariante (Isolation Forest)")
|
||||
# outlier_pct del modelo ya viene en escala 0-100.
|
||||
line = f" {_fmt_num(out.get('n_outliers'))} outliers"
|
||||
if out.get("outlier_pct") is not None:
|
||||
line += f" ({_fmt_num(out.get('outlier_pct'), 2)}%)"
|
||||
if out.get("threshold") is not None:
|
||||
line += f" umbral {_fmt_num(out.get('threshold'), 3)}"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
normality = models.get("normality")
|
||||
if isinstance(normality, dict):
|
||||
lines.append("## Normalidad (Jarque-Bera)")
|
||||
for col_name, res in normality.items():
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
jb = res.get("jarque_bera") or {}
|
||||
lines.append(
|
||||
f" {_truncate(col_name, 18):<18} normal={res.get('is_normal')}"
|
||||
f" JB p={_fmt_num(jb.get('p'), 4)}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
note = models.get("note")
|
||||
if note:
|
||||
lines.append(f"nota: {note}")
|
||||
|
||||
if not [ln for ln in lines if ln.strip()]:
|
||||
return 0
|
||||
return _paginate_text(pdf, "Modelos", lines)
|
||||
|
||||
|
||||
def _series_pages(pdf, series) -> int:
|
||||
"""Render the time-series block: one compact summary per series column."""
|
||||
if not isinstance(series, dict) or not series:
|
||||
return 0
|
||||
lines = []
|
||||
for col, s in series.items():
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
lines.append(f"## {col}")
|
||||
stat = s.get("stationarity") or {}
|
||||
if stat.get("verdict") is not None:
|
||||
lines.append(f" estacionariedad (ADF+KPSS): {stat.get('verdict')}")
|
||||
acf = s.get("acf_pacf") or {}
|
||||
if acf.get("is_autocorrelated") is not None:
|
||||
lines.append(
|
||||
" autocorrelada (Ljung-Box): "
|
||||
+ ("sí" if acf.get("is_autocorrelated") else "no")
|
||||
)
|
||||
stl = s.get("stl") or {}
|
||||
if stl.get("trend_strength") is not None:
|
||||
lines.append(
|
||||
f" fuerza tendencia (STL): {_fmt_num(stl.get('trend_strength'), 3)}")
|
||||
if stl.get("seasonal_strength") is not None:
|
||||
extra = (f" (periodo {stl.get('period')})"
|
||||
if stl.get("period") is not None else "")
|
||||
lines.append(
|
||||
f" fuerza estacional (STL): "
|
||||
f"{_fmt_num(stl.get('seasonal_strength'), 3)}{extra}")
|
||||
elif stl.get("note"):
|
||||
lines.append(f" STL: {_truncate(stl.get('note'), 60)}")
|
||||
if s.get("levels_suggested"):
|
||||
kind = s.get("levels_kind")
|
||||
if kind == "returns":
|
||||
lines.append(" sugerencia: convertir a retornos (serie financiera)")
|
||||
elif kind == "differences":
|
||||
lines.append(" sugerencia: trabajar sobre diferencias (serie física)")
|
||||
else:
|
||||
lines.append(" sugerencia: retornos o diferencias (serie de niveles)")
|
||||
lines.append("")
|
||||
if not [ln for ln in lines if ln.strip()]:
|
||||
return 0
|
||||
return _paginate_text(pdf, "Series temporales", lines)
|
||||
|
||||
|
||||
def _caveats_pages(pdf, caveats) -> int:
|
||||
"""Render the exploratory caveats block as a wrapped, readable list."""
|
||||
cav_list = []
|
||||
if isinstance(caveats, dict):
|
||||
cav_list = caveats.get("caveats") or []
|
||||
elif isinstance(caveats, list):
|
||||
cav_list = caveats
|
||||
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 ""
|
||||
lines.append(f"## {topic}")
|
||||
lines.extend(textwrap.wrap(str(msg), width=78) or [""])
|
||||
lines.append("")
|
||||
if not [ln for ln in lines if ln.strip()]:
|
||||
return 0
|
||||
return _paginate_text(pdf, "Avisos exploratorios", lines,
|
||||
subtitle="el EDA genera hipótesis, no conclusiones")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DB-level (relational) page builders — used by render_eda_pdf_relational.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _db_cover_page(pdf, db_profile: dict, title: str) -> int:
|
||||
"""Cover for a DatabaseProfile: name, date, table count, FK count."""
|
||||
fig = plt.figure(figsize=_A5_PORTRAIT)
|
||||
db_path = db_profile.get("db_path") or "(base sin nombre)"
|
||||
heading = title or f"EDA base — {os.path.basename(str(db_path))}"
|
||||
fig.text(0.08, 0.82, heading, fontsize=20, fontweight="bold", color=_INK,
|
||||
wrap=True)
|
||||
|
||||
sub = [f"fuente: {_truncate(db_path, 44)}"]
|
||||
when = db_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.74, "\n".join(sub), fontsize=10, color=_MUTED, va="top")
|
||||
|
||||
n_tables = db_profile.get("n_tables")
|
||||
fig.text(0.08, 0.58, f"{_fmt_num(n_tables)} tablas", fontsize=16,
|
||||
color=_ACCENT, fontweight="bold")
|
||||
n_fk = len(db_profile.get("fk_candidates") or [])
|
||||
fig.text(0.08, 0.51, f"{_fmt_num(n_fk)} relaciones FK candidatas",
|
||||
fontsize=12, color=_INK)
|
||||
|
||||
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 _db_tables_page(pdf, db_profile: dict) -> int:
|
||||
"""One text page summarising every table (rows / cols / quality)."""
|
||||
tables = db_profile.get("tables") or []
|
||||
if not isinstance(tables, list) or not tables:
|
||||
return 0
|
||||
lines = [f"{'tabla':<24}{'filas':>9}{'cols':>6}{'cal':>6}", "-" * 45]
|
||||
for t in tables:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
lines.append(
|
||||
f"{_truncate(t.get('table'), 24):<24}"
|
||||
f"{_fmt_num(t.get('n_rows')):>9}"
|
||||
f"{_fmt_num(t.get('n_cols')):>6}"
|
||||
f"{_fmt_num(t.get('quality_score'), 1):>6}"
|
||||
)
|
||||
return _paginate_text(pdf, "Tablas", lines, subtitle="resumen por tabla")
|
||||
|
||||
|
||||
def _db_fk_page(pdf, db_profile: dict) -> int:
|
||||
"""FK candidates table + the join-graph mermaid text."""
|
||||
fks = db_profile.get("fk_candidates") or []
|
||||
lines = []
|
||||
if isinstance(fks, list) and fks:
|
||||
lines.append(f"{'from':<26}{'to':<26}{'incl':>7}")
|
||||
lines.append("-" * 59)
|
||||
for fk in fks:
|
||||
if not isinstance(fk, dict):
|
||||
continue
|
||||
frm = f"{fk.get('from_table')}.{fk.get('from_col')}"
|
||||
to = f"{fk.get('to_table')}.{fk.get('to_col')}"
|
||||
inc = fk.get("inclusion")
|
||||
inc_s = (_fmt_num(inc, 3) if isinstance(inc, (int, float))
|
||||
and not isinstance(inc, bool) else str(inc))
|
||||
lines.append(
|
||||
f"{_truncate(frm, 25):<26}{_truncate(to, 25):<26}{inc_s:>7}")
|
||||
else:
|
||||
lines.append("(sin relaciones FK candidatas detectadas)")
|
||||
|
||||
mermaid = (db_profile.get("join_graph") or {}).get("mermaid")
|
||||
if mermaid:
|
||||
lines.append("")
|
||||
lines.append("## join graph (mermaid)")
|
||||
for raw in str(mermaid).splitlines():
|
||||
lines.append(_truncate(raw, 72))
|
||||
return _paginate_text(pdf, "Relaciones inter-tabla", lines,
|
||||
subtitle="FK candidatas + join graph")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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 shared with the relational renderer (module-level _RC).
|
||||
rc = _RC
|
||||
|
||||
# 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"))),
|
||||
("models", lambda p: _models_pages(p, profile.get("models"))),
|
||||
("series", lambda p: _series_pages(p, profile.get("series"))),
|
||||
("llm", lambda p: _llm_pages(p, profile.get("llm"))),
|
||||
("caveats", lambda p: _caveats_pages(p, profile.get("caveats"))),
|
||||
("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}
|
||||
|
||||
|
||||
def render_eda_pdf_relational(db_profile: dict, out_path: str,
|
||||
title: str = None) -> dict:
|
||||
"""Render a DatabaseProfile dict into a portable, mobile-readable PDF.
|
||||
|
||||
DB-level sibling of :func:`render_eda_pdf`: instead of a single table it
|
||||
summarises a whole database (the dict ``profile_database`` returns under
|
||||
``db_profile``). Pages are A5 portrait, single column, large type — built to
|
||||
be read on a phone. Three pages: a cover (table + FK counts), a per-table
|
||||
summary (rows / cols / quality) and the inter-table relations (FK candidates
|
||||
plus the join-graph mermaid text). Every key is read defensively and any
|
||||
section that fails is noted, never aborting the whole render.
|
||||
|
||||
Args:
|
||||
db_profile: DatabaseProfile dict from ``profile_database`` (the value
|
||||
under ``db_profile``). May have 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 cover title. Defaults to ``"EDA base — <db filename>"``.
|
||||
|
||||
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 db_profile is None:
|
||||
db_profile = {}
|
||||
if not isinstance(db_profile, dict):
|
||||
return {"pdf_path": None, "n_pages": 0,
|
||||
"note": f"db_profile no es dict: {type(db_profile).__name__}"}
|
||||
|
||||
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}"}
|
||||
|
||||
notes = []
|
||||
n_pages = 0
|
||||
|
||||
builders = [
|
||||
("cover", lambda p: _db_cover_page(p, db_profile, title)),
|
||||
("tables", lambda p: _db_tables_page(p, db_profile)),
|
||||
("relations", lambda p: _db_fk_page(p, db_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}")
|
||||
if n_pages == 0:
|
||||
n_pages += _text_page(
|
||||
pdf, title or "EDA base", ["(base vacía — 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,329 @@
|
||||
"""Tests para render_eda_pdf.
|
||||
|
||||
Importa el módulo directo (sys.path), igual que el resto de tests del grupo eda,
|
||||
para no depender del registro en __init__.py (lo añade el orquestador al integrar).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from render_eda_pdf import (
|
||||
render_eda_pdf,
|
||||
render_eda_pdf_relational,
|
||||
_models_pages,
|
||||
_series_pages,
|
||||
_caveats_pages,
|
||||
)
|
||||
|
||||
|
||||
class _StubPdf:
|
||||
"""Captura pdf.savefig sin escribir nada — para testear builders aislados."""
|
||||
|
||||
def __init__(self):
|
||||
self.figs = 0
|
||||
|
||||
def savefig(self, fig):
|
||||
self.figs += 1
|
||||
|
||||
|
||||
def _synthetic_profile() -> dict:
|
||||
"""TableProfile sintético mínimo: 2 numéricas + 1 categórica + overview."""
|
||||
return {
|
||||
"table": "ventas",
|
||||
"source": "data/ventas.csv",
|
||||
"profiled_at": "2026-06-28 10:00 UTC",
|
||||
"n_rows": 1000,
|
||||
"n_cols": 3,
|
||||
"null_cell_pct": 0.02,
|
||||
"duplicate_rows": 5,
|
||||
"duplicate_pct": 0.005,
|
||||
"quality_score": 92.5,
|
||||
"type_breakdown": {"numeric": 2, "categorical": 1},
|
||||
"key_candidates": ["id"],
|
||||
"columns": [
|
||||
{
|
||||
"name": "precio",
|
||||
"inferred_type": "numeric",
|
||||
"semantic_type": "currency",
|
||||
"null_pct": 0.0,
|
||||
"distinct_count": 850,
|
||||
"unique_pct": 0.85,
|
||||
"quality_score": 95.0,
|
||||
"flags": [],
|
||||
"numeric": {
|
||||
"min": 1.0, "max": 100.0, "median": 40.0, "mean": 42.5,
|
||||
"std": 12.3, "p25": 30.0, "p75": 55.0, "outlier_pct": 1.2,
|
||||
"distribution_type": "right-skewed",
|
||||
"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": "unidades",
|
||||
"inferred_type": "numeric",
|
||||
"semantic_type": "integer",
|
||||
"null_pct": 0.01,
|
||||
"distinct_count": 40,
|
||||
"unique_pct": 0.04,
|
||||
"quality_score": 88.0,
|
||||
"flags": ["has_nulls"],
|
||||
"numeric": {
|
||||
"min": 1.0, "max": 12.0, "median": 4.0, "mean": 4.8,
|
||||
"std": 2.1, "outlier_pct": 0.0,
|
||||
"distribution_type": "normal",
|
||||
"histogram": [
|
||||
{"lo": 1.0, "hi": 4.0, "count": 400},
|
||||
{"lo": 4.0, "hi": 8.0, "count": 450},
|
||||
{"lo": 8.0, "hi": 12.0, "count": 150},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "categoria",
|
||||
"inferred_type": "categorical",
|
||||
"semantic_type": "",
|
||||
"null_pct": 0.0,
|
||||
"distinct_count": 3,
|
||||
"unique_pct": 0.003,
|
||||
"quality_score": 99.0,
|
||||
"flags": [],
|
||||
"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},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"correlations": {
|
||||
"pairs": [
|
||||
{"a": "precio", "b": "unidades", "value": -0.42, "method": "pearson"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_golden_genera_pdf_multipagina(tmp_path):
|
||||
"""Caso real: profile completo -> PDF existe, pesa >0 y tiene varias páginas."""
|
||||
out = str(tmp_path / "eda_ventas.pdf")
|
||||
res = render_eda_pdf(_synthetic_profile(), out, title="EDA — ventas")
|
||||
|
||||
assert isinstance(res, dict)
|
||||
assert set(res.keys()) == {"pdf_path", "n_pages", "note"}
|
||||
assert res["pdf_path"] == out
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 0
|
||||
# Cover + overview + numéricas + categóricas + calidad + correlaciones >= 5.
|
||||
assert res["n_pages"] >= 5
|
||||
# Cabecera de archivo PDF.
|
||||
with open(out, "rb") as fh:
|
||||
assert fh.read(4) == b"%PDF"
|
||||
|
||||
|
||||
def test_edge_profile_vacio_no_revienta(tmp_path):
|
||||
"""Edge: dict vacío -> 1 página garantizada, sin excepción."""
|
||||
out = str(tmp_path / "vacio.pdf")
|
||||
res = render_eda_pdf({}, out)
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 0
|
||||
assert res["n_pages"] >= 1
|
||||
assert res["pdf_path"] == out
|
||||
|
||||
|
||||
def test_edge_profile_none_no_revienta(tmp_path):
|
||||
"""Edge: None -> tratado como vacío, 1 página, sin excepción."""
|
||||
out = str(tmp_path / "none.pdf")
|
||||
res = render_eda_pdf(None, out)
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] >= 1
|
||||
|
||||
|
||||
def test_edge_solo_numericas(tmp_path):
|
||||
"""Edge: profile sólo con columnas numéricas (sin categóricas ni corr)."""
|
||||
prof = {
|
||||
"table": "t",
|
||||
"n_rows": 10,
|
||||
"n_cols": 1,
|
||||
"columns": [
|
||||
{
|
||||
"name": "x",
|
||||
"inferred_type": "numeric",
|
||||
"quality_score": 80.0,
|
||||
"numeric": {
|
||||
"median": 2.0, "mean": 2.0,
|
||||
"histogram": [{"lo": 0.0, "hi": 4.0, "count": 10}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
out = str(tmp_path / "num.pdf")
|
||||
res = render_eda_pdf(prof, out)
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] >= 2 # cover + numéricas al menos.
|
||||
|
||||
|
||||
def test_forward_compat_seccion_desconocida(tmp_path):
|
||||
"""Error/forward-compat: un bloque nuevo del profile se vuelca, no rompe."""
|
||||
prof = {
|
||||
"table": "t",
|
||||
"n_rows": 5,
|
||||
"columns": [],
|
||||
# Bloques que este renderer no conoce (otros agentes los añaden):
|
||||
"models": {"kmeans": {"k": 3, "silhouette": 0.55}},
|
||||
"caveats": ["muestra pequeña", "fechas como texto"],
|
||||
}
|
||||
out = str(tmp_path / "fwd.pdf")
|
||||
res = render_eda_pdf(prof, out)
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] >= 1
|
||||
# No se perdió ninguna sección por error.
|
||||
assert "omitida" not in res["note"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# H4: builders dedicados para models / series / caveats (antes caían al volcado
|
||||
# genérico como str(dict) truncado). Se testean aislados con un stub de pdf.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _sample_models() -> dict:
|
||||
return {
|
||||
"n_numeric_cols": 3,
|
||||
"pca": {
|
||||
"n_components": 2, "n_rows_used": 1000, "n_features": 3,
|
||||
"explained_variance_ratio": [0.62, 0.21],
|
||||
"cumulative": [0.62, 0.83],
|
||||
"top_loadings": [
|
||||
{"component": 0, "feature": "precio", "loading": 0.71},
|
||||
{"component": 1, "feature": "unidades", "loading": -0.55},
|
||||
],
|
||||
},
|
||||
"kmeans": {
|
||||
"best_k": 3, "silhouette": 0.48, "cluster_sizes": [500, 300, 200],
|
||||
"scores_by_k": [{"k": 3, "silhouette": 0.48, "inertia": 900.0}],
|
||||
},
|
||||
"outliers": {"n_outliers": 35, "outlier_pct": 3.5, "threshold": -0.51},
|
||||
"normality": {"precio": {"jarque_bera": {"p": 0.0001}, "is_normal": False}},
|
||||
"note": "",
|
||||
}
|
||||
|
||||
|
||||
def _sample_series() -> dict:
|
||||
return {
|
||||
"precio": {
|
||||
"stationarity": {"verdict": "non_stationary"},
|
||||
"acf_pacf": {"is_autocorrelated": True},
|
||||
"stl": {"trend_strength": 0.95, "seasonal_strength": 0.10, "period": 7},
|
||||
"levels_suggested": True, "levels_kind": "returns",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sample_caveats() -> dict:
|
||||
return {
|
||||
"n": 1,
|
||||
"caveats": [
|
||||
{"id": "exploratory_nature", "topic": "naturaleza exploratoria",
|
||||
"message": "El EDA genera hipótesis, no conclusiones."},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_models_builder_produces_pages():
|
||||
pdf = _StubPdf()
|
||||
assert _models_pages(pdf, _sample_models()) >= 1
|
||||
assert pdf.figs >= 1
|
||||
|
||||
|
||||
def test_series_builder_produces_pages():
|
||||
pdf = _StubPdf()
|
||||
assert _series_pages(pdf, _sample_series()) >= 1
|
||||
assert pdf.figs >= 1
|
||||
|
||||
|
||||
def test_caveats_builder_produces_pages():
|
||||
pdf = _StubPdf()
|
||||
assert _caveats_pages(pdf, _sample_caveats()) >= 1
|
||||
assert pdf.figs >= 1
|
||||
|
||||
|
||||
def test_builders_tolerate_none_and_empty():
|
||||
pdf = _StubPdf()
|
||||
# None / vacío -> 0 páginas, sin excepción.
|
||||
assert _models_pages(pdf, None) == 0
|
||||
assert _series_pages(pdf, {}) == 0
|
||||
assert _caveats_pages(pdf, None) == 0
|
||||
assert pdf.figs == 0
|
||||
|
||||
|
||||
def test_models_series_caveats_no_caen_al_generico(tmp_path):
|
||||
# Con builder dedicado, models/series/caveats NO se vuelcan en "Otras
|
||||
# secciones" (genérico). El profile completo se renderiza sin error.
|
||||
prof = _synthetic_profile()
|
||||
prof["models"] = _sample_models()
|
||||
prof["series"] = _sample_series()
|
||||
prof["caveats"] = _sample_caveats()
|
||||
out = str(tmp_path / "full.pdf")
|
||||
res = render_eda_pdf(prof, out)
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 0
|
||||
assert "omitida" not in res["note"]
|
||||
# Cover+overview+num+cat+calidad+corr + models + series + caveats.
|
||||
assert res["n_pages"] >= 8
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# H9: render_eda_pdf_relational — PDF DB-level (resumen de tablas + join graph).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _synthetic_db_profile() -> dict:
|
||||
return {
|
||||
"db_path": "data/shop.duckdb",
|
||||
"profiled_at": "2026-06-29 01:00 UTC",
|
||||
"n_tables": 2,
|
||||
"tables": [
|
||||
{"table": "customers", "n_rows": 4, "n_cols": 3, "quality_score": 98.0,
|
||||
"key_candidates": ["id"]},
|
||||
{"table": "orders", "n_rows": 6, "n_cols": 3, "quality_score": 95.0,
|
||||
"key_candidates": ["order_id"]},
|
||||
],
|
||||
"fk_candidates": [
|
||||
{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id",
|
||||
"inclusion": 1.0, "cardinality": "N:1"},
|
||||
],
|
||||
"join_graph": {"mermaid": "graph LR\n orders --> customers"},
|
||||
}
|
||||
|
||||
|
||||
def test_relational_golden_genera_pdf(tmp_path):
|
||||
out = str(tmp_path / "eda_db.pdf")
|
||||
res = render_eda_pdf_relational(_synthetic_db_profile(), out, title="EDA base")
|
||||
assert isinstance(res, dict)
|
||||
assert set(res.keys()) == {"pdf_path", "n_pages", "note"}
|
||||
assert res["pdf_path"] == out
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 0
|
||||
# cover + tablas + relaciones >= 3.
|
||||
assert res["n_pages"] >= 3
|
||||
with open(out, "rb") as fh:
|
||||
assert fh.read(4) == b"%PDF"
|
||||
|
||||
|
||||
def test_relational_edge_vacio_no_revienta(tmp_path):
|
||||
out = str(tmp_path / "db_vacio.pdf")
|
||||
res = render_eda_pdf_relational({}, out)
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] >= 1
|
||||
|
||||
|
||||
def test_relational_edge_none_no_revienta(tmp_path):
|
||||
out = str(tmp_path / "db_none.pdf")
|
||||
res = render_eda_pdf_relational(None, out)
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] >= 1
|
||||
@@ -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,72 @@
|
||||
"""Tests para stl_decompose."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from stl_decompose import stl_decompose
|
||||
|
||||
|
||||
def _serie_estacional(n: int, period: int, trend: float, amp: float, seed: int) -> list:
|
||||
rng = np.random.default_rng(seed)
|
||||
return [
|
||||
trend * i + amp * np.sin(2 * np.pi * i / period) + rng.normal(0, 1)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_serie_con_tendencia_y_estacionalidad():
|
||||
serie = _serie_estacional(n=120, period=12, trend=0.3, amp=10.0, seed=0)
|
||||
res = stl_decompose(serie, period=12)
|
||||
assert res["period"] == 12
|
||||
assert res["trend_strength"] > 0.5
|
||||
assert res["seasonal_strength"] > 0.5
|
||||
assert len(res["trend"]["values"]) == 120
|
||||
|
||||
|
||||
def test_fuerza_estacional_alta_con_estacionalidad_fuerte():
|
||||
# Amplitud estacional grande, ruido pequeno => seasonal_strength cercano a 1.
|
||||
serie = _serie_estacional(n=120, period=12, trend=0.05, amp=20.0, seed=1)
|
||||
res = stl_decompose(serie, period=12)
|
||||
assert res["seasonal_strength"] > 0.9
|
||||
|
||||
|
||||
def test_infiere_periodo_si_none():
|
||||
serie = _serie_estacional(n=120, period=12, trend=0.1, amp=10.0, seed=2)
|
||||
res = stl_decompose(serie) # period=None
|
||||
assert res.get("period_inferred") is True
|
||||
assert res["period"] is not None
|
||||
|
||||
|
||||
def test_serie_corta_devuelve_nota():
|
||||
# period=12 pero solo 20 puntos (< 2*period=24): nota, no descompone.
|
||||
serie = _serie_estacional(n=20, period=12, trend=0.1, amp=5.0, seed=3)
|
||||
res = stl_decompose(serie, period=12)
|
||||
assert "note" in res
|
||||
assert res["trend_strength"] is None
|
||||
|
||||
|
||||
def test_muestra_insuficiente_devuelve_nota():
|
||||
res = stl_decompose([1, 2, 3, 4, 5])
|
||||
assert res["n"] == 5
|
||||
assert res["note"] == "datos insuficientes"
|
||||
assert res["seasonal_strength"] is None
|
||||
|
||||
|
||||
def test_descarta_none_y_nan():
|
||||
serie = _serie_estacional(n=120, period=12, trend=0.2, amp=8.0, seed=4)
|
||||
sucio = []
|
||||
for i, v in enumerate(serie):
|
||||
sucio.append(v)
|
||||
if i % 30 == 0:
|
||||
sucio.append(None)
|
||||
sucio.append(float("nan"))
|
||||
res = stl_decompose(sucio, period=12)
|
||||
assert res["n"] == 120
|
||||
|
||||
|
||||
def test_serie_larga_resume_sin_values():
|
||||
# >200 puntos: las componentes vienen resumidas sin 'values'.
|
||||
serie = _serie_estacional(n=300, period=12, trend=0.1, amp=10.0, seed=5)
|
||||
res = stl_decompose(serie, period=12)
|
||||
assert res["trend"]["values"] is None
|
||||
assert "mean" in res["trend"]
|
||||
assert "note" in res["trend"]
|
||||
@@ -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,97 @@
|
||||
"""Tests para suggest_reexpression."""
|
||||
|
||||
from suggest_reexpression import suggest_reexpression
|
||||
|
||||
|
||||
def test_aproximadamente_simetrica_recomienda_none():
|
||||
# |skew| < 0.5 -> no hace falta re-expresar.
|
||||
out = suggest_reexpression({"skew": 0.1, "min": 5.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||
assert out["recommended"] == "none"
|
||||
assert out["ladder_power"] == 1.0
|
||||
assert out["alternatives"] == []
|
||||
assert out["note"] == ""
|
||||
|
||||
|
||||
def test_positiva_fuerte_todo_positivo_recomienda_log():
|
||||
# Cola derecha larga sobre datos estrictamente positivos -> log.
|
||||
out = suggest_reexpression({"skew": 2.3, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||
assert out["recommended"] == "log"
|
||||
assert out["ladder_power"] == 0.0
|
||||
transforms = [a["transform"] for a in out["alternatives"]]
|
||||
assert "box-cox" in transforms
|
||||
|
||||
|
||||
def test_positiva_moderada_todo_positivo_recomienda_sqrt():
|
||||
out = suggest_reexpression({"skew": 0.7, "min": 2.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||
assert out["recommended"] == "sqrt"
|
||||
assert out["ladder_power"] == 0.5
|
||||
|
||||
|
||||
def test_positiva_con_ceros_fuerte_recomienda_log1p():
|
||||
# log(0) indefinido -> log1p en presencia de ceros.
|
||||
out = suggest_reexpression({"skew": 1.5, "min": 0.0, "zero_pct": 12.0, "negative_pct": 0.0})
|
||||
assert out["recommended"] == "log1p"
|
||||
assert out["ladder_power"] == 0.0
|
||||
|
||||
|
||||
def test_positiva_con_negativos_recomienda_yeo_johnson():
|
||||
# log/Box-Cox no admiten negativos -> Yeo-Johnson.
|
||||
out = suggest_reexpression({"skew": 1.8, "min": -4.0, "zero_pct": 0.0, "negative_pct": 20.0})
|
||||
assert out["recommended"] == "yeo-johnson"
|
||||
assert out["ladder_power"] is None # data-driven
|
||||
|
||||
|
||||
def test_negativa_fuerte_todo_positivo_recomienda_cube():
|
||||
# Cola izquierda -> subir por la escalera de Tukey.
|
||||
out = suggest_reexpression({"skew": -1.6, "min": 3.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||
assert out["recommended"] == "cube"
|
||||
assert out["ladder_power"] == 3.0
|
||||
|
||||
|
||||
def test_negativa_moderada_todo_positivo_recomienda_square():
|
||||
out = suggest_reexpression({"skew": -0.8, "min": 3.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||
assert out["recommended"] == "square"
|
||||
assert out["ladder_power"] == 2.0
|
||||
|
||||
|
||||
def test_dominio_desconocido_recomienda_yeo_johnson_con_nota():
|
||||
# Solo skew, sin min/zero_pct/negative_pct -> opción segura + nota.
|
||||
out = suggest_reexpression({"skew": 1.4})
|
||||
assert out["recommended"] == "yeo-johnson"
|
||||
assert "dominio desconocido" in out["note"]
|
||||
|
||||
|
||||
def test_acepta_columnprofile_completo_con_numeric_anidado():
|
||||
# Si llega un ColumnProfile entero, baja a su sub-bloque numeric.
|
||||
profile = {
|
||||
"name": "precio",
|
||||
"inferred_type": "numeric",
|
||||
"numeric": {"skew": 2.0, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0},
|
||||
}
|
||||
out = suggest_reexpression(profile)
|
||||
assert out["recommended"] == "log"
|
||||
|
||||
|
||||
def test_skew_ausente_devuelve_nota():
|
||||
out = suggest_reexpression({"min": 1.0, "max": 9.0})
|
||||
assert out["recommended"] is None
|
||||
assert "skew ausente" in out["note"]
|
||||
|
||||
|
||||
def test_stats_vacio_devuelve_nota():
|
||||
out = suggest_reexpression({})
|
||||
assert out["recommended"] is None
|
||||
assert out["alternatives"] == []
|
||||
assert out["note"]
|
||||
|
||||
|
||||
def test_no_dict_no_lanza():
|
||||
out = suggest_reexpression(None)
|
||||
assert out["recommended"] is None
|
||||
assert out["note"]
|
||||
|
||||
|
||||
def test_skew_no_numerico_devuelve_nota():
|
||||
out = suggest_reexpression({"skew": "mucho"})
|
||||
assert out["recommended"] is None
|
||||
assert out["skew"] is None
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests para to_returns."""
|
||||
|
||||
import math
|
||||
|
||||
from to_returns import to_returns
|
||||
|
||||
|
||||
def test_log_returns_valores_conocidos():
|
||||
precios = [100.0, 105.0, 103.0, 108.0]
|
||||
res = to_returns(precios, method="log")
|
||||
esperado = [
|
||||
math.log(105 / 100),
|
||||
math.log(103 / 105),
|
||||
math.log(108 / 103),
|
||||
]
|
||||
assert res["n_returns"] == 3
|
||||
assert res["n_skipped"] == 0
|
||||
for got, exp in zip(res["returns"], esperado):
|
||||
assert math.isclose(got, exp, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_simple_returns_valores_conocidos():
|
||||
precios = [100.0, 105.0, 103.0]
|
||||
res = to_returns(precios, method="simple")
|
||||
esperado = [105 / 100 - 1, 103 / 105 - 1]
|
||||
for got, exp in zip(res["returns"], esperado):
|
||||
assert math.isclose(got, exp, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_log_marca_no_positivo_como_invalido():
|
||||
# Un 0 invalida los dos pasos que lo tocan (prev=0 y cur=0).
|
||||
res = to_returns([100.0, 0.0, 50.0], method="log")
|
||||
assert res["n_skipped"] == 2
|
||||
assert res["returns"] == [None, None]
|
||||
assert res["mean"] is None
|
||||
|
||||
|
||||
def test_simple_admite_negativos():
|
||||
# Retornos negativos validos en simple; -10 no invalida (solo prev==0 lo hace).
|
||||
res = to_returns([100.0, 90.0, 81.0], method="simple")
|
||||
assert res["n_skipped"] == 0
|
||||
assert all(r < 0 for r in res["returns"])
|
||||
|
||||
|
||||
def test_method_invalido_devuelve_nota():
|
||||
res = to_returns([1.0, 2.0, 3.0], method="cuadratico")
|
||||
assert res["returns"] == []
|
||||
assert "method" in res["note"]
|
||||
|
||||
|
||||
def test_un_solo_punto_devuelve_nota():
|
||||
res = to_returns([100.0])
|
||||
assert res["n"] == 1
|
||||
assert res["note"] == "datos insuficientes"
|
||||
assert res["returns"] == []
|
||||
|
||||
|
||||
def test_descarta_none_y_nan():
|
||||
precios = [100.0, None, 105.0, float("nan"), 110.0]
|
||||
res = to_returns(precios, method="log")
|
||||
# Quedan 3 niveles validos (100, 105, 110) => 2 retornos.
|
||||
assert res["n_levels"] == 3
|
||||
assert res["n_returns"] == 2
|
||||
|
||||
|
||||
def test_stats_de_retornos():
|
||||
precios = [100.0, 110.0, 121.0] # +10% cada paso en simple
|
||||
res = to_returns(precios, method="simple")
|
||||
assert math.isclose(res["mean"], 0.10, rel_tol=1e-9)
|
||||
assert math.isclose(res["std"], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(res["min"], 0.10, rel_tol=1e-9)
|
||||
assert math.isclose(res["max"], 0.10, rel_tol=1e-9)
|
||||
@@ -5,8 +5,8 @@ lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_list_tables(db_path: str) -> dict"
|
||||
description: "Lista las tablas de una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Consulta information_schema.tables del esquema main y devuelve los nombres ordenados alfabeticamente. Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', tables} en exito y {status:'error', error} en fallo. Es la introspeccion 'que tablas hay' del grupo duckdb; complementa a duckdb_query_readonly_py_infra (lectura de filas) y a duckdb_table_schema_py_infra (schema de una tabla). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
signature: "def duckdb_list_tables(db_path: str, base_tables_only: bool = False) -> dict"
|
||||
description: "Lista las tablas de una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Consulta information_schema.tables del esquema main y devuelve los nombres ordenados alfabeticamente. Con base_tables_only=True filtra table_type='BASE TABLE', excluyendo las VIEWs (util para perfilar/relacionar solo tablas reales). Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', tables} en exito y {status:'error', error} en fallo. Es la introspeccion 'que tablas hay' del grupo duckdb; complementa a duckdb_query_readonly_py_infra (lectura de filas) y a duckdb_table_schema_py_infra (schema de una tabla). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
tags: [duckdb, sql, introspection, readonly, tables]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -17,12 +17,16 @@ imports: [duckdb]
|
||||
params:
|
||||
- name: db_path
|
||||
desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
|
||||
- name: base_tables_only
|
||||
desc: "si True (default False) filtra table_type='BASE TABLE', excluyendo las VIEWs del esquema main. Util para perfilar/relacionar solo tablas reales (perfilar una VIEW infla el conteo y multiplica relaciones FK falsas)."
|
||||
output: "dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla del esquema main ordenados alfabeticamente. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_lista_tablas_ordenadas"
|
||||
- "test_base_vacia_devuelve_lista_vacia"
|
||||
- "test_db_inexistente_devuelve_status_error"
|
||||
- "test_base_tables_only_excluye_views"
|
||||
- "test_attach_sqlite_materializado_lista_por_information_schema"
|
||||
test_file_path: "python/functions/infra/duckdb_list_tables_test.py"
|
||||
file_path: "python/functions/infra/duckdb_list_tables.py"
|
||||
---
|
||||
@@ -64,7 +68,8 @@ selector de tablas en una UI. Es el primer paso natural antes de
|
||||
- DuckDB es single-writer: si otro proceso tiene la base abierta en escritura con
|
||||
una version distinta del motor, la apertura read-only puede fallar con error de
|
||||
lock. El error se devuelve como `{status:'error', ...}`, no se lanza.
|
||||
- Solo lista tablas del esquema `main` (el por defecto). Vistas y tablas de otros
|
||||
esquemas no aparecen.
|
||||
- Solo lista objetos del esquema `main` (el por defecto); tablas de otros esquemas
|
||||
no aparecen. Por defecto incluye **vistas** (table_type VIEW) además de las tablas
|
||||
base; pasa `base_tables_only=True` para quedarte solo con las `BASE TABLE`.
|
||||
- Una base recien creada sin tablas devuelve `{status:'ok', tables:[]}` (no es un
|
||||
error): lista vacia.
|
||||
|
||||
@@ -13,12 +13,19 @@ introspeccion de alto nivel "que tablas hay" del grupo duckdb.
|
||||
"""
|
||||
|
||||
|
||||
def duckdb_list_tables(db_path: str) -> dict:
|
||||
def duckdb_list_tables(db_path: str, base_tables_only: bool = False) -> dict:
|
||||
"""Lista las tablas de una base DuckDB en modo solo lectura.
|
||||
|
||||
Args:
|
||||
db_path: ruta al archivo DuckDB. Debe existir: el modo read_only NO crea
|
||||
la base. Un path inexistente devuelve {status:'error', ...}.
|
||||
base_tables_only: si True (default False) filtra por
|
||||
`table_type = 'BASE TABLE'`, excluyendo las VIEWs (y demas objetos no
|
||||
tabla-base) del esquema `main`. Util para perfilar/relacionar solo las
|
||||
tablas reales: perfilar una VIEW infla el numero de tablas y multiplica
|
||||
las relaciones FK falsas. El default mantiene el comportamiento previo
|
||||
(lista todo lo que aparece en information_schema.tables del esquema
|
||||
main) para no romper consumidores existentes.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla
|
||||
@@ -28,10 +35,14 @@ def duckdb_list_tables(db_path: str) -> dict:
|
||||
conn = None
|
||||
try:
|
||||
conn = __import__("duckdb").connect(db_path, read_only=True)
|
||||
rows = conn.execute(
|
||||
sql = (
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'main' ORDER BY table_name"
|
||||
).fetchall()
|
||||
"WHERE table_schema = 'main'"
|
||||
)
|
||||
if base_tables_only:
|
||||
sql += " AND table_type = 'BASE TABLE'"
|
||||
sql += " ORDER BY table_name"
|
||||
rows = conn.execute(sql).fetchall()
|
||||
tables = [row[0] for row in rows]
|
||||
return {"status": "ok", "tables": tables}
|
||||
except Exception as e: # noqa: BLE001
|
||||
|
||||
@@ -38,3 +38,59 @@ def test_db_inexistente_devuelve_status_error(tmp_path):
|
||||
res = duckdb_list_tables(str(tmp_path / "noexiste.duckdb"))
|
||||
assert res["status"] == "error"
|
||||
assert "error" in res
|
||||
|
||||
|
||||
def test_base_tables_only_excluye_views(tmp_path):
|
||||
# Una BASE TABLE + una VIEW: por defecto se listan ambas; con
|
||||
# base_tables_only=True la VIEW se excluye.
|
||||
db = tmp_path / "withviews.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE ventas (id INTEGER, total DOUBLE)")
|
||||
con.execute("CREATE VIEW ventas_resumen AS SELECT id FROM ventas")
|
||||
con.close()
|
||||
|
||||
# Default: incluye la view.
|
||||
res_all = duckdb_list_tables(str(db))
|
||||
assert res_all["status"] == "ok"
|
||||
assert res_all["tables"] == ["ventas", "ventas_resumen"]
|
||||
|
||||
# base_tables_only: solo la tabla base.
|
||||
res_base = duckdb_list_tables(str(db), base_tables_only=True)
|
||||
assert res_base["status"] == "ok"
|
||||
assert res_base["tables"] == ["ventas"]
|
||||
|
||||
|
||||
def test_attach_sqlite_materializado_lista_por_information_schema(tmp_path):
|
||||
# Regresión H14: tras ATTACH de una base SQLite en DuckDB se materializan sus
|
||||
# tablas y se listan vía information_schema (NO sqlite_master, que no existe en
|
||||
# DuckDB). duckdb_list_tables debe verlas como tablas del esquema main.
|
||||
import sqlite3
|
||||
|
||||
sqlite_path = str(tmp_path / "src.sqlite")
|
||||
sconn = sqlite3.connect(sqlite_path)
|
||||
sconn.execute("CREATE TABLE clientes (id INTEGER PRIMARY KEY, nombre TEXT)")
|
||||
sconn.execute("INSERT INTO clientes VALUES (1,'Ana'),(2,'Luis')")
|
||||
sconn.execute("CREATE VIEW clientes_v AS SELECT id FROM clientes")
|
||||
sconn.commit()
|
||||
sconn.close()
|
||||
|
||||
ddb_path = str(tmp_path / "materialized.duckdb")
|
||||
con = duckdb.connect(ddb_path)
|
||||
con.execute("INSTALL sqlite")
|
||||
con.execute("LOAD sqlite")
|
||||
con.execute(f"ATTACH '{sqlite_path}' AS src (TYPE sqlite)")
|
||||
# Listar tablas base del catálogo attachado por information_schema (no
|
||||
# sqlite_master) y materializarlas como tablas nativas DuckDB.
|
||||
rows = con.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_catalog='src' AND table_type='BASE TABLE' "
|
||||
"AND table_name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
for (name,) in rows:
|
||||
con.execute(f'CREATE TABLE "{name}" AS SELECT * FROM src."{name}"')
|
||||
con.execute("DETACH src")
|
||||
con.close()
|
||||
|
||||
res = duckdb_list_tables(ddb_path)
|
||||
assert res["status"] == "ok"
|
||||
assert "clientes" in res["tables"]
|
||||
|
||||
@@ -12,6 +12,7 @@ Funciones del registry compuestas (NO se reimplementa su logica):
|
||||
- build_join_graph : grafo de relaciones inter-tabla + diagrama Mermaid.
|
||||
- duckdb_list_tables : introspeccion "que tablas hay" (read-only).
|
||||
- render_eda_markdown : report legible de un TableProfile.
|
||||
- render_eda_pdf_relational : PDF movil DB-level (resumen de tablas + join graph).
|
||||
|
||||
Aporta una capa propia de AGREGACION A NIVEL DE BASE: ensambla un DatabaseProfile
|
||||
con el resumen de cada tabla, los TableProfiles completos, las FK candidatas y el
|
||||
@@ -31,6 +32,7 @@ from datascience import (
|
||||
build_join_graph,
|
||||
infer_fk_containment_duckdb,
|
||||
render_eda_markdown,
|
||||
render_eda_pdf_relational,
|
||||
)
|
||||
from infra import duckdb_list_tables
|
||||
from pipelines.profile_table import profile_table
|
||||
@@ -118,6 +120,7 @@ def profile_database(
|
||||
report_dir: str = "reports",
|
||||
write_report: bool = True,
|
||||
min_inclusion: float = 0.9,
|
||||
emit_pdf: bool = False,
|
||||
) -> dict:
|
||||
"""Perfila una base DuckDB entera + sus relaciones inter-tabla.
|
||||
|
||||
@@ -134,11 +137,16 @@ def profile_database(
|
||||
paths del retorno son None.
|
||||
min_inclusion: umbral minimo de inclusion (0-1) para emitir una FK
|
||||
candidata (se pasa a infer_fk_containment_duckdb). Default 0.9.
|
||||
emit_pdf: si True (default False) renderiza un PDF movil DB-level con
|
||||
render_eda_pdf_relational (resumen de tablas + relaciones FK + join
|
||||
graph) junto a los reports y devuelve su ruta en report_pdf_path. Con
|
||||
False no se toca el PDF (retrocompatible) y report_pdf_path es None.
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw. En exito:
|
||||
{status:'ok', db_profile:<DatabaseProfile>,
|
||||
report_md_path:str|None, report_json_path:str|None}.
|
||||
report_md_path:str|None, report_json_path:str|None,
|
||||
report_pdf_path:str|None}.
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
|
||||
DatabaseProfile = {
|
||||
@@ -151,9 +159,11 @@ def profile_database(
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 1) Resolver lista de tablas.
|
||||
# 1) Resolver lista de tablas. Solo BASE TABLE: las VIEWs no son tablas
|
||||
# reales — perfilarlas infla n_tables y multiplica las FK falsas (sus
|
||||
# columnas son copias de las de las tablas base, con contención perfecta).
|
||||
if tables is None:
|
||||
lst = duckdb_list_tables(db_path)
|
||||
lst = duckdb_list_tables(db_path, base_tables_only=True)
|
||||
if lst.get("status") != "ok":
|
||||
return {"status": "error", "error": lst.get("error", "list failed")}
|
||||
tables = lst.get("tables", [])
|
||||
@@ -202,12 +212,13 @@ def profile_database(
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
# 6) Reports opcionales.
|
||||
# 6) Reports opcionales (markdown + JSON sidecar + PDF movil DB-level).
|
||||
report_md_path = None
|
||||
report_json_path = None
|
||||
report_pdf_path = None
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
if write_report:
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
report_json_path = os.path.join(report_dir, f"eda_db_{ts}.json")
|
||||
report_md_path = os.path.join(report_dir, f"eda_db_{ts}.md")
|
||||
with open(report_json_path, "w", encoding="utf-8") as fh:
|
||||
@@ -217,11 +228,23 @@ def profile_database(
|
||||
with open(report_md_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(_render_db_markdown(db_profile))
|
||||
|
||||
# PDF DB-level (legible en movil): resumen de tablas + join graph. Se
|
||||
# genera bajo demanda (emit_pdf) reusando el renderer relational del grupo.
|
||||
if emit_pdf:
|
||||
try:
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
pdf_target = os.path.join(report_dir, f"eda_db_{ts}.pdf")
|
||||
pres = render_eda_pdf_relational(db_profile, pdf_target)
|
||||
report_pdf_path = pres.get("pdf_path")
|
||||
except Exception: # noqa: BLE001
|
||||
report_pdf_path = None
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_profile": db_profile,
|
||||
"report_md_path": report_md_path,
|
||||
"report_json_path": report_json_path,
|
||||
"report_pdf_path": report_pdf_path,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@@ -78,6 +78,77 @@ def test_profile_database_two_related_tables():
|
||||
assert res["report_json_path"] is None
|
||||
|
||||
|
||||
def test_profile_database_excluye_views(tmp_path):
|
||||
# Regresión H5: una VIEW no es una tabla real. profile_database debe perfilar
|
||||
# solo las BASE TABLE y no contar las VIEWs (inflan n_tables y multiplican FK
|
||||
# falsas, al ser copias de columnas de las tablas base).
|
||||
db_path = os.path.join(str(tmp_path), "withviews.duckdb")
|
||||
_build_related_db(db_path)
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute("CREATE VIEW customers_v AS SELECT id, name FROM customers")
|
||||
con.execute("CREATE VIEW orders_v AS SELECT order_id, total FROM orders")
|
||||
con.close()
|
||||
|
||||
res = profile_database(db_path, write_report=False)
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
prof = res["db_profile"]
|
||||
# Solo las 2 tablas base; las 2 views quedan fuera.
|
||||
assert prof["n_tables"] == 2
|
||||
profiled = {tp["table"] for tp in prof["table_profiles"]}
|
||||
assert profiled == {"customers", "orders"}
|
||||
assert "customers_v" not in profiled
|
||||
assert "orders_v" not in profiled
|
||||
|
||||
|
||||
def test_profile_database_attach_sqlite_no_usa_sqlite_master(tmp_path):
|
||||
# Regresión H14: materializar una base SQLite vía ATTACH (information_schema,
|
||||
# no sqlite_master) y perfilarla con profile_database sin que falle. Blinda el
|
||||
# bug original 'sqlite_master does not exist'.
|
||||
import sqlite3
|
||||
|
||||
sqlite_path = os.path.join(str(tmp_path), "shop.sqlite")
|
||||
sconn = sqlite3.connect(sqlite_path)
|
||||
sconn.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
sconn.execute("INSERT INTO customers VALUES (1,'Ana'),(2,'Luis'),(3,'Marta')")
|
||||
sconn.execute(
|
||||
"CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, total REAL)"
|
||||
)
|
||||
sconn.execute(
|
||||
"INSERT INTO orders VALUES (10,1,99.5),(11,2,12.0),(12,3,7.25),(13,1,5.0)"
|
||||
)
|
||||
sconn.execute("CREATE VIEW big_orders AS SELECT * FROM orders WHERE total > 10")
|
||||
sconn.commit()
|
||||
sconn.close()
|
||||
|
||||
ddb_path = os.path.join(str(tmp_path), "shop_mat.duckdb")
|
||||
con = duckdb.connect(ddb_path)
|
||||
con.execute("INSTALL sqlite")
|
||||
con.execute("LOAD sqlite")
|
||||
con.execute(f"ATTACH '{sqlite_path}' AS src (TYPE sqlite)")
|
||||
rows = con.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_catalog='src' AND table_type='BASE TABLE' "
|
||||
"AND table_name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
for (name,) in rows:
|
||||
con.execute(f'CREATE TABLE "{name}" AS SELECT * FROM src."{name}"')
|
||||
con.execute("DETACH src")
|
||||
con.close()
|
||||
|
||||
res = profile_database(ddb_path, write_report=False)
|
||||
assert res["status"] == "ok", res
|
||||
prof = res["db_profile"]
|
||||
# Solo las 2 tablas base materializadas (la VIEW no se materializó).
|
||||
profiled = {tp["table"] for tp in prof["table_profiles"]}
|
||||
assert profiled == {"customers", "orders"}
|
||||
# FK orders.customer_id -> customers.id detectable.
|
||||
assert any(
|
||||
fk.get("from_table") == "orders" and fk.get("to_table") == "customers"
|
||||
for fk in prof["fk_candidates"]
|
||||
), prof["fk_candidates"]
|
||||
|
||||
|
||||
def test_profile_database_writes_report(tmp_path):
|
||||
db_path = os.path.join(str(tmp_path), "shop2.duckdb")
|
||||
_build_related_db(db_path)
|
||||
@@ -94,3 +165,36 @@ def test_profile_database_writes_report(tmp_path):
|
||||
assert "# EDA base —" in md
|
||||
assert "## Relaciones inter-tabla" in md
|
||||
assert "```mermaid" in md
|
||||
|
||||
|
||||
def test_profile_database_emit_pdf(tmp_path):
|
||||
# H9: con emit_pdf=True, profile_database genera un PDF DB-level (>0 bytes,
|
||||
# cabecera %PDF) además del markdown + JSON.
|
||||
db_path = os.path.join(str(tmp_path), "shop3.duckdb")
|
||||
_build_related_db(db_path)
|
||||
report_dir = os.path.join(str(tmp_path), "reports")
|
||||
|
||||
res = profile_database(
|
||||
db_path, report_dir=report_dir, write_report=True, emit_pdf=True
|
||||
)
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
pdf = res.get("report_pdf_path")
|
||||
assert pdf is not None
|
||||
assert os.path.exists(pdf)
|
||||
assert os.path.getsize(pdf) > 0
|
||||
with open(pdf, "rb") as fh:
|
||||
assert fh.read(4) == b"%PDF"
|
||||
|
||||
|
||||
def test_profile_database_emit_pdf_false_retrocompat(tmp_path):
|
||||
# Edge: emit_pdf=False (default) se comporta como antes — no genera PDF y
|
||||
# report_pdf_path es None.
|
||||
db_path = os.path.join(str(tmp_path), "shop4.duckdb")
|
||||
_build_related_db(db_path)
|
||||
report_dir = os.path.join(str(tmp_path), "reports")
|
||||
|
||||
res = profile_database(db_path, report_dir=report_dir, write_report=True)
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res.get("report_pdf_path") is None
|
||||
|
||||
@@ -5,17 +5,29 @@ lang: py
|
||||
domain: pipelines
|
||||
purity: impure
|
||||
version: "1.0.0"
|
||||
signature: "def profile_table(db_path: str, table: str, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True) -> dict"
|
||||
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla DuckDB end-to-end componiendo las 7 funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + render markdown) y emite el TableProfile completo mas (opcional) un report markdown y un JSON sidecar. Es la composicion canonica para hazme un EDA de esta tabla."
|
||||
tags: [eda, duckdb, profiling, data-quality, pipeline, dataops]
|
||||
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
|
||||
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla (DuckDB o PostgreSQL) end-to-end componiendo las funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + correlaciones con correccion FDR + re-expresion de Tukey + avisos exploratorios) y, opcional, modelos baratos (run_models), interpretacion LLM (run_llm) y analisis de serie temporal por columna (run_series: estacionariedad ADF+KPSS, ACF/PACF, STL, retornos). Emite el TableProfile completo mas (opcional) report markdown + JSON sidecar + PDF movil (emit_pdf). Es la composicion canonica para hazme un EDA de esta tabla."
|
||||
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
|
||||
uses_functions:
|
||||
- summarize_table_duckdb_py_datascience
|
||||
- summarize_table_pg_py_datascience
|
||||
- describe_numeric_py_datascience
|
||||
- summarize_categorical_py_datascience
|
||||
- infer_semantic_type_py_datascience
|
||||
- column_quality_score_py_datascience
|
||||
- association_matrix_py_datascience
|
||||
- run_eda_models_py_datascience
|
||||
- eda_llm_insights_py_datascience
|
||||
- adf_kpss_stationarity_py_datascience
|
||||
- acf_pacf_py_datascience
|
||||
- stl_decompose_py_datascience
|
||||
- to_returns_py_datascience
|
||||
- suggest_reexpression_py_datascience
|
||||
- exploratory_caveats_py_datascience
|
||||
- render_eda_markdown_py_datascience
|
||||
- render_eda_pdf_py_datascience
|
||||
- duckdb_query_readonly_py_infra
|
||||
- pg_query_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -28,16 +40,26 @@ test_file_path: "python/functions/pipelines/profile_table_test.py"
|
||||
file_path: "python/functions/pipelines/profile_table.py"
|
||||
params:
|
||||
- name: db_path
|
||||
desc: "Ruta al archivo DuckDB (read-only, debe existir; no se crea)."
|
||||
desc: "Ruta al archivo DuckDB (read-only, debe existir; no se crea) o DSN PostgreSQL si backend='postgres'."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla a perfilar."
|
||||
- name: backend
|
||||
desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado base (summarize) y de muestreo read-only."
|
||||
- name: sample
|
||||
desc: "Maximo de valores no nulos muestreados por columna para el enriquecimiento (describe_numeric / summarize_categorical / infer_semantic_type). Default 5000."
|
||||
- name: run_models
|
||||
desc: "Si True (default False) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad) y guarda el bloque en prof['models']."
|
||||
- name: run_llm
|
||||
desc: "Si True (default False) hace 1 llamada LLM sobre el perfil agregado y guarda el resultado en prof['llm']."
|
||||
- name: run_series
|
||||
desc: "Si True (default False) calcula por columna numerica un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF, STL y, si parece de niveles, retornos). Ordena por la primera columna datetime si existe; si no, por el orden fisico. Guardado en col['series'] y agregado en prof['series']."
|
||||
- name: emit_pdf
|
||||
desc: "Si True (default False) renderiza un PDF multipagina vertical (legible en movil) del perfil junto al report markdown y devuelve su ruta en pdf_path."
|
||||
- name: report_dir
|
||||
desc: "Directorio donde escribir los reports si write_report. Default 'reports'. Se crea si no existe."
|
||||
desc: "Directorio donde escribir los reports si write_report (y el PDF si emit_pdf). Default 'reports'. Se crea si no existe."
|
||||
- name: write_report
|
||||
desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths del retorno son None."
|
||||
output: "dict {status:'ok', profile:<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)."
|
||||
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, 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
|
||||
|
||||
@@ -29,16 +29,23 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datascience import (
|
||||
acf_pacf,
|
||||
adf_kpss_stationarity,
|
||||
association_matrix,
|
||||
column_quality_score,
|
||||
describe_numeric,
|
||||
eda_llm_insights,
|
||||
exploratory_caveats,
|
||||
infer_semantic_type,
|
||||
render_eda_markdown,
|
||||
render_eda_pdf,
|
||||
run_eda_models,
|
||||
stl_decompose,
|
||||
suggest_reexpression,
|
||||
summarize_categorical,
|
||||
summarize_table_duckdb,
|
||||
summarize_table_pg,
|
||||
to_returns,
|
||||
)
|
||||
from infra import duckdb_query_readonly, pg_query
|
||||
|
||||
@@ -50,6 +57,57 @@ _DATETIME_SEMANTIC = ("datetime_iso", "date_eu")
|
||||
# promocion a numeric (evita promocionar columnas mayormente no parseables).
|
||||
_PROMOTE_MIN_PARSE = 0.8
|
||||
|
||||
# Cardinalidad maxima (distinct_count) por debajo de la cual una columna numerica
|
||||
# se trata como NO continua (binaria / ordinal de pocos niveles) y, por tanto, no
|
||||
# es candidata a re-expresion de Tukey (la escalera de potencias no aplica a una
|
||||
# variable con pocos niveles discretos).
|
||||
_REEXPR_MIN_DISTINCT = 12
|
||||
|
||||
# Tokens en el nombre (o semantic_type currency) que sugieren que una serie de
|
||||
# niveles es FINANCIERA (precios/volumen): en ese caso la transformacion adecuada
|
||||
# son los retornos. Para magnitudes fisicas (temperatura, caudal) la transformacion
|
||||
# correcta son las diferencias, no los retornos.
|
||||
_FINANCIAL_TOKENS = (
|
||||
"price", "close", "open", "high", "low", "volume", "adj", "vwap",
|
||||
"bid", "ask", "return", "precio", "cierre", "apertura", "cotiz", "retorno",
|
||||
)
|
||||
|
||||
|
||||
def _is_continuous_for_reexpr(col: dict, vals_float: list) -> bool:
|
||||
"""True si la columna numerica es continua y justifica sugerir re-expresion.
|
||||
|
||||
Se saltan (devuelve False):
|
||||
- binarias / ordinales de baja cardinalidad (``distinct_count`` <= umbral):
|
||||
la escalera de potencias de Tukey no tiene sentido sobre pocos niveles
|
||||
discretos (p.ej. ``Survived`` 0/1, ``Pclass`` 1/2/3).
|
||||
- identificadores enteros (flag ``possible_id`` y todos los valores enteros):
|
||||
re-expresar un id (p.ej. ``PassengerId`` 1..n) no aporta nada.
|
||||
Los floats continuos de alta cardinalidad (precios, medidas) NO se saltan
|
||||
aunque lleven ``possible_id``, porque tienen parte decimal (no son enteros).
|
||||
"""
|
||||
dc = col.get("distinct_count")
|
||||
if isinstance(dc, int) and not isinstance(dc, bool) and dc <= _REEXPR_MIN_DISTINCT:
|
||||
return False
|
||||
flags = col.get("flags") or []
|
||||
if "possible_id" in flags and vals_float and all(
|
||||
float(f).is_integer() for f in vals_float
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _looks_financial(col: dict) -> bool:
|
||||
"""True si la columna parece una serie financiera (precio/volumen/divisa).
|
||||
|
||||
Heuristica por nombre (tokens OHLCV típicos) o ``semantic_type == currency``.
|
||||
Decide si una serie de niveles se debe transformar a retornos (financiera) o a
|
||||
diferencias (no financiera, p.ej. temperatura).
|
||||
"""
|
||||
name = (col.get("name") or "").lower()
|
||||
if any(tok in name for tok in _FINANCIAL_TOKENS):
|
||||
return True
|
||||
return (col.get("semantic_type") or "").lower() == "currency"
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
"""Parsea un valor a float limpiando simbolos de moneda y separadores.
|
||||
@@ -115,6 +173,96 @@ def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
|
||||
return q.get("rows", [])
|
||||
|
||||
|
||||
def _sample_series(query_fn, table: str, value_col: str, order_col, sample: int) -> list:
|
||||
"""Trae hasta `sample` valores no nulos de una columna en orden de serie temporal.
|
||||
|
||||
A diferencia de _sample_values, cuando hay una columna de orden temporal
|
||||
(`order_col`, normalmente la primera columna datetime de la tabla) se ordena
|
||||
ascendentemente por ella para que la secuencia recuperada respete el orden
|
||||
cronologico, requisito de los contrastes de serie temporal (ADF/KPSS, ACF/PACF,
|
||||
STL). Si `order_col` es None se cae al orden fisico de inserciones (columna
|
||||
numerica secuencial). query_fn es el lector read-only del backend activo.
|
||||
"""
|
||||
base = (
|
||||
f'SELECT "{value_col}" AS v FROM "{table}" '
|
||||
f'WHERE "{value_col}" IS NOT NULL'
|
||||
)
|
||||
if order_col:
|
||||
base += f' ORDER BY "{order_col}"'
|
||||
base += f" LIMIT {int(sample)}"
|
||||
q = query_fn(base)
|
||||
if q.get("status") != "ok":
|
||||
return []
|
||||
return [row.get("v") for row in q.get("rows", [])]
|
||||
|
||||
|
||||
def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int) -> dict:
|
||||
"""Construye el bloque `series` de una columna numerica (estilo dict-no-throw).
|
||||
|
||||
Compone los contrastes de serie temporal del grupo `eda` sobre la secuencia
|
||||
ordenada de la columna: estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF +
|
||||
Ljung-Box) y descomposicion STL (tendencia/estacional/resto). Cuando la columna
|
||||
parece de NIVELES (precios: estrictamente positiva y no claramente estacionaria)
|
||||
anade ademas la conversion a retornos (`to_returns`) como sugerencia, ya que
|
||||
correlacionar/modelar niveles no estacionarios produce relaciones espurias
|
||||
(Granger-Newbold).
|
||||
|
||||
Devuelve None si no hay suficientes puntos validos (<8) para ningun contraste.
|
||||
"""
|
||||
name = col.get("name")
|
||||
raw = _sample_series(query_fn, table, name, order_col, sample)
|
||||
series_vals = [f for f in (_to_float(v) for v in raw) if f is not None]
|
||||
if len(series_vals) < 8:
|
||||
return None
|
||||
|
||||
block: dict = {
|
||||
"order_col": order_col,
|
||||
"ordered": bool(order_col),
|
||||
"n": len(series_vals),
|
||||
"stationarity": adf_kpss_stationarity(series_vals),
|
||||
"acf_pacf": acf_pacf(series_vals),
|
||||
# stl_decompose auto-infiere el periodo; si no hay estacionalidad detectable
|
||||
# devuelve una nota y strengths None (se incluye igual, es informativo).
|
||||
"stl": stl_decompose(series_vals),
|
||||
}
|
||||
|
||||
# Sugerencia de transformacion solo si la columna parece de niveles:
|
||||
# estrictamente positiva y con veredicto de estacionariedad NO confirmado.
|
||||
# La transformacion adecuada depende de la SEMANTICA: retornos para series
|
||||
# financieras (precios/volumen), diferencias para magnitudes fisicas
|
||||
# (temperatura, caudal). Aplicar "retornos" a una temperatura no tiene sentido
|
||||
# fisico; la primera diferencia si la estaciona.
|
||||
nb = col.get("numeric") or {}
|
||||
minimum = nb.get("min")
|
||||
verdict = (block["stationarity"] or {}).get("verdict")
|
||||
if (
|
||||
isinstance(minimum, (int, float))
|
||||
and not isinstance(minimum, bool)
|
||||
and minimum > 0
|
||||
and verdict in ("non_stationary", "inconclusive")
|
||||
):
|
||||
block["levels_suggested"] = True
|
||||
if _looks_financial(col):
|
||||
block["levels_kind"] = "returns"
|
||||
block["to_returns"] = to_returns(series_vals, method="log")
|
||||
block["levels_reason"] = (
|
||||
"columna financiera estrictamente positiva y no claramente "
|
||||
"estacionaria (serie de niveles/precios): trabajar sobre retornos "
|
||||
"evita correlacion espuria (Granger-Newbold)."
|
||||
)
|
||||
else:
|
||||
block["levels_kind"] = "differences"
|
||||
block["levels_reason"] = (
|
||||
"serie de niveles no financiera y no claramente estacionaria: la "
|
||||
"primera diferencia la estaciona; los retornos no tienen sentido en "
|
||||
"magnitudes fisicas (p.ej. temperatura)."
|
||||
)
|
||||
else:
|
||||
block["levels_suggested"] = False
|
||||
|
||||
return block
|
||||
|
||||
|
||||
def profile_table(
|
||||
db_path: str,
|
||||
table: str,
|
||||
@@ -122,6 +270,8 @@ def profile_table(
|
||||
sample: int = 5000,
|
||||
run_models: bool = False,
|
||||
run_llm: bool = False,
|
||||
run_series: bool = False,
|
||||
emit_pdf: bool = False,
|
||||
report_dir: str = "reports",
|
||||
write_report: bool = True,
|
||||
) -> dict:
|
||||
@@ -135,6 +285,20 @@ def profile_table(
|
||||
sample: maximo de valores no nulos muestreados por columna para el
|
||||
enriquecimiento (describe_numeric / summarize_categorical /
|
||||
infer_semantic_type). Default 5000.
|
||||
run_models: si True (default False) corre los modelos baratos
|
||||
(PCA/KMeans/IsolationForest/normalidad) sobre las numericas y guarda
|
||||
el bloque en prof["models"].
|
||||
run_llm: si True (default False) hace 1 llamada LLM sobre el perfil
|
||||
agregado y guarda el resultado en prof["llm"].
|
||||
run_series: si True (default False) calcula, para cada columna numerica,
|
||||
un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF,
|
||||
descomposicion STL y, si parece de niveles, conversion a retornos).
|
||||
Si hay una columna datetime se usa como orden cronologico; si no, se
|
||||
usa el orden fisico de filas (columna numerica secuencial). Los bloques
|
||||
se guardan por columna en col["series"] y agregados en prof["series"].
|
||||
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
|
||||
(legible en movil) del perfil junto al report markdown y devuelve su
|
||||
ruta en pdf_path.
|
||||
report_dir: directorio donde escribir los reports si write_report.
|
||||
Default "reports". Se crea si no existe.
|
||||
write_report: si True (default), escribe un report markdown + un JSON
|
||||
@@ -143,8 +307,8 @@ def profile_table(
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', profile: <TableProfile>,
|
||||
report_md_path: str|None, report_json_path: str|None}. En error (sin
|
||||
lanzar): {status:'error', error:str}.
|
||||
report_md_path: str|None, report_json_path: str|None, pdf_path: str|None}.
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
# 1) Perfil base por columna (push-down SQL) + lector read-only del
|
||||
@@ -195,6 +359,12 @@ def profile_table(
|
||||
if inferred == "numeric":
|
||||
vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
|
||||
col["numeric"] = describe_numeric(vals_float)
|
||||
# Re-expresion sugerida (escalera de Tukey): que transformacion
|
||||
# simetriza mejor la columna a partir de su skew/dominio. Solo para
|
||||
# columnas CONTINUAS: no aplica a binarias/ordinales de baja
|
||||
# cardinalidad ni a identificadores enteros (la fila seria ruido).
|
||||
if _is_continuous_for_reexpr(col, vals_float):
|
||||
col["reexpression"] = suggest_reexpression(col["numeric"])
|
||||
elif inferred in ("categorical", "text"):
|
||||
col["categorical"] = summarize_categorical(vals)
|
||||
# Para columnas no promovidas que ya eran categorical/text y no
|
||||
@@ -299,12 +469,53 @@ def profile_table(
|
||||
except Exception: # noqa: BLE001
|
||||
prof["llm"] = None
|
||||
|
||||
# 9) Reports opcionales.
|
||||
# 8.7) Analisis de serie temporal opt-in. Para cada columna numerica se
|
||||
# calcula estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF) y
|
||||
# descomposicion STL sobre la secuencia ordenada; si parece de niveles se
|
||||
# anade la conversion a retornos. Si hay una columna datetime se usa como
|
||||
# orden cronologico; si no, el orden fisico (columna numerica secuencial).
|
||||
if run_series:
|
||||
try:
|
||||
order_col = next(
|
||||
(
|
||||
c.get("name")
|
||||
for c in cols
|
||||
if c.get("inferred_type") == "datetime"
|
||||
),
|
||||
None,
|
||||
)
|
||||
series_map: dict = {}
|
||||
for col in cols:
|
||||
if col.get("inferred_type") != "numeric":
|
||||
continue
|
||||
try:
|
||||
sblock = _build_series_block(
|
||||
_q, table, col, order_col, sample
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
sblock = None
|
||||
if sblock is not None:
|
||||
col["series"] = sblock
|
||||
series_map[col["name"]] = sblock
|
||||
prof["series"] = series_map or None
|
||||
except Exception: # noqa: BLE001
|
||||
prof["series"] = None
|
||||
|
||||
# 8.8) Avisos exploratorios: recuerdan que el EDA genera hipotesis, no
|
||||
# conclusiones. Se calculan sobre el perfil ya completo (correlaciones,
|
||||
# modelos, outliers, faltantes determinan que advertencias aplican).
|
||||
try:
|
||||
prof["caveats"] = exploratory_caveats(prof)
|
||||
except Exception: # noqa: BLE001
|
||||
prof["caveats"] = None
|
||||
|
||||
# 9) Reports opcionales (markdown + JSON sidecar + PDF movil).
|
||||
report_md_path = None
|
||||
report_json_path = None
|
||||
pdf_path = None
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
if write_report:
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
report_json_path = os.path.join(report_dir, f"eda_{table}_{ts}.json")
|
||||
report_md_path = os.path.join(report_dir, f"eda_{table}_{ts}.md")
|
||||
with open(report_json_path, "w", encoding="utf-8") as fh:
|
||||
@@ -312,11 +523,22 @@ def profile_table(
|
||||
with open(report_md_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(render_eda_markdown(prof))
|
||||
|
||||
# PDF multipagina vertical (legible en movil), junto al report markdown.
|
||||
if emit_pdf:
|
||||
try:
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
pdf_target = os.path.join(report_dir, f"eda_{table}_{ts}.pdf")
|
||||
pres = render_eda_pdf(prof, pdf_target)
|
||||
pdf_path = pres.get("pdf_path")
|
||||
except Exception: # noqa: BLE001
|
||||
pdf_path = None
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"profile": prof,
|
||||
"report_md_path": report_md_path,
|
||||
"report_json_path": report_json_path,
|
||||
"pdf_path": pdf_path,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@@ -13,7 +13,112 @@ import tempfile
|
||||
|
||||
import duckdb
|
||||
|
||||
from pipelines.profile_table import profile_table
|
||||
from pipelines.profile_table import (
|
||||
_is_continuous_for_reexpr,
|
||||
_looks_financial,
|
||||
profile_table,
|
||||
)
|
||||
|
||||
|
||||
# --- H12: re-expresión solo para columnas continuas -------------------------
|
||||
|
||||
def test_is_continuous_for_reexpr_baja_cardinalidad():
|
||||
# Binaria (2 niveles) y ordinal de baja cardinalidad (3 niveles): NO continuas.
|
||||
binaria = {"distinct_count": 2, "flags": []}
|
||||
ordinal = {"distinct_count": 3, "flags": []}
|
||||
assert _is_continuous_for_reexpr(binaria, [0.0, 1.0, 0.0, 1.0]) is False
|
||||
assert _is_continuous_for_reexpr(ordinal, [1.0, 2.0, 3.0, 2.0]) is False
|
||||
|
||||
|
||||
def test_is_continuous_for_reexpr_id_entero():
|
||||
# Identificador entero (possible_id + todos enteros): NO continua.
|
||||
idcol = {"distinct_count": 200, "flags": ["possible_id"]}
|
||||
vals = [float(i) for i in range(1, 201)]
|
||||
assert _is_continuous_for_reexpr(idcol, vals) is False
|
||||
|
||||
|
||||
def test_is_continuous_for_reexpr_float_continuo():
|
||||
# Float continuo de alta cardinalidad, aunque lleve possible_id, SÍ es continuo
|
||||
# (tiene parte decimal, no es un id entero).
|
||||
precio = {"distinct_count": 200, "flags": ["possible_id"]}
|
||||
vals = [i * 1.7 for i in range(200)]
|
||||
assert _is_continuous_for_reexpr(precio, vals) is True
|
||||
|
||||
|
||||
def test_reexpression_solo_para_columnas_continuas():
|
||||
# En una tabla con binaria/ordinal/id/continua, solo la continua trae el bloque
|
||||
# reexpression en su ColumnProfile.
|
||||
tmp_dir = tempfile.mkdtemp(prefix="reexpr_test_")
|
||||
db_path = os.path.join(tmp_dir, "t.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute(
|
||||
"CREATE TABLE t (pid INTEGER, surv INTEGER, pclass INTEGER, fare DOUBLE)"
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO t SELECT i, i%2, (i%3)+1, ((i*1.7)%50)+0.3 "
|
||||
"FROM range(300) tbl(i)"
|
||||
)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "t", write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
prof = r["profile"]
|
||||
|
||||
assert _col(prof, "pid").get("reexpression") is None # id entero
|
||||
assert _col(prof, "surv").get("reexpression") is None # binaria
|
||||
assert _col(prof, "pclass").get("reexpression") is None # ordinal baja card
|
||||
assert _col(prof, "fare").get("reexpression") is not None # continua
|
||||
|
||||
|
||||
# --- H13: retornos (financiera) vs diferencias (física) ---------------------
|
||||
|
||||
def test_looks_financial_por_nombre_y_semantic():
|
||||
assert _looks_financial({"name": "Close"}) is True
|
||||
assert _looks_financial({"name": "Adj Close"}) is True
|
||||
assert _looks_financial({"name": "Volume"}) is True
|
||||
assert _looks_financial({"name": "precio_cierre"}) is True
|
||||
assert _looks_financial({"name": "temp_max"}) is False
|
||||
assert _looks_financial({"name": "precipitation"}) is False
|
||||
assert _looks_financial({"name": "caudal", "semantic_type": "currency"}) is True
|
||||
|
||||
|
||||
def _make_series_db(value_col: str) -> str:
|
||||
"""DuckDB con una serie de niveles no estacionaria (random walk creciente)."""
|
||||
tmp_dir = tempfile.mkdtemp(prefix="series_test_")
|
||||
db_path = os.path.join(tmp_dir, "s.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute(f'CREATE TABLE s (ts INTEGER, "{value_col}" DOUBLE)')
|
||||
# Niveles estrictamente positivos con tendencia creciente (no estacionaria).
|
||||
level = 100.0
|
||||
rows = []
|
||||
for t in range(80):
|
||||
level += 1.0 + (t % 7) * 0.3 # incrementos positivos deterministas
|
||||
rows.append((t, level))
|
||||
con.executemany(f'INSERT INTO s VALUES (?, ?)', rows)
|
||||
con.close()
|
||||
return db_path
|
||||
|
||||
|
||||
def test_series_financiera_sugiere_retornos():
|
||||
db_path = _make_series_db("close")
|
||||
r = profile_table(db_path, "s", run_series=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
s = _col(r["profile"], "close").get("series")
|
||||
assert s is not None
|
||||
if s.get("levels_suggested"):
|
||||
assert s.get("levels_kind") == "returns"
|
||||
|
||||
|
||||
def test_series_no_financiera_sugiere_diferencias():
|
||||
db_path = _make_series_db("temp_max")
|
||||
r = profile_table(db_path, "s", run_series=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
s = _col(r["profile"], "temp_max").get("series")
|
||||
assert s is not None
|
||||
if s.get("levels_suggested"):
|
||||
assert s.get("levels_kind") == "differences"
|
||||
# Para diferencias no se computa el bloque de retornos.
|
||||
assert "to_returns" not in s
|
||||
|
||||
|
||||
def _make_db() -> str:
|
||||
|
||||
@@ -19,7 +19,9 @@ dependencies = [
|
||||
"google-cloud-storage>=3.10.1",
|
||||
"httpx",
|
||||
"matplotlib>=3.10.9",
|
||||
"opencv-contrib-python-headless>=4.13.0.92",
|
||||
"openpyxl>=3.1.5",
|
||||
"pillow>=12.2.0",
|
||||
"polars>=1.40.1",
|
||||
"pymeshlab>=2025.7.post1",
|
||||
"pymssql>=2.3.13",
|
||||
@@ -27,6 +29,7 @@ dependencies = [
|
||||
"pyproj>=3.7.2",
|
||||
"python-docx>=1.2.0",
|
||||
"pyyaml>=6.0.3",
|
||||
"qrcode[pil]>=8.2",
|
||||
"rapidfuzz>=3.14.5",
|
||||
"reportlab>=4.5.0",
|
||||
"scikit-image>=0.26.0",
|
||||
@@ -34,6 +37,7 @@ dependencies = [
|
||||
"scipy>=1.17.1",
|
||||
"seaborn>=0.13.2",
|
||||
"shapely>=2.1.2",
|
||||
"statsmodels>=0.14.6",
|
||||
"trimesh>=4.12.2",
|
||||
"xlrd>=2.0.2",
|
||||
]
|
||||
|
||||
Generated
+88
@@ -900,7 +900,9 @@ dependencies = [
|
||||
{ name = "google-cloud-storage" },
|
||||
{ name = "httpx" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "opencv-contrib-python-headless" },
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pillow" },
|
||||
{ name = "polars" },
|
||||
{ name = "pymeshlab" },
|
||||
{ name = "pymssql" },
|
||||
@@ -908,6 +910,7 @@ dependencies = [
|
||||
{ name = "pyproj" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qrcode", extra = ["pil"] },
|
||||
{ name = "rapidfuzz" },
|
||||
{ name = "reportlab" },
|
||||
{ name = "scikit-image" },
|
||||
@@ -915,6 +918,7 @@ dependencies = [
|
||||
{ name = "scipy" },
|
||||
{ name = "seaborn" },
|
||||
{ name = "shapely" },
|
||||
{ name = "statsmodels" },
|
||||
{ name = "trimesh" },
|
||||
{ name = "xlrd" },
|
||||
]
|
||||
@@ -956,7 +960,9 @@ requires-dist = [
|
||||
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
|
||||
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.10.9" },
|
||||
{ name = "opencv-contrib-python-headless", specifier = ">=4.13.0.92" },
|
||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||
{ name = "pillow", specifier = ">=12.2.0" },
|
||||
{ name = "polars", specifier = ">=1.40.1" },
|
||||
{ name = "pymeshlab", specifier = ">=2025.7.post1" },
|
||||
{ name = "pymssql", specifier = ">=2.3.13" },
|
||||
@@ -964,6 +970,7 @@ requires-dist = [
|
||||
{ name = "pyproj", specifier = ">=3.7.2" },
|
||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
|
||||
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
||||
{ name = "reportlab", specifier = ">=4.5.0" },
|
||||
{ name = "scikit-image", specifier = ">=0.26.0" },
|
||||
@@ -971,6 +978,7 @@ requires-dist = [
|
||||
{ name = "scipy", specifier = ">=1.17.1" },
|
||||
{ name = "seaborn", specifier = ">=0.13.2" },
|
||||
{ name = "shapely", specifier = ">=2.1.2" },
|
||||
{ name = "statsmodels", specifier = ">=0.14.6" },
|
||||
{ name = "trimesh", specifier = ">=4.12.2" },
|
||||
{ name = "xlrd", specifier = ">=2.0.2" },
|
||||
]
|
||||
@@ -2945,6 +2953,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-contrib-python-headless"
|
||||
version = "4.13.0.92"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/b5/9af5b81d9279e9982e21dad52f8a6aec10f7c891ae1e3d3d1b3ce111f8e7/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:b0467988c2d56c283b00fb808e0b57f5db2e3ca7743164a3b3efc733bfa03d3a", size = 52041681, upload-time = "2026-02-05T07:01:39.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/42/f0aef27baf1f376007b018b00f6c304c42c20d31aa8491633c53b18912cb/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:79e503b77880d806a1b106ff8182c6f898347ccfd1db58ffc9a6369acc236c4c", size = 38830456, upload-time = "2026-02-05T07:01:56.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/84/e6b3568f9147b4f114e881fb0e733fd97bdca15452feba78b510351584d1/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:449c1f00a685a3a7dff8d6fa93a70fbfe0de5537c24358ea03a1d996d12b33e8", size = 39355323, upload-time = "2026-02-05T10:17:31.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/09/89714580c617cf6e9f66eed9137759fc017ab6ab093c2a03227e8ee19578/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c9b028adc04f6579f37227eb1d648bead14fd6fefc58da86df37c8320351f7bd", size = 62147375, upload-time = "2026-02-05T10:20:03.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/29/abdd2ff2f8f07e9aa37c70edc9987b8aa63730ae70957c378f6f2e9d72d2/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdd974b34801f24735d18b1057cfaab1698d5cb02c9bba01dab7dc47201f2ef6", size = 40840722, upload-time = "2026-02-05T10:21:27.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0a/3194fdf035ef5123bd8cc3e3ad1a96c1ddeeedd0fdd12aaa0d2cfeb1649a/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9e26469baed9069f627ea56fa46819690c4545580362071dd09f1dcf47a40f2f", size = 66610130, upload-time = "2026-02-05T10:23:32.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/4b/afe9b43c02b86b675a3d3ac6fc220473a88016e1acb487f5138efd2d2630/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:696a6dd84d309a499efc63644e375f035447b1da777faa2954f2dea7626cc0e7", size = 36708602, upload-time = "2026-02-05T07:02:36.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/22/9fdc70520eb915b46d816f9cc5415458b1bd114a65d7a7e657cbd9b863e5/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:dcbb12d04ae74f5dcd782e3b166e1894c6fbdfaaf30866588746205d2a0cde5a", size = 46345416, upload-time = "2026-02-05T07:02:33.446Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
@@ -3075,6 +3101,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "patsy"
|
||||
version = "1.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
@@ -4020,6 +4058,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pil = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.14.5"
|
||||
@@ -4822,6 +4877,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsmodels"
|
||||
version = "0.14.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pandas" },
|
||||
{ name = "patsy" },
|
||||
{ name = "scipy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sympy"
|
||||
version = "1.14.0"
|
||||
|
||||
Reference in New Issue
Block a user