Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a4a83717 | |||
| fcf5a4c6a3 | |||
| cb7a7fc1fd | |||
| 9cdde4a341 | |||
| 5501507588 | |||
| 88eabb0457 | |||
| ebb00d8a42 | |||
| e142ef026d | |||
| 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.
|
||||
@@ -3,11 +3,11 @@ name: launch_fleetclaude
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.5.0"
|
||||
version: "1.6.0"
|
||||
purity: impure
|
||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
|
||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
||||
description: "Entrypoint de FleetView: abre una ventana de terminal con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. La terminal se AUTO-DETECTA sin config por PC: kitty si esta instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY), si no Windows Terminal (wt.exe) en WSL adjuntando via wsl.exe. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher, wsl, windows-terminal]
|
||||
params:
|
||||
- name: --cwd
|
||||
desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)."
|
||||
@@ -19,7 +19,7 @@ params:
|
||||
desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)."
|
||||
- name: --cols
|
||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana de terminal 'FleetView' adjunta a ella (kitty o Windows Terminal segun auto-deteccion), desacoplada del shell padre. Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
||||
uses_functions:
|
||||
- supervise_fleetview_tui_bash_infra
|
||||
uses_types: []
|
||||
@@ -49,7 +49,7 @@ launch_fleetclaude --reuse
|
||||
launch_fleetclaude --session trabajo --cols 50
|
||||
```
|
||||
|
||||
Tras invocarlo aparece una ventana kitty titulada `FleetView (<perfil>)` con dos
|
||||
Tras invocarlo aparece una ventana de terminal titulada `FleetView (<perfil>)` con dos
|
||||
panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
|
||||
`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux
|
||||
aislados con su propia flota: puedes tener varias FleetView abiertas a la vez.
|
||||
@@ -78,12 +78,24 @@ al retomar el trabajo en el repo `fn_registry`.
|
||||
`respawn-pane` de alt+R y los Claude nuevos hereden el socket). `main.go` los
|
||||
lee con fallback a `fleet`. Por eso cada panel ve SOLO los Claude de su perfil
|
||||
(cruza la lista del sistema con los panes de su socket).
|
||||
- **Auto-deteccion de terminal (sin config por PC)**: en la ruta ventana-nueva el
|
||||
launcher elige terminal solo. (1) `kitty` instalado **y** display usable
|
||||
(`$DISPLAY`/`$WAYLAND_DISPLAY`) → kitty (escritorio Linux nativo o WSLg con
|
||||
kitty). (2) Si no, WSL con `wt.exe` en el PATH → Windows Terminal ejecutando
|
||||
`wsl.exe [-d $WSL_DISTRO_NAME] -- bash -lic 'tmux -L <perfil> attach ...'`.
|
||||
(3) Ninguna → error con las salidas posibles. Asi el MISMO `fleetclaude`
|
||||
funciona en un PC con kitty y en otro WSL sin kitty, cada uno elige su
|
||||
terminal. Causa raiz del sintoma "se lanza la flota pero no se ve": kitty no
|
||||
instalado en WSL hacia que la sesion tmux se creara sin ventana que la mostrara.
|
||||
- **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de
|
||||
una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de
|
||||
nesting); cae a la ruta kitty y abre una ventana nueva. Fuera de tmux y con
|
||||
TTY, reutiliza la terminal actual con `exec tmux attach`.
|
||||
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
||||
nesting); cae a la ruta ventana-nueva (auto-deteccion de terminal). Fuera de
|
||||
tmux y con TTY, reutiliza la terminal actual con `exec tmux attach`.
|
||||
- **kitty detached (setsid)**: la ventana kitty se lanza con `setsid ... &` para
|
||||
sobrevivir al cierre de la terminal que la invoco. La ventana de Windows
|
||||
Terminal (wt.exe) ya es un proceso Windows independiente del arbol Linux, asi
|
||||
que sobrevive sola (se lanza con `&`+`disown` desde un subshell con cwd `/mnt/c`
|
||||
para evitar el warning de wt.exe por cwd UNC `\\wsl.localhost\...`).
|
||||
- **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un
|
||||
`exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que
|
||||
relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se
|
||||
@@ -116,14 +128,23 @@ al retomar el trabajo en el repo `fn_registry`.
|
||||
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
|
||||
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
|
||||
conmutar de Claude redistribuyen el espacio.
|
||||
- **tmux siempre, kitty solo sin TTY**: `tmux` es obligatorio (aborta != 0 si
|
||||
falta). `kitty` solo se necesita en la ruta sin-TTY (atajo de escritorio, cron,
|
||||
script), donde abre una ventana nueva. Invocado desde una terminal interactiva
|
||||
(el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
|
||||
`exec tmux attach` y NO necesita kitty — util en WSL u hosts sin kitty.
|
||||
- **tmux siempre; terminal (kitty/wt.exe) solo sin TTY**: `tmux` es obligatorio
|
||||
(aborta != 0 si falta). Una terminal nueva (kitty o Windows Terminal) solo se
|
||||
necesita en la ruta sin-TTY (dentro de tmux, atajo de escritorio, cron, script),
|
||||
donde abre una ventana nueva. Invocado desde una terminal interactiva fuera de
|
||||
tmux (el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
|
||||
`exec tmux attach` y no necesita ni kitty ni wt.exe.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.6.0 (2026-06-29) — **auto-deteccion de terminal (kitty ↔ Windows Terminal)**.
|
||||
La ruta ventana-nueva ya no asume kitty: elige terminal segun el host. kitty si
|
||||
esta instalado y hay display (`$DISPLAY`/`$WAYLAND_DISPLAY`); si no, en WSL abre
|
||||
Windows Terminal (`wt.exe`) ejecutando `wsl.exe [-d $WSL_DISTRO_NAME] -- bash
|
||||
-lic 'tmux ... attach'`. Mismo `fleetclaude` en un PC con kitty y en otro WSL
|
||||
sin kitty. Arregla el sintoma "se lanza la flota pero no se ve": en WSL sin
|
||||
kitty la sesion tmux se creaba pero ninguna ventana la mostraba. wt.exe se
|
||||
lanza desde un subshell con cwd `/mnt/c` para evitar el warning por cwd UNC.
|
||||
- v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre
|
||||
`exec fleetview` (una sola vida), sino el bucle supervisor
|
||||
`supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su
|
||||
|
||||
@@ -294,31 +294,61 @@ USAGE
|
||||
$T set-hook -g window-layout-changed "resize-pane -t $left_pane -x $cols"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con
|
||||
# setsid, para que no muera al cerrar la terminal invocadora.
|
||||
# (Mismo patron que reboot_all_claudes para relanzar terminales.)
|
||||
# Adjuntar la sesion en una terminal, DESACOPLADA del shell padre para que
|
||||
# no muera al cerrar la terminal invocadora.
|
||||
# -----------------------------------------------------------------------
|
||||
# Adjuntar la sesion:
|
||||
# - Terminal interactiva y FUERA de tmux: convertir ESA terminal en el
|
||||
# panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
|
||||
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
|
||||
# - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir
|
||||
# una ventana kitty nueva desacoplada (setsid). No hacemos `attach`
|
||||
# una ventana de terminal NUEVA desacoplada. No hacemos `attach`
|
||||
# anidado dentro de otra sesion tmux (rompe / da el warning de nesting).
|
||||
if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then
|
||||
exec tmux -L "$session" attach -t "$session"
|
||||
fi
|
||||
# Ruta ventana-nueva: necesitamos kitty para abrirla.
|
||||
if ! command -v kitty >/dev/null 2>&1; then
|
||||
echo "launch_fleetclaude: kitty no esta instalado (necesario para abrir ventana nueva)." >&2
|
||||
echo "launch_fleetclaude: lanzalo desde una terminal interactiva fuera de tmux, o instala kitty." >&2
|
||||
return 1
|
||||
fi
|
||||
setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$session'."
|
||||
return 0
|
||||
# -----------------------------------------------------------------------
|
||||
# Ruta ventana-nueva: AUTO-DETECTAR la terminal disponible (sin config por
|
||||
# PC). El mismo `fleetclaude` funciona en un escritorio Linux con kitty y en
|
||||
# un WSL sin kitty pero con Windows Terminal.
|
||||
# 1. kitty instalado + display usable ($DISPLAY/$WAYLAND_DISPLAY) -> kitty
|
||||
# (escritorio Linux nativo, o WSLg con kitty instalado).
|
||||
# 2. WSL con wt.exe alcanzable -> Windows Terminal ejecutando wsl.exe que
|
||||
# adjunta la sesion tmux (PCs WSL sin kitty: la ventana kitty nunca
|
||||
# aparece sin una terminal Linux real, por eso "se lanza pero no se ve").
|
||||
# 3. Ninguna -> error claro con las dos salidas posibles.
|
||||
# -----------------------------------------------------------------------
|
||||
if command -v kitty >/dev/null 2>&1 && [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
|
||||
setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$session'."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v wt.exe >/dev/null 2>&1; then
|
||||
# bash -lic <attach> dentro de wsl.exe: login+interactive para que tmux y
|
||||
# el PATH del perfil esten disponibles en la ventana de Windows Terminal.
|
||||
local attach_cmd
|
||||
attach_cmd="tmux -L $(printf '%q' "$session") attach -t $(printf '%q' "$session")"
|
||||
local distro="${WSL_DISTRO_NAME:-}"
|
||||
local wsl_args=(wsl.exe)
|
||||
[[ -n "$distro" ]] && wsl_args+=(-d "$distro")
|
||||
wsl_args+=(-- bash -lic "$attach_cmd")
|
||||
# cd a una ruta Windows (/mnt/c) evita el warning de wt.exe por cwd UNC
|
||||
# (\\wsl.localhost\...). El cwd real de los panes lo fija la sesion tmux.
|
||||
( cd /mnt/c 2>/dev/null || cd /
|
||||
wt.exe new-tab --title "FleetView ($session)" "${wsl_args[@]}" </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true )
|
||||
echo "launch_fleetclaude: Windows Terminal 'FleetView ($session)' adjunta al perfil '$session' (WSL distro '${distro:-default}')."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "launch_fleetclaude: no hay terminal para abrir una ventana nueva." >&2
|
||||
echo "launch_fleetclaude: - escritorio Linux: instala kitty y exporta DISPLAY/WAYLAND_DISPLAY." >&2
|
||||
echo "launch_fleetclaude: - WSL: usa Windows Terminal (wt.exe debe estar en el PATH)." >&2
|
||||
echo "launch_fleetclaude: - o lanza fleetclaude desde una terminal interactiva fuera de tmux." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: "0173"
|
||||
title: "EDA: bugs críticos de correctitud estadística (outlier_pct ×100, distribution_type por-skew)"
|
||||
status: resuelto
|
||||
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).
|
||||
|
||||
|
||||
## Resolucion (2026-06-29, sesion /ausente)
|
||||
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: caf8c25d. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
id: "0174"
|
||||
title: "EDA series temporales: período estacional roto + correlación de niveles + to_returns ciego"
|
||||
status: resuelto
|
||||
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.
|
||||
|
||||
|
||||
## Resolucion (2026-06-29, sesion /ausente)
|
||||
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: e142ef02. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
id: "0175"
|
||||
title: "EDA relational: precisión de FK inference (falsos positivos) + filtrar VIEWs + test ATTACH"
|
||||
status: resuelto
|
||||
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.
|
||||
|
||||
|
||||
## Resolucion (2026-06-29, sesion /ausente)
|
||||
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: e142ef02. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
id: "0176"
|
||||
title: "EDA render: models/series/caveats en markdown+PDF + PDF para profile_database"
|
||||
status: resuelto
|
||||
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.
|
||||
|
||||
|
||||
## Resolucion (2026-06-29, sesion /ausente)
|
||||
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: c4cff5ed. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
id: "0177"
|
||||
title: "EDA tipos: id secuencial fuera de correlación/PCA + η² espurio por cardinalidad + re-expresión no-continuas"
|
||||
status: resuelto
|
||||
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.
|
||||
|
||||
|
||||
## Resolucion (2026-06-29, sesion /ausente)
|
||||
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: e142ef02. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||
@@ -0,0 +1,299 @@
|
||||
# AutomaticEDA — contrato de capítulos
|
||||
|
||||
Documento autoritativo para **escribir capítulos** del informe AutomaticEDA. Léelo
|
||||
entero antes de añadir un capítulo: define el modelo de bloques, la firma del builder,
|
||||
el versionado, dónde colocar el módulo, cómo se registra en el orden del documento, qué
|
||||
claves del `profile` consume cada capítulo y un ejemplo completo de capítulo de
|
||||
referencia (OVERVIEW).
|
||||
|
||||
AutomaticEDA es la capa intermedia entre **contenido** (lo que un capítulo quiere
|
||||
decir) y **formato de salida** (PDF móvil + PPTX para compartir). Un mismo documento por
|
||||
capítulos se renderiza a los dos formatos con garantía de **no-corte**: el texto se
|
||||
envuelve a líneas completas, las tablas largas se parten por filas repitiendo la
|
||||
cabecera, y figuras/imágenes se escalan para caber enteras.
|
||||
|
||||
- Código del motor: `python/functions/datascience/automatic_eda/` (paquete de soporte).
|
||||
- Funciones públicas del registry (grupo `eda`): `render_automatic_eda_pdf`,
|
||||
`render_automatic_eda_pptx`.
|
||||
- Sustituye evolutivamente a `render_eda_pdf` **de forma aditiva** (ese sigue activo en
|
||||
`profile_table(emit_pdf=True)`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Modelo de documento
|
||||
|
||||
```
|
||||
Document = list[Chapter]
|
||||
Chapter = { id: str, title: str, version: str, blocks: list[Block] }
|
||||
Block = Heading | Markdown | KVTable | DataTable | Figure | Image | Caption | Note
|
||||
```
|
||||
|
||||
Importa el modelo desde `datascience.automatic_eda.model` (o
|
||||
`from datascience.automatic_eda import ...`). Todos los bloques son dataclasses; los
|
||||
renderers también aceptan **dicts** con la clave `kind` (lectura defensiva: lo no
|
||||
reconocido se degrada a `Note`, nunca lanza).
|
||||
|
||||
### Bloques
|
||||
|
||||
| Bloque | Construcción | Qué hace en el render |
|
||||
|---|---|---|
|
||||
| `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento |
|
||||
| `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** |
|
||||
| `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve |
|
||||
| `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **se parte por filas repitiendo cabecera**; las celdas largas se envuelven dentro de su columna |
|
||||
| `Figure(fig=None, make=None, caption=None, height_in=None)` | una `matplotlib.figure.Figure` ya construida (`fig`) o un callable `make()->Figure` (perezoso) | se rasteriza y escala para caber entera (nunca recortada) |
|
||||
| `Image(path, caption=None, height_in=None)` | ruta a PNG/JPG | se escala para caber entera |
|
||||
| `Caption(text)` / `Note(text)` | texto auxiliar pequeño | pie/nota en gris; `Note` es además el fallback de lo desconocido |
|
||||
|
||||
### Subset de markdown soportado (`Markdown`)
|
||||
|
||||
`#`/`##`/`###` → headings; `-`/`*` → viñetas; líneas `| a | b |` consecutivas → una
|
||||
`DataTable`; línea en blanco → separación de párrafo; `**bold**`/`__bold__`/`` `code` ``
|
||||
→ se quitan los marcadores y se conserva el texto. Todo lo demás se renderiza tal cual.
|
||||
Garantía: ningún carácter se pierde; lo que no cabe se envuelve o pasa de página/slide.
|
||||
|
||||
---
|
||||
|
||||
## 2. Firma del builder de capítulo (OBLIGATORIA)
|
||||
|
||||
Cada capítulo es un módulo `python/functions/datascience/automatic_eda/chapters/<id>.py`
|
||||
que expone **dos** símbolos:
|
||||
|
||||
```python
|
||||
CHAPTER_VERSION = "1.0.0" # semver de generación del capítulo (ver §4)
|
||||
|
||||
def build_<id>(profile: dict, ctx: dict) -> "Chapter | None":
|
||||
"""Construye el capítulo desde el TableProfile y el contexto de presentación.
|
||||
|
||||
Devuelve None si el capítulo NO aplica a este dataset (p.ej. timeseries sin
|
||||
columna fecha). Lee SIEMPRE defensivamente con .get y NUNCA lanza.
|
||||
"""
|
||||
```
|
||||
|
||||
- El nombre de la función es exactamente `build_<id>` donde `<id>` es el del módulo y
|
||||
el de `CHAPTER_ORDER` (§3). Ej.: `chapters/num_distr.py` → `build_num_distr`.
|
||||
- Devuelve un `model.Chapter(id, title, version=CHAPTER_VERSION, blocks=[...])` o `None`.
|
||||
- Un capítulo que devuelve `None` o cuyos `blocks` quedan vacíos se omite del documento.
|
||||
|
||||
---
|
||||
|
||||
## 3. Registro y orden del documento
|
||||
|
||||
El orden canónico está **pre-declarado** en
|
||||
`python/functions/datascience/automatic_eda/chapters_registry.py`:
|
||||
|
||||
```python
|
||||
CHAPTER_ORDER = [
|
||||
"portada", "overview", "num_distr", "cat_distr", "calidad", "correlacion",
|
||||
"modelos", "analisis_llm", "timeseries", "geospatial", "agregacion",
|
||||
]
|
||||
```
|
||||
|
||||
`build_document(profile, ctx)` recorre este orden, importa perezosamente
|
||||
`chapters/<id>.py` y llama `build_<id>`. **Para añadir un capítulo NO se edita
|
||||
`chapters_registry.py`**: basta crear el módulo `chapters/<id>.py` (con su `<id>` ya en
|
||||
`CHAPTER_ORDER`) y aparecerá automáticamente en su posición. Esto permite que muchos
|
||||
agentes trabajen **en paralelo** sin contención: cada uno toca solo su archivo.
|
||||
|
||||
Si tu capítulo usa un `<id>` que aún no está en `CHAPTER_ORDER`, añádelo en la posición
|
||||
correcta (única edición compartida; coordínala con el orquestador).
|
||||
|
||||
`build_document` nunca lanza: un capítulo cuyo módulo no existe se salta, y uno que falla
|
||||
o devuelve `None` se omite.
|
||||
|
||||
---
|
||||
|
||||
## 4. Versionado por capítulo + manifiesto
|
||||
|
||||
- `CHAPTER_VERSION` (semver) identifica la **generación** del capítulo. Bumpéalo cuando
|
||||
cambies qué/cómo emite el capítulo (no en cada corrida). Se estampa en el pie de cada
|
||||
página/slide: `<Título> · v<version>`.
|
||||
- `ENGINE_VERSION` (en `model.py`) versiona el motor global.
|
||||
- Al renderizar se escribe `automatic_eda_manifest.json` junto a la salida:
|
||||
|
||||
```json
|
||||
{
|
||||
"engine": "AutomaticEDA",
|
||||
"engine_version": "1.0.0",
|
||||
"generated_at": "2026-06-30 12:20:56 UTC",
|
||||
"chapters": {
|
||||
"portada": { "version": "1.0.0", "n_pages": 1, "n_slides": 1 },
|
||||
"overview": { "version": "1.0.0", "n_pages": 2, "n_slides": 2 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Llamar a uno o ambos renderers crea/actualiza el manifiesto (read-modify-write
|
||||
defensivo). Esto habilita el **seguimiento y la mejora continua por capítulo**.
|
||||
|
||||
---
|
||||
|
||||
## 5. `ctx` — contexto de presentación
|
||||
|
||||
`ctx` lleva metadatos que **no están** en el `TableProfile` (lo aporta el caller via
|
||||
`meta['ctx']`). Claves convencionales (todas opcionales):
|
||||
|
||||
| Clave | Uso |
|
||||
|---|---|
|
||||
| `dataset_name` | nombre del dataset (portada). Default: `profile['table']` |
|
||||
| `source_origin` | de dónde viene el dataset (portada). Default: `profile['source']` |
|
||||
| `storage` | tecnología de almacenamiento (portada). Default: inferido de `source` |
|
||||
| `generated_at` | fecha de generación (portada/manifiesto). Default: `profiled_at`/ahora |
|
||||
| `description` | frase de descripción del dataset (portada) |
|
||||
| `granularity` | "Cada fila es…" (portada). Default: derivado de `key_candidates` |
|
||||
| `quality_criteria` | criterios del score de calidad (portada) |
|
||||
| `head_rows` | `list[dict]` con `df.head` (overview). Ver §7 |
|
||||
|
||||
Un capítulo puede definir y consumir sus propias claves `ctx` — documenta cuáles en su
|
||||
docstring.
|
||||
|
||||
---
|
||||
|
||||
## 6. Claves del `profile` que consume cada capítulo
|
||||
|
||||
El `TableProfile` lo produce `profile_table(...)["profile"]` (grupo `eda`). Claves de
|
||||
nivel superior: `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, models, series, caveats`.
|
||||
|
||||
Cada `columns[i]`: `name, inferred_type, semantic_type, physical_type, distinct_count,
|
||||
unique_pct, null_count, null_pct, empty_count, empty_pct, flags, quality_score,
|
||||
numeric{min,max,mean,median,std,variance,cv,iqr,skew,kurtosis,p1..p99,mode,n_outliers,
|
||||
outlier_pct,zero_pct,negative_pct,distribution_type,histogram[{lo,hi,count}]},
|
||||
categorical{top[{value,count,pct}],mode,n_distinct,entropy,imbalance,len_min/mean/max},
|
||||
reexpression, series{...}`.
|
||||
|
||||
| Capítulo | Claves del profile que consume |
|
||||
|---|---|
|
||||
| `portada` | `table, source, profiled_at, n_rows, n_cols, quality_score, key_candidates` + `ctx` |
|
||||
| `overview` | `columns[].{name,inferred_type,semantic_type,physical_type,null_pct,null_count,categorical.top,numeric.{min,median,max,mean,std}}`, `head_rows` (ver §7) |
|
||||
| `num_distr` (pendiente) | `columns[] numeric.{histogram,mean,median,std,outlier_pct,...}` |
|
||||
| `cat_distr` (pendiente) | `columns[] categorical.{top,entropy,imbalance}` |
|
||||
| `calidad` (pendiente) | `quality_score`, `columns[].{quality_score,flags,issues}`, `duplicate_*`, `null_cell_pct`, `constant_cols`, `all_null_cols` |
|
||||
| `correlacion` (pendiente) | `correlations.pairs[{a,b,value,method}]`, `correlations.levels_caveat` |
|
||||
| `modelos` (pendiente) | `models.{pca,kmeans,outliers,normality}` |
|
||||
| `analisis_llm` (pendiente) | `llm` |
|
||||
| `timeseries` (pendiente) | `series{col:{stationarity,acf_pacf,stl,levels_*}}` |
|
||||
| `geospatial` (pendiente) | columnas con `semantic_type` geográfico (lat/lon) |
|
||||
| `agregacion` (pendiente) | `columns[]` + agregados que la fase de cálculo añada |
|
||||
|
||||
---
|
||||
|
||||
## 7. Claves nuevas del profile que la fase de cálculo debe añadir
|
||||
|
||||
El `TableProfile` actual **no** trae estas claves; el capítulo OVERVIEW las consume y, si
|
||||
faltan, degrada honestamente (placeholder + derivación de valores reales). Para un
|
||||
overview completo, la fase de cálculo (otro agente) debe añadir:
|
||||
|
||||
- `profile['head_rows']`: `list[dict]` con las primeras N filas (`df.head`), una por
|
||||
dict `{columna: valor}`. Mientras tanto OVERVIEW muestra un placeholder.
|
||||
- `columns[i]['examples']`: `list` de hasta N valores **no nulos** crudos de la columna.
|
||||
Mientras tanto OVERVIEW deriva ejemplos de `categorical.top[].value` (categóricas) y de
|
||||
`numeric.{min,median,max}` (numéricas) — son valores reales, no inventados.
|
||||
|
||||
Sugerencia de implementación (no obligatoria en esta fase): una función del registry que
|
||||
muestree `head_rows`/`examples` desde DuckDB y las inyecte en el profile antes de
|
||||
renderizar (delegar a `fn-constructor`, tag `eda`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Ejemplo COMPLETO de capítulo de referencia (OVERVIEW)
|
||||
|
||||
Copia este patrón. Archivo real:
|
||||
`python/functions/datascience/automatic_eda/chapters/overview.py`.
|
||||
|
||||
```python
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "overview"
|
||||
CHAPTER_TITLE = "Overview"
|
||||
|
||||
def _fmt_num(v, d=3):
|
||||
# ... formateo defensivo (None -> "—", floats compactos) ...
|
||||
...
|
||||
|
||||
def _examples_for(col: dict) -> str:
|
||||
# 1) col['examples'] si existe; 2) categorical.top[].value;
|
||||
# 3) numeric.{min,median,max}. Nunca celda vacía ni inventada.
|
||||
...
|
||||
|
||||
def build_overview(profile: dict, ctx: dict):
|
||||
profile = profile or {}
|
||||
ctx = ctx or {}
|
||||
cols = profile.get("columns") or []
|
||||
if not cols and not (ctx.get("head_rows") or profile.get("head_rows")):
|
||||
return None # no aplica.
|
||||
|
||||
blocks = [
|
||||
model.Heading(text="Primeras filas (df.head)", level=2),
|
||||
_head_block(profile, ctx), # DataTable(df.head) o Note si falta head_rows.
|
||||
]
|
||||
cols_block = _columns_block(profile) # DataTable: nombre/tipo/nulos/ejemplos.
|
||||
if cols_block is not None:
|
||||
blocks.append(model.Heading(text="Diccionario de columnas", level=2))
|
||||
blocks.append(cols_block)
|
||||
desc_block = _describe_block(profile) # DataTable: mean/median/min/max/std.
|
||||
if desc_block is not None:
|
||||
blocks.append(model.Heading(text="Resumen estadístico numérico", level=2))
|
||||
blocks.append(desc_block)
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
```
|
||||
|
||||
Puntos clave que todo capítulo debe respetar:
|
||||
|
||||
1. **Lectura defensiva**: `profile.get(...)`, `or []`, comprobar `isinstance` — nunca
|
||||
asumir que una clave existe ni lanzar.
|
||||
2. **`None` si no aplica**: devuelve `None` (o `blocks` vacíos) cuando el dataset no tiene
|
||||
lo que el capítulo necesita.
|
||||
3. **No inventar**: si falta un dato (p.ej. `df.head`), muestra un placeholder honesto o
|
||||
deriva de valores reales del perfil; deja el hueco documentado.
|
||||
4. **Tablas vía `DataTable`**: deja que el renderer las parta y repita cabecera; no
|
||||
pre-pagines tú.
|
||||
5. **Figuras vía `Figure(make=...)`**: pásalas perezosas; las dibuja y escala el renderer.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cómo se prueba un capítulo
|
||||
|
||||
```python
|
||||
from datascience.automatic_eda import build_document, render_pdf, render_pptx
|
||||
chapters = build_document(profile, ctx={"dataset_name": "..."})
|
||||
render_pdf(chapters, "reports/x.pdf", {"title": "EDA"})
|
||||
render_pptx(chapters, "reports/x.pptx", {"title": "EDA"})
|
||||
```
|
||||
|
||||
O directo desde las funciones públicas con el profile entero (construyen los capítulos):
|
||||
|
||||
```python
|
||||
from datascience import render_automatic_eda_pdf, render_automatic_eda_pptx
|
||||
render_automatic_eda_pdf(profile, "reports/x.pdf", {"ctx": {...}})
|
||||
render_automatic_eda_pptx(profile, "reports/x.pptx", {"ctx": {...}})
|
||||
```
|
||||
|
||||
Añade un test self-contained por capítulo (perfil sintético, sin DuckDB) que verifique
|
||||
sus bloques presentes y el no-corte (texto largo intacto en la salida). Patrón:
|
||||
`render_automatic_eda_pdf_test.py`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Integración futura con `profile_table` (siguiente fase)
|
||||
|
||||
`profile_table(emit_pdf=True)` usa hoy `render_eda_pdf` (intacto). En la siguiente fase
|
||||
se añadirá `emit_automatic=True` (o se migrará `emit_pdf`) para que cada EDA emita
|
||||
**siempre** PDF + PPTX del motor AutomaticEDA desde el mismo profile:
|
||||
|
||||
```python
|
||||
# Bosquejo de la integración aditiva (NO activar si rompe los tests actuales):
|
||||
if emit_automatic:
|
||||
ctx = {"dataset_name": table, "source_origin": db_path, ...}
|
||||
render_automatic_eda_pdf(prof, os.path.join(report_dir, f"aeda_{table}_{ts}.pdf"),
|
||||
{"title": f"EDA — {table}", "ctx": ctx})
|
||||
render_automatic_eda_pptx(prof, os.path.join(report_dir, f"aeda_{table}_{ts}.pptx"),
|
||||
{"title": f"EDA — {table}", "ctx": ctx})
|
||||
```
|
||||
|
||||
Hasta entonces los renderers se invocan directamente sobre el `profile` que
|
||||
`profile_table` ya devuelve.
|
||||
@@ -68,7 +68,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` |
|
||||
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
|
||||
| [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza |
|
||||
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
|
||||
| [eda](eda.md) | 29 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
|
||||
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
||||
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
||||
|
||||
@@ -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,36 @@ 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. |
|
||||
| `render_automatic_eda_pdf_py_datascience` | impure | Motor **AutomaticEDA**: documento por CAPÍTULOS (modelo de bloques independiente del formato) → PDF A5 móvil que **nunca corta** texto/tablas/imágenes (tablas largas se parten repitiendo cabecera) + manifiesto versionado por capítulo. Acepta el `TableProfile` o capítulos del modelo. Aditivo, no reemplaza `render_eda_pdf`. |
|
||||
| `render_automatic_eda_pptx_py_datascience` | impure | Motor **AutomaticEDA** → PPTX 16:9 para **compartir** desde el mismo documento por capítulos; mismo principio anti-corte (continúa en slide `(cont.)`). Motor `python-pptx`. |
|
||||
|
||||
> **AutomaticEDA** (núcleo nuevo, fase de capítulos): separa contenido (capítulos/bloques) de formato (PDF móvil + PPTX). Para escribir un capítulo nuevo (NUM DISTR, CAT DISTR, CALIDAD, CORRELACIÓN, MODELOS, ANÁLISIS LLM, TIMESERIES, GEOSPATIAL, AGREGACIÓN) lee el contrato: **`docs/automatic_eda_contract.md`**. Código del motor en `python/functions/datascience/automatic_eda/`; capítulos de referencia: `portada`, `overview`.
|
||||
|
||||
### 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 +88,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 +122,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 +159,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,31 @@ 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
|
||||
from .render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||
from .render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||
|
||||
__all__ = [
|
||||
"render_automatic_eda_pdf",
|
||||
"render_automatic_eda_pptx",
|
||||
"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]",
|
||||
@@ -117,15 +223,30 @@ def association_matrix(
|
||||
)
|
||||
|
||||
def _skip(name: str) -> bool:
|
||||
"""True si la columna no aporta asociacion util (pocos validos o text ruidoso)."""
|
||||
"""True si la columna no aporta asociacion util (pocos validos, datetime o cat casi-unica)."""
|
||||
col = columns[name]
|
||||
vals = col.get("values", [])
|
||||
ctype = col.get("type", "categorical")
|
||||
numeric = _is_numeric_type(ctype)
|
||||
if _valid_count(vals, numeric) < 3:
|
||||
nvalid = _valid_count(vals, numeric)
|
||||
if nvalid < 3:
|
||||
return True
|
||||
# Texto de cardinalidad ~ n: identificadores/free-text, sin asociacion util.
|
||||
if ctype == "text" and n_rows > 0 and _cardinality(vals) >= 0.9 * n_rows:
|
||||
if numeric:
|
||||
return False
|
||||
# Datetime: indice temporal unico-ish por fila. Como categorica da
|
||||
# correlation_ratio (eta) ~= 1 trivial frente a cualquier numerica (cada
|
||||
# fecha es su propio grupo de un solo valor) y Cramer's V / MI inflados.
|
||||
# La estacionalidad/tendencia se analizan en el bloque de series, no aqui.
|
||||
if ctype == "datetime":
|
||||
return True
|
||||
# Grupos casi singleton: si el tamano medio de grupo (valores presentes /
|
||||
# cardinalidad) es < 1.5, la varianza intra-grupo ~= 0 y correlation_ratio
|
||||
# sale ~= 1 por artefacto determinista (no por azar: el FDR no protege).
|
||||
# Cubre ids/free-text (Ticket: 681 distintos sobre 891) y categoricas
|
||||
# dispersas con muchos nulos (Cabin: 147 distintos sobre 204 presentes).
|
||||
# Se mide sobre valores PRESENTES, no sobre n_rows, para captar las dispersas.
|
||||
card = _cardinality(vals)
|
||||
if card >= 2 and (nvalid / card) < 1.5:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -168,20 +289,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 +325,55 @@ def association_matrix(
|
||||
"method": method,
|
||||
"value": value,
|
||||
"extra": extra,
|
||||
"p_value": p_value,
|
||||
}
|
||||
)
|
||||
|
||||
# Correccion de comparaciones multiples sobre los p-valores disponibles.
|
||||
# Se pasa la lista completa (incluidos los None de pares sin test): la
|
||||
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
|
||||
fdr = fdr_correction(
|
||||
[pair["p_value"] for pair in pairs],
|
||||
alpha=alpha,
|
||||
method=fdr_method,
|
||||
)
|
||||
for pair, padj, rej in zip(
|
||||
pairs, fdr["p_values_adjusted"], fdr["reject"]
|
||||
):
|
||||
pair["p_value_adjusted"] = padj
|
||||
pair["significant"] = bool(rej)
|
||||
|
||||
def _relevance(pair: dict) -> float:
|
||||
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
||||
|
||||
strong = [
|
||||
pair
|
||||
for pair in pairs
|
||||
if abs(pair["value"]) >= strong_threshold
|
||||
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
||||
]
|
||||
def _is_strong(pair: dict) -> bool:
|
||||
# Condicion 1: magnitud por encima del umbral (necesaria).
|
||||
magnitude_ok = (
|
||||
abs(pair["value"]) >= strong_threshold
|
||||
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
||||
)
|
||||
if not magnitude_ok:
|
||||
return False
|
||||
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
|
||||
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
|
||||
# admiten por magnitud, ya que no hay p-valor que corregir.
|
||||
if pair["p_value"] is None:
|
||||
return True
|
||||
return pair["significant"]
|
||||
|
||||
strong = [pair for pair in pairs if _is_strong(pair)]
|
||||
strong.sort(key=_relevance, reverse=True)
|
||||
strong = strong[:top_n]
|
||||
|
||||
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"strong": strong,
|
||||
"methods_legend": legend,
|
||||
"n_tests": len(pairs),
|
||||
"multiple_testing": {
|
||||
"method": fdr_method,
|
||||
"alpha": alpha,
|
||||
"n_tests": fdr["n_tests"],
|
||||
"n_rejected": fdr["n_rejected"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -80,3 +80,141 @@ 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
|
||||
|
||||
|
||||
# --- H6: correlation_ratio espurio por cardinalidad casi-unica ---------------
|
||||
|
||||
def test_h6_categorica_casi_unica_excluida():
|
||||
# Una categorica con cardinalidad ~ n (id/free-text como Ticket) hace que cada
|
||||
# grupo tenga un solo valor -> varianza intra-grupo ~= 0 -> correlation_ratio
|
||||
# = 1 trivial. No debe aparecer ni evaluado ni como par fuerte.
|
||||
n = 60
|
||||
columns = {
|
||||
"ticket": {"values": [f"T{i}" for i in range(n)], "type": "categorical"},
|
||||
"fare": {"values": [float(i) * 1.3 for i in range(n)], "type": "numeric"},
|
||||
}
|
||||
result = association_matrix(columns)
|
||||
assert _find_pair(result["pairs"], "ticket", "fare") is None
|
||||
assert _find_pair(result["strong"], "ticket", "fare") is None
|
||||
|
||||
|
||||
def test_h6_categorica_dispersa_con_nulos_excluida():
|
||||
# Categorica dispersa con muchos None (como Cabin: 147 distintos sobre 204
|
||||
# presentes): los pocos presentes son casi todos distintos -> grupos singleton.
|
||||
# Se mide sobre valores PRESENTES, no sobre n filas, para captarla.
|
||||
vals = [f"C{i}" if i % 4 == 0 else None for i in range(80)] # ~20 presentes, distintos
|
||||
columns = {
|
||||
"cabin": {"values": vals, "type": "categorical"},
|
||||
"fare": {"values": [float(i) for i in range(80)], "type": "numeric"},
|
||||
}
|
||||
result = association_matrix(columns)
|
||||
assert _find_pair(result["pairs"], "cabin", "fare") is None
|
||||
|
||||
|
||||
def test_h6_datetime_excluido_de_pares():
|
||||
# Datetime es indice unico-ish por fila -> correlation_ratio = 1 espurio contra
|
||||
# cualquier numerica. Se excluye de los pares de asociacion (las series se
|
||||
# analizan aparte, no aqui).
|
||||
columns = {
|
||||
"date": {
|
||||
"values": [f"2020-01-{i + 1:02d}" for i in range(10)],
|
||||
"type": "datetime",
|
||||
},
|
||||
"value": {"values": [float(i) for i in range(10)], "type": "numeric"},
|
||||
}
|
||||
result = association_matrix(columns)
|
||||
assert _find_pair(result["pairs"], "date", "value") is None
|
||||
|
||||
|
||||
def test_h6_categorica_legitima_se_conserva():
|
||||
# Edge anti-sobrefiltrado: una categorica de baja cardinalidad (grupos grandes,
|
||||
# tamano medio >= 1.5) SIGUE evaluandose y su asociacion fuerte se conserva.
|
||||
columns = {
|
||||
"region": {
|
||||
"values": ["N", "N", "S", "S", "E", "E", "W", "W"],
|
||||
"type": "categorical",
|
||||
},
|
||||
"score": {
|
||||
"values": [10.0, 11.0, 50.0, 49.0, 90.0, 91.0, 30.0, 31.0],
|
||||
"type": "numeric",
|
||||
},
|
||||
}
|
||||
result = association_matrix(columns)
|
||||
assert _find_pair(result["pairs"], "region", "score") is not None
|
||||
assert _find_pair(result["strong"], "region", "score") is not None
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""AutomaticEDA — chapter-based, versioned EDA document with PDF + PPTX output.
|
||||
|
||||
Public surface (support package for the registry functions
|
||||
``render_automatic_eda_pdf`` and ``render_automatic_eda_pptx``):
|
||||
|
||||
- Document model: ``Heading``, ``Markdown``, ``KVTable``, ``DataTable``,
|
||||
``Figure``, ``Image``, ``Caption``, ``Note``, ``Chapter``; normalizers
|
||||
``as_blocks`` / ``as_chapters``; ``ENGINE_VERSION`` / ``ENGINE_NAME``.
|
||||
- ``build_document(profile, ctx)`` — assemble the ordered chapters of a profile.
|
||||
- ``render_pdf(chapters, out_path, meta)`` / ``render_pptx(...)`` — the two
|
||||
renderers (used by the public registry functions).
|
||||
- ``merge_manifest(...)`` — write/update the per-chapter version manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import ( # noqa: F401
|
||||
ENGINE_NAME,
|
||||
ENGINE_VERSION,
|
||||
Caption,
|
||||
Chapter,
|
||||
DataTable,
|
||||
Figure,
|
||||
Heading,
|
||||
Image,
|
||||
KVTable,
|
||||
Markdown,
|
||||
Note,
|
||||
as_blocks,
|
||||
as_chapters,
|
||||
merge_manifest,
|
||||
)
|
||||
from .chapters_registry import CHAPTER_ORDER, build_chapter, build_document # noqa: F401
|
||||
from .render_pdf_impl import render_pdf # noqa: F401
|
||||
from .render_pptx_impl import render_pptx # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"ENGINE_NAME",
|
||||
"ENGINE_VERSION",
|
||||
"Heading",
|
||||
"Markdown",
|
||||
"KVTable",
|
||||
"DataTable",
|
||||
"Figure",
|
||||
"Image",
|
||||
"Caption",
|
||||
"Note",
|
||||
"Chapter",
|
||||
"as_blocks",
|
||||
"as_chapters",
|
||||
"merge_manifest",
|
||||
"CHAPTER_ORDER",
|
||||
"build_chapter",
|
||||
"build_document",
|
||||
"render_pdf",
|
||||
"render_pptx",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""AutomaticEDA chapters.
|
||||
|
||||
Each chapter is a module ``<id>.py`` exposing ``build_<id>(profile, ctx) ->
|
||||
Chapter | None`` and a ``CHAPTER_VERSION`` constant. The canonical document
|
||||
order lives in :mod:`automatic_eda.chapters_registry`. Implemented today:
|
||||
``portada`` and ``overview`` (the reference chapters other agents copy).
|
||||
"""
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Numeric distributions chapter (NUM DISTR) for AutomaticEDA.
|
||||
|
||||
For every numeric column the chapter draws, as a single indivisible figure, a
|
||||
histogram with the **mean, median and ±1σ band drawn as reference lines** and a
|
||||
**Tukey boxplot right below it** sharing the same X axis — exactly the user
|
||||
requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
|
||||
so the renderers rasterize and scale it to fit a whole page/slide and nothing is
|
||||
ever cut; columns with many numerics simply flow across pages as small
|
||||
multiples.
|
||||
|
||||
Data comes from the ``eda`` group profile and is never recomputed here:
|
||||
|
||||
- ``columns[i]['numeric']`` (the output of ``describe_numeric``) gives
|
||||
``mean, median, std, min, max, p25, p75, iqr, n_outliers, outlier_pct,
|
||||
distribution_type`` and the ``histogram`` bins ``[{lo, hi, count}]``.
|
||||
- The boxplot five-number summary + Tukey 1.5·IQR fences are derived by the
|
||||
pure registry function ``build_boxplot_stats`` (group ``eda``); this chapter
|
||||
only consumes its output, it does not reimplement the statistics.
|
||||
|
||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||
Reads everything defensively (``.get``) and never raises: a column whose figure
|
||||
cannot be built is degraded to a short note instead of aborting the chapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
# Pure registry function (group ``eda``) that derives the Tukey boxplot stats
|
||||
# from a ``numeric`` sub-block. Imported defensively so the chapter still builds
|
||||
# (degrading the boxplot to a note) if the function is somehow unavailable.
|
||||
try:
|
||||
from datascience.build_boxplot_stats import build_boxplot_stats
|
||||
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
|
||||
build_boxplot_stats = None # type: ignore[assignment]
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "num_distr"
|
||||
CHAPTER_TITLE = "Distribuciones numéricas"
|
||||
|
||||
# Plain-Spanish gloss for every label ``detect_distribution_type`` can emit, so a
|
||||
# non-expert reader understands the shape and the suggested next step (MUST-4.3).
|
||||
_DIST_GLOSS = {
|
||||
"normal-ish": "aproximadamente simétrica (campana); media y mediana casi "
|
||||
"coinciden.",
|
||||
"right-skewed": "asimétrica a la derecha (cola larga hacia valores altos); "
|
||||
"la media supera a la mediana — considera una transformación "
|
||||
"logarítmica.",
|
||||
"left-skewed": "asimétrica a la izquierda (cola larga hacia valores bajos); "
|
||||
"la media queda por debajo de la mediana.",
|
||||
"heavy-tail": "colas pesadas (curtosis alta): más valores extremos de lo "
|
||||
"que esperaría una normal — vigila los outliers.",
|
||||
"lognormal-ish": "compatible con lognormal (simétrica al tomar logaritmos); "
|
||||
"la re-expresión log suele normalizarla.",
|
||||
"multimodal": "varios picos: probablemente mezcla de subgrupos — conviene "
|
||||
"segmentar antes de resumir con una sola media.",
|
||||
"discrete": "pocos valores distintos (discreta/ordinal); el histograma "
|
||||
"cuenta niveles, no un continuo.",
|
||||
"too_few_samples": "muestra demasiado pequeña para clasificar la forma con "
|
||||
"fiabilidad.",
|
||||
"other": "forma no encuadrada en las categorías estándar.",
|
||||
}
|
||||
|
||||
|
||||
def _fmt_num(value, decimals: int = 3) -> str:
|
||||
"""Compact, defensive number formatting shared with the other chapters."""
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, bool):
|
||||
return str(value)
|
||||
if isinstance(value, int):
|
||||
return f"{value:,}".replace(",", ".")
|
||||
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 _numeric_columns(profile: dict) -> list:
|
||||
"""Return the list of (name, numeric_dict) for columns with usable stats."""
|
||||
out = []
|
||||
for col in profile.get("columns") or []:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
if col.get("inferred_type") != "numeric":
|
||||
continue
|
||||
num = col.get("numeric")
|
||||
if not isinstance(num, dict) or not num:
|
||||
continue
|
||||
# A numeric block is renderable when it carries at least a center.
|
||||
if num.get("mean") is None and num.get("median") is None:
|
||||
continue
|
||||
out.append((col.get("name") or "(columna)", num))
|
||||
return out
|
||||
|
||||
|
||||
def _make_hist_box(name: str, numeric: dict, box: dict):
|
||||
"""Build the histogram (with mean/median/±σ lines) + boxplot figure.
|
||||
|
||||
Returned lazily to the renderer (a zero-arg callable via ``Figure.make``) so
|
||||
matplotlib is only imported and the figure only drawn when a renderer needs
|
||||
it. The two stacked axes share the X axis and are produced as a single
|
||||
figure, which both renderers treat as one indivisible unit (scaled whole,
|
||||
never cut).
|
||||
"""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, (ax_h, ax_b) = plt.subplots(
|
||||
2, 1, figsize=(6.4, 3.4), sharex=True,
|
||||
gridspec_kw={"height_ratios": [3.2, 1.0], "hspace": 0.08})
|
||||
|
||||
# ---- Histogram from the precomputed equal-width bins {lo, hi, count}. ----
|
||||
hist = numeric.get("histogram") or []
|
||||
drew_bars = False
|
||||
for b in hist:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
lo = b.get("lo")
|
||||
hi = b.get("hi")
|
||||
count = b.get("count") or 0
|
||||
if lo is None or hi is None:
|
||||
continue
|
||||
width = (hi - lo) if hi > lo else max(abs(lo) * 1e-3, 1e-6)
|
||||
ax_h.bar(lo, count, width=width, align="edge", color="#9ec6df",
|
||||
edgecolor="#5b8aa6", linewidth=0.4, zorder=2)
|
||||
drew_bars = True
|
||||
if not drew_bars:
|
||||
ax_h.text(0.5, 0.5, "(sin histograma)", ha="center", va="center",
|
||||
fontsize=9, color="#8a8a8a", transform=ax_h.transAxes)
|
||||
|
||||
mean = numeric.get("mean")
|
||||
median = numeric.get("median")
|
||||
std = numeric.get("std")
|
||||
|
||||
# ±1σ band first (behind the lines), then median (solid) and mean (dashed).
|
||||
if mean is not None and std is not None and std > 0:
|
||||
ax_h.axvspan(mean - std, mean + std, color="#f0c27b", alpha=0.22,
|
||||
zorder=1, label="±1σ")
|
||||
if median is not None:
|
||||
ax_h.axvline(median, color="#2e8b57", linestyle="-", linewidth=1.6,
|
||||
zorder=4, label=f"mediana = {_fmt_num(median)}")
|
||||
if mean is not None:
|
||||
ax_h.axvline(mean, color="#c0392b", linestyle="--", linewidth=1.6,
|
||||
zorder=4, label=f"media = {_fmt_num(mean)}")
|
||||
|
||||
ax_h.set_ylabel("frecuencia", fontsize=8)
|
||||
ax_h.tick_params(labelsize=7)
|
||||
ax_h.legend(fontsize=6.5, loc="upper right", framealpha=0.85)
|
||||
for spine in ("top", "right"):
|
||||
ax_h.spines[spine].set_visible(False)
|
||||
|
||||
# ---- Tukey boxplot below, sharing the X axis (MUST-4.2). ----
|
||||
if box:
|
||||
stats = [{
|
||||
"med": box.get("median"),
|
||||
"q1": box.get("q1"),
|
||||
"q3": box.get("q3"),
|
||||
"whislo": box.get("whisker_lo"),
|
||||
"whishi": box.get("whisker_hi"),
|
||||
"fliers": [], # raw outlier values are not in the profile.
|
||||
"label": "",
|
||||
}]
|
||||
bxp_kw = dict(
|
||||
showfliers=False, widths=0.5, patch_artist=True,
|
||||
boxprops={"facecolor": "#9ec6df", "edgecolor": "#5b8aa6"},
|
||||
medianprops={"color": "#2e8b57", "linewidth": 1.6},
|
||||
whiskerprops={"color": "#5b8aa6"},
|
||||
capprops={"color": "#5b8aa6"})
|
||||
try:
|
||||
# ``orientation`` is the current API; older matplotlib uses ``vert``.
|
||||
try:
|
||||
ax_b.bxp(stats, orientation="horizontal", **bxp_kw)
|
||||
except TypeError:
|
||||
ax_b.bxp(stats, vert=False, **bxp_kw)
|
||||
except Exception: # noqa: BLE001 — never let one axis kill the figure.
|
||||
pass
|
||||
# Mark the presence of out-of-fence points (the raw values are unknown).
|
||||
if box.get("has_low_outliers") and box.get("min") is not None:
|
||||
ax_b.plot([box["min"]], [1], marker="o", markersize=3.5,
|
||||
color="#c0392b", zorder=5)
|
||||
if box.get("has_high_outliers") and box.get("max") is not None:
|
||||
ax_b.plot([box["max"]], [1], marker="o", markersize=3.5,
|
||||
color="#c0392b", zorder=5)
|
||||
else:
|
||||
ax_b.text(0.5, 0.5, "(boxplot no disponible)", ha="center", va="center",
|
||||
fontsize=8, color="#8a8a8a", transform=ax_b.transAxes)
|
||||
|
||||
ax_b.set_yticks([])
|
||||
ax_b.set_xlabel(name, fontsize=8)
|
||||
ax_b.tick_params(labelsize=7)
|
||||
for spine in ("top", "right", "left"):
|
||||
ax_b.spines[spine].set_visible(False)
|
||||
|
||||
fig.suptitle(name, fontsize=10, fontweight="bold", x=0.02, ha="left")
|
||||
return fig
|
||||
|
||||
|
||||
def _stats_note(name: str, numeric: dict, box: dict) -> str:
|
||||
"""One compact line of the key numbers + a plain-Spanish shape gloss."""
|
||||
bits = [
|
||||
f"media {_fmt_num(numeric.get('mean'))}",
|
||||
f"mediana {_fmt_num(numeric.get('median'))}",
|
||||
f"σ {_fmt_num(numeric.get('std'))}",
|
||||
f"min {_fmt_num(numeric.get('min'))}",
|
||||
f"max {_fmt_num(numeric.get('max'))}",
|
||||
f"IQR {_fmt_num(numeric.get('iqr'))}",
|
||||
]
|
||||
n_out = numeric.get("n_outliers")
|
||||
out_pct = numeric.get("outlier_pct")
|
||||
if n_out is not None:
|
||||
pct = f" ({_fmt_num(out_pct, 2)}%)" if out_pct is not None else ""
|
||||
bits.append(f"outliers {n_out}{pct}")
|
||||
if box and (box.get("lower_fence") is not None):
|
||||
bits.append(
|
||||
f"vallas Tukey [{_fmt_num(box.get('lower_fence'))}, "
|
||||
f"{_fmt_num(box.get('upper_fence'))}]")
|
||||
line = " · ".join(bits)
|
||||
|
||||
dist = numeric.get("distribution_type")
|
||||
gloss = _DIST_GLOSS.get(dist)
|
||||
if dist and gloss:
|
||||
line += f"\n\n**Forma ({dist}):** {gloss}"
|
||||
return line
|
||||
|
||||
|
||||
def _figure_maker(name: str, numeric: dict, box: dict):
|
||||
"""Bind the per-column arguments so the lazy closure is loop-safe."""
|
||||
def _make():
|
||||
return _make_hist_box(name, numeric, box)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
def build_num_distr(profile: dict, ctx: dict):
|
||||
"""Build the numeric-distributions Chapter, or None if no numeric column.
|
||||
|
||||
Args:
|
||||
profile: the ``eda`` group TableProfile dict.
|
||||
ctx: presentation context (unused here beyond defensive handling).
|
||||
|
||||
Returns:
|
||||
A ``model.Chapter`` with, per numeric column, a histogram+boxplot figure
|
||||
and a stats note; or ``None`` when the dataset has no numeric column.
|
||||
"""
|
||||
profile = profile or {}
|
||||
ctx = ctx or {}
|
||||
|
||||
numerics = _numeric_columns(profile)
|
||||
if not numerics:
|
||||
return None # chapter does not apply to a dataset with no numerics.
|
||||
|
||||
intro = (
|
||||
"Para cada columna numérica se muestra su **histograma** con tres líneas "
|
||||
"de referencia: la **media** (línea roja discontinua), la **mediana** "
|
||||
"(línea verde continua) y la banda **±1σ** (zona sombreada). Debajo, "
|
||||
"alineado al mismo eje, un **boxplot de Tukey**: la caja abarca del "
|
||||
"primer al tercer cuartil (P25–P75), la línea interior es la mediana y "
|
||||
"los bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay "
|
||||
"valores más allá de las vallas. Comparar media y mediana revela la "
|
||||
"asimetría de la distribución.")
|
||||
|
||||
blocks = [
|
||||
model.Heading(text=CHAPTER_TITLE, level=1),
|
||||
model.Markdown(text=intro),
|
||||
]
|
||||
|
||||
for name, numeric in numerics:
|
||||
box = {}
|
||||
if build_boxplot_stats is not None:
|
||||
try:
|
||||
box = build_boxplot_stats(numeric) or {}
|
||||
except Exception: # noqa: BLE001 — degrade, never raise.
|
||||
box = {}
|
||||
blocks.append(model.Heading(text=str(name), level=2))
|
||||
blocks.append(model.Figure(
|
||||
make=_figure_maker(name, numeric, box),
|
||||
caption=f"Distribución de «{name}» — histograma (media/mediana/±σ) "
|
||||
f"y boxplot."))
|
||||
blocks.append(model.Markdown(text=_stats_note(name, numeric, box)))
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for the NUM DISTR chapter — DoD: golden + edges + anti-cut.
|
||||
|
||||
Self-contained: builds synthetic ``numeric`` blocks (no DuckDB) so the suite is
|
||||
fast and deterministic. Verifies that the chapter emits, per numeric column, a
|
||||
histogram+boxplot figure plus a stats note; that the mean/median/±σ requirement
|
||||
and the boxplot are present; that a profile with no numeric column yields None;
|
||||
that None/empty never raises; and that with many numeric columns and long text
|
||||
both the PDF and the PPTX render without cutting anything (every column heading
|
||||
survives in the rendered output).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from datascience.automatic_eda.chapters.num_distr import (
|
||||
build_num_distr, CHAPTER_VERSION, _DIST_GLOSS,
|
||||
)
|
||||
from datascience.automatic_eda import model
|
||||
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||
|
||||
|
||||
def _numeric_block(mean, median, std, mn, mx, dist="normal-ish",
|
||||
n_outliers=0, nbins=10):
|
||||
"""A synthetic ``numeric`` sub-block shaped like describe_numeric's output."""
|
||||
width = (mx - mn) / nbins if mx > mn else 1.0
|
||||
hist = [{"lo": mn + i * width, "hi": mn + (i + 1) * width,
|
||||
"count": (i + 1) * 3} for i in range(nbins)]
|
||||
p25 = mn + (mx - mn) * 0.25
|
||||
p75 = mn + (mx - mn) * 0.75
|
||||
return {
|
||||
"min": mn, "max": mx, "mean": mean, "median": median, "std": std,
|
||||
"p25": p25, "p50": median, "p75": p75, "iqr": p75 - p25,
|
||||
"n_outliers": n_outliers, "outlier_pct": 100.0 * n_outliers / 300.0,
|
||||
"distribution_type": dist, "histogram": hist,
|
||||
}
|
||||
|
||||
|
||||
def _profile(n_numeric=2, extra_categorical=True):
|
||||
cols = []
|
||||
presets = [
|
||||
("precio", 42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5),
|
||||
("alcohol", 10.4, 10.3, 1.1, 8.0, 14.9, "normal-ish", 0),
|
||||
("sulfatos", 0.66, 0.62, 0.17, 0.33, 2.0, "heavy-tail", 9),
|
||||
("calidad", 5.6, 6.0, 0.8, 3.0, 8.0, "discrete", 0),
|
||||
]
|
||||
for i in range(n_numeric):
|
||||
name, mean, med, std, mn, mx, dist, no = presets[i % len(presets)]
|
||||
if i >= len(presets):
|
||||
name = f"{name}_{i}"
|
||||
cols.append({"name": name, "inferred_type": "numeric",
|
||||
"numeric": _numeric_block(mean, med, std, mn, mx, dist, no)})
|
||||
if extra_categorical:
|
||||
cols.append({"name": "categoria", "inferred_type": "categorical",
|
||||
"categorical": {"top": [{"value": "tinto", "count": 200}]}})
|
||||
return {"table": "vinos", "n_rows": 300, "n_cols": len(cols),
|
||||
"columns": cols}
|
||||
|
||||
|
||||
def _pdf_text(path: str) -> str:
|
||||
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
|
||||
return re.sub(r"\s+", " ", txt)
|
||||
|
||||
|
||||
def test_golden_chapter_estructura_y_bloques():
|
||||
ch = build_num_distr(_profile(n_numeric=2), {})
|
||||
assert ch is not None
|
||||
assert ch.id == "num_distr"
|
||||
assert ch.version == CHAPTER_VERSION
|
||||
kinds = [b.kind for b in ch.blocks]
|
||||
# Heading + intro Markdown, then per column: Heading + Figure + Markdown.
|
||||
assert kinds[0] == "heading"
|
||||
assert kinds[1] == "markdown"
|
||||
assert kinds.count("figure") == 2 # one figure per numeric column.
|
||||
assert kinds.count("heading") == 1 + 2 # chapter title + one per column.
|
||||
# Each figure has a lazy maker that produces a real matplotlib figure.
|
||||
figs = [b for b in ch.blocks if b.kind == "figure"]
|
||||
fig = figs[0].make()
|
||||
assert fig is not None
|
||||
# Two stacked axes: histogram + boxplot share the figure.
|
||||
assert len(fig.axes) == 2
|
||||
import matplotlib.pyplot as plt
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_golden_media_mediana_sigma_y_boxplot_presentes():
|
||||
# The intro documents the three reference lines and the Tukey boxplot; the
|
||||
# per-column note carries the actual mean/median/σ numbers and the shape.
|
||||
ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
|
||||
md_texts = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||
assert "media" in md_texts and "mediana" in md_texts
|
||||
assert "±1σ" in md_texts or "σ" in md_texts
|
||||
assert "boxplot" in md_texts.lower()
|
||||
assert "Tukey" in md_texts
|
||||
# distribution_type gloss surfaced for the column (right-skewed preset).
|
||||
assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts
|
||||
|
||||
|
||||
def test_boxplot_stats_se_consumen_del_registry():
|
||||
# The chapter must feed build_boxplot_stats (group eda) and the resulting
|
||||
# box must carry the Tukey fences for the figure.
|
||||
from datascience.build_boxplot_stats import build_boxplot_stats
|
||||
box = build_boxplot_stats(
|
||||
_numeric_block(42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5))
|
||||
assert box
|
||||
assert "lower_fence" in box and "upper_fence" in box
|
||||
assert box["q1"] is not None and box["q3"] is not None
|
||||
|
||||
|
||||
def test_edge_sin_columnas_numericas_devuelve_none():
|
||||
prof = {"columns": [{"name": "c", "inferred_type": "categorical",
|
||||
"categorical": {"top": []}}]}
|
||||
assert build_num_distr(prof, {}) is None
|
||||
|
||||
|
||||
def test_edge_profile_none_y_vacio_no_revienta():
|
||||
assert build_num_distr(None, None) is None
|
||||
assert build_num_distr({}, {}) is None
|
||||
assert build_num_distr({"columns": []}, {}) is None
|
||||
|
||||
|
||||
def test_anti_corte_muchas_columnas_pdf_y_pptx():
|
||||
# 8 numeric columns + long note text: nothing may be cut. Every column
|
||||
# heading must survive in both the PDF text and the PPTX deck.
|
||||
ch = build_num_distr(_profile(n_numeric=8), {})
|
||||
names = [b.text for b in ch.blocks if b.kind == "heading" and b.level == 2]
|
||||
assert len(names) == 8
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
pdf = os.path.join(d, "num.pdf")
|
||||
res_pdf = render_automatic_eda_pdf(_profile(n_numeric=8), pdf,
|
||||
{"write_manifest": False})
|
||||
assert res_pdf["path"] == pdf
|
||||
txt = _pdf_text(pdf)
|
||||
for name in names:
|
||||
assert name in txt, f"columna '{name}' cortada/ausente en el PDF"
|
||||
pptx = os.path.join(d, "num.pptx")
|
||||
res_pptx = render_automatic_eda_pptx(_profile(n_numeric=8), pptx,
|
||||
{"write_manifest": False})
|
||||
assert res_pptx["path"] == pptx
|
||||
assert res_pptx["n_slides"] >= 8 # at least one slide per column figure.
|
||||
|
||||
|
||||
def test_distribution_gloss_cubre_todas_las_etiquetas():
|
||||
# Every label detect_distribution_type can emit has a Spanish gloss.
|
||||
for label in ("normal-ish", "right-skewed", "left-skewed", "heavy-tail",
|
||||
"lognormal-ish", "multimodal", "discrete", "too_few_samples",
|
||||
"other"):
|
||||
assert label in _DIST_GLOSS and _DIST_GLOSS[label]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Overview chapter — df.head, column dictionary and describe (reference).
|
||||
|
||||
Second reference chapter for AutomaticEDA. Renders (across as many pages/slides
|
||||
as needed, the renderers paginate):
|
||||
|
||||
1. ``df.head`` — the first rows of the table. The current ``TableProfile`` does
|
||||
NOT carry the raw head, so this is read from ``ctx['head_rows']`` /
|
||||
``profile['head_rows']`` (a list of row dicts). When absent the chapter shows
|
||||
an honest placeholder documenting the missing key instead of inventing data.
|
||||
2. Column dictionary — name / type / nulls / non-null examples. Examples come
|
||||
from ``columns[i]['examples']`` when present; otherwise they are derived from
|
||||
real non-null profile values (categorical top values, numeric min/median/max)
|
||||
so the cell is never empty nor fabricated.
|
||||
3. ``df.describe`` — mean / median / min / max / std for every numeric column.
|
||||
|
||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "overview"
|
||||
CHAPTER_TITLE = "Overview"
|
||||
|
||||
# Profile/ctx keys the calculation phase must add for a full head + examples.
|
||||
HEAD_KEY = "head_rows" # list[dict] — df.head(n)
|
||||
EXAMPLES_KEY = "examples" # per column: list of non-null sample values
|
||||
|
||||
|
||||
def _fmt_num(value, decimals: int = 3) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, bool):
|
||||
return str(value)
|
||||
if isinstance(value, int):
|
||||
return f"{value:,}".replace(",", ".")
|
||||
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:
|
||||
if value is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(value) * 100:.{decimals}f}%"
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _examples_for(col: dict) -> str:
|
||||
"""Build a short string of real non-null example values for a column."""
|
||||
explicit = col.get(EXAMPLES_KEY)
|
||||
if isinstance(explicit, (list, tuple)) and explicit:
|
||||
return ", ".join(model._safe_str(v) for v in explicit[:4])
|
||||
cat = col.get("categorical") or {}
|
||||
top = cat.get("top") or []
|
||||
if top:
|
||||
vals = [model._safe_str((t or {}).get("value")) for t in top[:4]
|
||||
if isinstance(t, dict)]
|
||||
vals = [v for v in vals if v]
|
||||
if vals:
|
||||
return ", ".join(vals)
|
||||
num = col.get("numeric") or {}
|
||||
if num:
|
||||
bits = []
|
||||
for key in ("min", "median", "max"):
|
||||
v = num.get(key)
|
||||
if v is not None:
|
||||
bits.append(_fmt_num(v))
|
||||
if bits:
|
||||
return ", ".join(bits)
|
||||
return "—"
|
||||
|
||||
|
||||
def _head_block(profile: dict, ctx: dict):
|
||||
"""Return a DataTable for df.head, or a Note documenting the missing key."""
|
||||
head = ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)
|
||||
if isinstance(head, list) and head and isinstance(head[0], dict):
|
||||
# Column order from the profile, then any extra keys present in rows.
|
||||
cols = [c.get("name") for c in (profile.get("columns") or [])
|
||||
if c.get("name")]
|
||||
if not cols:
|
||||
cols = list(head[0].keys())
|
||||
rows = [[model._safe_str(r.get(c)) for c in cols] for r in head[:10]]
|
||||
return model.DataTable(header=cols, rows=rows,
|
||||
note=f"primeras {len(rows)} filas")
|
||||
return model.Note(
|
||||
"df.head no disponible: el TableProfile no incluye 'head_rows'. La fase "
|
||||
"de cálculo debe añadir profile['head_rows'] (lista de dicts fila) o "
|
||||
"pasarlo en ctx['head_rows'] para mostrar las primeras filas.")
|
||||
|
||||
|
||||
def _columns_block(profile: dict):
|
||||
cols = profile.get("columns") or []
|
||||
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)"]
|
||||
rows = []
|
||||
for c in cols:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
name = c.get("name") or "(col)"
|
||||
ctype = c.get("inferred_type") or c.get("physical_type") or "—"
|
||||
sem = c.get("semantic_type")
|
||||
if sem:
|
||||
ctype = f"{ctype} ({sem})"
|
||||
null_pct = c.get("null_pct")
|
||||
null_count = c.get("null_count")
|
||||
if null_pct is not None:
|
||||
nulls = _fmt_pct(null_pct)
|
||||
if null_count is not None:
|
||||
nulls += f" ({null_count})"
|
||||
elif null_count is not None:
|
||||
nulls = str(null_count)
|
||||
else:
|
||||
nulls = "—"
|
||||
rows.append([name, ctype, nulls, _examples_for(c)])
|
||||
if not rows:
|
||||
return None
|
||||
return model.DataTable(header=header, rows=rows, title="Columnas")
|
||||
|
||||
|
||||
def _describe_block(profile: dict):
|
||||
cols = profile.get("columns") or []
|
||||
header = ["Columna", "mean", "median", "min", "max", "std"]
|
||||
rows = []
|
||||
for c in cols:
|
||||
if not isinstance(c, dict) or c.get("inferred_type") != "numeric":
|
||||
continue
|
||||
num = c.get("numeric") or {}
|
||||
if not num:
|
||||
continue
|
||||
rows.append([
|
||||
c.get("name") or "(col)",
|
||||
_fmt_num(num.get("mean")),
|
||||
_fmt_num(num.get("median")),
|
||||
_fmt_num(num.get("min")),
|
||||
_fmt_num(num.get("max")),
|
||||
_fmt_num(num.get("std")),
|
||||
])
|
||||
if not rows:
|
||||
return None
|
||||
return model.DataTable(header=header, rows=rows, title="Estadística (describe)")
|
||||
|
||||
|
||||
def build_overview(profile: dict, ctx: dict):
|
||||
"""Build the Overview Chapter, or None if the profile has no columns."""
|
||||
profile = profile or {}
|
||||
ctx = ctx or {}
|
||||
cols = profile.get("columns") or []
|
||||
if not cols and not (ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)):
|
||||
return None
|
||||
|
||||
blocks = [
|
||||
model.Heading(text="Primeras filas (df.head)", level=2),
|
||||
_head_block(profile, ctx),
|
||||
]
|
||||
cols_block = _columns_block(profile)
|
||||
if cols_block is not None:
|
||||
blocks.append(model.Heading(
|
||||
text="Diccionario de columnas", level=2))
|
||||
blocks.append(cols_block)
|
||||
desc_block = _describe_block(profile)
|
||||
if desc_block is not None:
|
||||
blocks.append(model.Heading(
|
||||
text="Resumen estadístico numérico", level=2))
|
||||
blocks.append(desc_block)
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Cover chapter (PORTADA) — the reference chapter for AutomaticEDA.
|
||||
|
||||
Builds the document cover from a TableProfile plus an optional ``ctx`` of
|
||||
presentation metadata. Reads everything defensively (``.get``) and degrades
|
||||
honestly: a field that is neither in the profile nor in ``ctx`` is shown as a
|
||||
placeholder rather than invented, leaving a hook for the LLM layer to fill it.
|
||||
|
||||
Contract for chapter authors (see ``docs/capabilities/automatic_eda.md``):
|
||||
build_<id>(profile: dict, ctx: dict) -> Chapter | None
|
||||
CHAPTER_VERSION = "x.y.z"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "portada"
|
||||
CHAPTER_TITLE = "Portada"
|
||||
|
||||
# Default human description of what the table quality score measures. Chapters
|
||||
# can override it via ctx["quality_criteria"].
|
||||
_DEFAULT_QUALITY_CRITERIA = (
|
||||
"media de los scores por columna (0–100): completitud (sin nulos/vacíos), "
|
||||
"validez (tipo y rango coherentes) y consistencia (sin duplicados/constantes)."
|
||||
)
|
||||
|
||||
|
||||
def _storage_from_source(source: str) -> str:
|
||||
"""Infer the storage technology the dataset currently lives in.
|
||||
|
||||
Heuristic on the profile ``source`` string (a path, DSN or backend name).
|
||||
Returns a human label; falls back to the raw source when unknown.
|
||||
"""
|
||||
s = (source or "").strip().lower()
|
||||
if not s:
|
||||
return "—"
|
||||
if s.endswith(".csv") or s.endswith(".tsv"):
|
||||
return "CSV"
|
||||
if s.endswith(".parquet") or s.endswith(".pq"):
|
||||
return "Parquet"
|
||||
if s.endswith(".json") or s.endswith(".ndjson"):
|
||||
return "JSON"
|
||||
if s.endswith(".xlsx") or s.endswith(".xls"):
|
||||
return "Excel"
|
||||
if s.endswith((".duckdb", ".ddb")) or s == "duckdb" or s.endswith(".db"):
|
||||
return "DuckDB"
|
||||
if s.startswith(("postgres://", "postgresql://")) or "postgres" in s:
|
||||
return "PostgreSQL"
|
||||
if s.startswith("bigquery") or "bigquery" in s or s.count(".") == 2 and " " not in s:
|
||||
return "BigQuery"
|
||||
if "sqlite" in s:
|
||||
return "SQLite"
|
||||
# Unknown: show the raw source so nothing is hidden.
|
||||
return source
|
||||
|
||||
|
||||
def _fmt_int(v) -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{int(v):,}".replace(",", ".")
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def _fmt_date_eu(value) -> str:
|
||||
"""Format a date/ISO string as European DD/MM/AAAA HH:mm (UI convention).
|
||||
|
||||
Accepts a datetime, an ISO-8601 string (with or without microseconds/tz) or
|
||||
any other string. Non-parseable strings are returned verbatim so nothing is
|
||||
lost; None yields a placeholder.
|
||||
"""
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%d/%m/%Y %H:%M")
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return "—"
|
||||
try:
|
||||
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
return dt.strftime("%d/%m/%Y %H:%M")
|
||||
except (TypeError, ValueError):
|
||||
# Try a couple of common forms before giving up.
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S UTC", "%Y-%m-%d %H:%M UTC",
|
||||
"%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime("%d/%m/%Y %H:%M")
|
||||
except ValueError:
|
||||
continue
|
||||
return s
|
||||
|
||||
|
||||
def build_portada(profile: dict, ctx: dict):
|
||||
"""Build the cover Chapter, or None if there is truly nothing to show."""
|
||||
profile = profile or {}
|
||||
ctx = ctx or {}
|
||||
|
||||
dataset_name = (ctx.get("dataset_name") or profile.get("table")
|
||||
or "(dataset sin nombre)")
|
||||
source = profile.get("source") or ""
|
||||
# Where the dataset comes from (origin), distinct from where it is stored.
|
||||
source_origin = ctx.get("source_origin") or source or "—"
|
||||
storage = ctx.get("storage") or _storage_from_source(source)
|
||||
|
||||
when = _fmt_date_eu(
|
||||
ctx.get("generated_at") or profile.get("profiled_at")
|
||||
or datetime.now(timezone.utc))
|
||||
|
||||
n_rows = profile.get("n_rows")
|
||||
n_cols = profile.get("n_cols")
|
||||
shape = f"{_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas"
|
||||
|
||||
score = profile.get("quality_score")
|
||||
quality_criteria = ctx.get("quality_criteria") or _DEFAULT_QUALITY_CRITERIA
|
||||
quality_value = "—" if score is None else f"{score} / 100"
|
||||
|
||||
# Granularity: ctx wins; else derive from key candidates; else be honest.
|
||||
granularity = ctx.get("granularity")
|
||||
if not granularity:
|
||||
keys = profile.get("key_candidates") or []
|
||||
if keys:
|
||||
granularity = ("Cada fila parece identificada por "
|
||||
+ ", ".join(str(k) for k in keys[:3]) + ".")
|
||||
else:
|
||||
granularity = ("Cada fila es… (granularidad no determinada — "
|
||||
"pendiente de la capa de cálculo/LLM).")
|
||||
|
||||
description = ctx.get("description")
|
||||
if not description:
|
||||
description = ("Descripción no provista — pendiente de la capa LLM "
|
||||
"(`run_llm`) o de `ctx['description']`.")
|
||||
|
||||
blocks = [
|
||||
model.Heading(text=str(dataset_name), level=1),
|
||||
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
|
||||
model.KVTable(rows=[
|
||||
("Fuente", source_origin),
|
||||
("Almacenamiento", storage),
|
||||
("Generado", when),
|
||||
("Tamaño", shape),
|
||||
("Calidad", quality_value),
|
||||
("Criterios de calidad", quality_criteria),
|
||||
]),
|
||||
model.Heading(text="Descripción", level=2),
|
||||
model.Markdown(text=str(description)),
|
||||
model.Heading(text="Granularidad", level=2),
|
||||
model.Markdown(text=str(granularity)),
|
||||
]
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Chapter registry — the canonical order of an AutomaticEDA document.
|
||||
|
||||
``CHAPTER_ORDER`` declares every chapter the engine will *ever* place, in the
|
||||
order they appear in the document. Each id maps by convention to a module
|
||||
``automatic_eda/chapters/<id>.py`` exposing ``build_<id>(profile, ctx) ->
|
||||
Chapter | None`` and a ``CHAPTER_VERSION`` constant.
|
||||
|
||||
This pre-declared order is what lets many agents add chapters in parallel
|
||||
without contention: an agent only creates its own ``chapters/<id>.py`` module —
|
||||
it never edits this file. ``build_document`` imports each chapter lazily; a
|
||||
chapter whose module does not exist yet (not implemented) is simply skipped, so
|
||||
the document is always renderable with whatever chapters are present today.
|
||||
|
||||
``build_document`` never raises: a chapter that errors out is dropped with a
|
||||
note, and a chapter that returns ``None`` (does not apply to this dataset, e.g.
|
||||
time series on a dataset with no date column) is omitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
from . import model
|
||||
|
||||
# Canonical document order. Implemented today: portada, overview. The rest are
|
||||
# placeholders other agents will fill by creating chapters/<id>.py — they will
|
||||
# appear in this exact position automatically once their module exists.
|
||||
CHAPTER_ORDER = [
|
||||
"portada", # cover
|
||||
"overview", # df.head + columns/types/nulls/examples + describe
|
||||
"num_distr", # numeric distributions
|
||||
"cat_distr", # categorical distributions
|
||||
"calidad", # data quality
|
||||
"correlacion", # correlations / associations
|
||||
"modelos", # cheap models (PCA/KMeans/outliers)
|
||||
"analisis_llm", # LLM interpretation
|
||||
"timeseries", # time-series analysis
|
||||
"geospatial", # geospatial
|
||||
"agregacion", # aggregations / pivots
|
||||
]
|
||||
|
||||
|
||||
def build_chapter(chapter_id: str, profile: dict, ctx: dict):
|
||||
"""Build a single chapter by id, or None if absent/not-applicable/error.
|
||||
|
||||
Looks up ``automatic_eda.chapters.<chapter_id>`` and calls its
|
||||
``build_<chapter_id>(profile, ctx)``. Returns a normalized Chapter, or None
|
||||
when the module is missing, the builder returns None, or anything raises.
|
||||
"""
|
||||
mod_name = f"{__package__}.chapters.{chapter_id}"
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
except Exception: # noqa: BLE001 — chapter not implemented yet → skip.
|
||||
return None
|
||||
builder = getattr(mod, f"build_{chapter_id}", None)
|
||||
if builder is None:
|
||||
return None
|
||||
try:
|
||||
result = builder(profile or {}, ctx or {})
|
||||
except Exception: # noqa: BLE001 — a broken chapter never aborts the doc.
|
||||
return None
|
||||
return model.as_chapter(result)
|
||||
|
||||
|
||||
def build_document(profile: dict, ctx: dict = None) -> list:
|
||||
"""Build the full ordered list of chapters for a TableProfile.
|
||||
|
||||
Args:
|
||||
profile: the ``eda`` group TableProfile dict (may be None/empty).
|
||||
ctx: optional context dict carrying presentation metadata not present in
|
||||
the profile (dataset_name, source_origin, storage, generated_at,
|
||||
description, granularity, quality_criteria, head_rows, ...).
|
||||
|
||||
Returns:
|
||||
list[Chapter] in canonical order, containing only the chapters that are
|
||||
implemented and applicable. Never raises.
|
||||
"""
|
||||
if profile is None:
|
||||
profile = {}
|
||||
if not isinstance(profile, dict):
|
||||
profile = {}
|
||||
if ctx is None:
|
||||
ctx = {}
|
||||
chapters = []
|
||||
for cid in CHAPTER_ORDER:
|
||||
ch = build_chapter(cid, profile, ctx)
|
||||
if ch is not None and ch.blocks:
|
||||
chapters.append(ch)
|
||||
return chapters
|
||||
@@ -0,0 +1,310 @@
|
||||
"""AutomaticEDA document model — format-independent blocks and chapters.
|
||||
|
||||
This is the intermediate layer between *content* (what an EDA chapter wants to
|
||||
say) and *output format* (PDF for mobile reading, PPTX for sharing). A document
|
||||
is an ordered list of :class:`Chapter`. A chapter is ``{id, title, version,
|
||||
blocks}``. A block is one of a small, closed set of presentation primitives
|
||||
(heading, markdown, key/value table, data table, figure, image, caption, note).
|
||||
|
||||
Neither renderer knows anything about the EDA profile: they only know how to lay
|
||||
out blocks so that **nothing is ever cut** — long text wraps to whole lines,
|
||||
long tables split by rows repeating the header, figures and images are scaled to
|
||||
fit entirely. Each chapter declares its own ``version`` so every page/slide can
|
||||
be stamped ``<Chapter> · v<version>`` and tracked in a manifest for continuous,
|
||||
per-chapter improvement.
|
||||
|
||||
Reading is defensive throughout (the ``eda`` group "dict-no-throw" style): the
|
||||
normalizers accept dataclass blocks *or* plain dicts, coerce anything unknown
|
||||
into a readable :class:`Note` instead of raising, and the renderers degrade a
|
||||
malformed block to text rather than crashing the whole document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# Global engine version. Bump when the document model or a renderer changes in a
|
||||
# way that affects output. Individual chapters carry their own CHAPTER_VERSION.
|
||||
ENGINE_VERSION = "1.0.0"
|
||||
ENGINE_NAME = "AutomaticEDA"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Block primitives. Each carries a stable ``kind`` string so renderers can
|
||||
# dispatch by kind (works for dataclass instances and for plain dicts alike).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass
|
||||
class Heading:
|
||||
"""A section heading. ``level`` 1 (largest) .. 3 (smallest)."""
|
||||
|
||||
text: str = ""
|
||||
level: int = 1
|
||||
kind: str = field(default="heading", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Markdown:
|
||||
"""A block of light markdown text.
|
||||
|
||||
Supported subset (everything else is rendered verbatim, never dropped):
|
||||
``#``/``##``/``###`` headings, ``-``/``*`` bullet lists, ``| a | b |``
|
||||
tables (consecutive pipe lines become a data table), blank lines as
|
||||
paragraph breaks, and ``**bold**`` inline markers (markers are stripped, the
|
||||
text is kept). Text is wrapped to whole lines so it is never cut mid-line.
|
||||
"""
|
||||
|
||||
text: str = ""
|
||||
kind: str = field(default="markdown", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KVTable:
|
||||
"""A two-column key/value table. ``rows`` is a list of ``(label, value)``."""
|
||||
|
||||
rows: list = field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
kind: str = field(default="kv_table", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataTable:
|
||||
"""A tabular block with a header row.
|
||||
|
||||
If it does not fit in the remaining page/slide space it is split by rows,
|
||||
**repeating the header** on each continuation. Long cell text wraps inside
|
||||
its column (the row grows taller) so no cell content is ever lost.
|
||||
"""
|
||||
|
||||
header: list = field(default_factory=list)
|
||||
rows: list = field(default_factory=list) # list[list[Any]]
|
||||
title: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
kind: str = field(default="data_table", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Figure:
|
||||
"""A matplotlib figure, scaled to fit entirely (never cropped).
|
||||
|
||||
Provide either an already-built ``fig`` (a ``matplotlib.figure.Figure``) or
|
||||
a zero-arg ``make`` callable that returns one (lazy: only built when the
|
||||
renderer needs it). ``height_in`` is an optional hint for the target height
|
||||
on the page; renderers clamp it to the available space preserving aspect.
|
||||
"""
|
||||
|
||||
fig: Any = None
|
||||
make: Optional[Callable[[], Any]] = None
|
||||
caption: Optional[str] = None
|
||||
height_in: Optional[float] = None
|
||||
kind: str = field(default="figure", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
"""A raster image (PNG/JPG) by path, scaled to fit entirely."""
|
||||
|
||||
path: str = ""
|
||||
caption: Optional[str] = None
|
||||
height_in: Optional[float] = None
|
||||
kind: str = field(default="image", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Caption:
|
||||
"""Small auxiliary text rendered under a figure/table."""
|
||||
|
||||
text: str = ""
|
||||
kind: str = field(default="caption", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
"""Small auxiliary note (italic). Also the fallback for unknown content."""
|
||||
|
||||
text: str = ""
|
||||
kind: str = field(default="note", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chapter:
|
||||
"""An ordered set of blocks with an id, a title and a generation version."""
|
||||
|
||||
id: str = ""
|
||||
title: str = ""
|
||||
version: str = "1.0.0"
|
||||
blocks: list = field(default_factory=list)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Defensive normalizers — accept dataclasses OR plain dicts, never raise.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_BLOCK_BY_KIND = {
|
||||
"heading": Heading,
|
||||
"markdown": Markdown,
|
||||
"kv_table": KVTable,
|
||||
"data_table": DataTable,
|
||||
"figure": Figure,
|
||||
"image": Image,
|
||||
"caption": Caption,
|
||||
"note": Note,
|
||||
}
|
||||
|
||||
|
||||
def as_block(obj: Any):
|
||||
"""Coerce a value into a block dataclass. Unknown values become a Note."""
|
||||
if isinstance(obj, (Heading, Markdown, KVTable, DataTable, Figure, Image,
|
||||
Caption, Note)):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
kind = obj.get("kind")
|
||||
cls = _BLOCK_BY_KIND.get(kind)
|
||||
if cls is None:
|
||||
return Note(text=_safe_str(obj))
|
||||
# Build only with fields the dataclass accepts (ignore extras).
|
||||
try:
|
||||
if cls is Heading:
|
||||
return Heading(text=_safe_str(obj.get("text")),
|
||||
level=int(obj.get("level", 1) or 1))
|
||||
if cls is Markdown:
|
||||
return Markdown(text=_safe_str(obj.get("text")))
|
||||
if cls is KVTable:
|
||||
return KVTable(rows=list(obj.get("rows") or []),
|
||||
title=obj.get("title"))
|
||||
if cls is DataTable:
|
||||
return DataTable(header=list(obj.get("header") or []),
|
||||
rows=list(obj.get("rows") or []),
|
||||
title=obj.get("title"), note=obj.get("note"))
|
||||
if cls is Figure:
|
||||
return Figure(fig=obj.get("fig"), make=obj.get("make"),
|
||||
caption=obj.get("caption"),
|
||||
height_in=obj.get("height_in"))
|
||||
if cls is Image:
|
||||
return Image(path=_safe_str(obj.get("path")),
|
||||
caption=obj.get("caption"),
|
||||
height_in=obj.get("height_in"))
|
||||
if cls is Caption:
|
||||
return Caption(text=_safe_str(obj.get("text")))
|
||||
if cls is Note:
|
||||
return Note(text=_safe_str(obj.get("text")))
|
||||
except Exception: # noqa: BLE001 — never raise on a malformed block.
|
||||
return Note(text=_safe_str(obj))
|
||||
return Note(text=_safe_str(obj))
|
||||
|
||||
|
||||
def as_blocks(seq: Any) -> list:
|
||||
"""Normalize an arbitrary sequence into a list of block dataclasses."""
|
||||
if seq is None:
|
||||
return []
|
||||
if not isinstance(seq, (list, tuple)):
|
||||
return [as_block(seq)]
|
||||
return [as_block(b) for b in seq]
|
||||
|
||||
|
||||
def as_chapter(obj: Any) -> Optional[Chapter]:
|
||||
"""Coerce a value into a Chapter (or None). Accepts a dict or a Chapter."""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, Chapter):
|
||||
obj.blocks = as_blocks(obj.blocks)
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return Chapter(
|
||||
id=_safe_str(obj.get("id")),
|
||||
title=_safe_str(obj.get("title")) or _safe_str(obj.get("id")),
|
||||
version=_safe_str(obj.get("version")) or "1.0.0",
|
||||
blocks=as_blocks(obj.get("blocks")),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def as_chapters(seq: Any) -> list:
|
||||
"""Normalize a sequence of chapters, dropping anything that can't coerce."""
|
||||
if seq is None:
|
||||
return []
|
||||
if isinstance(seq, Chapter):
|
||||
return [as_chapter(seq)]
|
||||
if not isinstance(seq, (list, tuple)):
|
||||
return []
|
||||
out = []
|
||||
for c in seq:
|
||||
ch = as_chapter(c)
|
||||
if ch is not None:
|
||||
out.append(ch)
|
||||
return out
|
||||
|
||||
|
||||
def _safe_str(v: Any) -> str:
|
||||
"""str() that never raises and maps None to ''."""
|
||||
if v is None:
|
||||
return ""
|
||||
try:
|
||||
return str(v)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Manifest — per-chapter versions and page/slide counts for tracking.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def merge_manifest(manifest_path: str, renderer: str, chapters_meta: list,
|
||||
generated_at: str,
|
||||
engine_version: str = ENGINE_VERSION) -> dict:
|
||||
"""Read-modify-write the AutomaticEDA manifest, merging one renderer's run.
|
||||
|
||||
The manifest lives next to the outputs as ``automatic_eda_manifest.json``
|
||||
and records, per chapter, its version plus the page count (PDF) and slide
|
||||
count (PPTX). Calling either renderer creates or updates it. Never raises:
|
||||
on any error returns the in-memory manifest without writing.
|
||||
|
||||
Args:
|
||||
manifest_path: path to the JSON manifest to create or update.
|
||||
renderer: "pdf" or "pptx" — selects which count key is written.
|
||||
chapters_meta: list of ``{"id", "version", "n_pages"|"n_slides"}``.
|
||||
generated_at: ISO-ish timestamp string for this run.
|
||||
engine_version: AutomaticEDA engine version.
|
||||
|
||||
Returns:
|
||||
The merged manifest dict (also written to disk on success).
|
||||
"""
|
||||
data: dict = {}
|
||||
try:
|
||||
if manifest_path and os.path.exists(manifest_path):
|
||||
with open(manifest_path, "r", encoding="utf-8") as fh:
|
||||
loaded = json.load(fh)
|
||||
if isinstance(loaded, dict):
|
||||
data = loaded
|
||||
except Exception: # noqa: BLE001 — a corrupt manifest is overwritten.
|
||||
data = {}
|
||||
|
||||
data["engine"] = ENGINE_NAME
|
||||
data["engine_version"] = engine_version
|
||||
data["generated_at"] = generated_at
|
||||
chapters = data.get("chapters")
|
||||
if not isinstance(chapters, dict):
|
||||
chapters = {}
|
||||
count_key = "n_slides" if renderer == "pptx" else "n_pages"
|
||||
for cm in chapters_meta or []:
|
||||
if not isinstance(cm, dict):
|
||||
continue
|
||||
cid = cm.get("id")
|
||||
if not cid:
|
||||
continue
|
||||
entry = chapters.get(cid)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
entry["version"] = cm.get("version") or entry.get("version") or "1.0.0"
|
||||
entry[count_key] = cm.get(count_key, cm.get("n_pages", cm.get("n_slides")))
|
||||
chapters[cid] = entry
|
||||
data["chapters"] = chapters
|
||||
|
||||
try:
|
||||
parent = os.path.dirname(os.path.abspath(manifest_path))
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(manifest_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception: # noqa: BLE001 — never raise from the manifest writer.
|
||||
pass
|
||||
return data
|
||||
@@ -0,0 +1,532 @@
|
||||
"""AutomaticEDA PDF renderer — A5 portrait, mobile-first, never cuts content.
|
||||
|
||||
A flow paginator: it measures each block (using the deterministic character grid
|
||||
from :mod:`text_layout`) and places it top-to-bottom on the current page. When a
|
||||
unit does not fit in the remaining space it moves whole to the next page —
|
||||
text by whole lines (never mid-line, never mid-word), data tables by rows
|
||||
**repeating the header**, figures/images scaled to fit entirely (never cropped).
|
||||
|
||||
Each chapter starts on a fresh page and every page is stamped in the footer with
|
||||
``<Chapter> · v<version>`` plus the engine version and a running page number, so
|
||||
output is versioned per chapter for continuous improvement.
|
||||
|
||||
dict-no-throw: a failure inside one block is caught and noted; the PDF is always
|
||||
produced and at least one page is guaranteed. Engine: matplotlib ``PdfPages``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.image as mpimg # noqa: E402
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.backends.backend_pdf import PdfPages # noqa: E402
|
||||
from matplotlib.patches import Rectangle # noqa: E402
|
||||
|
||||
from . import model # noqa: E402
|
||||
from . import text_layout as tl # noqa: E402
|
||||
|
||||
# A5 portrait, inches.
|
||||
_W, _H = 5.83, 8.27
|
||||
_ML, _MR, _MT, _MB = 0.5, 0.42, 0.55, 0.5
|
||||
_FOOTER_H = 0.34
|
||||
_USABLE_W = _W - _ML - _MR
|
||||
_CONTENT_TOP = _MT
|
||||
_CONTENT_BOTTOM = _H - _MB - _FOOTER_H
|
||||
|
||||
# Palette / type (inherits the Tufte-ish mobile look of render_eda_pdf).
|
||||
_INK = "#1b1b1b"
|
||||
_ACCENT = "#2a6f97"
|
||||
_MUTED = "#8a8a8a"
|
||||
_RULE = "#cccccc"
|
||||
_HEAD_BG = "#eef3f6"
|
||||
|
||||
_RC = {
|
||||
"font.size": 10,
|
||||
"font.family": "sans-serif",
|
||||
"figure.facecolor": "white",
|
||||
"savefig.facecolor": "white",
|
||||
"pdf.fonttype": 42, # embed TrueType — text stays selectable on mobile.
|
||||
}
|
||||
|
||||
# Font sizes (pt) and derived line heights (in).
|
||||
_FS_H1, _FS_H2, _FS_H3 = 17, 13, 11
|
||||
_FS_BODY, _FS_CELL, _FS_NOTE = 10.5, 9.0, 9.0
|
||||
_GAP = 0.12 # vertical gap after a block, inches.
|
||||
_CELL_PAD = 0.06 # horizontal padding inside a table cell, inches.
|
||||
_ROW_VPAD = 0.05 # vertical padding inside a table row, inches.
|
||||
|
||||
|
||||
class _PdfState:
|
||||
"""Mutable layout cursor for the running PDF document."""
|
||||
|
||||
def __init__(self, pdf, title: str):
|
||||
self.pdf = pdf
|
||||
self.title = title
|
||||
self.fig = None
|
||||
self.y = _CONTENT_TOP # inches from the top of the page.
|
||||
self.page = 0 # global page counter.
|
||||
self.chapter = None # current Chapter (for the footer).
|
||||
self.chapter_pages = 0 # pages produced for the current chapter.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Coordinate helpers (inches-from-top → matplotlib figure fraction).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _yf(y_in: float) -> float:
|
||||
return 1.0 - (y_in / _H)
|
||||
|
||||
|
||||
def _xf(x_in: float) -> float:
|
||||
return x_in / _W
|
||||
|
||||
|
||||
def _new_page(st: _PdfState) -> None:
|
||||
"""Close the current page (if any) and open a fresh one with a footer."""
|
||||
_flush_page(st)
|
||||
st.fig = plt.figure(figsize=(_W, _H))
|
||||
st.y = _CONTENT_TOP
|
||||
st.page += 1
|
||||
st.chapter_pages += 1
|
||||
_draw_footer(st)
|
||||
|
||||
|
||||
def _flush_page(st: _PdfState) -> None:
|
||||
if st.fig is not None:
|
||||
st.pdf.savefig(st.fig)
|
||||
plt.close(st.fig)
|
||||
st.fig = None
|
||||
|
||||
|
||||
def _draw_footer(st: _PdfState) -> None:
|
||||
ch = st.chapter
|
||||
left = ""
|
||||
if ch is not None:
|
||||
left = f"{ch.title} · v{ch.version}"
|
||||
right = f"{model.ENGINE_NAME} v{model.ENGINE_VERSION} · p.{st.page}"
|
||||
yb = (_MB * 0.45) / _H
|
||||
st.fig.text(_xf(_ML), yb, left, fontsize=7.5, color=_MUTED,
|
||||
ha="left", va="center")
|
||||
st.fig.text(_xf(_W - _MR), yb, right, fontsize=7.5, color=_MUTED,
|
||||
ha="right", va="center")
|
||||
# A thin rule above the footer.
|
||||
st.fig.add_artist(Rectangle(
|
||||
(_xf(_ML), (_MB + _FOOTER_H * 0.5) / _H),
|
||||
_xf(_W - _MR) - _xf(_ML), 0.0008,
|
||||
transform=st.fig.transFigure, color=_RULE, lw=0.6))
|
||||
|
||||
|
||||
def _remaining(st: _PdfState) -> float:
|
||||
return _CONTENT_BOTTOM - st.y
|
||||
|
||||
|
||||
def _ensure_space(st: _PdfState, height: float) -> None:
|
||||
"""Open a new page if ``height`` does not fit in the remaining space."""
|
||||
if _remaining(st) < height:
|
||||
_new_page(st)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Block placers. Each advances st.y and paginates as needed.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _place_heading(st: _PdfState, block) -> None:
|
||||
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
|
||||
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
||||
text = tl.strip_inline_md(getattr(block, "text", ""))
|
||||
max_chars = tl.chars_per_line(_USABLE_W, fs)
|
||||
lines = tl.wrap(text, max_chars)
|
||||
lh = tl.line_height_in(fs, leading=1.2)
|
||||
block_h = lh * len(lines) + 0.06
|
||||
# Keep at least the heading + a couple of body lines together when possible.
|
||||
_ensure_space(st, min(block_h + tl.line_height_in(_FS_BODY) * 2,
|
||||
_CONTENT_BOTTOM - _CONTENT_TOP))
|
||||
for ln in lines:
|
||||
_ensure_space(st, lh)
|
||||
st.fig.text(_xf(_ML), _yf(st.y), ln, fontsize=fs, fontweight="bold",
|
||||
color=_INK, ha="left", va="top")
|
||||
st.y += lh
|
||||
if level == 1:
|
||||
# Accent underline under a top-level heading.
|
||||
st.fig.add_artist(Rectangle(
|
||||
(_xf(_ML), _yf(st.y + 0.02)), _xf(_ML + 1.4) - _xf(_ML), 0.0016,
|
||||
transform=st.fig.transFigure, color=_ACCENT, lw=0))
|
||||
st.y += 0.10
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_text_lines(st: _PdfState, lines: list, fs: float, color: str,
|
||||
style: str = "normal", indent: float = 0.0) -> None:
|
||||
lh = tl.line_height_in(fs)
|
||||
for ln in lines:
|
||||
_ensure_space(st, lh)
|
||||
st.fig.text(_xf(_ML + indent), _yf(st.y), ln, fontsize=fs, color=color,
|
||||
ha="left", va="top", style=style)
|
||||
st.y += lh
|
||||
|
||||
|
||||
def _place_markdown(st: _PdfState, block) -> None:
|
||||
raw = getattr(block, "text", "") or ""
|
||||
md_lines = str(raw).split("\n")
|
||||
i = 0
|
||||
n = len(md_lines)
|
||||
while i < n:
|
||||
line = md_lines[i]
|
||||
stripped = line.strip()
|
||||
# Consecutive pipe-table lines → a DataTable.
|
||||
if stripped.startswith("|") and stripped.endswith("|"):
|
||||
j = i
|
||||
tbl_lines = []
|
||||
while j < n and md_lines[j].strip().startswith("|") \
|
||||
and md_lines[j].strip().endswith("|"):
|
||||
tbl_lines.append(md_lines[j])
|
||||
j += 1
|
||||
parsed = tl.parse_md_table(tbl_lines)
|
||||
if parsed:
|
||||
header, rows = parsed
|
||||
_place_data_table(st, model.DataTable(header=header, rows=rows))
|
||||
i = j
|
||||
continue
|
||||
if stripped == "":
|
||||
st.y += tl.line_height_in(_FS_BODY) * 0.5
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("### "):
|
||||
_place_heading(st, model.Heading(stripped[4:], level=3))
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("## "):
|
||||
_place_heading(st, model.Heading(stripped[3:], level=2))
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("# "):
|
||||
_place_heading(st, model.Heading(stripped[2:], level=1))
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("- ") or stripped.startswith("* "):
|
||||
content = tl.strip_inline_md(stripped[2:])
|
||||
bullet_chars = tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY)
|
||||
wrapped = tl.wrap(content, bullet_chars)
|
||||
first = True
|
||||
for w in wrapped:
|
||||
prefix = "• " if first else " "
|
||||
_place_text_lines(st, [prefix + w], _FS_BODY, _INK,
|
||||
indent=0.0)
|
||||
first = False
|
||||
i += 1
|
||||
continue
|
||||
# Plain paragraph (gather following plain lines into one paragraph).
|
||||
para = [tl.strip_inline_md(stripped)]
|
||||
j = i + 1
|
||||
while j < n:
|
||||
nxt = md_lines[j].strip()
|
||||
if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
|
||||
break
|
||||
para.append(tl.strip_inline_md(nxt))
|
||||
j += 1
|
||||
text = " ".join(para)
|
||||
max_chars = tl.chars_per_line(_USABLE_W, _FS_BODY)
|
||||
_place_text_lines(st, tl.wrap(text, max_chars), _FS_BODY, _INK)
|
||||
i = j
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_kv_table(st: _PdfState, block) -> None:
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
_place_heading(st, model.Heading(title, level=2))
|
||||
rows = getattr(block, "rows", []) or []
|
||||
key_w = 1.9 # inches reserved for the label column.
|
||||
val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY)
|
||||
lh = tl.line_height_in(_FS_BODY)
|
||||
for row in rows:
|
||||
try:
|
||||
label, value = row[0], row[1]
|
||||
except Exception: # noqa: BLE001
|
||||
label, value = str(row), ""
|
||||
v_lines = tl.wrap(model._safe_str(value), val_chars)
|
||||
row_h = lh * len(v_lines) + _ROW_VPAD
|
||||
_ensure_space(st, row_h)
|
||||
y0 = st.y
|
||||
st.fig.text(_xf(_ML), _yf(y0), tl.strip_inline_md(model._safe_str(label)),
|
||||
fontsize=_FS_BODY, color=_MUTED, ha="left", va="top")
|
||||
for k, vl in enumerate(v_lines):
|
||||
st.fig.text(_xf(_ML + key_w), _yf(y0 + k * lh), vl,
|
||||
fontsize=_FS_BODY, color=_INK, ha="left", va="top")
|
||||
st.y = y0 + row_h
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _col_widths(header: list, rows: list, fs: float) -> list:
|
||||
"""Distribute usable width across columns proportional to content length."""
|
||||
ncol = len(header) if header else (len(rows[0]) if rows else 1)
|
||||
ncol = max(1, ncol)
|
||||
natural = [3] * ncol
|
||||
for c in range(ncol):
|
||||
if header and c < len(header):
|
||||
natural[c] = max(natural[c], len(model._safe_str(header[c])))
|
||||
for r in rows:
|
||||
if c < len(r):
|
||||
natural[c] = max(natural[c], len(model._safe_str(r[c])))
|
||||
# Clamp so one very long column does not starve the others.
|
||||
clamped = [min(max(w, 4), 40) for w in natural]
|
||||
total = float(sum(clamped)) or 1.0
|
||||
widths = [_USABLE_W * w / total for w in clamped]
|
||||
# Enforce a minimum readable column width.
|
||||
min_w = 0.45
|
||||
widths = [max(w, min_w) for w in widths]
|
||||
# Renormalize if the minimums pushed us over the usable width.
|
||||
s = sum(widths)
|
||||
if s > _USABLE_W:
|
||||
widths = [w * _USABLE_W / s for w in widths]
|
||||
return widths
|
||||
|
||||
|
||||
def _wrap_row(cells: list, widths: list, fs: float) -> list:
|
||||
"""Wrap each cell to its column width → list of line-lists per cell."""
|
||||
out = []
|
||||
for c, w in enumerate(widths):
|
||||
text = model._safe_str(cells[c]) if c < len(cells) else ""
|
||||
max_chars = tl.chars_per_line(w - _CELL_PAD * 2, fs)
|
||||
out.append(tl.wrap(text, max_chars))
|
||||
return out
|
||||
|
||||
|
||||
def _draw_table_row(st: _PdfState, cells_lines: list, widths: list, fs: float,
|
||||
y0: float, header: bool) -> float:
|
||||
lh = tl.line_height_in(fs)
|
||||
nlines = max((len(c) for c in cells_lines), default=1)
|
||||
row_h = lh * nlines + _ROW_VPAD * 2
|
||||
if header:
|
||||
st.fig.add_artist(Rectangle(
|
||||
(_xf(_ML), _yf(y0 + row_h)), _xf(_ML + _USABLE_W) - _xf(_ML),
|
||||
_yf(y0) - _yf(y0 + row_h), transform=st.fig.transFigure,
|
||||
color=_HEAD_BG, lw=0, zorder=0))
|
||||
x = _ML
|
||||
for c, lines in enumerate(cells_lines):
|
||||
for k, ln in enumerate(lines):
|
||||
st.fig.text(_xf(x + _CELL_PAD), _yf(y0 + _ROW_VPAD + k * lh), ln,
|
||||
fontsize=fs, color=_INK,
|
||||
fontweight="bold" if header else "normal",
|
||||
ha="left", va="top", zorder=2)
|
||||
x += widths[c]
|
||||
# Bottom rule of the row.
|
||||
st.fig.add_artist(Rectangle(
|
||||
(_xf(_ML), _yf(y0 + row_h)), _xf(_ML + _USABLE_W) - _xf(_ML), 0.0006,
|
||||
transform=st.fig.transFigure, color=_RULE, lw=0, zorder=1))
|
||||
return row_h
|
||||
|
||||
|
||||
def _place_data_table(st: _PdfState, block) -> None:
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
_place_heading(st, model.Heading(title, level=2))
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows, fs)
|
||||
header_lines = _wrap_row(header, widths, fs) if header else None
|
||||
lh = tl.line_height_in(fs)
|
||||
|
||||
def header_h() -> float:
|
||||
if not header_lines:
|
||||
return 0.0
|
||||
return lh * max((len(c) for c in header_lines), default=1) + _ROW_VPAD * 2
|
||||
|
||||
def draw_header() -> None:
|
||||
if header_lines:
|
||||
st.y += _draw_table_row(st, header_lines, widths, fs, st.y,
|
||||
header=True)
|
||||
|
||||
# Ensure header + first row fit, else start on a new page.
|
||||
first_row_h = 0.0
|
||||
if rows:
|
||||
first_lines = _wrap_row(rows[0], widths, fs)
|
||||
first_row_h = lh * max((len(c) for c in first_lines), default=1) \
|
||||
+ _ROW_VPAD * 2
|
||||
_ensure_space(st, header_h() + max(first_row_h, lh))
|
||||
draw_header()
|
||||
for r in rows:
|
||||
cells_lines = _wrap_row(r, widths, fs)
|
||||
row_h = lh * max((len(c) for c in cells_lines), default=1) \
|
||||
+ _ROW_VPAD * 2
|
||||
if _remaining(st) < row_h:
|
||||
_new_page(st)
|
||||
draw_header() # repeat header on the continuation page.
|
||||
st.y += _draw_table_row(st, cells_lines, widths, fs, st.y, header=False)
|
||||
note = getattr(block, "note", None)
|
||||
if note:
|
||||
_place_text_lines(st, tl.wrap(model._safe_str(note),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)),
|
||||
_FS_NOTE, _MUTED, style="italic")
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _resolve_figure(block):
|
||||
fig = getattr(block, "fig", None)
|
||||
if fig is not None:
|
||||
return fig, False
|
||||
make = getattr(block, "make", None)
|
||||
if callable(make):
|
||||
try:
|
||||
return make(), True
|
||||
except Exception: # noqa: BLE001
|
||||
return None, False
|
||||
return None, False
|
||||
|
||||
|
||||
def _png_from_figure(fig) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _place_image_array(st: _PdfState, arr, caption) -> None:
|
||||
h_px, w_px = arr.shape[0], arr.shape[1]
|
||||
aspect = (h_px / w_px) if w_px else 1.0
|
||||
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||
target_w = _USABLE_W
|
||||
target_h = target_w * aspect
|
||||
if target_h > max_h:
|
||||
target_h = max_h
|
||||
target_w = target_h / aspect if aspect else _USABLE_W
|
||||
cap_h = tl.line_height_in(_FS_NOTE) + 0.04 if caption else 0.0
|
||||
# Move whole image to next page if it does not fit in remaining space.
|
||||
if _remaining(st) < target_h + cap_h:
|
||||
if (max_h) >= target_h + cap_h:
|
||||
_new_page(st)
|
||||
else:
|
||||
# Taller than a full page even at min — already clamped to max_h.
|
||||
_new_page(st)
|
||||
left_frac = _xf(_ML + (_USABLE_W - target_w) / 2.0)
|
||||
bottom_frac = _yf(st.y + target_h)
|
||||
ax = st.fig.add_axes([left_frac, bottom_frac, target_w / _W, target_h / _H])
|
||||
ax.imshow(arr)
|
||||
ax.axis("off")
|
||||
st.y += target_h + 0.04
|
||||
if caption:
|
||||
_place_text_lines(st, tl.wrap(model._safe_str(caption),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)),
|
||||
_FS_NOTE, _MUTED, style="italic")
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_figure(st: _PdfState, block) -> None:
|
||||
fig, owned = _resolve_figure(block)
|
||||
if fig is None:
|
||||
_place_text_lines(st, ["(figura no disponible)"], _FS_NOTE, _MUTED,
|
||||
style="italic")
|
||||
st.y += _GAP
|
||||
return
|
||||
try:
|
||||
png = _png_from_figure(fig)
|
||||
finally:
|
||||
if owned:
|
||||
try:
|
||||
plt.close(fig)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
arr = mpimg.imread(io.BytesIO(png))
|
||||
_place_image_array(st, arr, getattr(block, "caption", None))
|
||||
|
||||
|
||||
def _place_image(st: _PdfState, block) -> None:
|
||||
path = getattr(block, "path", "")
|
||||
if not path or not os.path.exists(path):
|
||||
_place_text_lines(st, [f"(imagen no encontrada: {path})"], _FS_NOTE,
|
||||
_MUTED, style="italic")
|
||||
st.y += _GAP
|
||||
return
|
||||
arr = mpimg.imread(path)
|
||||
_place_image_array(st, arr, getattr(block, "caption", None))
|
||||
|
||||
|
||||
def _place_caption(st: _PdfState, block) -> None:
|
||||
_place_text_lines(st, tl.wrap(getattr(block, "text", ""),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)),
|
||||
_FS_NOTE, _MUTED, style="italic")
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_note(st: _PdfState, block) -> None:
|
||||
_place_text_lines(st, tl.wrap(getattr(block, "text", ""),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)),
|
||||
_FS_NOTE, _MUTED, style="italic")
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
_PLACERS = {
|
||||
"heading": _place_heading,
|
||||
"markdown": _place_markdown,
|
||||
"kv_table": _place_kv_table,
|
||||
"data_table": _place_data_table,
|
||||
"figure": _place_figure,
|
||||
"image": _place_image,
|
||||
"caption": _place_caption,
|
||||
"note": _place_note,
|
||||
}
|
||||
|
||||
|
||||
def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
"""Render a list of Chapters into an A5-portrait, mobile-readable PDF.
|
||||
|
||||
Never raises. Returns ``{path, n_pages, chapters, note}`` where ``chapters``
|
||||
is a list of ``{id, version, n_pages}`` for the manifest. On a fatal write
|
||||
error ``path`` is None and ``note`` explains why.
|
||||
"""
|
||||
meta = meta or {}
|
||||
chapters = model.as_chapters(chapters)
|
||||
notes = []
|
||||
|
||||
try:
|
||||
parent = os.path.dirname(os.path.abspath(out_path))
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
except OSError as e:
|
||||
return {"path": None, "n_pages": 0, "chapters": [],
|
||||
"note": f"no se pudo crear el directorio destino: {e}"}
|
||||
|
||||
title = meta.get("title") or model.ENGINE_NAME
|
||||
chapters_meta = []
|
||||
try:
|
||||
with plt.rc_context(_RC):
|
||||
with PdfPages(out_path) as pdf:
|
||||
st = _PdfState(pdf, title)
|
||||
for ch in chapters:
|
||||
st.chapter = ch
|
||||
st.chapter_pages = 0
|
||||
_new_page(st) # each chapter starts on a fresh page.
|
||||
for block in ch.blocks:
|
||||
placer = _PLACERS.get(getattr(block, "kind", ""),
|
||||
_place_note)
|
||||
try:
|
||||
placer(st, block)
|
||||
except Exception as e: # noqa: BLE001
|
||||
notes.append(
|
||||
f"bloque '{getattr(block, 'kind', '?')}' del "
|
||||
f"capítulo '{ch.id}' omitido: {e}")
|
||||
chapters_meta.append({"id": ch.id, "version": ch.version,
|
||||
"n_pages": st.chapter_pages})
|
||||
_flush_page(st)
|
||||
if st.page == 0:
|
||||
# No chapters at all → guarantee one valid page.
|
||||
st.chapter = model.Chapter(id="vacio", title=title,
|
||||
version=model.ENGINE_VERSION)
|
||||
_new_page(st)
|
||||
_place_note(st, model.Note(
|
||||
"(documento vacío — sin capítulos aplicables)"))
|
||||
_flush_page(st)
|
||||
n_pages = st.page
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"path": None, "n_pages": 0, "chapters": [],
|
||||
"note": f"fallo al escribir el PDF: {e}"}
|
||||
|
||||
note = f"{n_pages} páginas"
|
||||
if notes:
|
||||
note += " · " + "; ".join(notes)
|
||||
return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta,
|
||||
"note": note}
|
||||
@@ -0,0 +1,518 @@
|
||||
"""AutomaticEDA PPTX renderer — 16:9 slides, never cuts content.
|
||||
|
||||
Same flow principle as the PDF renderer but onto PowerPoint slides: measure each
|
||||
block and place it top-to-bottom; when it does not fit in the remaining slide
|
||||
space, continue on a new slide titled ``<Chapter> (cont.)``. Data tables split by
|
||||
rows **repeating the header**; figures/images are scaled to fit entirely. Every
|
||||
slide carries a footer ``<Chapter> · v<version>`` plus the engine version.
|
||||
|
||||
dict-no-throw: a failure inside one block is caught and noted; the deck is always
|
||||
produced with at least one slide. Engine: ``python-pptx`` (added dependency).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from . import model
|
||||
from . import text_layout as tl
|
||||
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
_PPTX_OK = True
|
||||
_PPTX_ERR = ""
|
||||
except Exception as _e: # noqa: BLE001 — surfaced as a dict-no-throw note.
|
||||
_PPTX_OK = False
|
||||
_PPTX_ERR = str(_e)
|
||||
|
||||
# 16:9 widescreen, inches.
|
||||
_W, _H = 13.333, 7.5
|
||||
_ML, _MR = 0.7, 0.7
|
||||
_TITLE_TOP, _TITLE_H = 0.28, 0.7
|
||||
_CONTENT_TOP = 1.12
|
||||
_FOOTER_H = 0.4
|
||||
_CONTENT_BOTTOM = _H - _FOOTER_H - 0.15
|
||||
_USABLE_W = _W - _ML - _MR
|
||||
|
||||
_INK = (0x1B, 0x1B, 0x1B)
|
||||
_ACCENT = (0x2A, 0x6F, 0x97)
|
||||
_MUTED = (0x8A, 0x8A, 0x8A)
|
||||
_HEAD_BG = (0xEE, 0xF3, 0xF6)
|
||||
_WHITE = (0xFF, 0xFF, 0xFF)
|
||||
|
||||
_FS_TITLE = 26
|
||||
_FS_H1, _FS_H2, _FS_H3 = 20, 16, 13
|
||||
_FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11
|
||||
_GAP = 0.12
|
||||
|
||||
|
||||
class _PptxState:
|
||||
def __init__(self, prs, title: str):
|
||||
self.prs = prs
|
||||
self.title = title
|
||||
self.slide = None
|
||||
self.y = _CONTENT_TOP
|
||||
self.chapter = None
|
||||
self.slide_no = 0
|
||||
self.chapter_slides = 0
|
||||
|
||||
|
||||
def _rgb(c):
|
||||
return RGBColor(*c)
|
||||
|
||||
|
||||
def _new_slide(st: _PptxState, cont: bool = False) -> None:
|
||||
blank = st.prs.slide_layouts[6]
|
||||
st.slide = st.prs.slides.add_slide(blank)
|
||||
st.y = _CONTENT_TOP
|
||||
st.slide_no += 1
|
||||
st.chapter_slides += 1
|
||||
_draw_title(st, cont)
|
||||
_draw_footer(st)
|
||||
|
||||
|
||||
def _draw_title(st: _PptxState, cont: bool) -> None:
|
||||
ch = st.chapter
|
||||
title = ch.title if ch is not None else st.title
|
||||
if cont:
|
||||
title = f"{title} (cont.)"
|
||||
box = st.slide.shapes.add_textbox(
|
||||
Inches(_ML), Inches(_TITLE_TOP), Inches(_USABLE_W), Inches(_TITLE_H))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = title
|
||||
run.font.size = Pt(_FS_TITLE)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = _rgb(_INK)
|
||||
|
||||
|
||||
def _draw_footer(st: _PptxState) -> None:
|
||||
ch = st.chapter
|
||||
left = f"{ch.title} · v{ch.version}" if ch is not None else ""
|
||||
right = f"{model.ENGINE_NAME} v{model.ENGINE_VERSION} · {st.slide_no}"
|
||||
box = st.slide.shapes.add_textbox(
|
||||
Inches(_ML), Inches(_H - _FOOTER_H), Inches(_USABLE_W),
|
||||
Inches(_FOOTER_H * 0.7))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = False
|
||||
p = tf.paragraphs[0]
|
||||
r = p.add_run()
|
||||
r.text = left
|
||||
r.font.size = Pt(9)
|
||||
r.font.color.rgb = _rgb(_MUTED)
|
||||
# Right-aligned engine stamp on a second textbox.
|
||||
box2 = st.slide.shapes.add_textbox(
|
||||
Inches(_ML), Inches(_H - _FOOTER_H), Inches(_USABLE_W),
|
||||
Inches(_FOOTER_H * 0.7))
|
||||
tf2 = box2.text_frame
|
||||
p2 = tf2.paragraphs[0]
|
||||
p2.alignment = PP_ALIGN.RIGHT
|
||||
r2 = p2.add_run()
|
||||
r2.text = right
|
||||
r2.font.size = Pt(9)
|
||||
r2.font.color.rgb = _rgb(_MUTED)
|
||||
|
||||
|
||||
def _remaining(st: _PptxState) -> float:
|
||||
return _CONTENT_BOTTOM - st.y
|
||||
|
||||
|
||||
def _ensure(st: _PptxState, height: float) -> None:
|
||||
if _remaining(st) < height:
|
||||
_new_slide(st, cont=True)
|
||||
|
||||
|
||||
def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
|
||||
italic=False, indent=0.0, bullet=False) -> None:
|
||||
lh = tl.line_height_in(fs)
|
||||
height = lh * len(lines) + 0.05
|
||||
_ensure(st, height)
|
||||
box = st.slide.shapes.add_textbox(
|
||||
Inches(_ML + indent), Inches(st.y), Inches(_USABLE_W - indent),
|
||||
Inches(height))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
first = True
|
||||
for ln in lines:
|
||||
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
||||
first = False
|
||||
run = p.add_run()
|
||||
run.text = ("• " + ln) if bullet else ln
|
||||
run.font.size = Pt(fs)
|
||||
run.font.bold = bold
|
||||
run.font.italic = italic
|
||||
run.font.color.rgb = _rgb(color)
|
||||
st.y += height
|
||||
|
||||
|
||||
def _place_heading(st: _PptxState, block) -> None:
|
||||
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
|
||||
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
||||
text = tl.strip_inline_md(getattr(block, "text", ""))
|
||||
lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
|
||||
_add_text(st, lines, fs, _INK, bold=True)
|
||||
st.y += 0.04
|
||||
|
||||
|
||||
def _place_markdown(st: _PptxState, block) -> None:
|
||||
raw = str(getattr(block, "text", "") or "")
|
||||
md_lines = raw.split("\n")
|
||||
i, n = 0, len(md_lines)
|
||||
while i < n:
|
||||
stripped = md_lines[i].strip()
|
||||
if stripped.startswith("|") and stripped.endswith("|"):
|
||||
j = i
|
||||
tbl = []
|
||||
while j < n and md_lines[j].strip().startswith("|") \
|
||||
and md_lines[j].strip().endswith("|"):
|
||||
tbl.append(md_lines[j])
|
||||
j += 1
|
||||
parsed = tl.parse_md_table(tbl)
|
||||
if parsed:
|
||||
header, rows = parsed
|
||||
_place_data_table(st, model.DataTable(header=header, rows=rows))
|
||||
i = j
|
||||
continue
|
||||
if stripped == "":
|
||||
st.y += tl.line_height_in(_FS_BODY) * 0.4
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("### "):
|
||||
_place_heading(st, model.Heading(stripped[4:], level=3))
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("## "):
|
||||
_place_heading(st, model.Heading(stripped[3:], level=2))
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("# "):
|
||||
_place_heading(st, model.Heading(stripped[2:], level=1))
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("- ") or stripped.startswith("* "):
|
||||
content = tl.strip_inline_md(stripped[2:])
|
||||
lines = tl.wrap(content, tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
|
||||
_add_text(st, lines, _FS_BODY, _INK, bullet=True)
|
||||
i += 1
|
||||
continue
|
||||
para = [tl.strip_inline_md(stripped)]
|
||||
j = i + 1
|
||||
while j < n:
|
||||
nxt = md_lines[j].strip()
|
||||
if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
|
||||
break
|
||||
para.append(tl.strip_inline_md(nxt))
|
||||
j += 1
|
||||
text = " ".join(para)
|
||||
_add_text(st, tl.wrap(text, tl.chars_per_line(_USABLE_W, _FS_BODY)),
|
||||
_FS_BODY, _INK)
|
||||
i = j
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_kv_table(st: _PptxState, block) -> None:
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
_place_heading(st, model.Heading(title, level=2))
|
||||
rows = getattr(block, "rows", []) or []
|
||||
data_rows = []
|
||||
for row in rows:
|
||||
try:
|
||||
label, value = row[0], row[1]
|
||||
except Exception: # noqa: BLE001
|
||||
label, value = str(row), ""
|
||||
data_rows.append([model._safe_str(label), model._safe_str(value)])
|
||||
_place_data_table(st, model.DataTable(header=["Campo", "Valor"],
|
||||
rows=data_rows), shaded_header=True,
|
||||
key_value=True)
|
||||
|
||||
|
||||
def _col_widths(header, rows):
|
||||
ncol = len(header) if header else (len(rows[0]) if rows else 1)
|
||||
ncol = max(1, ncol)
|
||||
natural = [3] * ncol
|
||||
for c in range(ncol):
|
||||
if header and c < len(header):
|
||||
natural[c] = max(natural[c], len(model._safe_str(header[c])))
|
||||
for r in rows:
|
||||
if c < len(r):
|
||||
natural[c] = max(natural[c], len(model._safe_str(r[c])))
|
||||
clamped = [min(max(w, 4), 44) for w in natural]
|
||||
total = float(sum(clamped)) or 1.0
|
||||
return [_USABLE_W * w / total for w in clamped]
|
||||
|
||||
|
||||
def _row_height_in(cells, widths, fs) -> float:
|
||||
lh = tl.line_height_in(fs)
|
||||
maxlines = 1
|
||||
for c, w in enumerate(widths):
|
||||
text = model._safe_str(cells[c]) if c < len(cells) else ""
|
||||
lines = tl.wrap(text, tl.chars_per_line(w - 0.12, fs))
|
||||
maxlines = max(maxlines, len(lines))
|
||||
return lh * maxlines + 0.10
|
||||
|
||||
|
||||
def _emit_table(st: _PptxState, header, chunk, widths, fs) -> None:
|
||||
nrows = len(chunk) + (1 if header else 0)
|
||||
ncol = len(widths)
|
||||
# Pre-measure total height to size the shape (pptx still auto-grows rows).
|
||||
heights = []
|
||||
if header:
|
||||
heights.append(_row_height_in(header, widths, fs))
|
||||
for r in chunk:
|
||||
heights.append(_row_height_in(r, widths, fs))
|
||||
total_h = sum(heights)
|
||||
gtable = st.slide.shapes.add_table(
|
||||
nrows, ncol, Inches(_ML), Inches(st.y), Inches(_USABLE_W),
|
||||
Inches(total_h)).table
|
||||
gtable.first_row = bool(header)
|
||||
gtable.horz_banding = False
|
||||
for c in range(ncol):
|
||||
gtable.columns[c].width = Emu(int(Inches(widths[c])))
|
||||
ridx = 0
|
||||
if header:
|
||||
for c in range(ncol):
|
||||
cell = gtable.cell(0, c)
|
||||
cell.text = model._safe_str(header[c]) if c < len(header) else ""
|
||||
_style_cell(cell, fs, _INK, bold=True, fill=_HEAD_BG)
|
||||
ridx = 1
|
||||
for r in chunk:
|
||||
for c in range(ncol):
|
||||
cell = gtable.cell(ridx, c)
|
||||
cell.text = model._safe_str(r[c]) if c < len(r) else ""
|
||||
_style_cell(cell, fs, _INK, bold=False, fill=_WHITE)
|
||||
ridx += 1
|
||||
st.y += total_h + _GAP
|
||||
|
||||
|
||||
def _style_cell(cell, fs, color, bold, fill) -> None:
|
||||
cell.fill.solid()
|
||||
cell.fill.fore_color.rgb = _rgb(fill)
|
||||
cell.margin_left = Inches(0.05)
|
||||
cell.margin_right = Inches(0.05)
|
||||
cell.margin_top = Inches(0.02)
|
||||
cell.margin_bottom = Inches(0.02)
|
||||
for p in cell.text_frame.paragraphs:
|
||||
for run in p.runs:
|
||||
run.font.size = Pt(fs)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = _rgb(color)
|
||||
|
||||
|
||||
def _place_data_table(st: _PptxState, block, shaded_header=True,
|
||||
key_value=False) -> None:
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
_place_heading(st, model.Heading(title, level=2))
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows)
|
||||
header_h = _row_height_in(header, widths, fs) if header else 0.0
|
||||
|
||||
idx = 0
|
||||
n = len(rows)
|
||||
if n == 0:
|
||||
# Header-only table still rendered (one slide).
|
||||
_ensure(st, header_h + 0.2)
|
||||
_emit_table(st, header, [], widths, fs)
|
||||
return
|
||||
while idx < n:
|
||||
# Greedily fill the current slide with as many rows as fit.
|
||||
if _remaining(st) < header_h + _row_height_in(rows[idx], widths, fs):
|
||||
_new_slide(st, cont=True)
|
||||
avail = _remaining(st) - header_h
|
||||
chunk = []
|
||||
used = 0.0
|
||||
while idx < n:
|
||||
rh = _row_height_in(rows[idx], widths, fs)
|
||||
if used + rh > avail and chunk:
|
||||
break
|
||||
chunk.append(rows[idx])
|
||||
used += rh
|
||||
idx += 1
|
||||
_emit_table(st, header, chunk, widths, fs)
|
||||
note = getattr(block, "note", None)
|
||||
if note:
|
||||
_add_text(st, tl.wrap(model._safe_str(note),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)), _FS_NOTE, _MUTED,
|
||||
italic=True)
|
||||
|
||||
|
||||
def _img_size_px(data: bytes):
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
return im.size # (w, h)
|
||||
except Exception: # noqa: BLE001
|
||||
return (1200, 800)
|
||||
|
||||
|
||||
def _resolve_png(block):
|
||||
fig = getattr(block, "fig", None)
|
||||
make = getattr(block, "make", None)
|
||||
f = fig
|
||||
owned = False
|
||||
if f is None and callable(make):
|
||||
try:
|
||||
f = make()
|
||||
owned = True
|
||||
except Exception: # noqa: BLE001
|
||||
f = None
|
||||
if f is None:
|
||||
return None
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
buf = io.BytesIO()
|
||||
f.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
finally:
|
||||
if owned:
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
plt.close(f)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _place_picture_bytes(st: _PptxState, data: bytes, caption) -> None:
|
||||
w_px, h_px = _img_size_px(data)
|
||||
aspect = (h_px / w_px) if w_px else 0.66
|
||||
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||
target_w = _USABLE_W
|
||||
target_h = target_w * aspect
|
||||
if target_h > max_h:
|
||||
target_h = max_h
|
||||
target_w = target_h / aspect if aspect else _USABLE_W
|
||||
cap_h = tl.line_height_in(_FS_NOTE) + 0.05 if caption else 0.0
|
||||
if _remaining(st) < target_h + cap_h:
|
||||
_new_slide(st, cont=True)
|
||||
left = _ML + (_USABLE_W - target_w) / 2.0
|
||||
st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y),
|
||||
width=Inches(target_w), height=Inches(target_h))
|
||||
st.y += target_h + 0.05
|
||||
if caption:
|
||||
_add_text(st, tl.wrap(model._safe_str(caption),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)), _FS_NOTE, _MUTED,
|
||||
italic=True)
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_figure(st: _PptxState, block) -> None:
|
||||
png = _resolve_png(block)
|
||||
if png is None:
|
||||
_add_text(st, ["(figura no disponible)"], _FS_NOTE, _MUTED, italic=True)
|
||||
st.y += _GAP
|
||||
return
|
||||
_place_picture_bytes(st, png, getattr(block, "caption", None))
|
||||
|
||||
|
||||
def _place_image(st: _PptxState, block) -> None:
|
||||
path = getattr(block, "path", "")
|
||||
if not path or not os.path.exists(path):
|
||||
_add_text(st, [f"(imagen no encontrada: {path})"], _FS_NOTE, _MUTED,
|
||||
italic=True)
|
||||
st.y += _GAP
|
||||
return
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_add_text(st, [f"(no se pudo leer la imagen: {e})"], _FS_NOTE, _MUTED,
|
||||
italic=True)
|
||||
st.y += _GAP
|
||||
return
|
||||
_place_picture_bytes(st, data, getattr(block, "caption", None))
|
||||
|
||||
|
||||
def _place_caption(st: _PptxState, block) -> None:
|
||||
_add_text(st, tl.wrap(getattr(block, "text", ""),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE)), _FS_NOTE, _MUTED,
|
||||
italic=True)
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_note(st: _PptxState, block) -> None:
|
||||
_place_caption(st, block)
|
||||
|
||||
|
||||
_PLACERS = {
|
||||
"heading": _place_heading,
|
||||
"markdown": _place_markdown,
|
||||
"kv_table": _place_kv_table,
|
||||
"data_table": _place_data_table,
|
||||
"figure": _place_figure,
|
||||
"image": _place_image,
|
||||
"caption": _place_caption,
|
||||
"note": _place_note,
|
||||
}
|
||||
|
||||
|
||||
def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
"""Render a list of Chapters into a 16:9 PPTX deck. Never raises.
|
||||
|
||||
Returns ``{path, n_slides, chapters, note}`` where ``chapters`` is a list of
|
||||
``{id, version, n_slides}`` for the manifest. On a fatal error ``path`` is
|
||||
None and ``note`` explains why (e.g. python-pptx not installed).
|
||||
"""
|
||||
meta = meta or {}
|
||||
if not _PPTX_OK:
|
||||
return {"path": None, "n_slides": 0, "chapters": [],
|
||||
"note": f"python-pptx no disponible: {_PPTX_ERR}"}
|
||||
|
||||
chapters = model.as_chapters(chapters)
|
||||
notes = []
|
||||
try:
|
||||
parent = os.path.dirname(os.path.abspath(out_path))
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
except OSError as e:
|
||||
return {"path": None, "n_slides": 0, "chapters": [],
|
||||
"note": f"no se pudo crear el directorio destino: {e}"}
|
||||
|
||||
title = meta.get("title") or model.ENGINE_NAME
|
||||
chapters_meta = []
|
||||
try:
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(_W)
|
||||
prs.slide_height = Inches(_H)
|
||||
st = _PptxState(prs, title)
|
||||
for ch in chapters:
|
||||
st.chapter = ch
|
||||
st.chapter_slides = 0
|
||||
_new_slide(st, cont=False)
|
||||
for block in ch.blocks:
|
||||
placer = _PLACERS.get(getattr(block, "kind", ""), _place_note)
|
||||
try:
|
||||
placer(st, block)
|
||||
except Exception as e: # noqa: BLE001
|
||||
notes.append(
|
||||
f"bloque '{getattr(block, 'kind', '?')}' del capítulo "
|
||||
f"'{ch.id}' omitido: {e}")
|
||||
chapters_meta.append({"id": ch.id, "version": ch.version,
|
||||
"n_slides": st.chapter_slides})
|
||||
if st.slide_no == 0:
|
||||
st.chapter = model.Chapter(id="vacio", title=title,
|
||||
version=model.ENGINE_VERSION)
|
||||
_new_slide(st, cont=False)
|
||||
_place_note(st, model.Note(
|
||||
"(documento vacío — sin capítulos aplicables)"))
|
||||
prs.save(out_path)
|
||||
n_slides = st.slide_no
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"path": None, "n_slides": 0, "chapters": [],
|
||||
"note": f"fallo al escribir el PPTX: {e}"}
|
||||
|
||||
note = f"{n_slides} slides"
|
||||
if notes:
|
||||
note += " · " + "; ".join(notes)
|
||||
return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
|
||||
"note": note}
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Shared text-measurement helpers for the AutomaticEDA renderers.
|
||||
|
||||
Both renderers flow content top-to-bottom and must know, *before* placing a
|
||||
block, how much vertical space it will take — that is what guarantees nothing is
|
||||
cut: a unit either fits in the remaining space or moves to the next page/slide
|
||||
whole. Measuring proportional text exactly in matplotlib/pptx is impractical, so
|
||||
we use a deterministic character-grid estimate (chars-per-line from an average
|
||||
glyph width) which slightly over-estimates and is therefore safe: it never
|
||||
claims something fits when it would overflow.
|
||||
|
||||
Wrapping is word-aware (``textwrap``) and additionally hard-splits any single
|
||||
token longer than the line so a 200-character value still wraps instead of
|
||||
overflowing — that is wrapping, not loss: every character is still rendered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
|
||||
def avg_char_width_in(fontsize_pt: float) -> float:
|
||||
"""Approximate average glyph width in inches for a sans-serif font.
|
||||
|
||||
~0.5 of the point size is a conservative mean advance width for proportional
|
||||
sans fonts; dividing by 72 converts points to inches.
|
||||
"""
|
||||
return 0.5 * fontsize_pt / 72.0
|
||||
|
||||
|
||||
def line_height_in(fontsize_pt: float, leading: float = 1.32) -> float:
|
||||
"""Line height in inches for a given font size and leading."""
|
||||
return leading * fontsize_pt / 72.0
|
||||
|
||||
|
||||
def chars_per_line(width_in: float, fontsize_pt: float) -> int:
|
||||
"""How many average glyphs fit in ``width_in`` at ``fontsize_pt``."""
|
||||
cw = avg_char_width_in(fontsize_pt)
|
||||
if cw <= 0:
|
||||
return 80
|
||||
n = int(width_in / cw)
|
||||
return max(1, n)
|
||||
|
||||
|
||||
def wrap(text: str, max_chars: int) -> list:
|
||||
"""Word-wrap ``text`` to lines of at most ``max_chars``, never losing chars.
|
||||
|
||||
Long tokens (no spaces) are hard-split so they cannot overflow. Existing
|
||||
newlines are honored as hard breaks. Empty input yields a single empty line
|
||||
so callers can still reserve a row.
|
||||
"""
|
||||
if max_chars < 1:
|
||||
max_chars = 1
|
||||
s = "" if text is None else str(text)
|
||||
out: list = []
|
||||
for raw_line in s.split("\n"):
|
||||
if raw_line == "":
|
||||
out.append("")
|
||||
continue
|
||||
# textwrap with break_long_words so no token overflows the column.
|
||||
wrapped = textwrap.wrap(
|
||||
raw_line, width=max_chars, break_long_words=True,
|
||||
break_on_hyphens=False, replace_whitespace=True,
|
||||
drop_whitespace=True,
|
||||
)
|
||||
if not wrapped:
|
||||
out.append("")
|
||||
else:
|
||||
out.extend(wrapped)
|
||||
return out or [""]
|
||||
|
||||
|
||||
def strip_inline_md(text: str) -> str:
|
||||
"""Strip a tiny subset of inline markdown markers, keeping the text.
|
||||
|
||||
Removes ``**bold**`` / ``__bold__`` / ``*em*`` / `` `code` `` markers so the
|
||||
content is preserved without trying to style spans (which the line-grid
|
||||
layout cannot do). Nothing is dropped except the markers themselves.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
s = str(text)
|
||||
for marker in ("**", "__", "`"):
|
||||
s = s.replace(marker, "")
|
||||
return s
|
||||
|
||||
|
||||
def parse_md_table(lines: list):
|
||||
"""Parse consecutive ``| a | b |`` lines into ``(header, rows)`` or None.
|
||||
|
||||
Accepts an optional separator row (``|---|---|``) right after the header,
|
||||
which is ignored. Returns None if the lines are not a pipe table.
|
||||
"""
|
||||
cells_rows = []
|
||||
for ln in lines:
|
||||
s = ln.strip()
|
||||
if not (s.startswith("|") and s.endswith("|")):
|
||||
return None
|
||||
parts = [c.strip() for c in s.strip("|").split("|")]
|
||||
cells_rows.append(parts)
|
||||
if not cells_rows:
|
||||
return None
|
||||
header = cells_rows[0]
|
||||
body = cells_rows[1:]
|
||||
# Drop a markdown separator row (all cells are dashes/colons).
|
||||
if body and all(set(c) <= set("-: ") and "-" in c for c in body[0]):
|
||||
body = body[1:]
|
||||
return header, body
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: build_boxplot_stats
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_boxplot_stats(numeric: dict) -> dict"
|
||||
description: "Deriva las estadisticas de un boxplot de Tukey desde el sub-bloque numeric de un ColumnProfile del grupo eda (salida de describe_numeric). Aplica la regla del 1.5*IQR a los percentiles p25/p50/p75 para obtener cuartiles, fences, bigotes reales y flags de outliers. Lectura defensiva con .get; NUNCA lanza. Si faltan los percentiles clave devuelve {} para que el caller omita el grafico."
|
||||
tags: [eda, statistics, profiling, boxplot, tukey, iqr, datascience]
|
||||
params:
|
||||
- name: numeric
|
||||
desc: "Sub-bloque numeric de un ColumnProfile del grupo eda (la salida de describe_numeric). Claves esperadas (todas pueden ser None): min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95, p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct, distribution_type, histogram. Solo se usan p25, median/p50, p75, min, max y n_outliers."
|
||||
output: "Dict con las cifras de un boxplot horizontal de Tukey: {q1=p25, median=median(o p50), q3=p75, iqr=q3-q1, lower_fence=q1-1.5*iqr, upper_fence=q3+1.5*iqr, whisker_lo=max(min,lower_fence), whisker_hi=min(max,upper_fence), min, max, has_low_outliers=min<lower_fence, has_high_outliers=max>upper_fence, n_outliers}. Numericos en float, flags en bool nativo, n_outliers en int. Si faltan p25/median(o p50)/p75 devuelve {} (dict vacio). Cuando min/max faltan, los bigotes caen a la fence correspondiente."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_boxplot_tukey_basico", "test_percentiles_faltan_devuelve_vacio", "test_median_cae_a_p50", "test_whiskers_usan_fence_si_falta_min_max", "test_tipos_salida_float_bool_int"]
|
||||
test_file_path: "python/functions/datascience/build_boxplot_stats_test.py"
|
||||
file_path: "python/functions/datascience/build_boxplot_stats.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.build_boxplot_stats import build_boxplot_stats
|
||||
|
||||
# Sub-bloque numeric tal y como lo produce describe_numeric:
|
||||
numeric = {
|
||||
"min": 1.0, "max": 100.0,
|
||||
"p25": 10.0, "median": 25.0, "p75": 40.0,
|
||||
"iqr": 30.0, "n_outliers": 3,
|
||||
}
|
||||
box = build_boxplot_stats(numeric)
|
||||
print(box["lower_fence"], box["upper_fence"]) # -35.0 85.0
|
||||
print(box["whisker_lo"], box["whisker_hi"]) # 1.0 85.0
|
||||
print(box["has_low_outliers"], box["has_high_outliers"]) # False True
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Usala al dibujar un boxplot horizontal bajo el histograma en el capitulo `num_distr` de `AutomaticEDA`: convierte el bloque `numeric` de un `ColumnProfile` en las cifras exactas que el renderer necesita (cuartiles, fences, extremos de los bigotes y flags de outliers).
|
||||
- Cuando ya tengas los percentiles calculados (salida de `describe_numeric`) y solo necesites derivar la geometria del boxplot de Tukey sin volver a tocar los valores crudos.
|
||||
- Cuando quieras decidir si una columna tiene cola alta/baja (`has_high_outliers` / `has_low_outliers`) antes de proponer una transformacion (log, winsorize).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion pura, sin I/O y determinista. Lectura defensiva con `.get`: NUNCA lanza. Si faltan `p25`, `median`/`p50` o `p75` devuelve `{}` (dict vacio) — el caller debe omitir el boxplot.
|
||||
- Los `n_outliers` que se propagan vienen del bloque z-score del profile (`detect_outliers`, threshold 3.0), NO de la regla IQR. Son informativos: el conteo de Tukey que esta funcion calcula son los **fences** (`lower_fence`/`upper_fence`), no un recuento de puntos.
|
||||
- No recibe los valores crudos de la columna, solo deriva cifras desde los percentiles ya calculados. Por eso no puede contar cuantos puntos caen fuera de las fences, solo si los extremos (`min`/`max`) las superan.
|
||||
- `iqr` se recalcula como `q3 - q1` aunque el bloque traiga `numeric['iqr']`: asi funciona aunque esa clave falte.
|
||||
- Cuando `min`/`max` faltan, los bigotes caen a la fence correspondiente y los flags de outliers quedan en `False` (sin extremo real no se afirma cola).
|
||||
@@ -0,0 +1,94 @@
|
||||
"""build_boxplot_stats — Tukey boxplot statistics from an EDA `numeric` sub-block.
|
||||
|
||||
Pure function: no I/O, deterministic. Takes the `numeric` dict of a ColumnProfile
|
||||
(group `eda`, the output of describe_numeric) and derives the figures needed to
|
||||
draw a horizontal Tukey boxplot using the 1.5 * IQR rule.
|
||||
|
||||
It only derives numbers from already-computed percentiles; it never sees the raw
|
||||
column values. Reading is defensive (.get throughout) and the function NEVER
|
||||
raises: if the key percentiles (p25 / p50 / p75) are missing it returns {} so the
|
||||
caller can simply skip the boxplot.
|
||||
"""
|
||||
|
||||
|
||||
def _num(value):
|
||||
"""Coerce to float defensively; return None for None/bool/non-numeric."""
|
||||
# bool is a subclass of int; a percentile value is never a real bool, so
|
||||
# treat True/False as missing instead of silently coercing to 1.0/0.0.
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def build_boxplot_stats(numeric: dict) -> dict:
|
||||
"""Derive Tukey boxplot statistics from the `numeric` sub-block of a profile.
|
||||
|
||||
Reads the percentiles already computed by describe_numeric and applies the
|
||||
classic 1.5 * IQR fence rule to obtain the whisker extremes and outlier
|
||||
flags of a horizontal boxplot. No raw values are needed.
|
||||
|
||||
Args:
|
||||
numeric: The `numeric` sub-block of an eda ColumnProfile (output of
|
||||
describe_numeric). Every value may be None; read defensively.
|
||||
|
||||
Returns:
|
||||
Dict with the boxplot figures
|
||||
{q1, median, q3, iqr, lower_fence, upper_fence, whisker_lo, whisker_hi,
|
||||
min, max, has_low_outliers, has_high_outliers, n_outliers}.
|
||||
If p25, p50/median or p75 are missing (None) returns {} (empty dict) so
|
||||
the caller omits the plot.
|
||||
"""
|
||||
if not isinstance(numeric, dict):
|
||||
return {}
|
||||
|
||||
q1 = _num(numeric.get("p25"))
|
||||
q3 = _num(numeric.get("p75"))
|
||||
# Prefer the explicit median; fall back to p50 (they are the same quantile).
|
||||
median = _num(numeric.get("median"))
|
||||
if median is None:
|
||||
median = _num(numeric.get("p50"))
|
||||
|
||||
# Without the three quartiles a boxplot cannot be drawn.
|
||||
if q1 is None or q3 is None or median is None:
|
||||
return {}
|
||||
|
||||
# Recompute the IQR from the quartiles rather than trusting numeric['iqr'],
|
||||
# which may be missing even when the percentiles are present.
|
||||
iqr = q3 - q1
|
||||
lower_fence = q1 - 1.5 * iqr
|
||||
upper_fence = q3 + 1.5 * iqr
|
||||
|
||||
mn = _num(numeric.get("min"))
|
||||
mx = _num(numeric.get("max"))
|
||||
|
||||
# Whisker extremes: the real data range clamped to the fences. When the
|
||||
# corresponding extreme is missing, fall back to the fence itself.
|
||||
whisker_lo = max(mn, lower_fence) if mn is not None else lower_fence
|
||||
whisker_hi = min(mx, upper_fence) if mx is not None else upper_fence
|
||||
|
||||
has_low_outliers = bool(mn is not None and mn < lower_fence)
|
||||
has_high_outliers = bool(mx is not None and mx > upper_fence)
|
||||
|
||||
# Informative only: these outliers come from the z-score block of the
|
||||
# profile, not from this IQR fence computation.
|
||||
raw_n = numeric.get("n_outliers")
|
||||
n_outliers = int(raw_n) if isinstance(raw_n, (int, float)) and not isinstance(raw_n, bool) else 0
|
||||
|
||||
return {
|
||||
"q1": q1,
|
||||
"median": median,
|
||||
"q3": q3,
|
||||
"iqr": iqr,
|
||||
"lower_fence": lower_fence,
|
||||
"upper_fence": upper_fence,
|
||||
"whisker_lo": whisker_lo,
|
||||
"whisker_hi": whisker_hi,
|
||||
"min": mn,
|
||||
"max": mx,
|
||||
"has_low_outliers": has_low_outliers,
|
||||
"has_high_outliers": has_high_outliers,
|
||||
"n_outliers": n_outliers,
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests para build_boxplot_stats."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from build_boxplot_stats import build_boxplot_stats
|
||||
|
||||
# Keys that a non-empty result dict must always contain.
|
||||
_EXPECTED_KEYS = {
|
||||
"q1", "median", "q3", "iqr", "lower_fence", "upper_fence",
|
||||
"whisker_lo", "whisker_hi", "min", "max",
|
||||
"has_low_outliers", "has_high_outliers", "n_outliers",
|
||||
}
|
||||
|
||||
|
||||
def test_boxplot_tukey_basico():
|
||||
"""Golden: bloque numeric con outlier alto claro -> fences IQR de Tukey."""
|
||||
numeric = {
|
||||
"min": 1.0, "max": 100.0,
|
||||
"p25": 10.0, "median": 25.0, "p75": 40.0,
|
||||
"iqr": 30.0, "n_outliers": 3,
|
||||
}
|
||||
box = build_boxplot_stats(numeric)
|
||||
|
||||
assert set(box.keys()) == _EXPECTED_KEYS
|
||||
|
||||
assert box["q1"] == 10.0
|
||||
assert box["median"] == 25.0
|
||||
assert box["q3"] == 40.0
|
||||
# iqr recomputado desde los cuartiles.
|
||||
assert box["iqr"] == 30.0
|
||||
# lower = 10 - 1.5*30 = -35 ; upper = 40 + 1.5*30 = 85.
|
||||
assert box["lower_fence"] == -35.0
|
||||
assert box["upper_fence"] == 85.0
|
||||
# whisker_lo = max(min=1, -35) = 1 ; whisker_hi = min(max=100, 85) = 85.
|
||||
assert box["whisker_lo"] == 1.0
|
||||
assert box["whisker_hi"] == 85.0
|
||||
assert box["min"] == 1.0
|
||||
assert box["max"] == 100.0
|
||||
# Solo hay outliers altos (100 > 85), no bajos (1 no < -35).
|
||||
assert box["has_low_outliers"] is False
|
||||
assert box["has_high_outliers"] is True
|
||||
# n_outliers se propaga del bloque z-score (informativo).
|
||||
assert box["n_outliers"] == 3
|
||||
|
||||
|
||||
def test_percentiles_faltan_devuelve_vacio():
|
||||
"""Si falta p25/median/p75 -> {} (caller omite el boxplot)."""
|
||||
# Falta p25.
|
||||
assert build_boxplot_stats({"median": 25.0, "p75": 40.0}) == {}
|
||||
# Falta p75.
|
||||
assert build_boxplot_stats({"p25": 10.0, "median": 25.0}) == {}
|
||||
# Falta median y p50.
|
||||
assert build_boxplot_stats({"p25": 10.0, "p75": 40.0}) == {}
|
||||
# numeric None / no dict tambien es vacio, nunca lanza.
|
||||
assert build_boxplot_stats(None) == {}
|
||||
assert build_boxplot_stats({}) == {}
|
||||
|
||||
|
||||
def test_median_cae_a_p50():
|
||||
"""median ausente cae a p50."""
|
||||
numeric = {"min": 0.0, "max": 10.0, "p25": 2.0, "p50": 5.0, "p75": 8.0}
|
||||
box = build_boxplot_stats(numeric)
|
||||
assert box["median"] == 5.0
|
||||
assert box["q1"] == 2.0
|
||||
assert box["q3"] == 8.0
|
||||
|
||||
|
||||
def test_whiskers_usan_fence_si_falta_min_max():
|
||||
"""Sin min/max los bigotes caen a las fences y no hay outliers marcados."""
|
||||
numeric = {"p25": 10.0, "median": 25.0, "p75": 40.0} # sin min ni max
|
||||
box = build_boxplot_stats(numeric)
|
||||
|
||||
assert box["min"] is None
|
||||
assert box["max"] is None
|
||||
# iqr = 30, fences -35 / 85; los bigotes caen a las fences.
|
||||
assert box["whisker_lo"] == box["lower_fence"] == -35.0
|
||||
assert box["whisker_hi"] == box["upper_fence"] == 85.0
|
||||
# Sin extremos reales, no se afirma que haya outliers.
|
||||
assert box["has_low_outliers"] is False
|
||||
assert box["has_high_outliers"] is False
|
||||
# n_outliers ausente -> 0.
|
||||
assert box["n_outliers"] == 0
|
||||
|
||||
|
||||
def test_tipos_salida_float_bool_int():
|
||||
"""Numericos en float, flags bool nativos, n_outliers int."""
|
||||
numeric = {
|
||||
"min": -50.0, "max": 200.0,
|
||||
"p25": 10.0, "median": 25.0, "p75": 40.0,
|
||||
"n_outliers": 7,
|
||||
}
|
||||
box = build_boxplot_stats(numeric)
|
||||
|
||||
for key in ("q1", "median", "q3", "iqr", "lower_fence", "upper_fence",
|
||||
"whisker_lo", "whisker_hi", "min", "max"):
|
||||
assert isinstance(box[key], float), f"{key} debe ser float"
|
||||
|
||||
assert isinstance(box["has_low_outliers"], bool)
|
||||
assert isinstance(box["has_high_outliers"], bool)
|
||||
assert isinstance(box["n_outliers"], int) and not isinstance(box["n_outliers"], bool)
|
||||
|
||||
# min=-50 < lower_fence=-35 -> outlier bajo ; max=200 > upper_fence=85 -> alto.
|
||||
assert box["has_low_outliers"] is True
|
||||
assert box["has_high_outliers"] is True
|
||||
assert box["n_outliers"] == 7
|
||||
@@ -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))
|
||||
@@ -4,11 +4,11 @@ name: detect_distribution_type
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def detect_distribution_type(values: list[float]) -> dict"
|
||||
description: "Classifies the shape of a numeric distribution using skewness, excess kurtosis, tail ratio and log-skewness. Returns a type label and raw stats."
|
||||
tags: [statistics, distribution, classification, skewness, kurtosis, pendiente-usar]
|
||||
description: "Classifies the shape of a numeric distribution using cardinality (distinct values), number of prominent modes, skewness, excess kurtosis, tail ratio and log-skewness. Returns a type label and raw stats. Discrete/ordinal and multimodal columns are detected before the symmetric normal-ish test so they are never mislabeled normal."
|
||||
tags: [statistics, distribution, classification, skewness, kurtosis, multimodal, cardinality, eda]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -27,15 +27,21 @@ tests:
|
||||
- "test_detect_right_skewed"
|
||||
- "test_detect_stats_keys"
|
||||
- "test_detect_exactly_30"
|
||||
- "test_detect_discrete_low_cardinality"
|
||||
- "test_detect_multimodal"
|
||||
- "test_detect_normal_still_normal_after_fix"
|
||||
- "test_detect_stats_has_new_keys"
|
||||
- "test_detect_unimodal_skewed_not_multimodal"
|
||||
test_file_path: "python/functions/datascience/tests/test_detect_distribution_type.py"
|
||||
file_path: "python/functions/datascience/detect_distribution_type.py"
|
||||
params:
|
||||
- name: values
|
||||
desc: "List of numeric values to classify. Minimum 30 for meaningful classification."
|
||||
output: >
|
||||
Dict with "type" (str) and "stats" (dict). Type is one of: normal-ish,
|
||||
lognormal-ish, heavy-tail, right-skewed, left-skewed, other, too_few_samples.
|
||||
Stats contains: n, skew, kurtosis, tail_ratio, log_skew.
|
||||
Dict with "type" (str) and "stats" (dict). Type is one of: discrete,
|
||||
multimodal, heavy-tail, normal-ish, lognormal-ish, right-skewed, left-skewed,
|
||||
other, too_few_samples. Stats contains: n, skew, kurtosis, tail_ratio,
|
||||
log_skew, n_unique, n_modes, jb_stat, jb_pvalue.
|
||||
source_repo: "internal:footprint_aurgi"
|
||||
source_license: "internal-aurgi"
|
||||
source_file: "aurgi_mapas/generar_pdf_reporte.py:133"
|
||||
@@ -56,8 +62,14 @@ detect_distribution_type([1]*5)
|
||||
|
||||
## Logica de clasificacion
|
||||
|
||||
El orden importa: cardinalidad y modalidad se evaluan **antes** del test simetrico
|
||||
`normal-ish`, para que una columna discreta/ordinal o multimodal nunca se etiquete
|
||||
"normal" solo porque su skewness sea pequena.
|
||||
|
||||
- n < 30 → too_few_samples
|
||||
- n_unique <= 15 → discrete (ordinal / counts de pocos niveles)
|
||||
- excess kurtosis > 3 → heavy-tail
|
||||
- n >= 100 AND n_modes >= 2 → multimodal
|
||||
- |skew| <= 0.5 AND |kurt| <= 1 → normal-ish
|
||||
- skew > 0.5 AND log_skew cerca de 0 AND tail_ratio > 2 → lognormal-ish
|
||||
- skew > 0.5 → right-skewed
|
||||
@@ -65,3 +77,35 @@ detect_distribution_type([1]*5)
|
||||
- default → other
|
||||
|
||||
tail_ratio = p99/p50; log_skew calculado solo si hay >= 30 positivos.
|
||||
|
||||
`n_modes` cuenta picos prominentes de un histograma suavizado (~sqrt(n) bins,
|
||||
suavizado triangular) separados por un valle profundo (cae por debajo del 60% del
|
||||
pico menor). Esto evita modos espurios por ruido en continuas unimodales sesgadas.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando perfiles una columna numerica y quieras saber su forma para elegir el
|
||||
estadistico/visualizacion adecuados (media+desv vs mediana+IQR, histograma vs
|
||||
boxplot). Distingue discretas/ordinales y multimodales que un criterio por-skew
|
||||
confunde con normales.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Jarque-Bera NO es gate de `normal-ish`.** `jb_stat`/`jb_pvalue` se reportan en
|
||||
`stats` como senal diagnostica, pero con n grande Jarque-Bera rechaza normalidad
|
||||
para columnas perfectamente acampanadas (p.ej. pH o density del vino, n~1600,
|
||||
jb_p≈0 pese a ser normal-ish). Usarlo como umbral duro produce falsos negativos
|
||||
masivos. La robustez ante el tamano muestral la dan cardinalidad y modalidad.
|
||||
- El umbral `n_unique <= 15` etiqueta como `discrete` cualquier continua con muy
|
||||
pocos valores distintos: eso es correcto (es discreta/ordinal de facto), no un
|
||||
falso positivo.
|
||||
- `multimodal` solo se evalua con `n >= 100`; por debajo el histograma es demasiado
|
||||
ruidoso para afirmar multimodalidad y se cae a la logica de skew/kurt.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-29) — H11: anade deteccion de cardinalidad (`discrete`) y
|
||||
modalidad (`multimodal`) antes del test `normal-ish`, mas `n_unique`, `n_modes`,
|
||||
`jb_stat`, `jb_pvalue` en stats. Corrige falsos "normal-ish" en discretas/ordinales
|
||||
(wine `quality`) y multimodales (precios BTC). Retrocompatible: continuas normales,
|
||||
sesgadas y heavy-tail no cambian.
|
||||
|
||||
@@ -7,9 +7,26 @@ import numpy as np
|
||||
def detect_distribution_type(values: list[float]) -> dict:
|
||||
"""Classify the distribution shape of a numeric sample.
|
||||
|
||||
Uses skewness, excess kurtosis, tail ratio (p99/p50), and log-skewness
|
||||
to assign one of: normal-ish, lognormal-ish, heavy-tail, right-skewed,
|
||||
left-skewed, other, or too_few_samples (n < 30).
|
||||
Uses cardinality (number of distinct values), number of prominent modes,
|
||||
skewness, excess kurtosis, tail ratio (p99/p50) and log-skewness to assign
|
||||
one of: discrete, multimodal, heavy-tail, normal-ish, lognormal-ish,
|
||||
right-skewed, left-skewed, other, or too_few_samples (n < 30).
|
||||
|
||||
A skew-only criterion mislabels discrete/ordinal and multimodal columns as
|
||||
"normal-ish" (e.g. a 6-level rating, or multimodal asset prices whose
|
||||
skewness happens to be small). To avoid that, cardinality and modality are
|
||||
checked *before* the symmetric normal-ish test:
|
||||
|
||||
* ``n_unique <= 15`` -> "discrete" (ordinal / low-cardinality counts).
|
||||
* ``n_modes >= 2`` (with ``n >= 100``) -> "multimodal".
|
||||
|
||||
The Jarque-Bera statistic and its p-value are computed from the already
|
||||
available skewness and excess kurtosis and reported in ``stats`` as a
|
||||
diagnostic signal. It is deliberately NOT used as a hard gate for the
|
||||
"normal-ish" label: with large samples Jarque-Bera rejects normality for
|
||||
trivially non-normal but perfectly bell-shaped columns, which would produce
|
||||
massive false negatives. Cardinality and modality, by contrast, are robust
|
||||
to sample size.
|
||||
|
||||
Args:
|
||||
values: List of numeric values.
|
||||
@@ -17,7 +34,8 @@ def detect_distribution_type(values: list[float]) -> dict:
|
||||
Returns:
|
||||
Dict with keys:
|
||||
"type" (str): distribution label.
|
||||
"stats" (dict): {"n", "skew", "kurtosis", "tail_ratio", "log_skew"}.
|
||||
"stats" (dict): {"n", "skew", "kurtosis", "tail_ratio", "log_skew",
|
||||
"n_unique", "n_modes", "jb_stat", "jb_pvalue"}.
|
||||
"""
|
||||
n = len(values)
|
||||
if n < 30:
|
||||
@@ -58,17 +76,37 @@ def detect_distribution_type(values: list[float]) -> dict:
|
||||
else:
|
||||
log_skew = math.nan
|
||||
|
||||
# Cardinality and modality (robust to sample size).
|
||||
n_unique = int(np.unique(arr).size)
|
||||
n_modes = _count_modes(arr)
|
||||
|
||||
# Jarque-Bera statistic from the moments already computed. Under the null
|
||||
# of normality it follows a chi-squared distribution with 2 degrees of
|
||||
# freedom, whose survival function is exp(-x / 2).
|
||||
jb_stat = n / 6.0 * (skew ** 2 + (kurt ** 2) / 4.0)
|
||||
jb_pvalue = math.exp(-jb_stat / 2.0)
|
||||
|
||||
stats = {
|
||||
"n": n,
|
||||
"skew": skew,
|
||||
"kurtosis": kurt,
|
||||
"tail_ratio": tail_ratio,
|
||||
"log_skew": log_skew,
|
||||
"n_unique": n_unique,
|
||||
"n_modes": n_modes,
|
||||
"jb_stat": jb_stat,
|
||||
"jb_pvalue": jb_pvalue,
|
||||
}
|
||||
|
||||
# Classification logic
|
||||
if kurt > 3.0:
|
||||
# Classification logic. Cardinality and modality come first so a discrete or
|
||||
# multimodal column is never mislabeled "normal-ish" on the basis of a small
|
||||
# skewness alone.
|
||||
if n_unique <= 15:
|
||||
dist_type = "discrete"
|
||||
elif kurt > 3.0:
|
||||
dist_type = "heavy-tail"
|
||||
elif n >= 100 and n_modes >= 2:
|
||||
dist_type = "multimodal"
|
||||
elif abs(skew) <= 0.5 and abs(kurt) <= 1.0:
|
||||
dist_type = "normal-ish"
|
||||
elif (
|
||||
@@ -87,3 +125,58 @@ def detect_distribution_type(values: list[float]) -> dict:
|
||||
dist_type = "other"
|
||||
|
||||
return {"type": dist_type, "stats": stats}
|
||||
|
||||
|
||||
def _count_modes(values, prom_frac: float = 0.15, valley_frac: float = 0.6) -> int:
|
||||
"""Count prominent modes separated by deep valleys in a histogram.
|
||||
|
||||
A naive local-maximum count over a raw histogram is dominated by sampling
|
||||
noise, so this:
|
||||
|
||||
1. Bins the data into ~sqrt(n) bins and applies a light triangular smooth.
|
||||
2. Keeps local maxima taller than ``prom_frac`` of the global peak.
|
||||
3. Merges two adjacent peaks unless the lowest point between them falls
|
||||
below ``valley_frac`` of the smaller peak (a genuine separating valley).
|
||||
|
||||
Args:
|
||||
values: Numeric numpy array.
|
||||
prom_frac: Minimum peak height as a fraction of the tallest peak.
|
||||
valley_frac: Two peaks count as distinct modes only if the valley
|
||||
between them dips below this fraction of the smaller peak.
|
||||
|
||||
Returns:
|
||||
Number of distinct modes (0 for an empty/degenerate sample).
|
||||
"""
|
||||
arr = np.asarray(values, dtype=float)
|
||||
n = arr.size
|
||||
if n == 0:
|
||||
return 0
|
||||
n_bins = max(10, min(50, int(round(math.sqrt(n)))))
|
||||
counts, _ = np.histogram(arr, bins=n_bins)
|
||||
kernel = np.array([1.0, 2.0, 1.0])
|
||||
kernel /= kernel.sum()
|
||||
smooth = np.convolve(counts.astype(float), kernel, mode="same")
|
||||
|
||||
peak_global = float(smooth.max())
|
||||
if peak_global <= 0:
|
||||
return 0
|
||||
threshold = peak_global * prom_frac
|
||||
|
||||
peaks = []
|
||||
for i in range(len(smooth)):
|
||||
left = smooth[i - 1] if i > 0 else -1.0
|
||||
right = smooth[i + 1] if i < len(smooth) - 1 else -1.0
|
||||
if smooth[i] >= threshold and smooth[i] > left and smooth[i] >= right:
|
||||
peaks.append(i)
|
||||
if len(peaks) <= 1:
|
||||
return len(peaks)
|
||||
|
||||
kept = [peaks[0]]
|
||||
for p in peaks[1:]:
|
||||
prev = kept[-1]
|
||||
valley = float(smooth[prev:p + 1].min())
|
||||
if valley <= valley_frac * min(smooth[prev], smooth[p]):
|
||||
kept.append(p) # separated by a deep valley -> distinct mode
|
||||
elif smooth[p] > smooth[prev]:
|
||||
kept[-1] = p # same mode, keep the taller peak
|
||||
return len(kept)
|
||||
|
||||
@@ -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"])
|
||||
@@ -3,10 +3,10 @@ name: infer_fk_containment_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def infer_fk_containment_duckdb(db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000) -> dict"
|
||||
description: "Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores: para un par (col A de T1, col B de T2), inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|; si inclusion >= min_inclusion y B parece clave (distinct/count >= 0.95) entonces A -> B es FK candidata. Poda por tipo base y push-down SQL (COUNT DISTINCT / INTERSECT) sin traer filas a RAM. Parte del grupo eda (relaciones inter-tabla)."
|
||||
signature: "def infer_fk_containment_duckdb(db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000, require_name_signal: bool = True) -> dict"
|
||||
description: "Infiere FOREIGN KEYs candidatas entre tablas DuckDB combinando SEÑAL DE NOMBRE y containment de valores: exige primero que el origen nombre/contenga la tabla destino (patron <X>Id -> X.<X>Id / <x>_id -> x) y que el destino sea su PK nombrada, excluyendo PKs propias y columnas de medida como origen; luego confirma con inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)| >= min_inclusion y B key-ish (distinct/count >= 0.95). El filtro de nombre va ANTES del INTERSECT: mata el 10-20x de falsos positivos de la contencion pura y acelera (menos pares). Degrada a contencion pura si el esquema no usa convencion de nombres. Poda por tipo base y push-down SQL sin traer filas a RAM. Parte del grupo eda (relaciones inter-tabla)."
|
||||
tags: [eda, relations, duckdb, foreign-key, schema-inference, datascience, exploratory-data-analysis]
|
||||
params:
|
||||
- name: db_path
|
||||
@@ -17,7 +17,9 @@ params:
|
||||
desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata. inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|. Default 0.9."
|
||||
- name: max_card
|
||||
desc: "Tope de filas en la tabla destino (lado B, el caro del INTERSECT). Si count(T2) > max_card, los pares hacia T2 se saltan para no disparar un INTERSECT gigante; se acumula una nota en skipped[]. Default 200000."
|
||||
output: "dict dict-no-throw. En exito {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key}, ...], tables:[str], skipped:[str]} con fk_candidates ordenado por inclusion descendente; cardinality es '1:1' (A casi unica en T1) o 'N:1' (A se repite, apunta a la key de T2). En error {status:'error', error:str}."
|
||||
- name: require_name_signal
|
||||
desc: "Si True (default) exige señal de nombre ademas de contencion: el origen debe nombrar/contener la tabla destino y NO ser la PK de su propia tabla; el destino debe ser su PK nombrada (o `id`). Filtra los pares ANTES del INTERSECT (precision + velocidad, issues H3+H10). Degrada automaticamente a contencion pura si el esquema no usa convencion de nombres de clave (ninguna columna `...id`). Con False nunca se exige señal (comportamiento historico de contencion pura)."
|
||||
output: "dict dict-no-throw. En exito {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key, name_match}, ...], tables:[str], skipped:[str], name_signal_enforced:bool} con fk_candidates ordenado por (name_match, inclusion) descendente; name_match indica si la candidata tiene señal de nombre; name_signal_enforced indica si el filtro de nombre se aplico (False si se degrado a contencion pura); cardinality es '1:1' (A casi unica en T1) o 'N:1' (A se repite, apunta a la key de T2). En error {status:'error', error:str}."
|
||||
uses_functions: [duckdb_list_tables_py_infra, duckdb_table_schema_py_infra, duckdb_query_readonly_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -25,7 +27,7 @@ returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_detecta_fk_orders_customer_id", "test_shape_resultado", "test_no_inventa_fk_columnas_no_relacionadas", "test_no_fk_entre_tipos_incompatibles", "test_min_inclusion_alto_filtra", "test_subset_explicito_de_tablas", "test_db_inexistente_devuelve_error", "test_tabla_invalida_devuelve_error"]
|
||||
tests: ["test_detecta_fk_orders_customer_id", "test_shape_resultado", "test_no_inventa_fk_columnas_no_relacionadas", "test_no_fk_entre_tipos_incompatibles", "test_min_inclusion_alto_filtra", "test_subset_explicito_de_tablas", "test_db_inexistente_devuelve_error", "test_tabla_invalida_devuelve_error", "test_name_signal_helpers", "test_conserva_fk_reales_con_nombre", "test_excluye_medida_y_pk_como_origen", "test_degrada_a_contencion_sin_pistas_de_nombre", "test_require_name_signal_false_es_historico", "test_flujo_materializado_create_table_as_no_vacia"]
|
||||
test_file_path: "python/functions/datascience/infer_fk_containment_duckdb_test.py"
|
||||
file_path: "python/functions/datascience/infer_fk_containment_duckdb.py"
|
||||
---
|
||||
@@ -71,6 +73,8 @@ else:
|
||||
- **Impura**: lee de disco via las primitivas read-only del grupo `duckdb` (no crea ni modifica la base). El `db_path` debe existir.
|
||||
- **Coste O(pares podados)**: el numero de comparaciones es O(tablas^2 x columnas^2) ANTES de la poda. La poda por tipo base (solo se comparan columnas de la misma clase: ambos enteros, ambos varchar, ...) recorta drasticamente ese espacio, pero en esquemas con muchas tablas y columnas del mismo tipo puede seguir siendo costoso. Cada par evaluado dispara un `INTERSECT` en el motor.
|
||||
- **`INTERSECT` puede ser caro en tablas enormes**: por eso `max_card` (default 200000) limita el lado destino. Si `count(T2) > max_card`, los pares hacia T2 se saltan y se anota en `skipped[]`. Sube `max_card` con cuidado: el INTERSECT materializa los distintos de ambos lados.
|
||||
- **Señal de nombre obligatoria por defecto (`require_name_signal=True`)**: para emitir una candidata, el origen debe NOMBRAR o CONTENER la tabla destino (`AlbumId -> Album`, `customer_id -> customers`, `manager_staff_id -> staff`) y el destino debe ser su PK nombrada (o `id`). Esto mata el grueso de falsos de la contencion pura (sin el filtro, chinook daba 111 candidatas vs 9 reales; sakila 565 vs ~21). Limite: una FK con **nombre divergente** que no contiene la tabla destino (p.ej. `Customer.SupportRepId -> Employee.EmployeeId`) NO es alcanzable por nombre y se pierde; y las **self-FK** (`Employee.ReportsTo -> Employee`) nunca se infieren (la funcion exige T1 != T2). Si tu esquema usa convencion de nombres pero tiene FK con columnas que no terminan en `id`, esas tambien se pierden en modo enforce.
|
||||
- **Degrada a contencion pura sin convencion de nombres**: si NINGUNA columna del esquema termina en `id`, no se exige señal y se vuelve al comportamiento historico de solo-contencion (`name_signal_enforced=False` en el retorno). Tambien puedes forzarlo con `require_name_signal=False`.
|
||||
- **Containment != FK declarada**: que A este contenido en B (con B key-ish) es una FK *probable*, no una garantia. Una columna puede estar contenida por coincidencia (rangos pequenos de enteros, banderas, fechas solapadas) sin ser una relacion real. Revisa siempre las candidatas; trata `inclusion` y `cardinality` como senales, no como verdad.
|
||||
- **Entero y float NO se mezclan**: la poda por tipo pone INTEGER/BIGINT/... en la clase `integer` y FLOAT/DOUBLE/DECIMAL en `float`, y solo empareja columnas de la misma clase. Una FK entera contra una columna float casi nunca es real, asi que se descarta de entrada.
|
||||
- **Solo esquema `main`** cuando `tables=None`: hereda el alcance de `duckdb_list_tables` (esquema `main`).
|
||||
@@ -101,6 +105,30 @@ filas a RAM. Los `count(*)` por tabla y los `distinct` por columna se cachean pa
|
||||
no recomputarlos entre pares.
|
||||
```text
|
||||
fk_candidate = {
|
||||
from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key
|
||||
from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key,
|
||||
name_match
|
||||
}
|
||||
```
|
||||
|
||||
Antes del criterio de containment (pasos 1-5), cuando `require_name_signal=True` y
|
||||
el esquema usa convencion de nombres, se aplica un filtro de SEÑAL DE NOMBRE que
|
||||
recorta los pares evaluados (por eso baja tambien el coste, issue H10):
|
||||
|
||||
0a. El origen no puede ser la PK de su propia tabla, detectada SOLO por NOMBRE: el
|
||||
stem de la columna casa con el nombre de la tabla (`Genre.GenreId`, `film.film_id`)
|
||||
o es el generico `id`. NO se usa la PRIMARY KEY declarada — asi funciona sobre
|
||||
tablas materializadas con `CREATE TABLE AS` (sin PK), donde `Track.AlbumId`
|
||||
(stem 'album' != tabla 'track') NO es PK propia y se conserva como FK.
|
||||
0b. El destino debe ser la PK nombrada de su tabla: `to_col` nombra `to_table`
|
||||
(`store_id` en store) o es el generico `id`.
|
||||
0c. El origen debe nombrar la tabla destino: su stem casa con `to_table`
|
||||
(`<X>Id -> X`) o la contiene como subcadena (`manager_staff_id -> staff`).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-29) — añade `require_name_signal` (default True): filtro de señal
|
||||
de nombre ANTES del containment. Corrige falsos positivos masivos de la
|
||||
inferencia por sola contencion (chinook 111->9, sakila 565->21) y acelera (chinook
|
||||
6.8s->0.4s, sakila 23.4s->0.9s). Issues H3 + H10 del benchmark EDA. Retrocompatible:
|
||||
degrada a contencion pura si el esquema no usa convencion de nombres de clave.
|
||||
Nuevos campos en el retorno: `name_match` por candidata y `name_signal_enforced`.
|
||||
|
||||
@@ -94,6 +94,119 @@ def _valid_idents(*names) -> bool:
|
||||
return all(isinstance(n, str) and _IDENT_RE.match(n) for n in names)
|
||||
|
||||
|
||||
# --- Señal de nombre (precisión de FK, issues H3 + H10) -----------------------
|
||||
# La contención de valores por si sola produce 10-20x falsos positivos: cualquier
|
||||
# clave entera pequeña (1..N) esta contenida en cualquier clave mas grande, y las
|
||||
# columnas de medida (cantidades, importes) caen dentro del rango de los ids. La
|
||||
# señal mas fuerte de una FK real es el NOMBRE de la columna: el patron canonico
|
||||
# `<X>Id -> <X>.<X>Id` (PascalCase de chinook) o `<x>_id -> <x>.<x>_id` (snake_case
|
||||
# de sakila). Filtrar candidatos por nombre ANTES del INTERSECT corrige precision
|
||||
# y rendimiento a la vez (menos pares que evaluar).
|
||||
|
||||
|
||||
def _norm(s) -> str:
|
||||
"""Normaliza un identificador: minusculas, solo [a-z0-9] (quita `_`, espacios)."""
|
||||
return re.sub(r"[^a-z0-9]", "", str(s).lower())
|
||||
|
||||
|
||||
def _singular(s: str) -> str:
|
||||
"""Singular ingles aproximado (KISS): customers->customer, cities->city.
|
||||
|
||||
Heuristica suficiente para casar columna `<x>_id` con tabla `<x>s`/`<x>`.
|
||||
"""
|
||||
if len(s) > 4 and s.endswith("ies"):
|
||||
return s[:-3] + "y"
|
||||
if len(s) > 3 and s.endswith("s") and not s.endswith("ss"):
|
||||
return s[:-1]
|
||||
return s
|
||||
|
||||
|
||||
def _ends_id(norm: str) -> bool:
|
||||
"""True si el nombre normalizado termina en `id` (incluye el propio `id`)."""
|
||||
return norm.endswith("id") and norm != ""
|
||||
|
||||
|
||||
def _id_stem(norm: str) -> str:
|
||||
"""Quita el sufijo `id` de un nombre ya normalizado: albumid->album, id->''."""
|
||||
return norm[:-2] if norm.endswith("id") else norm
|
||||
|
||||
|
||||
def _name_eq(a, b) -> bool:
|
||||
"""Igualdad de nombres tolerante a singular/plural (normaliza ambos lados)."""
|
||||
na, nb = _norm(a), _norm(b)
|
||||
if not na or not nb:
|
||||
return False
|
||||
return _singular(na) == _singular(nb)
|
||||
|
||||
|
||||
def _col_is_own_pk(col: str, table: str) -> bool:
|
||||
"""True si `col` parece la PRIMARY KEY de su propia `table` por nombre.
|
||||
|
||||
Una PK no es origen de FK en un esquema normal: `Genre.GenreId` referencia a
|
||||
su propia tabla, no a otra. Casos: `GenreId` en Genre, `film_id` en film, o el
|
||||
generico `id`. Esto impide que las PKs pequeñas (1..N), contenidas por
|
||||
construccion en cualquier clave mayor, se emitan como FK absurdas (origen).
|
||||
"""
|
||||
nc = _norm(col)
|
||||
if nc == "id":
|
||||
return True
|
||||
if _ends_id(nc) and _name_eq(_id_stem(nc), table):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _name_signal(from_col: str, to_table: str, to_col: str) -> bool:
|
||||
"""True si (from_col -> to_table.to_col) tiene señal de nombre de FK real.
|
||||
|
||||
Dos condiciones, AMBAS necesarias:
|
||||
|
||||
1. El DESTINO es la PK nombrada de su tabla: `to_col` nombra `to_table`
|
||||
(`store_id` en store, `AlbumId` en Album) o es el generico `id`. Esto ancla
|
||||
el destino a una clave real, no a una columna cualquiera de la tabla.
|
||||
|
||||
2. El ORIGEN apunta a esa tabla por su nombre: el stem de `from_col` casa con
|
||||
`to_table` (`<X>Id -> X`, `<x>_id -> x`) o lo CONTIENE como subcadena
|
||||
(`manager_staff_id -> staff`). El origen debe terminar en `id`.
|
||||
|
||||
Mata el grueso de falsos de la contencion pura: `ArtistId -> Invoice.InvoiceId`
|
||||
falla porque "artist" no nombra ni contiene "invoice"; `Quantity -> AlbumId`
|
||||
falla porque "quantity" no termina en id. Conserva las FK reales con nombre que
|
||||
casa (`Track.AlbumId -> Album.AlbumId`). Limite conocido: FK con nombre
|
||||
divergente que no contiene la tabla destino (`Customer.SupportRepId ->
|
||||
Employee.EmployeeId`) no son alcanzables por nombre.
|
||||
"""
|
||||
nfc = _norm(from_col)
|
||||
if not _ends_id(nfc):
|
||||
return False
|
||||
ntc = _norm(to_col)
|
||||
# (1) destino = PK nombrada del to_table, o `id` generico.
|
||||
to_col_ok = (_ends_id(ntc) and _name_eq(_id_stem(ntc), to_table)) or ntc == "id"
|
||||
if not to_col_ok:
|
||||
return False
|
||||
# (2) origen nombra (o contiene) la tabla destino.
|
||||
fstem = _id_stem(nfc)
|
||||
if _name_eq(fstem, to_table):
|
||||
return True
|
||||
sing_t = _singular(_norm(to_table))
|
||||
if sing_t and (sing_t in fstem or _norm(to_table) in fstem):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _schema_has_name_hints(cols_by_table: dict) -> bool:
|
||||
"""True si el esquema usa convencion de nombres de clave (alguna columna `...id`).
|
||||
|
||||
Permite degradar a contencion pura (retrocompatible) en bases con columnas
|
||||
cripticas (`c1`, `c2`) que no siguen ninguna convencion: ahi la señal de
|
||||
nombre no aplica y se vuelve al comportamiento historico.
|
||||
"""
|
||||
for cols in cols_by_table.values():
|
||||
for c in cols:
|
||||
if _ends_id(_norm(c["name"])):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _scalar(res: dict):
|
||||
"""Extrae el unico valor escalar de un resultado duckdb_query_readonly.
|
||||
|
||||
@@ -111,6 +224,7 @@ def infer_fk_containment_duckdb(
|
||||
tables: list = None,
|
||||
min_inclusion: float = 0.9,
|
||||
max_card: int = 200000,
|
||||
require_name_signal: bool = True,
|
||||
) -> dict:
|
||||
"""Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores.
|
||||
|
||||
@@ -125,13 +239,23 @@ def infer_fk_containment_duckdb(
|
||||
max_card: tope de filas en la tabla destino (lado B, el caro del INTERSECT).
|
||||
Si count(T2) > max_card, el par se salta para no disparar un INTERSECT
|
||||
gigante; se acumula una nota en skipped[]. Default 200000.
|
||||
require_name_signal: si True (default) exige SEÑAL DE NOMBRE ademas de
|
||||
contencion: la columna origen debe nombrar la tabla destino (patron
|
||||
`<X>Id -> X.<X>Id` / `<x>_id -> x.<x>_id`) o referenciar su PK
|
||||
nombrada, y NO puede ser la PK de su propia tabla. Esto elimina el
|
||||
10-20x de falsos positivos de la contencion pura (issues H3+H10) y, al
|
||||
filtrar pares ANTES del INTERSECT, acelera. Degrada automaticamente a
|
||||
contencion pura si el esquema no usa convencion de nombres de clave
|
||||
(ninguna columna `...id`), por retrocompatibilidad. Con False nunca se
|
||||
exige señal (comportamiento historico).
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw. En exito:
|
||||
{status:'ok',
|
||||
fk_candidates:[{from_table, from_col, to_table, to_col, inclusion,
|
||||
cardinality, to_is_key}, ...], # ordenado por inclusion desc
|
||||
tables:[str], skipped:[str]}
|
||||
cardinality, to_is_key, name_match}, ...],
|
||||
# ordenado por inclusion desc
|
||||
tables:[str], skipped:[str], name_signal_enforced:bool}
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
@@ -214,6 +338,11 @@ def infer_fk_containment_duckdb(
|
||||
key_cache[cache_key] = (ratio >= 0.95, ratio)
|
||||
return key_cache[cache_key]
|
||||
|
||||
# 4b) ¿Exigir señal de nombre? Se exige si el caller lo pide Y el esquema
|
||||
# usa convencion de nombres de clave. Si no hay convencion (columnas
|
||||
# cripticas), se degrada a contencion pura (retrocompatible).
|
||||
enforce_name = require_name_signal and _schema_has_name_hints(cols_by_table)
|
||||
|
||||
candidates = []
|
||||
|
||||
# 5) Pares (A en T1, B en T2) con T1 != T2 y misma clase de tipo (PODA).
|
||||
@@ -235,11 +364,24 @@ def infer_fk_containment_duckdb(
|
||||
for a in cols_by_table[t1]:
|
||||
if a["type_class"] == "other":
|
||||
continue
|
||||
# PODA por nombre: una PK nunca es ORIGEN de FK. Excluir aqui
|
||||
# (antes del bucle interno) mata pares absurdos como
|
||||
# `Genre.GenreId -> Track.TrackId` de raiz.
|
||||
if enforce_name and _col_is_own_pk(a["name"], t1):
|
||||
continue
|
||||
for b in cols_by_table[t2]:
|
||||
# PODA: solo pares con la misma clase de tipo base.
|
||||
if a["type_class"] != b["type_class"]:
|
||||
continue
|
||||
|
||||
# PODA POR NOMBRE (issues H3+H10): exigir señal de nombre
|
||||
# ANTES del INTERSECT. Recorta el grueso de pares falsos y
|
||||
# evita el coste del containment sobre ellos.
|
||||
if enforce_name and not _name_signal(
|
||||
a["name"], t2, b["name"]
|
||||
):
|
||||
continue
|
||||
|
||||
# distinct(A); si es 0, no hay containment que medir.
|
||||
d_a = distinct_count(t1, a["name"])
|
||||
if d_a == 0:
|
||||
@@ -281,16 +423,25 @@ def infer_fk_containment_duckdb(
|
||||
"inclusion": inclusion,
|
||||
"cardinality": cardinality,
|
||||
"to_is_key": True,
|
||||
"name_match": _name_signal(
|
||||
a["name"], t2, b["name"]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda c: c["inclusion"], reverse=True)
|
||||
# Orden: primero las que tienen señal de nombre (FK mas fiables), luego por
|
||||
# inclusion descendente. En modo degradado (sin señal) todas son False y el
|
||||
# orden cae a inclusion, como antes.
|
||||
candidates.sort(
|
||||
key=lambda c: (c["name_match"], c["inclusion"]), reverse=True
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"fk_candidates": candidates,
|
||||
"tables": tables,
|
||||
"skipped": skipped,
|
||||
"name_signal_enforced": enforce_name,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@@ -145,3 +145,169 @@ def test_tabla_invalida_devuelve_error(db):
|
||||
"""Un nombre de tabla no interpolable devuelve error sin tocar la base."""
|
||||
res = infer_fk_containment_duckdb(db, tables=["orders; DROP TABLE orders"])
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
# --- Señal de nombre: precisión de FK (issues H3 + H10) -----------------------
|
||||
|
||||
|
||||
def test_name_signal_helpers():
|
||||
"""Unit tests del nucleo de señal de nombre (puro, sin DB).
|
||||
|
||||
Cubre el patron canonico `<X>Id -> X.<X>Id`, el snake_case `<x>_id -> x`, el
|
||||
nombre compuesto que contiene la tabla (`manager_staff_id -> staff`), y los
|
||||
falsos que la contencion pura dejaba pasar.
|
||||
"""
|
||||
from .infer_fk_containment_duckdb import _col_is_own_pk, _name_signal
|
||||
|
||||
# Golden — FK reales con nombre que casa.
|
||||
assert _name_signal("AlbumId", "Album", "AlbumId") is True
|
||||
assert _name_signal("customer_id", "customers", "id") is True
|
||||
assert _name_signal("ArtistId", "Artist", "ArtistId") is True
|
||||
# Nombre compuesto que CONTIENE la tabla destino.
|
||||
assert _name_signal("manager_staff_id", "staff", "staff_id") is True
|
||||
|
||||
# Falsos que mata el fix.
|
||||
assert _name_signal("Quantity", "Album", "AlbumId") is False # no es id-ref
|
||||
assert _name_signal("ArtistId", "Invoice", "InvoiceId") is False # no nombra Invoice
|
||||
assert _name_signal("GenreId", "Track", "TrackId") is False # no nombra Track
|
||||
|
||||
# PK de su propia tabla: nunca es ORIGEN de FK.
|
||||
assert _col_is_own_pk("GenreId", "Genre") is True
|
||||
assert _col_is_own_pk("film_id", "film") is True
|
||||
assert _col_is_own_pk("id", "customers") is True
|
||||
assert _col_is_own_pk("AlbumId", "Track") is False # FK, no PK propia
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_relational(tmp_path):
|
||||
"""Esquema mini estilo chinook: FK con nombre + medida + PK pequeña.
|
||||
|
||||
artist(ArtistId PK 1..3), album(AlbumId PK 1..5, ArtistId FK->artist),
|
||||
track(TrackId PK 1..10, AlbumId FK->album, Quantity 1..3 medida).
|
||||
|
||||
La contencion PURA inventaria:
|
||||
- artist.ArtistId (1..3) ⊆ album.AlbumId (1..5) → falso (ArtistId es PK).
|
||||
- track.Quantity (1..3) ⊆ artist.ArtistId (1..3) → falso (Quantity es medida).
|
||||
El fix por nombre debe eliminar ambos y conservar solo las 2 FK reales.
|
||||
"""
|
||||
path = str(tmp_path / "rel_test.duckdb")
|
||||
con = duckdb.connect(path)
|
||||
con.execute("CREATE TABLE artist (ArtistId INTEGER, Name VARCHAR)")
|
||||
con.execute("INSERT INTO artist VALUES (1,'a'),(2,'b'),(3,'c')")
|
||||
con.execute("CREATE TABLE album (AlbumId INTEGER, ArtistId INTEGER, Title VARCHAR)")
|
||||
con.execute(
|
||||
"INSERT INTO album VALUES "
|
||||
"(1,1,'x'),(2,1,'y'),(3,2,'z'),(4,3,'w'),(5,3,'v')"
|
||||
)
|
||||
con.execute("CREATE TABLE track (TrackId INTEGER, AlbumId INTEGER, Quantity INTEGER)")
|
||||
con.execute(
|
||||
"INSERT INTO track VALUES "
|
||||
"(1,1,1),(2,1,2),(3,2,1),(4,2,3),(5,3,2),"
|
||||
"(6,3,1),(7,4,2),(8,4,3),(9,5,1),(10,5,2)"
|
||||
)
|
||||
con.close()
|
||||
return path
|
||||
|
||||
|
||||
def test_conserva_fk_reales_con_nombre(db_relational):
|
||||
"""Golden: las 2 FK con nombre que casa se conservan."""
|
||||
res = infer_fk_containment_duckdb(db_relational)
|
||||
assert res["status"] == "ok"
|
||||
assert res["name_signal_enforced"] is True
|
||||
c = res["fk_candidates"]
|
||||
assert _find(c, "album", "ArtistId", "artist", "ArtistId") is not None
|
||||
assert _find(c, "track", "AlbumId", "album", "AlbumId") is not None
|
||||
# Cada candidata trae el flag name_match True (enforce activo).
|
||||
assert all(fk["name_match"] is True for fk in c)
|
||||
|
||||
|
||||
def test_excluye_medida_y_pk_como_origen(db_relational):
|
||||
"""Golden anti-falsos: Quantity (medida) y PKs propias no son origen de FK."""
|
||||
res = infer_fk_containment_duckdb(db_relational)
|
||||
c = res["fk_candidates"]
|
||||
# Quantity es una cantidad, jamas una FK.
|
||||
assert not any(fk["from_col"] == "Quantity" for fk in c)
|
||||
# artist.ArtistId es PK de artist: nunca origen (no album falso).
|
||||
assert not any(
|
||||
fk["from_table"] == "artist" and fk["from_col"] == "ArtistId" for fk in c
|
||||
)
|
||||
# album.AlbumId es PK de album: nunca origen.
|
||||
assert not any(
|
||||
fk["from_table"] == "album" and fk["from_col"] == "AlbumId" for fk in c
|
||||
)
|
||||
# Resultado total acotado: solo las 2 FK reales (cero ruido).
|
||||
assert len(c) == 2
|
||||
|
||||
|
||||
def test_degrada_a_contencion_sin_pistas_de_nombre(tmp_path):
|
||||
"""Edge retrocompatible: esquema sin convencion de nombres degrada a contencion.
|
||||
|
||||
Columnas cripticas (a, b, c, d) sin sufijo id → no se exige señal de nombre y
|
||||
se emite por contencion pura, como el comportamiento historico.
|
||||
"""
|
||||
path = str(tmp_path / "cryptic.duckdb")
|
||||
con = duckdb.connect(path)
|
||||
con.execute("CREATE TABLE t1 (a INTEGER, b VARCHAR)")
|
||||
con.execute("INSERT INTO t1 VALUES (1,'p'),(1,'q'),(2,'r'),(3,'s')") # a no unica
|
||||
con.execute("CREATE TABLE t2 (c INTEGER, d VARCHAR)")
|
||||
con.execute("INSERT INTO t2 VALUES (1,'p'),(2,'q'),(3,'r')") # c key 1..3
|
||||
con.close()
|
||||
|
||||
res = infer_fk_containment_duckdb(path)
|
||||
assert res["status"] == "ok"
|
||||
# Sin pistas de nombre → no se exige señal (degrada).
|
||||
assert res["name_signal_enforced"] is False
|
||||
# t1.a (1..3) ⊆ t2.c (1..3): se emite por contencion pura.
|
||||
fk = _find(res["fk_candidates"], "t1", "a", "t2", "c")
|
||||
assert fk is not None
|
||||
assert fk["name_match"] is False # no hay señal, pero se emite por contencion
|
||||
|
||||
|
||||
def test_flujo_materializado_create_table_as_no_vacia(tmp_path):
|
||||
"""Regresion del flujo real: tablas materializadas con CREATE TABLE AS (sin
|
||||
PRIMARY KEY declarada, como hace profile_database al ATTACH sqlite) NO deben
|
||||
vaciar el resultado. La señal de nombre se basa en el PATRON de nombres +
|
||||
unicidad/cardinalidad como proxy de clave, nunca en la PK fisica declarada.
|
||||
|
||||
Confirma el caso critico `Track.AlbumId -> Album.AlbumId`: AlbumId es unica-ish
|
||||
dentro de Track pero NO es la PK de Track (stem 'album' != tabla 'track'), asi
|
||||
que NO se excluye como origen.
|
||||
"""
|
||||
path = str(tmp_path / "materialized.duckdb")
|
||||
con = duckdb.connect(path)
|
||||
# CREATE TABLE AS SELECT: sin PK, exactamente como la materializacion sqlite.
|
||||
con.execute(
|
||||
"CREATE TABLE Artist AS SELECT * FROM "
|
||||
"(VALUES (1,'a'),(2,'b'),(3,'c')) t(ArtistId, Name)"
|
||||
)
|
||||
con.execute(
|
||||
"CREATE TABLE Album AS SELECT * FROM "
|
||||
"(VALUES (1,1,'x'),(2,1,'y'),(3,2,'z'),(4,3,'w'),(5,3,'v')) "
|
||||
"t(AlbumId, ArtistId, Title)"
|
||||
)
|
||||
con.execute(
|
||||
"CREATE TABLE Track AS SELECT * FROM "
|
||||
"(VALUES (1,1),(2,1),(3,2),(4,2),(5,3),(6,4),(7,5)) t(TrackId, AlbumId)"
|
||||
)
|
||||
con.close()
|
||||
|
||||
res = infer_fk_containment_duckdb(path)
|
||||
assert res["status"] == "ok"
|
||||
c = res["fk_candidates"]
|
||||
# NO debe vaciar: sin PK declarada el patron de nombres sigue detectando FK.
|
||||
assert len(c) > 0, "flujo materializado sin PK no debe dar 0 candidatas"
|
||||
# FK reales conservadas pese a no haber PRIMARY KEY fisica.
|
||||
assert _find(c, "Track", "AlbumId", "Album", "AlbumId") is not None
|
||||
assert _find(c, "Album", "ArtistId", "Artist", "ArtistId") is not None
|
||||
|
||||
|
||||
def test_require_name_signal_false_es_historico(db_relational):
|
||||
"""Apagar require_name_signal vuelve al comportamiento de contencion pura.
|
||||
|
||||
Sin el filtro de nombre reaparecen los falsos (mas candidatas que las 2 reales).
|
||||
"""
|
||||
res = infer_fk_containment_duckdb(db_relational, require_name_signal=False)
|
||||
assert res["status"] == "ok"
|
||||
assert res["name_signal_enforced"] is False
|
||||
# La contencion pura genera mas que las 2 FK reales (incluye PKs/medidas).
|
||||
assert len(res["fk_candidates"]) > 2
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: render_automatic_eda_pdf
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_automatic_eda_pdf(chapters_or_profile, out_path: str, meta: dict = None) -> dict"
|
||||
description: "Renderiza un documento AutomaticEDA por CAPÍTULOS (modelo de bloques independiente del formato) en un PDF A5 retrato pensado para LEER EN EL MÓVIL. Acepta una lista de capítulos del modelo o directamente un TableProfile del grupo eda (en cuyo caso construye los capítulos canónicos con build_document). El paginador MIDE cada bloque y NUNCA corta nada: el texto se envuelve a líneas completas, las tablas largas se parten por filas REPITIENDO la cabecera, figuras e imágenes se escalan para caber enteras. Cada capítulo empieza en página nueva con pie 'Capítulo · vX.Y.Z' y se escribe un manifiesto automatic_eda_manifest.json junto a la salida para seguimiento por capítulo. dict-no-throw: nunca lanza, devuelve {path, n_pages, chapters, manifest_path, note}. Motor matplotlib PdfPages. Aditivo: NO reemplaza render_eda_pdf."
|
||||
tags: [eda, pdf, render, report, mobile, automatic-eda, chapters, versioned, no-cut, pagination, matplotlib, datascience, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, matplotlib, "datascience.automatic_eda"]
|
||||
params:
|
||||
- name: chapters_or_profile
|
||||
desc: "una lista de capítulos del modelo AutomaticEDA (dataclasses Chapter o dicts {id,title,version,blocks}) O un TableProfile dict del grupo eda. Si es un TableProfile, los capítulos canónicos se construyen con build_document(profile, meta['ctx']). Un capítulo es {id,title,version,blocks}; un bloque es uno de: heading, markdown, kv_table, data_table, figure, image, caption, note. Lectura defensiva: cualquier cosa no reconocida se degrada a Note, nunca lanza."
|
||||
- name: out_path
|
||||
desc: "ruta del archivo PDF de salida. Los directorios padre se crean si faltan. Si está en un directorio no escribible (p.ej. /proc/...) devuelve {path:None, note:<causa>} sin lanzar."
|
||||
- name: meta
|
||||
desc: "dict opcional. Claves: title (título de portada/pie), ctx (contexto de presentación pasado a los builders de capítulo cuando se da un profile: dataset_name, source_origin, storage, generated_at, description, granularity, quality_criteria, head_rows...), manifest_path (override; por defecto automatic_eda_manifest.json junto a out_path), write_manifest (False para no escribirlo), generated_at."
|
||||
output: "dict (nunca lanza): {path: str|None, n_pages: int, chapters: list[{id,version,n_pages}], manifest_path: str|None, note: str}. En éxito path es la ruta escrita, n_pages el total de páginas, chapters el desglose por capítulo para el manifiesto. En error fatal path es None y note explica la causa."
|
||||
tested: true
|
||||
tests: ["test_golden_profile_genera_pdf_portada_y_overview", "test_edge_tabla_larga_parte_repitiendo_cabecera", "test_edge_celda_larga_no_se_corta", "test_no_corta_texto_markdown", "test_edge_profile_none_y_vacio_un_pagina", "test_error_path_directorio_no_escribible_no_revienta"]
|
||||
test_file_path: "python/functions/datascience/render_automatic_eda_pdf_test.py"
|
||||
file_path: "python/functions/datascience/render_automatic_eda_pdf.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import render_automatic_eda_pdf
|
||||
|
||||
# Caso 1: directamente desde un TableProfile del grupo eda.
|
||||
# profile = profile_table(db, "ventas", backend="duckdb")["profile"]
|
||||
profile = {
|
||||
"table": "ventas", "source": "/data/ventas.csv",
|
||||
"n_rows": 1000, "n_cols": 2, "quality_score": 92.5,
|
||||
"columns": [
|
||||
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.01,
|
||||
"null_count": 10,
|
||||
"numeric": {"mean": 42.5, "median": 40.0, "min": 1.0, "max": 100.0,
|
||||
"std": 12.3}},
|
||||
{"name": "categoria", "inferred_type": "categorical", "null_pct": 0.0,
|
||||
"categorical": {"top": [{"value": "neumaticos", "count": 500},
|
||||
{"value": "aceite", "count": 300}]}},
|
||||
],
|
||||
}
|
||||
res = render_automatic_eda_pdf(
|
||||
profile, "reports/ventas_aeda.pdf",
|
||||
{"title": "EDA — ventas",
|
||||
"ctx": {"dataset_name": "Ventas", "source_origin": "ERP export",
|
||||
"description": "Líneas de venta del ERP.",
|
||||
"granularity": "Cada fila es una línea de venta."}})
|
||||
print(res["n_pages"], res["chapters"], res["manifest_path"])
|
||||
# -> 3 [{'id':'portada','version':'1.0.0','n_pages':1},
|
||||
# {'id':'overview','version':'1.0.0','n_pages':2}] reports/automatic_eda_manifest.json
|
||||
|
||||
# Caso 2: desde capítulos construidos a mano (modelo de bloques).
|
||||
from datascience.automatic_eda.model import Chapter, Heading, DataTable
|
||||
ch = Chapter(id="resumen", title="Resumen", version="1.0.0", blocks=[
|
||||
Heading("Tabla", 1),
|
||||
DataTable(header=["col", "valor"], rows=[["a", "1"], ["b", "2"]]),
|
||||
])
|
||||
render_automatic_eda_pdf([ch], "reports/manual.pdf")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras el **PDF móvil del nuevo motor AutomaticEDA por capítulos** (portada
|
||||
+ overview + los capítulos que existan): después de `profile_table(...)`, pásale el
|
||||
`profile` y obtienes un PDF A5 retrato versionado por capítulo, con manifiesto. Úsala
|
||||
como capa de presentación PDF del grupo `eda` cuando necesites **garantía de no-corte**
|
||||
(texto, tablas e imágenes nunca recortados) y **versionado por capítulo** para mejora
|
||||
continua. Es el reemplazo evolutivo de `render_eda_pdf`: comparte estética Tufte/móvil
|
||||
pero separa contenido (capítulos/bloques) de formato (renderer), de modo que el mismo
|
||||
documento se emite también como PPTX (`render_automatic_eda_pptx`). Para añadir un
|
||||
capítulo nuevo, ver `docs/capabilities/automatic_eda.md`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe el PDF en `out_path` (crea los directorios padre) y, salvo
|
||||
`meta['write_manifest']=False`, un `automatic_eda_manifest.json` junto a la salida.
|
||||
Backend headless `Agg` de matplotlib (corre en agentes/CI sin display).
|
||||
- **Nunca lanza** (dict-no-throw): un bloque o capítulo que falle se omite y se anota
|
||||
en `note`; el PDF se genera igual. Un profile `None`/`{}` produce un PDF de 1 página
|
||||
válido. `out_path` no escribible → `{path: None, note: <causa>}`.
|
||||
- **No corta nada**: el paginador mide cada bloque con una rejilla de caracteres
|
||||
(sobre-estima ligeramente, nunca afirma que algo cabe cuando se desbordaría). El
|
||||
texto se envuelve a líneas completas (sin cortar a media palabra), las tablas largas
|
||||
se parten por filas **repitiendo la cabecera**, las celdas con texto largo se
|
||||
envuelven dentro de su columna (la fila crece), y figuras/imágenes se escalan para
|
||||
caber enteras (nunca se recortan).
|
||||
- **Tablas muy anchas**: con muchas columnas (>10) cada columna se estrecha y su texto
|
||||
se envuelve en varias líneas (sigue sin perderse). El reparto por columnas-en-grupos
|
||||
para tablas muy anchas es una mejora pendiente (ver capability page).
|
||||
- **head_rows / examples**: el capítulo Overview muestra `df.head` desde
|
||||
`ctx['head_rows']`/`profile['head_rows']` y ejemplos no-nulos desde
|
||||
`columns[i]['examples']`; si el profile no los trae (hoy no los trae), degrada con un
|
||||
placeholder honesto y deriva los ejemplos de los valores reales del perfil (top
|
||||
categóricos, min/median/max numéricos). Documentado en el contrato.
|
||||
- **Registro en el package**: el `## Ejemplo` usa `from datascience import
|
||||
render_automatic_eda_pdf` (añadido al `__init__.py`); el test importa el módulo
|
||||
directo para no depender de ese registro.
|
||||
- **Fechas en UI europeas**: la portada formatea la fecha como `DD/MM/AAAA HH:mm`.
|
||||
@@ -0,0 +1,83 @@
|
||||
"""render_automatic_eda_pdf — chapter-based EDA report as an A5-portrait PDF.
|
||||
|
||||
Public ``eda``-group entry point of the AutomaticEDA engine. Takes either a list
|
||||
of chapters (the format-independent document model) or an ``eda`` TableProfile
|
||||
dict (in which case the canonical chapters are built with ``build_document``),
|
||||
and renders a mobile-first PDF whose paginator MEASURES every block and never
|
||||
cuts text, tables or images: text wraps to whole lines, long tables split by
|
||||
rows repeating the header, figures/images scale to fit entirely. Each chapter
|
||||
starts on a fresh page stamped ``<Chapter> · v<version>`` in the footer, and a
|
||||
per-chapter manifest (``automatic_eda_manifest.json``) is written next to the
|
||||
output for version tracking.
|
||||
|
||||
dict-no-throw: never raises. Returns ``{path, n_pages, chapters, manifest_path,
|
||||
note}``; on a fatal write error ``path`` is None and ``note`` explains why.
|
||||
|
||||
Additive: this does NOT replace ``render_eda_pdf`` (still used by
|
||||
``profile_table(emit_pdf=True)``). It is the new engine that will, in the next
|
||||
phase, let every EDA emit both a PDF and a PPTX from the same chapter model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from datascience.automatic_eda import build_document, merge_manifest, render_pdf
|
||||
from datascience.automatic_eda.model import as_chapter, as_chapters
|
||||
|
||||
|
||||
def _coerce_chapters(chapters_or_profile, meta: dict) -> list:
|
||||
"""Accept chapters OR an eda profile and return a list of Chapter."""
|
||||
arg = chapters_or_profile
|
||||
if isinstance(arg, (list, tuple)):
|
||||
return as_chapters(list(arg))
|
||||
if isinstance(arg, dict):
|
||||
# A single chapter dict has 'blocks'; a profile has columns/table/rows.
|
||||
if "blocks" in arg and "columns" not in arg:
|
||||
ch = as_chapter(arg)
|
||||
return [ch] if ch is not None else []
|
||||
# Treat as an eda TableProfile.
|
||||
return build_document(arg, (meta or {}).get("ctx"))
|
||||
return []
|
||||
|
||||
|
||||
def render_automatic_eda_pdf(chapters_or_profile, out_path: str,
|
||||
meta: dict = None) -> dict:
|
||||
"""Render an AutomaticEDA document into a mobile-readable PDF.
|
||||
|
||||
Args:
|
||||
chapters_or_profile: either a list of chapters (``Chapter`` dataclasses
|
||||
or dicts following the document model) or an ``eda`` TableProfile
|
||||
dict — in the latter case the canonical chapters are built via
|
||||
``build_document(profile, meta['ctx'])``.
|
||||
out_path: filesystem path for the PDF (parent dirs are created).
|
||||
meta: optional dict. Recognised keys: ``title`` (cover/footer title),
|
||||
``ctx`` (presentation context passed to chapter builders when a
|
||||
profile is given), ``manifest_path`` (override; defaults to
|
||||
``automatic_eda_manifest.json`` beside ``out_path``),
|
||||
``write_manifest`` (set False to skip), ``generated_at``.
|
||||
|
||||
Returns:
|
||||
dict (never raises): ``{path, n_pages, chapters, manifest_path, note}``.
|
||||
"""
|
||||
meta = dict(meta or {})
|
||||
chapters = _coerce_chapters(chapters_or_profile, meta)
|
||||
result = render_pdf(chapters, out_path, meta)
|
||||
|
||||
manifest_path = None
|
||||
if meta.get("write_manifest", True) and result.get("path"):
|
||||
manifest_path = meta.get("manifest_path")
|
||||
if not manifest_path:
|
||||
manifest_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(out_path)),
|
||||
"automatic_eda_manifest.json")
|
||||
generated_at = meta.get("generated_at") or _now_iso()
|
||||
merge_manifest(manifest_path, "pdf", result.get("chapters") or [],
|
||||
generated_at)
|
||||
result["manifest_path"] = manifest_path
|
||||
return result
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for render_automatic_eda_pdf — DoD: golden + edges + error path.
|
||||
|
||||
Self-contained: builds a synthetic TableProfile (no DuckDB) so the suite is fast
|
||||
and deterministic. Verifies the cover/overview reference chapters render, that
|
||||
long tables split by rows repeating the header without losing any cell text,
|
||||
that an empty/None profile still yields a valid 1-page PDF, and that an
|
||||
unwritable destination returns ``{path: None}`` without raising.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||
from datascience.automatic_eda.model import Chapter, DataTable, Heading, Markdown
|
||||
|
||||
|
||||
def _profile() -> dict:
|
||||
return {
|
||||
"table": "ventas",
|
||||
"source": "/data/ventas.csv",
|
||||
"profiled_at": "2026-06-30T10:00:00+00:00",
|
||||
"n_rows": 1000,
|
||||
"n_cols": 3,
|
||||
"quality_score": 92.5,
|
||||
"key_candidates": ["id"],
|
||||
"type_breakdown": {"numeric": 2, "categorical": 1},
|
||||
"columns": [
|
||||
{"name": "id", "inferred_type": "numeric", "null_pct": 0.0,
|
||||
"null_count": 0,
|
||||
"numeric": {"mean": 500.0, "median": 500.0, "min": 1.0,
|
||||
"max": 1000.0, "std": 288.7}},
|
||||
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.01,
|
||||
"null_count": 10,
|
||||
"numeric": {"mean": 42.5, "median": 40.0, "min": 1.0,
|
||||
"max": 100.0, "std": 12.3}},
|
||||
{"name": "categoria", "inferred_type": "categorical",
|
||||
"null_pct": 0.0, "null_count": 0,
|
||||
"categorical": {"top": [{"value": "neumaticos", "count": 500},
|
||||
{"value": "aceite", "count": 300}]}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _pdf_text(path: str) -> str:
|
||||
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
|
||||
return re.sub(r"\s+", " ", txt)
|
||||
|
||||
|
||||
def test_golden_profile_genera_pdf_portada_y_overview():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "eda.pdf")
|
||||
res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA — ventas"})
|
||||
assert res["path"] == out
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] >= 2 # portada + overview (1+ each).
|
||||
ids = [c["id"] for c in res["chapters"]]
|
||||
assert "portada" in ids and "overview" in ids
|
||||
# Manifest written next to the output with both chapters versioned.
|
||||
assert res["manifest_path"] and os.path.exists(res["manifest_path"])
|
||||
txt = _pdf_text(out)
|
||||
# Cover fields.
|
||||
assert "Automatic-EDA" in txt
|
||||
assert "CSV" in txt # storage inferred from .csv source.
|
||||
assert "Calidad" in txt and "92.5" in txt
|
||||
assert "Fuente" in txt
|
||||
# Overview content: column dictionary + describe.
|
||||
assert "precio" in txt and "categoria" in txt
|
||||
assert "median" in txt
|
||||
|
||||
|
||||
def test_edge_tabla_larga_parte_repitiendo_cabecera():
|
||||
# 60 rows over 6 wide columns: the table must split across pages and repeat
|
||||
# the header on every continuation page (headers wide enough not to wrap).
|
||||
header = ["ALPHA", "BETA", "GAMMA", "DELTA", "EPSILON", "ZETA"]
|
||||
rows = [[f"r{r}c{c}" for c in range(6)] for r in range(60)]
|
||||
ch = Chapter(id="edge", title="Edge", version="1.0.0",
|
||||
blocks=[Heading("Tabla", 1),
|
||||
DataTable(header=header, rows=rows)])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "edge.pdf")
|
||||
res = render_automatic_eda_pdf([ch], out, {"write_manifest": False})
|
||||
assert res["path"] == out
|
||||
reader = PdfReader(out)
|
||||
n_pages = len(reader.pages)
|
||||
assert n_pages > 1 # table spilled to several pages.
|
||||
pages_with_header = sum(
|
||||
1 for pg in reader.pages if "ALPHA" in (pg.extract_text() or ""))
|
||||
assert pages_with_header == n_pages # header repeated on every page.
|
||||
|
||||
|
||||
def test_edge_celda_larga_no_se_corta():
|
||||
# A single cell with ~150 chars must wrap inside its column (the row grows),
|
||||
# never truncated: all of its words survive in the rendered PDF.
|
||||
long_cell = ("Lorem ipsum dolor sit amet consectetur adipiscing elit sed do "
|
||||
"eiusmod tempor incididunt ut labore et dolore magna aliqua "
|
||||
"reprehenderit voluptate")
|
||||
header = ["clave", "descripcion"]
|
||||
rows = [["k1", long_cell], ["k2", "corto"]]
|
||||
ch = Chapter(id="edge2", title="Edge2", version="1.0.0",
|
||||
blocks=[DataTable(header=header, rows=rows)])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "edge2.pdf")
|
||||
render_automatic_eda_pdf([ch], out, {"write_manifest": False})
|
||||
txt = _pdf_text(out)
|
||||
# Every word of the long cell present (wrapped, not truncated).
|
||||
for word in ("Lorem", "incididunt", "reprehenderit", "voluptate"):
|
||||
assert word in txt
|
||||
|
||||
|
||||
def test_no_corta_texto_markdown():
|
||||
para = " ".join(f"palabra{i}" for i in range(120))
|
||||
ch = Chapter(id="md", title="MD", version="1.0.0",
|
||||
blocks=[Markdown(text=para)])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "md.pdf")
|
||||
render_automatic_eda_pdf([ch], out, {"write_manifest": False})
|
||||
txt = _pdf_text(out)
|
||||
for i in (0, 60, 119): # first, middle, last words all present.
|
||||
assert f"palabra{i}" in txt
|
||||
|
||||
|
||||
def test_edge_profile_none_y_vacio_un_pagina():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for arg, name in ((None, "none"), ({}, "empty")):
|
||||
out = os.path.join(d, f"{name}.pdf")
|
||||
res = render_automatic_eda_pdf(arg, out, {"write_manifest": False})
|
||||
assert res["path"] == out
|
||||
assert os.path.exists(out)
|
||||
assert res["n_pages"] == 1
|
||||
|
||||
|
||||
def test_error_path_directorio_no_escribible_no_revienta():
|
||||
res = render_automatic_eda_pdf(_profile(), "/proc/nope/x.pdf",
|
||||
{"write_manifest": False})
|
||||
assert res["path"] is None
|
||||
assert res["n_pages"] == 0
|
||||
assert res["note"]
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: render_automatic_eda_pptx
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_automatic_eda_pptx(chapters_or_profile, out_path: str, meta: dict = None) -> dict"
|
||||
description: "Renderiza un documento AutomaticEDA por CAPÍTULOS (modelo de bloques independiente del formato) en una presentación PPTX 16:9 pensada para COMPARTIR. Acepta una lista de capítulos del modelo o directamente un TableProfile del grupo eda (construye los capítulos canónicos con build_document). Mismo principio anti-corte que el renderer PDF: cada bloque se mide y, si no cabe en la slide, continúa en una slide '<Capítulo> (cont.)'; las tablas largas se parten por filas REPITIENDO la cabecera; las figuras matplotlib se exportan a PNG e insertan escaladas para caber enteras. Cada slide lleva pie 'Capítulo · vX.Y.Z' y se escribe automatic_eda_manifest.json junto a la salida. dict-no-throw: nunca lanza, devuelve {path, n_slides, chapters, manifest_path, note}. Motor python-pptx (dependencia declarada en python/pyproject.toml)."
|
||||
tags: [eda, pptx, render, report, share, automatic-eda, chapters, versioned, no-cut, slides, python-pptx, datascience, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, "python-pptx", "datascience.automatic_eda"]
|
||||
params:
|
||||
- name: chapters_or_profile
|
||||
desc: "una lista de capítulos del modelo AutomaticEDA (dataclasses Chapter o dicts {id,title,version,blocks}) O un TableProfile dict del grupo eda. Si es un TableProfile, los capítulos canónicos se construyen con build_document(profile, meta['ctx']). Bloques soportados: heading, markdown, kv_table, data_table, figure, image, caption, note. Lectura defensiva: lo no reconocido se degrada a Note, nunca lanza."
|
||||
- name: out_path
|
||||
desc: "ruta del archivo PPTX de salida. Los directorios padre se crean si faltan. Directorio no escribible → {path:None, note:<causa>} sin lanzar."
|
||||
- name: meta
|
||||
desc: "dict opcional. Claves: title (título), ctx (contexto de presentación para los builders de capítulo cuando se da un profile), manifest_path (override; por defecto automatic_eda_manifest.json junto a out_path), write_manifest (False para no escribirlo), generated_at."
|
||||
output: "dict (nunca lanza): {path: str|None, n_slides: int, chapters: list[{id,version,n_slides}], manifest_path: str|None, note: str}. En error fatal (incluida python-pptx no instalada) path es None y note explica la causa."
|
||||
tested: true
|
||||
tests: ["test_golden_profile_genera_pptx_portada_y_overview", "test_edge_tabla_larga_parte_repitiendo_cabecera_sin_cortar", "test_edge_profile_none_y_vacio_un_slide", "test_error_path_directorio_no_escribible_no_revienta"]
|
||||
test_file_path: "python/functions/datascience/render_automatic_eda_pptx_test.py"
|
||||
file_path: "python/functions/datascience/render_automatic_eda_pptx.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import render_automatic_eda_pptx
|
||||
|
||||
# Desde un TableProfile del grupo eda (mismo modelo que el renderer PDF).
|
||||
profile = {
|
||||
"table": "ventas", "source": "/data/ventas.csv",
|
||||
"n_rows": 1000, "n_cols": 2, "quality_score": 92.5,
|
||||
"columns": [
|
||||
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.01,
|
||||
"numeric": {"mean": 42.5, "median": 40.0, "min": 1.0, "max": 100.0,
|
||||
"std": 12.3}},
|
||||
{"name": "categoria", "inferred_type": "categorical", "null_pct": 0.0,
|
||||
"categorical": {"top": [{"value": "neumaticos", "count": 500}]}},
|
||||
],
|
||||
}
|
||||
res = render_automatic_eda_pptx(
|
||||
profile, "reports/ventas_aeda.pptx",
|
||||
{"title": "EDA — ventas",
|
||||
"ctx": {"dataset_name": "Ventas", "source_origin": "ERP export"}})
|
||||
print(res["n_slides"], res["chapters"], res["manifest_path"])
|
||||
# -> 3 [{'id':'portada','version':'1.0.0','n_slides':1},
|
||||
# {'id':'overview','version':'1.0.0','n_slides':2}] reports/automatic_eda_manifest.json
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras **compartir el EDA como una presentación** (no para móvil sino para
|
||||
enseñar a alguien): mismo documento por capítulos que el PDF, emitido como PPTX 16:9.
|
||||
Úsala junto a `render_automatic_eda_pdf` para que cada EDA tenga sus dos salidas (PDF
|
||||
móvil + PPTX para compartir) desde el mismo modelo de capítulos. Garantiza no-corte:
|
||||
ningún texto, tabla ni imagen se recorta — lo que no cabe en una slide continúa en otra
|
||||
`(cont.)` con la cabecera repetida en las tablas. Para añadir capítulos nuevos al
|
||||
documento, ver `docs/capabilities/automatic_eda.md`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe el PPTX en `out_path` y, salvo `meta['write_manifest']=False`, el
|
||||
manifiesto `automatic_eda_manifest.json` junto a la salida.
|
||||
- **Dependencia python-pptx**: declarada en `python/pyproject.toml`
|
||||
(`python-pptx>=1.0.2`). Si no está instalada, devuelve `{path: None, note:
|
||||
'python-pptx no disponible: ...'}` sin lanzar. Instalar:
|
||||
`uv pip install --python python/.venv/bin/python3 python-pptx`.
|
||||
- **Nunca lanza** (dict-no-throw): un bloque que falle se omite y se anota en `note`; el
|
||||
deck se genera igual. Un profile `None`/`{}` produce un deck de 1 slide válido.
|
||||
- **No corta nada**: cada bloque se mide; si no cabe en la slide actual, abre una slide
|
||||
`(cont.)`. Las tablas largas se parten por filas **repitiendo la cabecera** (las filas
|
||||
restantes pasan a la siguiente slide). Las figuras matplotlib se exportan a PNG en
|
||||
memoria y se insertan escaladas para caber enteras (nunca recortadas).
|
||||
- **Figuras**: un bloque `figure` puede traer una `matplotlib.figure.Figure` ya
|
||||
construida o un callable `make` (se construye perezosamente). Se cierra tras
|
||||
rasterizar. Las imágenes (`image`) por ruta se escalan manteniendo el aspecto.
|
||||
- **Tablas anchas**: con muchas columnas el ancho por columna se reduce y el texto se
|
||||
envuelve dentro de la celda (sigue sin perderse). El reparto por grupos de columnas
|
||||
para tablas muy anchas es mejora pendiente.
|
||||
@@ -0,0 +1,76 @@
|
||||
"""render_automatic_eda_pptx — chapter-based EDA report as a 16:9 PPTX deck.
|
||||
|
||||
Public ``eda``-group entry point that renders an AutomaticEDA document (a list
|
||||
of chapters, or an ``eda`` TableProfile from which the canonical chapters are
|
||||
built) into a PowerPoint deck for sharing. Same anti-cut principle as the PDF
|
||||
renderer: every block is measured and, when it does not fit, continues on a new
|
||||
slide titled ``<Chapter> (cont.)``; data tables split by rows repeating the
|
||||
header; matplotlib figures are exported to PNG and inserted scaled to fit
|
||||
entirely. Each slide is stamped ``<Chapter> · v<version>`` and a per-chapter
|
||||
manifest (``automatic_eda_manifest.json``) is written next to the output.
|
||||
|
||||
dict-no-throw: never raises. Returns ``{path, n_slides, chapters,
|
||||
manifest_path, note}``; on a fatal error ``path`` is None and ``note`` explains
|
||||
why (e.g. python-pptx not installed).
|
||||
|
||||
Engine: ``python-pptx`` (added dependency; declared in python/pyproject.toml).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from datascience.automatic_eda import build_document, merge_manifest, render_pptx
|
||||
from datascience.automatic_eda.model import as_chapter, as_chapters
|
||||
|
||||
|
||||
def _coerce_chapters(chapters_or_profile, meta: dict) -> list:
|
||||
"""Accept chapters OR an eda profile and return a list of Chapter."""
|
||||
arg = chapters_or_profile
|
||||
if isinstance(arg, (list, tuple)):
|
||||
return as_chapters(list(arg))
|
||||
if isinstance(arg, dict):
|
||||
if "blocks" in arg and "columns" not in arg:
|
||||
ch = as_chapter(arg)
|
||||
return [ch] if ch is not None else []
|
||||
return build_document(arg, (meta or {}).get("ctx"))
|
||||
return []
|
||||
|
||||
|
||||
def render_automatic_eda_pptx(chapters_or_profile, out_path: str,
|
||||
meta: dict = None) -> dict:
|
||||
"""Render an AutomaticEDA document into a shareable PPTX deck.
|
||||
|
||||
Args:
|
||||
chapters_or_profile: a list of chapters (``Chapter`` dataclasses or
|
||||
dicts) or an ``eda`` TableProfile dict (chapters built via
|
||||
``build_document(profile, meta['ctx'])``).
|
||||
out_path: filesystem path for the PPTX (parent dirs are created).
|
||||
meta: optional dict. Recognised keys: ``title``, ``ctx``,
|
||||
``manifest_path`` (defaults to ``automatic_eda_manifest.json`` beside
|
||||
``out_path``), ``write_manifest`` (False to skip), ``generated_at``.
|
||||
|
||||
Returns:
|
||||
dict (never raises): ``{path, n_slides, chapters, manifest_path, note}``.
|
||||
"""
|
||||
meta = dict(meta or {})
|
||||
chapters = _coerce_chapters(chapters_or_profile, meta)
|
||||
result = render_pptx(chapters, out_path, meta)
|
||||
|
||||
manifest_path = None
|
||||
if meta.get("write_manifest", True) and result.get("path"):
|
||||
manifest_path = meta.get("manifest_path")
|
||||
if not manifest_path:
|
||||
manifest_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(out_path)),
|
||||
"automatic_eda_manifest.json")
|
||||
generated_at = meta.get("generated_at") or _now_iso()
|
||||
merge_manifest(manifest_path, "pptx", result.get("chapters") or [],
|
||||
generated_at)
|
||||
result["manifest_path"] = manifest_path
|
||||
return result
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for render_automatic_eda_pptx — DoD: golden + edges + error path.
|
||||
|
||||
Self-contained synthetic TableProfile (no DuckDB). Verifies the cover/overview
|
||||
chapters render to slides, that long tables split across slides repeating the
|
||||
header without losing cell text, that an empty/None profile yields a valid
|
||||
1-slide deck, and that an unwritable destination returns ``{path: None}``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from pptx import Presentation
|
||||
|
||||
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||
from datascience.automatic_eda.model import Chapter, DataTable, Heading
|
||||
|
||||
|
||||
def _profile() -> dict:
|
||||
return {
|
||||
"table": "ventas",
|
||||
"source": "/data/ventas.csv",
|
||||
"profiled_at": "2026-06-30T10:00:00+00:00",
|
||||
"n_rows": 1000,
|
||||
"n_cols": 2,
|
||||
"quality_score": 92.5,
|
||||
"columns": [
|
||||
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.01,
|
||||
"null_count": 10,
|
||||
"numeric": {"mean": 42.5, "median": 40.0, "min": 1.0,
|
||||
"max": 100.0, "std": 12.3}},
|
||||
{"name": "categoria", "inferred_type": "categorical",
|
||||
"null_pct": 0.0, "null_count": 0,
|
||||
"categorical": {"top": [{"value": "neumaticos", "count": 500},
|
||||
{"value": "aceite", "count": 300}]}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _slide_texts(path: str) -> list:
|
||||
prs = Presentation(path)
|
||||
out = []
|
||||
for sl in prs.slides:
|
||||
parts = []
|
||||
for sh in sl.shapes:
|
||||
if sh.has_text_frame:
|
||||
parts.append(sh.text_frame.text)
|
||||
if sh.has_table:
|
||||
tb = sh.table
|
||||
for r in range(len(tb.rows)):
|
||||
for c in range(len(tb.columns)):
|
||||
parts.append(tb.cell(r, c).text)
|
||||
out.append(" ".join(parts))
|
||||
return out
|
||||
|
||||
|
||||
def test_golden_profile_genera_pptx_portada_y_overview():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "eda.pptx")
|
||||
res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA — ventas"})
|
||||
assert res["path"] == out
|
||||
assert os.path.exists(out)
|
||||
assert res["n_slides"] >= 2
|
||||
ids = [c["id"] for c in res["chapters"]]
|
||||
assert "portada" in ids and "overview" in ids
|
||||
assert res["manifest_path"] and os.path.exists(res["manifest_path"])
|
||||
joined = " ".join(_slide_texts(out))
|
||||
assert "Automatic-EDA" in joined
|
||||
assert "CSV" in joined
|
||||
assert "92.5" in joined
|
||||
assert "precio" in joined and "categoria" in joined
|
||||
assert "median" in joined
|
||||
|
||||
|
||||
def test_edge_tabla_larga_parte_repitiendo_cabecera_sin_cortar():
|
||||
long_cell = ("Lorem ipsum dolor sit amet consectetur adipiscing elit sed do "
|
||||
"eiusmod tempor incididunt reprehenderit voluptate")
|
||||
header = ["ALPHA", "BETA", "GAMMA", "DELTA"]
|
||||
rows = [[f"r{r}c{c}" for c in range(4)] for r in range(50)]
|
||||
rows[0][1] = long_cell
|
||||
ch = Chapter(id="edge", title="Edge", version="1.0.0",
|
||||
blocks=[Heading("Tabla", 1),
|
||||
DataTable(header=header, rows=rows)])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "edge.pptx")
|
||||
res = render_automatic_eda_pptx([ch], out, {"write_manifest": False})
|
||||
assert res["path"] == out
|
||||
texts = _slide_texts(out)
|
||||
assert res["n_slides"] > 1 # table spilled to several slides.
|
||||
# Header repeated: every slide that carries table rows shows "ALPHA".
|
||||
slides_with_header = sum(1 for t in texts if "ALPHA" in t)
|
||||
assert slides_with_header >= 2
|
||||
joined = " ".join(texts)
|
||||
assert "Lorem ipsum dolor" in joined and "reprehenderit voluptate" in joined
|
||||
# No row lost: every data cell r0..r49 col0 present.
|
||||
for r in (0, 25, 49):
|
||||
assert f"r{r}c0" in joined
|
||||
|
||||
|
||||
def test_edge_profile_none_y_vacio_un_slide():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for arg, name in ((None, "none"), ({}, "empty")):
|
||||
out = os.path.join(d, f"{name}.pptx")
|
||||
res = render_automatic_eda_pptx(arg, out, {"write_manifest": False})
|
||||
assert res["path"] == out
|
||||
assert os.path.exists(out)
|
||||
assert res["n_slides"] == 1
|
||||
|
||||
|
||||
def test_error_path_directorio_no_escribible_no_revienta():
|
||||
res = render_automatic_eda_pptx(_profile(), "/proc/nope/x.pptx",
|
||||
{"write_manifest": False})
|
||||
assert res["path"] is None
|
||||
assert res["n_slides"] == 0
|
||||
assert res["note"]
|
||||
@@ -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):
|
||||
corr_rows.append([
|
||||
pair.get("a") or pair.get("col_a"),
|
||||
pair.get("b") or pair.get("col_b"),
|
||||
_fmt_num(pair.get("value") if pair.get("value") is not None
|
||||
else pair.get("corr")),
|
||||
])
|
||||
for pair in shown or []:
|
||||
if not isinstance(pair, dict):
|
||||
continue
|
||||
padj = pair.get("p_value_adjusted")
|
||||
sig = pair.get("significant")
|
||||
corr_rows.append([
|
||||
pair.get("a") or pair.get("col_a"),
|
||||
pair.get("b") or pair.get("col_b"),
|
||||
pair.get("method", ""),
|
||||
_fmt_num(pair.get("value") if pair.get("value") is not None
|
||||
else pair.get("corr")),
|
||||
_fmt_num(padj) if padj is not None else "",
|
||||
"sí" if sig else ("no" if sig is not None else ""),
|
||||
])
|
||||
if corr_rows:
|
||||
parts.append("## Correlaciones")
|
||||
parts.append(_md_table(["a", "b", "corr"], corr_rows))
|
||||
if isinstance(multiple_testing, dict):
|
||||
parts.append(
|
||||
"Corrección de comparaciones múltiples: "
|
||||
f"{multiple_testing.get('method')} "
|
||||
f"(α={multiple_testing.get('alpha')}); "
|
||||
f"{multiple_testing.get('n_rejected')} de "
|
||||
f"{multiple_testing.get('n_tests')} pares significativos tras la "
|
||||
"corrección. Mostrando "
|
||||
f"{'solo pares fuertes' if strong else 'todos los pares evaluados'}."
|
||||
)
|
||||
parts.append(_md_table(
|
||||
["a", "b", "method", "value", "p_adj (FDR)", "sig"], corr_rows))
|
||||
|
||||
# 7b. Re-expresión sugerida (escalera de potencias de Tukey) por columna
|
||||
# numérica. `suggest_reexpression` decide la transformación que más simetriza;
|
||||
# aquí solo se rinde su recomendación y razón.
|
||||
reexp_rows = []
|
||||
for col in columns:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
rx = col.get("reexpression")
|
||||
if not isinstance(rx, dict) or rx.get("recommended") is None:
|
||||
continue
|
||||
ladder = rx.get("ladder_power")
|
||||
reexp_rows.append([
|
||||
col.get("name"),
|
||||
_fmt_num(rx.get("skew")),
|
||||
rx.get("recommended"),
|
||||
_fmt_num(ladder) if ladder is not None else "",
|
||||
rx.get("reason", ""),
|
||||
])
|
||||
if reexp_rows:
|
||||
parts.append("## Re-expresión sugerida")
|
||||
parts.append(_md_table(
|
||||
["column", "skew", "transform", "ladder_power", "reason"], reexp_rows))
|
||||
|
||||
# 7c. Series temporales. Bloque por columna numérica cuando el pipeline corrió
|
||||
# con run_series: estacionariedad (ADF+KPSS), autocorrelación (ACF/PACF +
|
||||
# Ljung-Box), descomposición STL y, si es una serie de niveles, sugerencia de
|
||||
# retornos.
|
||||
series_blocks = []
|
||||
for col in columns:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
s = col.get("series")
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
name = col.get("name") or "(col)"
|
||||
block = [f"### {name}"]
|
||||
rows = []
|
||||
stat = s.get("stationarity") or {}
|
||||
if stat.get("verdict") is not None:
|
||||
rows.append(["estacionariedad (ADF+KPSS)", stat.get("verdict")])
|
||||
acf = s.get("acf_pacf") or {}
|
||||
if acf.get("is_autocorrelated") is not None:
|
||||
rows.append([
|
||||
"autocorrelada (Ljung-Box)",
|
||||
"sí" if acf.get("is_autocorrelated") else "no",
|
||||
])
|
||||
sig_lags = acf.get("significant_acf_lags")
|
||||
if sig_lags:
|
||||
rows.append([
|
||||
"lags ACF significativos",
|
||||
", ".join(str(lag) for lag in sig_lags[:12]),
|
||||
])
|
||||
stl = s.get("stl") or {}
|
||||
if stl.get("trend_strength") is not None:
|
||||
rows.append(["fuerza de tendencia (STL)", _fmt_num(stl.get("trend_strength"))])
|
||||
if stl.get("seasonal_strength") is not None:
|
||||
rows.append(["fuerza estacional (STL)", _fmt_num(stl.get("seasonal_strength"))])
|
||||
if stl.get("period") is not None:
|
||||
rows.append(["periodo estacional", stl.get("period")])
|
||||
elif stl.get("note"):
|
||||
rows.append(["STL", stl.get("note")])
|
||||
if s.get("levels_suggested"):
|
||||
# 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,208 @@
|
||||
"""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 del residuo detrended.
|
||||
|
||||
Busca el retardo (entre 2 y ``max_period``) que maximiza la autocorrelacion
|
||||
de la serie tras restarle su tendencia lineal. El detrend es clave: sobre una
|
||||
serie con tendencia la autocorrelacion cruda decae monotonamente y el retardo
|
||||
minimo (2) gana siempre, produciendo un ``period=2`` espurio que enmascara la
|
||||
estacionalidad real (falso negativo). Quitando primero la recta de mejor ajuste
|
||||
por minimos cuadrados, el lag ganador refleja el ciclo estacional y no la deriva.
|
||||
Devuelve None si no encuentra un pico claro (autocorrelacion maxima por debajo
|
||||
de un umbral pequeno).
|
||||
"""
|
||||
n = len(arr)
|
||||
if n < 6:
|
||||
return None
|
||||
# Detrend lineal: resta la recta de mejor ajuste para que la tendencia no
|
||||
# domine la autocorrelacion (si no, lag=2 gana siempre en series con deriva).
|
||||
t = np.arange(n, dtype=float)
|
||||
try:
|
||||
slope, intercept = np.polyfit(t, arr, 1)
|
||||
detrended = arr - (slope * t + intercept)
|
||||
except (np.linalg.LinAlgError, ValueError):
|
||||
detrended = arr - arr.mean()
|
||||
x = detrended - detrended.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,106 @@
|
||||
"""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"]
|
||||
|
||||
|
||||
# --- H2: deteccion de periodo robusta a tendencia (detrend) ------------------
|
||||
|
||||
def test_h2_infer_period_detrend_con_tendencia_fuerte():
|
||||
# Golden: serie con tendencia FUERTE + estacionalidad de periodo 12. Sin detrend
|
||||
# la autocorrelacion cruda decae monotonamente y el lag minimo (2) gana siempre
|
||||
# (period=2 espurio). Con el detrend lineal el lag ganador es el periodo real.
|
||||
from stl_decompose import _infer_period
|
||||
|
||||
serie = _serie_estacional(n=120, period=12, trend=0.8, amp=10.0, seed=0)
|
||||
arr = np.asarray(serie, dtype=float)
|
||||
assert _infer_period(arr, max_period=60) == 12
|
||||
|
||||
|
||||
def test_h2_auto_period_no_degenera_a_2():
|
||||
# End-to-end: stl_decompose(period=None) sobre serie con tendencia fuerte detecta
|
||||
# estacionalidad real en vez de reportar period=2 y seasonal_strength ~ 0
|
||||
# (el falso negativo de estacionalidad que motivo el fix H2).
|
||||
serie = _serie_estacional(n=120, period=12, trend=0.8, amp=10.0, seed=0)
|
||||
res = stl_decompose(serie)
|
||||
assert res["period"] != 2
|
||||
assert res["seasonal_strength"] > 0.5
|
||||
|
||||
|
||||
def test_h2_serie_sin_estacionalidad_no_inventa_periodo():
|
||||
# Edge: serie con SOLO tendencia (sin componente estacional) no debe inventar un
|
||||
# periodo; tras el detrend el residuo es ruido sin pico de autocorrelacion claro.
|
||||
rng = np.random.default_rng(7)
|
||||
serie = [0.5 * i + rng.normal(0, 1) for i in range(120)]
|
||||
res = stl_decompose(serie)
|
||||
# Sin periodo fiable: nota explicita, nunca seasonal_strength=0 como conclusion.
|
||||
assert res["trend_strength"] is None
|
||||
assert "note" in res
|
||||
@@ -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
|
||||
@@ -43,3 +43,57 @@ def test_detect_exactly_30():
|
||||
values = rng.normal(0, 1, 30).tolist()
|
||||
result = detect_distribution_type(values)
|
||||
assert result["type"] != "too_few_samples"
|
||||
|
||||
|
||||
# --- H11: discrete / multimodal no deben etiquetarse "normal-ish" ---
|
||||
|
||||
|
||||
def test_detect_discrete_low_cardinality():
|
||||
# Rating ordinal de 6 niveles (como wine `quality`): skewness pequena,
|
||||
# antes caia en "normal-ish"; ahora debe ser "discrete".
|
||||
rng = np.random.default_rng(3)
|
||||
values = rng.integers(3, 9, size=1500).astype(float).tolist() # 6 valores distintos
|
||||
result = detect_distribution_type(values)
|
||||
assert result["type"] == "discrete", f"Got {result['type']}"
|
||||
assert result["stats"]["n_unique"] <= 15
|
||||
|
||||
|
||||
def test_detect_multimodal():
|
||||
# Mezcla bimodal claramente separada con skewness ~0: antes "normal-ish",
|
||||
# ahora "multimodal".
|
||||
rng = np.random.default_rng(4)
|
||||
values = np.concatenate(
|
||||
[rng.normal(-4, 0.6, 1000), rng.normal(4, 0.6, 1000)]
|
||||
).tolist()
|
||||
result = detect_distribution_type(values)
|
||||
assert result["type"] == "multimodal", f"Got {result['type']}"
|
||||
assert result["stats"]["n_modes"] >= 2
|
||||
|
||||
|
||||
def test_detect_normal_still_normal_after_fix():
|
||||
# Retrocompatibilidad: una normal continua genuina sigue "normal-ish"
|
||||
# pese a los nuevos checks de cardinalidad / modos.
|
||||
rng = np.random.default_rng(5)
|
||||
values = rng.normal(10, 2, 2000).tolist()
|
||||
result = detect_distribution_type(values)
|
||||
assert result["type"] == "normal-ish", f"Got {result['type']}"
|
||||
assert result["stats"]["n_modes"] == 1
|
||||
assert result["stats"]["n_unique"] > 15
|
||||
|
||||
|
||||
def test_detect_stats_has_new_keys():
|
||||
rng = np.random.default_rng(6)
|
||||
values = rng.normal(0, 1, 200).tolist()
|
||||
stats = detect_distribution_type(values)["stats"]
|
||||
for key in ("n_unique", "n_modes", "jb_stat", "jb_pvalue"):
|
||||
assert key in stats, f"missing {key}"
|
||||
|
||||
|
||||
def test_detect_unimodal_skewed_not_multimodal():
|
||||
# Continua unimodal sesgada (exponencial): el detector de modos no debe
|
||||
# inventar modos espurios y la etiqueta no debe ser "multimodal".
|
||||
rng = np.random.default_rng(8)
|
||||
values = rng.exponential(1.0, 2000).tolist()
|
||||
result = detect_distribution_type(values)
|
||||
assert result["type"] != "multimodal", f"Got {result['type']}"
|
||||
assert result["stats"]["n_modes"] == 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,19 +26,26 @@ Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, 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,155 @@ _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_ordinal_days(value) -> float | None:
|
||||
"""Convierte un valor fecha/datetime/ISO-string a dias ordinales (float), o None.
|
||||
|
||||
Soporta los tipos que devuelve DuckDB para columnas DATE/TIMESTAMP
|
||||
(``datetime.date`` / ``datetime.datetime``) y strings ISO. Devuelve None para
|
||||
cualquier cosa que no parsee a una fecha (numeros sueltos, basura).
|
||||
"""
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.toordinal() + value.hour / 24.0 + value.minute / 1440.0
|
||||
if isinstance(value, date):
|
||||
return float(value.toordinal())
|
||||
if isinstance(value, (int, float)):
|
||||
return None # entero/float suelto: no es una fecha fiable
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return float(datetime.fromisoformat(s).toordinal())
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return float(date.fromisoformat(s[:10]).toordinal())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _infer_period_from_dates(dates: list, n_series: int) -> int | None:
|
||||
"""Deriva el periodo estacional de la FRECUENCIA del indice datetime.
|
||||
|
||||
En vez de adivinar el periodo por autocorrelacion cruda (que en series con
|
||||
tendencia degenera a 2 y produce un falso negativo de estacionalidad), se mide
|
||||
el delta mediano en dias entre observaciones consecutivas ordenadas y se mapea
|
||||
a su periodo estacional dominante:
|
||||
|
||||
- diario (delta ~= 1 dia) -> 365 si hay >= 2 anios de datos; si no, 7.
|
||||
- semanal (delta ~= 7 dias) -> 52.
|
||||
- mensual (delta ~= 30 dias)-> 12.
|
||||
- trimestral (delta ~= 91) -> 4.
|
||||
|
||||
Devuelve None si no hay fechas suficientes, si la frecuencia no encaja en un
|
||||
patron conocido, o si la serie es demasiado corta para dos ciclos del periodo.
|
||||
"""
|
||||
ords = [d for d in (_to_ordinal_days(v) for v in dates) if d is not None]
|
||||
if len(ords) < 3:
|
||||
return None
|
||||
ords.sort()
|
||||
deltas = sorted(b - a for a, b in zip(ords[:-1], ords[1:]) if b - a > 0)
|
||||
if not deltas:
|
||||
return None
|
||||
med = deltas[len(deltas) // 2] # delta mediano en dias
|
||||
if med <= 2.0: # diario
|
||||
if n_series >= 730: # >= 2 anios: estacionalidad anual
|
||||
return 365
|
||||
return 7 if n_series >= 14 else None
|
||||
if 5.0 <= med <= 10.0: # semanal
|
||||
return 52 if n_series >= 104 else None
|
||||
if 25.0 <= med <= 35.0: # mensual
|
||||
return 12 if n_series >= 24 else None
|
||||
if 85.0 <= med <= 100.0: # trimestral
|
||||
return 4 if n_series >= 8 else None
|
||||
return None
|
||||
|
||||
|
||||
def _is_sequential_id(col: dict) -> bool:
|
||||
"""True si la columna numerica es un id ENTERO secuencial (indice de fila).
|
||||
|
||||
Distingue ``PassengerId`` (1..n, enteros densos, monotono) de un float continuo
|
||||
de alta cardinalidad (precios): el id no debe entrar en correlacion ni en
|
||||
PCA/KMeans (es ruido que infla pares espurios y distorsiona componentes); el
|
||||
precio si. Criterio: flag ``possible_id`` + min/max enteros + rango denso (casi
|
||||
todos los enteros del intervalo presentes). Un precio tiene parte decimal en
|
||||
min/max, asi que NUNCA lo marca.
|
||||
"""
|
||||
if col.get("inferred_type") != "numeric":
|
||||
return False
|
||||
if "possible_id" not in (col.get("flags") or []):
|
||||
return False
|
||||
nb = col.get("numeric") or {}
|
||||
mn, mx = nb.get("min"), nb.get("max")
|
||||
if not (
|
||||
isinstance(mn, (int, float))
|
||||
and not isinstance(mn, bool)
|
||||
and isinstance(mx, (int, float))
|
||||
and not isinstance(mx, bool)
|
||||
):
|
||||
return False
|
||||
if not (float(mn).is_integer() and float(mx).is_integer()):
|
||||
return False # float continuo (precios): mantener
|
||||
dc = col.get("distinct_count")
|
||||
if isinstance(dc, int) and not isinstance(dc, bool) and dc > 1:
|
||||
span = float(mx) - float(mn) + 1.0
|
||||
if span > 0 and (dc / span) >= 0.95:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
"""Parsea un valor a float limpiando simbolos de moneda y separadores.
|
||||
@@ -115,6 +271,111 @@ 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
|
||||
|
||||
# Periodo estacional derivado de la FRECUENCIA del indice datetime (mensual->12,
|
||||
# diario->365/7), que es fiable, en vez de dejar que stl_decompose lo adivine por
|
||||
# autocorrelacion (que en series con tendencia degenera a period=2 -> falso
|
||||
# negativo de estacionalidad). Si no hay order_col datetime o la frecuencia no
|
||||
# encaja, period queda None y stl_decompose lo infiere (ya con detrend) o avisa.
|
||||
period = None
|
||||
period_source = "autocorr"
|
||||
if order_col:
|
||||
raw_dates = _sample_series(query_fn, table, order_col, order_col, sample)
|
||||
period = _infer_period_from_dates(raw_dates, len(series_vals))
|
||||
if period is not None:
|
||||
period_source = "datetime_freq"
|
||||
|
||||
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),
|
||||
# Periodo de la frecuencia del indice si se pudo derivar; si no, stl_decompose
|
||||
# lo infiere por autocorrelacion del residuo detrended o devuelve una nota
|
||||
# ("periodo no determinado") sin reportar seasonal_strength=0 como conclusion.
|
||||
"period_source": period_source,
|
||||
"stl": stl_decompose(series_vals, period=period),
|
||||
}
|
||||
|
||||
# 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 +383,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 +398,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 +420,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 +472,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
|
||||
@@ -253,9 +536,16 @@ def profile_table(
|
||||
def _skip_for_assoc(c):
|
||||
it = c.get("inferred_type")
|
||||
flags = c.get("flags") or []
|
||||
return it in ("categorical", "text") and (
|
||||
# Categoricas/text id-like por cardinalidad ~ n.
|
||||
if it in ("categorical", "text") and (
|
||||
"possible_id" in flags or "high_cardinality" in flags
|
||||
)
|
||||
):
|
||||
return True
|
||||
# Id ENTERO secuencial numerico (PassengerId 1..n): indice de fila,
|
||||
# genera pares espurios (correlation_ratio ~0.9 sig=si) y entra como
|
||||
# feature ruidosa en PCA/KMeans. Los floats continuos (precios) NO
|
||||
# se saltan aunque lleven possible_id.
|
||||
return _is_sequential_id(c)
|
||||
|
||||
assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
|
||||
rows = _sample_rows(
|
||||
@@ -285,7 +575,30 @@ def profile_table(
|
||||
# reales: un try/except compartido ponia ambos campos a None).
|
||||
if run_models:
|
||||
try:
|
||||
prof["models"] = run_eda_models(assoc_input)
|
||||
# Subconjunto de features para PCA/KMeans: solo numericas CONTINUAS.
|
||||
# Se excluyen binarias/ordinales/target de baja cardinalidad (Survived
|
||||
# 0/1, Pclass 1/2/3): como dimensiones del PCA/clustering anaden ruido
|
||||
# y distorsionan componentes y centroides. Los id secuenciales ya
|
||||
# quedaron fuera de assoc_input via _skip_for_assoc.
|
||||
def _is_model_feature(cname):
|
||||
c = next(
|
||||
(x for x in assoc_cols if x.get("name") == cname), None
|
||||
)
|
||||
if c is None or c.get("inferred_type") != "numeric":
|
||||
return False
|
||||
dc = c.get("distinct_count")
|
||||
if (
|
||||
isinstance(dc, int)
|
||||
and not isinstance(dc, bool)
|
||||
and dc <= _REEXPR_MIN_DISTINCT
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
models_input = {
|
||||
n: v for n, v in assoc_input.items() if _is_model_feature(n)
|
||||
}
|
||||
prof["models"] = run_eda_models(models_input)
|
||||
except Exception: # noqa: BLE001
|
||||
prof["models"] = None
|
||||
|
||||
@@ -299,12 +612,104 @@ 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.75) Marcar las correlaciones num-num calculadas sobre NIVELES de series
|
||||
# no estacionarias (autocorreladas) como posible espuria (Granger-Newbold):
|
||||
# Close-Open=0.998 es un artefacto de la raiz unitaria, no una relacion util.
|
||||
# Para uso financiero lo correcto es correlacionar retornos (col["series"]
|
||||
# ["to_returns"]). Si corrio run_series se usa el veredicto ADF/KPSS por
|
||||
# columna; si no, una heuristica financiera por nombre + dominio positivo.
|
||||
try:
|
||||
corr = prof.get("correlations")
|
||||
if isinstance(corr, dict):
|
||||
levels_cols: set = set()
|
||||
series_map = prof.get("series") or {}
|
||||
if series_map:
|
||||
for cname, sb in series_map.items():
|
||||
if not isinstance(sb, dict):
|
||||
continue
|
||||
verdict = (sb.get("stationarity") or {}).get("verdict")
|
||||
if sb.get("levels_suggested") or verdict in (
|
||||
"non_stationary",
|
||||
"inconclusive",
|
||||
):
|
||||
levels_cols.add(cname)
|
||||
else:
|
||||
for c in cols:
|
||||
if c.get("inferred_type") == "numeric" and _looks_financial(c):
|
||||
mn = (c.get("numeric") or {}).get("min")
|
||||
if (
|
||||
isinstance(mn, (int, float))
|
||||
and not isinstance(mn, bool)
|
||||
and mn > 0
|
||||
):
|
||||
levels_cols.add(c.get("name"))
|
||||
n_marked = 0
|
||||
for pair in corr.get("pairs", []):
|
||||
if (
|
||||
pair.get("method") == "pearson/spearman"
|
||||
and pair.get("a") in levels_cols
|
||||
and pair.get("b") in levels_cols
|
||||
):
|
||||
pair["levels_possible_spurious"] = True
|
||||
n_marked += 1
|
||||
if n_marked:
|
||||
corr["levels_caveat"] = (
|
||||
f"{n_marked} par(es) de correlacion se calculan sobre NIVELES "
|
||||
"de series no estacionarias (autocorreladas): la correlacion "
|
||||
"puede ser espuria (Granger-Newbold). Para uso financiero, "
|
||||
"correlacionar sobre retornos/diferencias (ver el bloque "
|
||||
"series de cada columna)."
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 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 +717,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,250 @@ import tempfile
|
||||
|
||||
import duckdb
|
||||
|
||||
from pipelines.profile_table import profile_table
|
||||
from pipelines.profile_table import (
|
||||
_infer_period_from_dates,
|
||||
_is_continuous_for_reexpr,
|
||||
_is_sequential_id,
|
||||
_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
|
||||
|
||||
|
||||
# --- H2: periodo estacional derivado de la frecuencia del indice datetime ---
|
||||
|
||||
def test_infer_period_from_dates_mensual_y_diario():
|
||||
from datetime import date as _date, timedelta
|
||||
|
||||
# Mensual (delta ~30 dias) con 72 puntos -> periodo 12.
|
||||
mensual = [_date(2000 + i // 12, i % 12 + 1, 1) for i in range(72)]
|
||||
assert _infer_period_from_dates(mensual, n_series=72) == 12
|
||||
|
||||
# Diario con >= 2 anios de datos -> estacionalidad anual (365).
|
||||
diario = [_date(2010, 1, 1) + timedelta(days=i) for i in range(800)]
|
||||
assert _infer_period_from_dates(diario, n_series=800) == 365
|
||||
|
||||
# Diario corto (< 2 anios) -> cae a semanal (7).
|
||||
diario_corto = [_date(2010, 1, 1) + timedelta(days=i) for i in range(100)]
|
||||
assert _infer_period_from_dates(diario_corto, n_series=100) == 7
|
||||
|
||||
# Sin fechas validas -> None (stl_decompose infiere o avisa).
|
||||
assert _infer_period_from_dates(["x", None, 3], n_series=50) is None
|
||||
|
||||
|
||||
def test_h2_periodo_de_frecuencia_datetime_end_to_end():
|
||||
import math
|
||||
from datetime import date as _date
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="h2_period_test_")
|
||||
db_path = os.path.join(tmp_dir, "m.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute("CREATE TABLE m (d DATE, v DOUBLE)")
|
||||
rows = []
|
||||
for i in range(72): # 6 anios mensual con estacionalidad de periodo 12
|
||||
dt = _date(2000 + i // 12, i % 12 + 1, 1)
|
||||
v = 10.0 + 0.1 * i + 5.0 * math.sin(2 * math.pi * (i % 12) / 12)
|
||||
rows.append((dt, v))
|
||||
con.executemany("INSERT INTO m VALUES (?, ?)", rows)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "m", run_series=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
s = _col(r["profile"], "v").get("series") or {}
|
||||
assert s.get("period_source") == "datetime_freq"
|
||||
stl = s.get("stl") or {}
|
||||
assert stl.get("period") == 12
|
||||
# Estacionalidad sinusoidal clara -> fuerza estacional alta (antes salia ~0).
|
||||
assert (stl.get("seasonal_strength") or 0) > 0.3
|
||||
|
||||
|
||||
# --- H7: id entero secuencial fuera de correlacion y de PCA/KMeans -----------
|
||||
|
||||
def test_is_sequential_id_distingue_id_de_precio():
|
||||
# Id entero secuencial denso (1..n): True.
|
||||
idcol = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": ["possible_id"],
|
||||
"distinct_count": 300,
|
||||
"numeric": {"min": 1.0, "max": 300.0},
|
||||
}
|
||||
assert _is_sequential_id(idcol) is True
|
||||
# Float continuo de alta cardinalidad (precios): min/max con decimales -> False.
|
||||
precio = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": ["possible_id"],
|
||||
"distinct_count": 300,
|
||||
"numeric": {"min": 24.35, "max": 189.7},
|
||||
}
|
||||
assert _is_sequential_id(precio) is False
|
||||
# Entero disperso (anios): no es indice denso -> False.
|
||||
disperso = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": ["possible_id"],
|
||||
"distinct_count": 3,
|
||||
"numeric": {"min": 1990.0, "max": 2010.0},
|
||||
}
|
||||
assert _is_sequential_id(disperso) is False
|
||||
# Sin flag possible_id -> nunca id secuencial.
|
||||
sin_flag = {
|
||||
"inferred_type": "numeric",
|
||||
"flags": [],
|
||||
"distinct_count": 300,
|
||||
"numeric": {"min": 1.0, "max": 300.0},
|
||||
}
|
||||
assert _is_sequential_id(sin_flag) is False
|
||||
|
||||
|
||||
def test_h7_id_secuencial_fuera_de_correlacion_y_modelos():
|
||||
tmp_dir = tempfile.mkdtemp(prefix="h7_id_test_")
|
||||
db_path = os.path.join(tmp_dir, "t.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute("CREATE TABLE t (rid INTEGER, age DOUBLE, fare DOUBLE)")
|
||||
# rid 0..299: indice de fila (id secuencial). age/fare: floats continuos.
|
||||
con.execute(
|
||||
"INSERT INTO t SELECT i, ((i*0.13)%80)+1.5, ((i*1.7)%50)+0.3 "
|
||||
"FROM range(300) tbl(i)"
|
||||
)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "t", run_models=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
prof = r["profile"]
|
||||
|
||||
# rid (id secuencial) no entra en correlaciones fuertes.
|
||||
strong = (prof.get("correlations") or {}).get("strong", [])
|
||||
assert not any("rid" in (p["a"], p["b"]) for p in strong)
|
||||
|
||||
# rid no entra como feature de los modelos (normality solo sobre continuas).
|
||||
norm = (prof.get("models") or {}).get("normality") or {}
|
||||
assert "rid" not in norm
|
||||
# age/fare (continuas) SI se mantienen como features.
|
||||
assert "age" in norm and "fare" in norm
|
||||
|
||||
|
||||
# --- H8: correlacion sobre niveles no estacionarios marcada espuria ----------
|
||||
|
||||
def test_h8_correlacion_niveles_marcada_posible_espuria():
|
||||
tmp_dir = tempfile.mkdtemp(prefix="h8_levels_test_")
|
||||
db_path = os.path.join(tmp_dir, "s.duckdb")
|
||||
con = duckdb.connect(db_path)
|
||||
con.execute('CREATE TABLE s (ts INTEGER, "close" DOUBLE, "open" DOUBLE)')
|
||||
rows = []
|
||||
level = 100.0
|
||||
for t in range(90): # niveles crecientes (no estacionarios), close~open
|
||||
level += 1.0 + (t % 5) * 0.4
|
||||
rows.append((t, level, level - 0.5))
|
||||
con.executemany("INSERT INTO s VALUES (?, ?, ?)", rows)
|
||||
con.close()
|
||||
|
||||
r = profile_table(db_path, "s", run_series=True, write_report=False)
|
||||
assert r["status"] == "ok", r
|
||||
corr = r["profile"].get("correlations") or {}
|
||||
co = [p for p in corr.get("pairs", []) if {p["a"], p["b"]} == {"close", "open"}]
|
||||
assert co, "par close-open no encontrado"
|
||||
# Ambas son series financieras de niveles no estacionarias -> par marcado.
|
||||
assert co[0].get("levels_possible_spurious") is True
|
||||
assert "levels_caveat" in corr
|
||||
|
||||
|
||||
def _make_db() -> str:
|
||||
|
||||
@@ -19,14 +19,18 @@ 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",
|
||||
"pypdf>=6.10.0",
|
||||
"pyproj>=3.7.2",
|
||||
"python-docx>=1.2.0",
|
||||
"python-pptx>=1.0.2",
|
||||
"pyyaml>=6.0.3",
|
||||
"qrcode[pil]>=8.2",
|
||||
"rapidfuzz>=3.14.5",
|
||||
"reportlab>=4.5.0",
|
||||
"scikit-image>=0.26.0",
|
||||
@@ -34,6 +38,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