Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f3dca823 | |||
| cb7a7fc1fd | |||
| 9cdde4a341 | |||
| 5501507588 | |||
| 88eabb0457 | |||
| ebb00d8a42 | |||
| e142ef026d | |||
| c4cff5ed5b | |||
| caf8c25d99 | |||
| 7ac69ab4fb | |||
| 02301aaed3 | |||
| 2729629f0a | |||
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 | |||
| c79f33265e | |||
| 31c2f6ac7f | |||
| 3bc97828e3 | |||
| ccdd529bdc | |||
| 741724f633 | |||
| 2be62f6ef6 | |||
| 8e9e1e6c8a | |||
| ec46aae04c | |||
| b173ac2703 | |||
| ec0a5e53ac | |||
| 5280499df5 | |||
| 346f859b86 | |||
| 604d3d4feb | |||
| 287abbd6ee | |||
| f8793f96ac | |||
| 643ebfb849 | |||
| 537516e32e | |||
| ca07b25297 | |||
| fbbff7d5e7 | |||
| bdd841d9af |
@@ -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.
|
||||
@@ -14,7 +14,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
|
||||
| Grupo | N | Que cubre |
|
||||
|---|---|---|
|
||||
| [gamedev-2d](gamedev-2d.md) | 36 | Assets 2D para Godot via ComfyUI: 31 builders de workflow (pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX...) + 5 de apoyo: post-proceso (pixelize, luma->alpha) + puente de assets a Godot 4 (.import + reimport headless). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
||||
| [gamedev-2d](gamedev-2d.md) | 47 | Assets 2D para Godot via ComfyUI: 36 builders de workflow (31 de generación desde texto: pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX... + 5 de transformación desde imagen: asset_variant/sprite_from_sketch/inpaint_asset/outpaint_asset/directional_sprite) + 11 de apoyo: post-proceso (pixelize, luma->alpha, flatten_alpha), puente de assets a Godot 4 (.import + reimport headless), style presets (get/apply_gamedev_style_preset) y pipelines one-shot (asset_pack/character_set/styled_asset). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
||||
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
||||
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
||||
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
||||
@@ -68,12 +68,13 @@ 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 |
|
||||
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
|
||||
| [comfyui-skill](comfyui-skill.md) | 11 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
||||
| [comfyui](comfyui.md) | 126 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Cubre txt2img/img2img/inpaint/controlnet/sdxl-refiner/flux, upscale + hires-fix + facedetailer, vídeo (LTX/Wan/SVD), audio (ACE-Step), imagen→3D nativo (Hunyuan3D-2) + post-proceso de malla, templates oficiales, civitai harvest y control de cola. N = funciones con tag `comfyui` (incluye los sub-grupos `comfyui-skill`/`comfyui-styles` y 45 de `gamedev-2d`); las páginas madre de cada sub-grupo desglosan su parte |
|
||||
| [comfyui-skill](comfyui-skill.md) | 17 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
||||
| [comfyui-styles](comfyui-styles.md) | 5 | Capa de estilo reutilizable sobre los builders ComfyUI. Catálogo WAS (tag `comfyui-styles`): `curated_styles_catalog` (~190 estilos), `generate_styles_llm` (genera estilos por LLM via ask_llm), `append_styles` (merge+dedup+backup sobre el styles.json del selector WAS). Style presets gamedev (tag `gamedev-2d`): `get_gamedev_style_preset` (gameboy/ghibli/pixel-art-retro como datos puros) + `apply_style_preset` (preset+subject → kwargs de un builder gamedev-2d). El estilo se trata como dato curado, no como prompt repetido |
|
||||
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
@@ -8,7 +8,9 @@ Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
|
||||
|
||||
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
||||
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
||||
- [comfyui-styles.md](comfyui-styles.md) — grupo `comfyui-styles`: presets + catálogo de estilo (selector WAS).
|
||||
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
||||
- [gamedev-2d.md](gamedev-2d.md) — grupo `gamedev-2d`: 47 builders de assets 2D para Godot (45 también `comfyui`).
|
||||
|
||||
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
||||
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
||||
@@ -25,7 +27,9 @@ Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-
|
||||
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
||||
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
||||
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
||||
| 04b | **styles (presets/catálogo)** | estilo reutilizable: catálogo WAS + presets gamedev | `curated_styles_catalog`, `generate_styles_llm`, `append_styles`, `get_gamedev_style_preset`, `apply_style_preset` | — | — |
|
||||
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
||||
| 05b | **audio** | texto → música/SFX/voz (ACE-Step) | `build_audio_workflow`, `fetch_output_audio` | — | — |
|
||||
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
||||
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
||||
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
||||
@@ -67,6 +71,16 @@ sus IDs reales cuando se ejecute `fn index`.
|
||||
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
||||
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
||||
|
||||
### 04b · styles (presets / catálogo)
|
||||
|
||||
Página madre: [comfyui-styles.md](comfyui-styles.md). Estilo reutilizable como dato, no como prompt repetido.
|
||||
|
||||
- `comfyui_curated_styles_catalog_py_ml` (pura) — catálogo curado (~190 estilos) para el selector WAS.
|
||||
- `comfyui_generate_styles_llm_py_ml` (impura) — genera N estilos de una categoría vía `ask_llm`.
|
||||
- `comfyui_append_styles_py_ml` (impura) — fusiona estilos sobre el `styles.json` WAS (merge+dedup+backup).
|
||||
- `comfyui_get_gamedev_style_preset_py_ml` (pura) — receta de *style preset* gamedev (gameboy/ghibli/pixel-art-retro).
|
||||
- `comfyui_apply_style_preset_py_ml` (pura) — traduce un preset + subject a los kwargs de un builder gamedev-2d.
|
||||
|
||||
### 05 · video
|
||||
|
||||
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
||||
|
||||
@@ -12,6 +12,13 @@ submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
||||
|
||||
> **Tamaño del grupo (al 28/06/2026):** 17 funciones con tag `comfyui-skill` — CRUD de recetas
|
||||
> (save/load/list), compilación a workflow (`build_skill_workflow`), inyectores encadenables
|
||||
> (`inject_hires_fix`/`inject_multi_lora`, `build_ipadapter_workflow`), bucle de mejora
|
||||
> genera→juzga→bump (`generate_with_skill_oneshot` + `update_skill_score` + `bump_skill_version`),
|
||||
> export a grafo (`export_skill_template`), mixer de capacidades (`compose_capabilities` +
|
||||
> `generate_mixed_oneshot`) y cosecha de Civitai (`extract_recipe_from_png` + `harvest_civitai_skill_oneshot`).
|
||||
|
||||
## Qué es una skill
|
||||
|
||||
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# ComfyUI Styles — presets y catálogo de estilo
|
||||
|
||||
Tag: `comfyui-styles` (+ `gamedev-2d` para los dos presets gamedev). Sub-grupo de
|
||||
[`comfyui`](comfyui.md) que añade una **capa de estilo reutilizable** sobre los builders de
|
||||
workflow: en vez de repetir a mano los mismos modificadores de cámara/iluminación/render en cada
|
||||
prompt, el estilo se trata como un dato curado y reusable.
|
||||
|
||||
Dos vertientes complementarias:
|
||||
|
||||
- **Catálogo WAS** (`comfyui-styles`): ~190 estilos curados en el formato exacto del selector WAS de
|
||||
ComfyUI (*Prompt Styles Selector* / *Prompt Multiple Styles Selector*), generación de estilos
|
||||
nuevos por LLM, y fusión segura sobre el `styles.json` del usuario.
|
||||
- **Style presets gamedev** (`gamedev-2d`): recetas que empaquetan como datos puros el *look* de un
|
||||
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño, post-proceso) y se
|
||||
traducen a los kwargs que consume un builder de sujeto del grupo [`gamedev-2d`](gamedev-2d.md).
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-styles"` (catálogo WAS) y
|
||||
`mcp__registry__fn_search query="style preset" tag="gamedev-2d"` (presets gamedev).
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
### Catálogo WAS — dominio `ml` (tag `comfyui-styles`)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en el formato exacto `{nombre: {prompt, negative_prompt}}` que consume el selector WAS. Cada `prompt` son modificadores de estilo potentes (cámara, lente, iluminación, render engine, medio artístico, paleta, mood), no descripciones de escena. Filtra por `category`. **Pura**. |
|
||||
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-20251001') -> dict` | Genera N estilos de una categoría temática usando `ask_llm` (grupo claude-direct, API directa, arranque 0), en el mismo formato `{nombre: {prompt, negative_prompt}}`. `avoid` evita duplicar nombres ya existentes. **Impura** (LLM). |
|
||||
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=DEFAULT_STYLES_PATH, overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup por nombre) un dict de estilos sobre el `styles.json` del selector WAS de forma SEGURA y NO destructiva: preserva todos los existentes (ganan salvo `overwrite=True`), hace backup con timestamp antes de escribir. `dry_run=True` previsualiza sin tocar disco. **Impura** (I/O disco). |
|
||||
|
||||
### Style presets gamedev — dominio `ml` (tag `gamedev-2d`)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (`gameboy`, `ghibli`, `pixel-art-retro`) o el catálogo de nombres si `name=None`. Un preset empaqueta como DATOS puros el look de un juego entero: `subject_prefix`/`suffix`, `style`, `negative`, checkpoint recomendado, LoRA + strength, `size`, `transparent`, post-proceso. **Pura**. |
|
||||
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev (de `get_gamedev_style_preset`) + un `subject` del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo y los kwargs comunes (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`, resolución) listos para `**spread`. `style`/`negative` permiten override puntual. **Pura**. |
|
||||
|
||||
## Ejemplo canónico — generar un estilo, fusionarlo y aplicarlo
|
||||
|
||||
Dos flujos típicos: (1) ampliar el catálogo del selector WAS, y (2) usar un preset gamedev para
|
||||
generar un asset con look consistente.
|
||||
|
||||
### A) Ampliar el catálogo WAS con estilos nuevos por LLM
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm
|
||||
from ml.comfyui_append_styles import comfyui_append_styles
|
||||
|
||||
# 1. Pedir 6 estilos de una categoría. Devuelve el dict {nombre: {prompt, negative_prompt}}
|
||||
# directo (best-effort: {} si el LLM falla).
|
||||
nuevos = comfyui_generate_styles_llm("film noir cinematic", n=6, prefix="noir-")
|
||||
|
||||
# 2. Previsualizar la fusión (no escribe), luego aplicar con backup.
|
||||
if nuevos:
|
||||
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) # nº tras fusionar, sin tocar disco
|
||||
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura
|
||||
print(res["total_before"], "->", res["total_after"], "añadidos:", len(res["added"]))
|
||||
```
|
||||
|
||||
### B) Aplicar un style preset gamedev a un sujeto
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||
|
||||
preset = comfyui_get_gamedev_style_preset("gameboy") # receta pura del look Game Boy
|
||||
ap = comfyui_apply_style_preset(preset, "a wizard casting a spell")
|
||||
# ap = {subject, builder_kwargs, size, transparent, post, ...} listo para un builder gamedev-2d:
|
||||
wf = comfyui_build_enemy_creature_workflow(
|
||||
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||
)
|
||||
```
|
||||
|
||||
El catálogo curado completo se consulta sin red (devuelve el dict plano directo):
|
||||
|
||||
```python
|
||||
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
|
||||
print(comfyui_curated_styles_catalog("__categories__")) # {'categories': {...}, 'total': 190}
|
||||
todos = comfyui_curated_styles_catalog() # dict {nombre: {prompt, negative_prompt}}
|
||||
print(len(todos), list(todos)[:5])
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No genera imágenes**: este sub-grupo produce y gestiona DATOS de estilo (dicts de prompt /
|
||||
negative, presets). Generar el asset es trabajo de los builders del grupo [`comfyui`](comfyui.md)
|
||||
y [`gamedev-2d`](gamedev-2d.md), o de los pipelines oneshot (p.ej.
|
||||
`comfyui_generate_styled_asset_oneshot_py_pipelines`, que compone un preset + un builder + submit).
|
||||
- **El catálogo WAS asume el custom node WAS instalado**: `append_styles` escribe sobre el
|
||||
`styles.json` que lee el selector WAS en la UI. Sin ese node, el catálogo sigue siendo usable como
|
||||
dict de modificadores, pero el selector no aparecerá en el grafo.
|
||||
- **Los dos presets gamedev (`get`/`apply`) llevan tag `gamedev-2d`**, no `comfyui-styles`: son la
|
||||
vía de estilo para los builders de assets de juego, no para el selector WAS genérico. Se listan
|
||||
aquí por afinidad de capacidad (estilo reutilizable).
|
||||
- **Formato exacto**: el dict de estilos es `{nombre: {prompt, negative_prompt}}`. Los prompts son
|
||||
modificadores (cámara/lente/luz/render/medio/paleta/mood), no descripciones de escena — la escena
|
||||
la pone el `subject` del usuario.
|
||||
@@ -13,6 +13,17 @@ Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/c
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
||||
|
||||
> **Tamaño del grupo (al 28/06/2026):** 126 funciones con tag `comfyui` (63 puras, 50 impuras,
|
||||
> 13 pipelines). El grupo se reparte en sub-grupos con página madre propia:
|
||||
> [`comfyui-skill`](comfyui-skill.md) (recetas de estilo versionadas),
|
||||
> [`comfyui-styles`](comfyui-styles.md) (presets + catálogo de estilo para el selector WAS),
|
||||
> [`comfyui-judge`](comfyui-judge.md) (panel de calidad) y
|
||||
> [`gamedev-2d`](gamedev-2d.md) (assets 2D para Godot: 47 funciones, 45 de ellas también `comfyui`).
|
||||
> Esta página documenta el **núcleo** (lifecycle del server, API HTTP, builders, I/O de workflows,
|
||||
> imagen→3D, UI por CDP, audio, templates); los builders específicos de gamedev-2d viven en su
|
||||
> propia página. El mapa cross-grupo de capacidades está en
|
||||
> [comfyui-overview.md](comfyui-overview.md).
|
||||
|
||||
## Dos caminos, mismo motor
|
||||
|
||||
```
|
||||
@@ -44,7 +55,7 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow`
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
||||
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **Pura**. |
|
||||
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, variant='schnell', width=1024, height=1024, steps=None, guidance=3.5, seed=0, unet_name=None, clip_l_name='clip_l.safetensors', t5xxl_name='t5xxl_fp8_e4m3fn_scaled.safetensors', vae_name='ae.safetensors', weight_dtype='default', sampler_name='euler', scheduler='simple', ...) -> dict` | Builder txt2img para **Flux** (`variant='schnell'` o `'dev'`): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → camino custom-advanced (RandomNoise + KSamplerSelect + BasicScheduler → BasicGuider → SamplerCustomAdvanced) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. `steps=None` autoselecciona por variante (~4 schnell); `unet_name=None` deduce el checkpoint de la variante; `weight_dtype='default'`. **Pura**. |
|
||||
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
||||
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
||||
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
||||
@@ -207,6 +218,22 @@ un error accionable, sin lanzar.
|
||||
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
|
||||
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
|
||||
|
||||
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
|
||||
|
||||
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
|
||||
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
|
||||
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
|
||||
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
|
||||
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
|
||||
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
|
||||
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
|
||||
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
|
||||
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
|
||||
|
||||
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
||||
|
||||
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,9 @@ Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
|
||||
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
||||
con sus import settings.
|
||||
|
||||
Tag único del grupo: `gamedev-2d` (los 31 builders de workflow + las 5 funciones de
|
||||
apoyo de post-proceso y puente). El tag plano `gamedev` quedó deprecado y unificado a
|
||||
Tag único del grupo: `gamedev-2d` — **47 funciones**: 36 builders de workflow (31 de
|
||||
generación desde texto + 5 de transformación desde una imagen de entrada) + 11 de apoyo
|
||||
(post-proceso, puente a Godot, style presets y pipelines one-shot). El tag plano `gamedev` quedó deprecado y unificado a
|
||||
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
||||
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
||||
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
||||
|
||||
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,352 @@
|
||||
"""Correlation chapter — association matrix plus top positive/negative pairs.
|
||||
|
||||
Builds the CORRELACION chapter of an AutomaticEDA document from a TableProfile.
|
||||
It renders exactly what the user asked for:
|
||||
|
||||
1. A correlation/association **matrix** (heatmap) reconstructed from the evaluated
|
||||
pairs, signed for numeric-numeric pairs (Pearson/Spearman, ``[-1, 1]``) and as
|
||||
magnitude for the mixed-type metrics (Cramér's V, correlation ratio, mutual
|
||||
information, ``[0, 1]``). Labels are ordered by total connectivity so strong
|
||||
associations cluster together instead of being scattered alphabetically.
|
||||
2. The **TOP positive** pairs and the **TOP negative** pairs as two separate
|
||||
tables. Only numeric-numeric metrics carry a sign, so negative pairs are by
|
||||
construction Pearson/Spearman; positive pairs may use any method.
|
||||
3. The methods legend and the multiple-testing (FDR) summary, so the reader sees
|
||||
how many pairs survive the correction.
|
||||
4. A spuriousness caveat when the profile flags level-based correlations on
|
||||
non-stationary series (Granger–Newbold).
|
||||
|
||||
All data comes from ``profile['correlations']`` — the output of the ``eda`` group
|
||||
function ``association_matrix`` (optionally enriched by ``profile_table``). The
|
||||
chapter never recomputes any statistic; it only lays the existing values out as
|
||||
format-independent blocks. The renderers paginate tables (repeating the header)
|
||||
and scale the heatmap to fit entirely, so nothing is ever cut.
|
||||
|
||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "correlacion"
|
||||
CHAPTER_TITLE = "Correlación"
|
||||
|
||||
# Methods whose value carries a sign (direction). Everything else is a magnitude
|
||||
# in [0, 1] and therefore only ever contributes to the positive side.
|
||||
_SIGNED_METHODS = ("pearson", "spearman")
|
||||
|
||||
# Cap the heatmap to the most-connected variables so it stays legible on a phone
|
||||
# screen / a slide. The renderer would scale a bigger matrix to fit, but the
|
||||
# cells become unreadable; we instead show the top-N and say so.
|
||||
_MAX_MATRIX_LABELS = 16
|
||||
|
||||
# How many pairs to show in each of the top-positive / top-negative tables.
|
||||
_TOP_N = 10
|
||||
|
||||
|
||||
def _is_num(v) -> bool:
|
||||
"""True for a real, finite int/float (not bool, not NaN/inf)."""
|
||||
return (
|
||||
isinstance(v, (int, float))
|
||||
and not isinstance(v, bool)
|
||||
and not (isinstance(v, float) and (math.isnan(v) or math.isinf(v)))
|
||||
)
|
||||
|
||||
|
||||
def _fmt_val(value, decimals: int = 2) -> str:
|
||||
"""Format an association value compactly, signed, with a fixed width feel."""
|
||||
if not _is_num(value):
|
||||
return "—"
|
||||
text = f"{float(value):+.{decimals}f}"
|
||||
# Strip a trailing -0.00 / +0.00 into a clean 0.00 for readability.
|
||||
if text in ("+0.00", "-0.00"):
|
||||
return "0.00"
|
||||
return text
|
||||
|
||||
|
||||
def _fmt_p(value) -> str:
|
||||
"""Format an adjusted p-value; tiny values collapse to a '<' threshold."""
|
||||
if not _is_num(value):
|
||||
return "—"
|
||||
p = float(value)
|
||||
if p < 0.001:
|
||||
return "<0.001"
|
||||
return f"{p:.3f}"
|
||||
|
||||
|
||||
def _is_signed(pair: dict) -> bool:
|
||||
"""True if the pair's method reports a directional (signed) value."""
|
||||
method = str(pair.get("method") or "").lower()
|
||||
return any(m in method for m in _SIGNED_METHODS)
|
||||
|
||||
|
||||
def _significant(pair: dict) -> bool:
|
||||
"""True if the pair is significant after FDR (or has no test to correct)."""
|
||||
if pair.get("significant") is True:
|
||||
return True
|
||||
# Pairs without an applicable test (p_value None) are not penalised: they are
|
||||
# admitted on magnitude alone upstream, so treat missing as "not rejected".
|
||||
return pair.get("p_value") is None and pair.get("significant") is None
|
||||
|
||||
|
||||
def _label(pair: dict) -> str:
|
||||
"""Human label for a pair, e.g. 'alcohol ↔ density'."""
|
||||
return f"{model._safe_str(pair.get('a'))} ↔ {model._safe_str(pair.get('b'))}"
|
||||
|
||||
|
||||
def _split_top(pairs: list, top_n: int = _TOP_N):
|
||||
"""Split evaluated pairs into ranked top-positive and top-negative lists.
|
||||
|
||||
Positive: any pair with a positive value, ranked by value descending.
|
||||
Negative: only signed (numeric-numeric) pairs with a negative value, ranked
|
||||
by value ascending (most negative first). Non-finite values are dropped.
|
||||
"""
|
||||
positive = []
|
||||
negative = []
|
||||
for pair in pairs:
|
||||
if not isinstance(pair, dict):
|
||||
continue
|
||||
value = pair.get("value")
|
||||
if not _is_num(value):
|
||||
continue
|
||||
if value > 0:
|
||||
positive.append(pair)
|
||||
elif value < 0 and _is_signed(pair):
|
||||
negative.append(pair)
|
||||
positive.sort(key=lambda p: float(p.get("value", 0.0)), reverse=True)
|
||||
negative.sort(key=lambda p: float(p.get("value", 0.0)))
|
||||
return positive[:top_n], negative[:top_n]
|
||||
|
||||
|
||||
def _top_table(pairs: list, title: str):
|
||||
"""Build a DataTable for a list of pairs, or None if there are none."""
|
||||
if not pairs:
|
||||
return None
|
||||
header = ["Par", "Método", "Valor", "p (FDR)", "Sig."]
|
||||
rows = []
|
||||
for pair in pairs:
|
||||
method = model._safe_str(pair.get("method")) or "—"
|
||||
rows.append([
|
||||
_label(pair),
|
||||
method,
|
||||
_fmt_val(pair.get("value")),
|
||||
_fmt_p(pair.get("p_value_adjusted")),
|
||||
"sí" if _significant(pair) else "no",
|
||||
])
|
||||
return model.DataTable(header=header, rows=rows, title=title)
|
||||
|
||||
|
||||
def _ordered_labels(pairs: list):
|
||||
"""Pick and order the matrix labels by total connectivity (descending).
|
||||
|
||||
Returns the list of variable names to place on the axes, capped at
|
||||
``_MAX_MATRIX_LABELS`` (the most-connected ones), plus a boolean saying
|
||||
whether the cap trimmed anything.
|
||||
"""
|
||||
strength = {}
|
||||
for pair in pairs:
|
||||
if not isinstance(pair, dict):
|
||||
continue
|
||||
value = pair.get("value")
|
||||
if not _is_num(value):
|
||||
continue
|
||||
mag = abs(float(value))
|
||||
for key in ("a", "b"):
|
||||
name = pair.get(key)
|
||||
if name is None:
|
||||
continue
|
||||
strength[name] = strength.get(name, 0.0) + mag
|
||||
if not strength:
|
||||
return [], False
|
||||
ordered = sorted(strength, key=lambda n: strength[n], reverse=True)
|
||||
trimmed = len(ordered) > _MAX_MATRIX_LABELS
|
||||
return ordered[:_MAX_MATRIX_LABELS], trimmed
|
||||
|
||||
|
||||
def _matrix_figure(pairs: list, labels: list):
|
||||
"""Return a Figure (lazy) with the signed association heatmap, or None.
|
||||
|
||||
The matplotlib figure is built lazily inside ``make`` so importing this
|
||||
module never requires matplotlib and a malformed plot degrades to nothing
|
||||
instead of aborting the chapter.
|
||||
"""
|
||||
if len(labels) < 2:
|
||||
return None
|
||||
|
||||
index = {name: i for i, name in enumerate(labels)}
|
||||
|
||||
def make():
|
||||
import numpy as np
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
n = len(labels)
|
||||
grid = np.full((n, n), np.nan, dtype=float)
|
||||
for i in range(n):
|
||||
grid[i, i] = 1.0
|
||||
for pair in pairs:
|
||||
if not isinstance(pair, dict):
|
||||
continue
|
||||
a = pair.get("a")
|
||||
b = pair.get("b")
|
||||
value = pair.get("value")
|
||||
if a not in index or b not in index or not _is_num(value):
|
||||
continue
|
||||
v = float(value)
|
||||
# Mixed-type magnitudes are non-negative; keep them as-is on [0, 1].
|
||||
ia, ib = index[a], index[b]
|
||||
grid[ia, ib] = v
|
||||
grid[ib, ia] = v
|
||||
|
||||
import matplotlib
|
||||
|
||||
masked = np.ma.masked_invalid(grid)
|
||||
fig = Figure(figsize=(6.2, 5.6))
|
||||
ax = fig.add_subplot(111)
|
||||
cmap = matplotlib.colormaps["RdBu_r"].copy()
|
||||
cmap.set_bad(color="#eeeeee")
|
||||
im = ax.imshow(masked, cmap=cmap, vmin=-1.0, vmax=1.0, aspect="auto")
|
||||
ax.set_xticks(range(n))
|
||||
ax.set_yticks(range(n))
|
||||
short = [str(s)[:14] for s in labels]
|
||||
ax.set_xticks(range(n))
|
||||
ax.set_xticklabels(short, rotation=90, fontsize=7)
|
||||
ax.set_yticklabels(short, fontsize=7)
|
||||
# Annotate cells only when the matrix is small enough to stay legible.
|
||||
if n <= 8:
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
cell = grid[i, j]
|
||||
if _is_num(cell):
|
||||
ax.text(j, i, f"{cell:+.2f}".replace("+", "") if cell < 0
|
||||
else f"{cell:.2f}",
|
||||
ha="center", va="center", fontsize=6,
|
||||
color="#222222")
|
||||
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04,
|
||||
label="asociación (signo en num-num)")
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
return model.Figure(make=make,
|
||||
caption="Matriz de asociación. Azul = positiva, rojo = "
|
||||
"negativa (sólo num-num lleva signo); gris = par "
|
||||
"no evaluado.")
|
||||
|
||||
|
||||
def _methods_block(corr: dict):
|
||||
"""Build a KVTable with the legend of the methods actually present."""
|
||||
legend = corr.get("methods_legend")
|
||||
if not isinstance(legend, dict) or not legend:
|
||||
return None
|
||||
rows = [(model._safe_str(k), model._safe_str(v)) for k, v in legend.items()]
|
||||
return model.KVTable(rows=rows, title="Métodos de asociación")
|
||||
|
||||
|
||||
def _fdr_text(corr: dict) -> str | None:
|
||||
"""One-line summary of the multiple-testing (FDR) correction, or None."""
|
||||
mt = corr.get("multiple_testing")
|
||||
if not isinstance(mt, dict) or not mt:
|
||||
return None
|
||||
method = model._safe_str(mt.get("method")).upper() or "FDR"
|
||||
alpha = mt.get("alpha")
|
||||
n_tests = mt.get("n_tests")
|
||||
n_rej = mt.get("n_rejected")
|
||||
parts = [f"Corrección por comparaciones múltiples ({method}"]
|
||||
if _is_num(alpha):
|
||||
parts[0] += f", α={float(alpha):g}"
|
||||
parts[0] += ")."
|
||||
if _is_num(n_tests):
|
||||
rej = n_rej if _is_num(n_rej) else "—"
|
||||
parts.append(
|
||||
f"De {int(n_tests)} pares con test, {rej} siguen siendo "
|
||||
f"significativos tras la corrección.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_correlacion(profile: dict, ctx: dict):
|
||||
"""Build the Correlation Chapter, or None if there are no pairs to show.
|
||||
|
||||
Reads ``profile['correlations']`` (the ``association_matrix`` output). Returns
|
||||
``None`` when the dataset has fewer than two associable columns (no evaluated
|
||||
pairs), so the chapter is omitted instead of showing an empty section. Never
|
||||
raises: every access is defensive.
|
||||
|
||||
ctx keys consumed: none specific (presentation metadata is inherited from the
|
||||
document). The chapter reads everything it needs from the profile.
|
||||
"""
|
||||
profile = profile or {}
|
||||
ctx = ctx or {}
|
||||
|
||||
corr = profile.get("correlations")
|
||||
if not isinstance(corr, dict):
|
||||
return None
|
||||
pairs = corr.get("pairs")
|
||||
if not isinstance(pairs, list) or not pairs:
|
||||
return None
|
||||
|
||||
blocks: list = []
|
||||
|
||||
# Intro: what this chapter shows and how to read the sign.
|
||||
blocks.append(model.Markdown(text=(
|
||||
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
|
||||
"sus tipos (Pearson/Spearman entre numéricas — con **signo**; Cramér's V "
|
||||
"entre categóricas; razón de correlación num-categórica; información mutua "
|
||||
"como medida común no lineal). Sólo las correlaciones **num-num** tienen "
|
||||
"dirección: por eso los pares **negativos** son siempre num-num.")))
|
||||
|
||||
# 1) Association matrix (heatmap).
|
||||
labels, trimmed = _ordered_labels(pairs)
|
||||
fig = _matrix_figure(pairs, labels)
|
||||
if fig is not None:
|
||||
blocks.append(model.Heading(text="Matriz de asociación", level=2))
|
||||
blocks.append(fig)
|
||||
if trimmed:
|
||||
blocks.append(model.Note(text=(
|
||||
f"Se muestran las {len(labels)} variables más conectadas de la "
|
||||
"matriz para mantenerla legible; el resto de pares siguen en las "
|
||||
"tablas de abajo.")))
|
||||
|
||||
# 2) Top positive / top negative pairs.
|
||||
positive, negative = _split_top(pairs, _TOP_N)
|
||||
pos_table = _top_table(positive, f"Top {len(positive)} positivas")
|
||||
neg_table = _top_table(negative, f"Top {len(negative)} negativas")
|
||||
if pos_table is not None:
|
||||
blocks.append(model.Heading(text="Pares más correlacionados (positivos)",
|
||||
level=2))
|
||||
blocks.append(pos_table)
|
||||
if neg_table is not None:
|
||||
blocks.append(model.Heading(text="Pares más correlacionados (negativos)",
|
||||
level=2))
|
||||
blocks.append(neg_table)
|
||||
elif pos_table is not None:
|
||||
# No signed-negative pairs at all: say so honestly rather than omit.
|
||||
blocks.append(model.Note(text=(
|
||||
"No se han hallado correlaciones negativas significativas entre "
|
||||
"columnas numéricas.")))
|
||||
|
||||
# 3) Spuriousness caveat for level-based correlations (Granger–Newbold).
|
||||
caveat = corr.get("levels_caveat")
|
||||
if isinstance(caveat, str) and caveat.strip():
|
||||
blocks.append(model.Note(text=caveat.strip()))
|
||||
elif corr.get("levels_possible_spurious"):
|
||||
blocks.append(model.Note(text=(
|
||||
"Aviso: algunas correlaciones se calcularon sobre niveles de series "
|
||||
"no estacionarias y pueden ser espurias (Granger–Newbold). Compáralas "
|
||||
"sobre los retornos/diferencias antes de interpretarlas.")))
|
||||
|
||||
# 4) FDR summary + methods legend.
|
||||
fdr_text = _fdr_text(corr)
|
||||
if fdr_text:
|
||||
blocks.append(model.Markdown(text=fdr_text))
|
||||
methods = _methods_block(corr)
|
||||
if methods is not None:
|
||||
blocks.append(model.Heading(text="Métodos y leyenda", level=2))
|
||||
blocks.append(methods)
|
||||
|
||||
if not blocks:
|
||||
return None
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for the CORRELACION chapter — DoD: golden + edges + error/anti-cut.
|
||||
|
||||
Self-contained: builds a synthetic TableProfile carrying a ``correlations`` block
|
||||
shaped exactly like ``association_matrix`` output (no DuckDB), so the suite is
|
||||
fast and deterministic. Verifies that the chapter emits the association-matrix
|
||||
figure plus separate top-positive / top-negative tables with the right pairs,
|
||||
that it returns None when the profile has no pairs, that a None/empty profile
|
||||
does not raise, and that a wide matrix with long labels renders to PDF *and* PPTX
|
||||
without cutting anything.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from datascience.automatic_eda.chapters.correlacion import (
|
||||
CHAPTER_VERSION,
|
||||
build_correlacion,
|
||||
)
|
||||
from datascience.automatic_eda.model import DataTable, Figure
|
||||
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||
|
||||
|
||||
def _pair(a, b, value, method, padj, sig, p=0.0001):
|
||||
return {
|
||||
"a": a, "b": b, "a_type": "numeric", "b_type": "numeric",
|
||||
"method": method, "value": value, "extra": {"mi": abs(value) * 0.5},
|
||||
"p_value": p, "p_value_adjusted": padj, "significant": sig,
|
||||
}
|
||||
|
||||
|
||||
def _profile() -> dict:
|
||||
"""Synthetic wine-like profile with signed and unsigned associations."""
|
||||
pairs = [
|
||||
_pair("alcohol", "quality", 0.48, "pearson/spearman", 0.0005, True),
|
||||
_pair("density", "alcohol", -0.78, "pearson/spearman", 0.0001, True),
|
||||
_pair("ph", "fixed_acidity", -0.68, "pearson/spearman", 0.0002, True),
|
||||
_pair("sulphates", "quality", 0.25, "pearson/spearman", 0.03, True),
|
||||
# Unsigned mixed-type metrics: only ever positive, never in the neg table.
|
||||
{"a": "region", "b": "type", "a_type": "categorical",
|
||||
"b_type": "categorical", "method": "cramers_v", "value": 0.55,
|
||||
"extra": {"mi": 0.3}, "p_value": 0.001, "p_value_adjusted": 0.004,
|
||||
"significant": True},
|
||||
]
|
||||
return {
|
||||
"table": "wine",
|
||||
"source": "/data/wine.csv",
|
||||
"n_rows": 1599,
|
||||
"n_cols": 12,
|
||||
"correlations": {
|
||||
"pairs": pairs,
|
||||
"strong": [p for p in pairs if abs(p["value"]) >= 0.5],
|
||||
"methods_legend": {
|
||||
"pearson": "num-num lineal (Pearson r), [-1, 1]",
|
||||
"cramers_v": "cat-cat simétrica (Cramér's V), [0, 1]",
|
||||
},
|
||||
"multiple_testing": {"method": "bh", "alpha": 0.05,
|
||||
"n_tests": 5, "n_rejected": 5},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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_tiene_matriz_y_top_positivos_y_negativos():
|
||||
ch = build_correlacion(_profile(), {})
|
||||
assert ch is not None
|
||||
assert ch.id == "correlacion"
|
||||
assert ch.version == CHAPTER_VERSION
|
||||
kinds = [b.kind for b in ch.blocks]
|
||||
assert "figure" in kinds # association matrix heatmap.
|
||||
figs = [b for b in ch.blocks if isinstance(b, Figure)]
|
||||
assert figs and figs[0].make is not None # lazy figure.
|
||||
|
||||
tables = [b for b in ch.blocks if isinstance(b, DataTable)]
|
||||
assert len(tables) >= 2 # top positive + top negative.
|
||||
flat = " ".join(str(c) for t in tables for r in t.rows for c in r)
|
||||
# Strongest positive present and signed +, strongest negative present and -.
|
||||
assert "alcohol" in flat and "quality" in flat
|
||||
assert "+0.48" in flat
|
||||
assert "density" in flat and "-0.78" in flat
|
||||
|
||||
|
||||
def test_golden_render_pdf_y_pptx_muestran_lo_exigido():
|
||||
prof = _profile()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
pdf = os.path.join(d, "corr.pdf")
|
||||
pptx = os.path.join(d, "corr.pptx")
|
||||
rp = render_automatic_eda_pdf(prof, pdf, {"title": "EDA — wine"})
|
||||
rx = render_automatic_eda_pptx(prof, pptx, {"title": "EDA — wine"})
|
||||
assert rp["path"] == pdf and rp["n_pages"] >= 1
|
||||
assert rx["path"] == pptx and rx["n_slides"] >= 1
|
||||
assert "correlacion" in [c["id"] for c in rp["chapters"]]
|
||||
assert "correlacion" in [c["id"] for c in rx["chapters"]]
|
||||
txt = _pdf_text(pdf)
|
||||
# The requirement: matrix + top positive/negative pairs, all visible.
|
||||
assert "Correlaci" in txt # chapter title (accents may vary in extract).
|
||||
assert "density" in txt and "alcohol" in txt and "quality" in txt
|
||||
assert "0.78" in txt and "0.48" in txt
|
||||
# Both signs surfaced as separate sections.
|
||||
assert "positiv" in txt.lower() and "negativ" in txt.lower()
|
||||
|
||||
|
||||
def test_edge_sin_pares_devuelve_none():
|
||||
# No correlations key, empty pairs, and wrong types all yield None, not error.
|
||||
assert build_correlacion({"table": "x"}, {}) is None
|
||||
assert build_correlacion({"correlations": {}}, {}) is None
|
||||
assert build_correlacion({"correlations": {"pairs": []}}, {}) is None
|
||||
assert build_correlacion({"correlations": {"pairs": "nope"}}, {}) is None
|
||||
assert build_correlacion(None, None) is None
|
||||
assert build_correlacion({}, {}) is None
|
||||
|
||||
|
||||
def test_edge_solo_positivos_emite_nota_sin_tabla_negativa():
|
||||
prof = {
|
||||
"correlations": {
|
||||
"pairs": [
|
||||
_pair("a", "b", 0.6, "pearson/spearman", 0.001, True),
|
||||
{"a": "c", "b": "d", "a_type": "categorical",
|
||||
"b_type": "categorical", "method": "cramers_v", "value": 0.7,
|
||||
"extra": {"mi": 0.4}, "p_value": 0.001,
|
||||
"p_value_adjusted": 0.003, "significant": True},
|
||||
],
|
||||
},
|
||||
}
|
||||
ch = build_correlacion(prof, {})
|
||||
assert ch is not None
|
||||
tables = [b for b in ch.blocks if isinstance(b, DataTable)]
|
||||
assert len(tables) == 1 # only the positive table.
|
||||
notes = " ".join(b.text for b in ch.blocks if b.kind == "note")
|
||||
assert "negativas" in notes # honest "no negative correlations" note.
|
||||
|
||||
|
||||
def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
|
||||
# 20 numeric vars with long names -> matrix trimmed to top-N + both renderers
|
||||
# must lay the chapter out without raising and keep a long label intact.
|
||||
long_a = "concentracion_de_dioxido_de_azufre_libre"
|
||||
long_b = "concentracion_de_dioxido_de_azufre_total"
|
||||
pairs = [_pair(long_a, long_b, -0.72, "pearson/spearman", 0.0001, True)]
|
||||
for i in range(20):
|
||||
pairs.append(_pair(f"variable_numerica_larga_{i:02d}",
|
||||
f"variable_numerica_larga_{(i + 1) % 20:02d}",
|
||||
0.55 - i * 0.02, "pearson/spearman", 0.01, True))
|
||||
prof = {"correlations": {"pairs": pairs,
|
||||
"multiple_testing": {"method": "bh", "alpha": 0.05,
|
||||
"n_tests": len(pairs),
|
||||
"n_rejected": len(pairs)}}}
|
||||
ch = build_correlacion(prof, {})
|
||||
assert ch is not None
|
||||
# A "showing top-N most connected" note appears when the matrix is trimmed.
|
||||
notes = " ".join(b.text for b in ch.blocks if b.kind == "note")
|
||||
assert "más conectadas" in notes
|
||||
# Anti-cut guarantee at the block level: the long pair reaches the renderer
|
||||
# whole (the block never truncates); the renderer then wraps the cell inside
|
||||
# its column. Both long labels are present, intact, in a table cell.
|
||||
tables = [b for b in ch.blocks if isinstance(b, DataTable)]
|
||||
cells = [str(c) for t in tables for r in t.rows for c in r]
|
||||
assert any(long_a in c and long_b in c for c in cells)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
pdf = os.path.join(d, "wide.pdf")
|
||||
pptx = os.path.join(d, "wide.pptx")
|
||||
rp = render_automatic_eda_pdf(prof, pdf, {"write_manifest": False})
|
||||
rx = render_automatic_eda_pptx(prof, pptx, {"write_manifest": False})
|
||||
# Both renderers lay the wide chapter out without raising and produce a
|
||||
# non-empty document (nothing dropped, just wrapped/scaled to fit).
|
||||
assert rp["path"] == pdf and os.path.exists(pdf) and rp["n_pages"] >= 1
|
||||
assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
|
||||
# A short, unbreakable fragment of the long label survives the wrap.
|
||||
assert "azufre" in _pdf_text(pdf)
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: assemble_animated_sprite
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def assemble_animated_sprite(frame_paths: list, out_dir: str, *, name: str = \"anim\", fps: int = 8, fmt: str = \"webp\", loop: bool = True, spritesheet: bool = True, pad: int = 0) -> dict"
|
||||
description: "Ensambla N frames PNG RGBA (p.ej. los frames de un walk cycle ya pixelizados a 32x32 con alpha) en DOS entregables: un sprite sheet horizontal (1 fila x N columnas) PNG RGBA con la transparencia intacta, y una animacion en loop WEBP lossless o GIF animado. Es la pieza de ensamblado final de cualquier animacion de sprite. Salta frames que falten o no abran (aviso en error, no aborta); normaliza tamano al primer frame valido reescalando con NEAREST. Solo PIL. No-throw. Devuelve {ok, spritesheet_path, animation_path, n_frames, frame_size, fmt, error}."
|
||||
tags: [gamedev-2d, comfyui, sprite, animation]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: frame_paths
|
||||
desc: "lista de rutas a PNG RGBA en orden de reproduccion; los que falten o no abran se saltan (aviso en error)."
|
||||
- name: out_dir
|
||||
desc: "directorio de salida; se crea si no existe. Se escriben '<name>_sheet.png' y '<name>.<ext>' dentro."
|
||||
- name: name
|
||||
desc: "nombre base de los ficheros generados (keyword-only, default 'anim')."
|
||||
- name: fps
|
||||
desc: "frames por segundo de la animacion; duration_ms = round(1000/max(1,fps)) por frame (keyword-only, default 8)."
|
||||
- name: fmt
|
||||
desc: "formato de la animacion: 'webp' (recomendado, lossless, alpha completo) o 'gif' (alpha binario) (keyword-only)."
|
||||
- name: loop
|
||||
desc: "si True la animacion se repite indefinidamente (loop=0); si False una sola vez (keyword-only, default True)."
|
||||
- name: spritesheet
|
||||
desc: "si True genera tambien el sprite sheet horizontal PNG RGBA (keyword-only, default True)."
|
||||
- name: pad
|
||||
desc: "pixeles de separacion transparente entre columnas del sheet (keyword-only, default 0)."
|
||||
output: "dict con ok (bool, True si se produjo la animacion con >=1 frame valido), spritesheet_path (str, '' si spritesheet=False o fallo), animation_path (str, '' si fallo), n_frames (int, frames validos usados), frame_size ([w,h] del frame normalizado), fmt (str, 'webp'|'gif'), error (str, avisos y/o error; '' si limpio)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/assemble_animated_sprite.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.assemble_animated_sprite import assemble_animated_sprite
|
||||
|
||||
# Frames de un walk cycle ya pixelizados a 32x32 RGBA (p.ej. salida del pipeline ComfyUI):
|
||||
frames = [
|
||||
"/tmp/walk/frame_00.png",
|
||||
"/tmp/walk/frame_01.png",
|
||||
"/tmp/walk/frame_02.png",
|
||||
"/tmp/walk/frame_03.png",
|
||||
]
|
||||
res = assemble_animated_sprite(frames, "/tmp/walk_out", name="hero_walk", fps=8, fmt="webp")
|
||||
# {'ok': True,
|
||||
# 'spritesheet_path': '/tmp/walk_out/hero_walk_sheet.png',
|
||||
# 'animation_path': '/tmp/walk_out/hero_walk.webp',
|
||||
# 'n_frames': 4, 'frame_size': [32, 32], 'fmt': 'webp', 'error': ''}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al final de cualquier pipeline de animacion de sprite, cuando ya tienes los frames
|
||||
sueltos (pixelizados, con alpha) y necesitas (a) verlos animados en bucle para validar
|
||||
el ciclo a ojo y (b) un sprite sheet horizontal listo para que un motor de juego lo
|
||||
trocee por columnas. Tipico despues de generar un walk cycle frame a frame con ComfyUI
|
||||
y pasarlo por el pixelizado: este es el paso de "juntarlo todo". Usa `fmt="webp"` por
|
||||
defecto; `fmt="gif"` solo si necesitas compatibilidad con visores que no abren WEBP.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **GIF solo tiene alpha binario** (1 bit): cada pixel es opaco o totalmente
|
||||
transparente, los pixeles con `alpha < 128` se vuelven transparentes y se pierde el
|
||||
anti-aliasing del borde. **WEBP (lossless) es el formato recomendado** para sprites con
|
||||
alpha — conserva el canal alpha completo y no ensucia el pixel-art. Usa GIF solo por
|
||||
compatibilidad.
|
||||
- Al guardar GIF, PIL **reoptimiza la paleta** y el indice de transparencia puede
|
||||
cambiar (p.ej. de 255 a 1 al releer): es normal, los pixeles transparentes se
|
||||
preservan (verificable convirtiendo el frame a RGBA y mirando el canal alpha).
|
||||
- **Frames que faltan o no abren se SALTAN** (se anota en `error`), no se aborta: la
|
||||
animacion se monta con los frames validos. Si quedan **0 frames validos** → `ok=False`.
|
||||
- El campo `error` puede venir **no vacio aunque `ok=True`**: ahi van los avisos de
|
||||
frames saltados. `ok` refleja si se genero la animacion, no la ausencia de avisos.
|
||||
- El tamano se normaliza al **primer frame valido**; los frames de tamano distinto se
|
||||
reescalan con **NEAREST** (sin interpolacion, preserva el pixel-art duro), lo que puede
|
||||
deformarlos si su aspect ratio difiere. Asegurate de que todos los frames ya vienen al
|
||||
mismo tamano.
|
||||
- Escribe en disco: crea `out_dir` si no existe; si no hay permiso de escritura, el
|
||||
fallo del sheet va a `error` como aviso y el de la animacion pone `ok=False`.
|
||||
- `disposal=2` limpia el lienzo entre frames (transparencia correcta en cada paso); sin
|
||||
el, los frames se acumularian unos sobre otros.
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Ensambla frames PNG RGBA en un sprite sheet horizontal + una animacion en loop.
|
||||
|
||||
Funcion impura: lee N frames de disco (los frames ya pixelizados de un walk cycle,
|
||||
por ejemplo) y escribe DOS entregables:
|
||||
|
||||
1. Un sprite sheet horizontal (1 fila x N columnas) PNG RGBA, con la transparencia
|
||||
de cada frame intacta.
|
||||
2. Una animacion en bucle (WEBP lossless o GIF animado) que reproduce los frames.
|
||||
|
||||
Es la pieza de ensamblado final de cualquier animacion de sprite: convierte una lista
|
||||
de frames sueltos en algo que se ve animado (la .webp/.gif) y algo que un motor de
|
||||
juego puede trocear (el sheet). Solo depende de PIL (Pillow), presente en el venv del
|
||||
registry. No lanza excepciones: cualquier problema se reporta en el campo "error".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def assemble_animated_sprite(
|
||||
frame_paths: list,
|
||||
out_dir: str,
|
||||
*,
|
||||
name: str = "anim",
|
||||
fps: int = 8,
|
||||
fmt: str = "webp",
|
||||
loop: bool = True,
|
||||
spritesheet: bool = True,
|
||||
pad: int = 0,
|
||||
) -> dict:
|
||||
"""Monta un sprite sheet horizontal y una animacion en loop a partir de N frames.
|
||||
|
||||
Carga cada ruta de ``frame_paths`` como RGBA. Los frames que falten o no abran se
|
||||
SALTAN (se anota un aviso en ``error``, no se aborta): se anima con los que haya.
|
||||
El tamano se normaliza al del primer frame valido; los frames de tamano distinto se
|
||||
reescalan con NEAREST a ese tamano (preserva el pixel-art duro, sin interpolacion).
|
||||
|
||||
Args:
|
||||
frame_paths: lista de rutas a PNG RGBA, en orden de reproduccion.
|
||||
out_dir: directorio de salida; se crea si no existe.
|
||||
name: nombre base de los ficheros generados (``<name>_sheet.png`` y
|
||||
``<name>.<ext>``). keyword-only.
|
||||
fps: frames por segundo de la animacion; duration_ms = round(1000/max(1,fps)).
|
||||
keyword-only.
|
||||
fmt: formato de la animacion, "webp" (recomendado) o "gif". keyword-only.
|
||||
loop: si True la animacion se repite indefinidamente; si False se reproduce una
|
||||
sola vez. keyword-only.
|
||||
spritesheet: si True genera tambien el sprite sheet horizontal PNG RGBA.
|
||||
keyword-only.
|
||||
pad: pixeles de separacion transparente entre columnas del sheet (default 0).
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si se produjo al menos la animacion con >=1 frame valido.
|
||||
- spritesheet_path (str): ruta del PNG del sheet ("" si spritesheet=False o fallo).
|
||||
- animation_path (str): ruta de la animacion WEBP/GIF ("" si fallo).
|
||||
- n_frames (int): numero de frames validos efectivamente usados.
|
||||
- frame_size ([w, h]): tamano del frame normalizado.
|
||||
- fmt (str): formato real de la animacion ("webp" o "gif").
|
||||
- error (str): avisos y/o mensaje de error; "" si todo fue limpio.
|
||||
"""
|
||||
out = {
|
||||
"ok": False,
|
||||
"spritesheet_path": "",
|
||||
"animation_path": "",
|
||||
"n_frames": 0,
|
||||
"frame_size": [0, 0],
|
||||
"fmt": "",
|
||||
"error": "",
|
||||
}
|
||||
warnings: list = []
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
|
||||
return out
|
||||
|
||||
if not frame_paths:
|
||||
out["error"] = "frame_paths vacio: no hay nada que ensamblar"
|
||||
return out
|
||||
|
||||
fmt = str(fmt).lower().strip()
|
||||
if fmt not in ("webp", "gif"):
|
||||
out["error"] = f"fmt invalido {fmt!r}: usa 'webp' o 'gif'"
|
||||
return out
|
||||
out["fmt"] = fmt
|
||||
|
||||
# --- Cargar y normalizar frames (saltando los invalidos) ---
|
||||
frames: list = []
|
||||
target = None # (w, h) del primer frame valido
|
||||
for path in frame_paths:
|
||||
if not os.path.isfile(path):
|
||||
warnings.append(f"falta: {path}")
|
||||
continue
|
||||
try:
|
||||
with Image.open(path) as src:
|
||||
im = src.convert("RGBA")
|
||||
except (OSError, ValueError) as exc:
|
||||
warnings.append(f"no abre {path}: {exc}")
|
||||
continue
|
||||
if target is None:
|
||||
target = (im.width, im.height)
|
||||
elif (im.width, im.height) != target:
|
||||
im = im.resize(target, Image.NEAREST)
|
||||
frames.append(im)
|
||||
|
||||
if not frames:
|
||||
out["error"] = "; ".join(["0 frames validos"] + warnings)
|
||||
return out
|
||||
|
||||
w, h = target
|
||||
out["frame_size"] = [w, h]
|
||||
out["n_frames"] = len(frames)
|
||||
n = len(frames)
|
||||
|
||||
try:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
except OSError as exc:
|
||||
out["error"] = "; ".join([f"no se pudo crear out_dir {out_dir!r}: {exc}"] + warnings)
|
||||
return out
|
||||
|
||||
# --- Sprite sheet horizontal (1 fila x N columnas), RGBA transparente ---
|
||||
if spritesheet:
|
||||
pad = max(0, int(pad))
|
||||
sheet_w = n * w + (n - 1) * pad if n > 0 else 0
|
||||
sheet = Image.new("RGBA", (sheet_w, h), (0, 0, 0, 0))
|
||||
for i, im in enumerate(frames):
|
||||
x = i * (w + pad)
|
||||
# Tercer arg = mascara alpha del propio frame: respeta su transparencia.
|
||||
sheet.paste(im, (x, 0), im)
|
||||
sheet_path = os.path.join(out_dir, f"{name}_sheet.png")
|
||||
try:
|
||||
sheet.save(sheet_path, format="PNG")
|
||||
out["spritesheet_path"] = sheet_path
|
||||
except OSError as exc:
|
||||
warnings.append(f"sheet no guardado: {exc}")
|
||||
|
||||
# --- Animacion en loop (WEBP lossless o GIF con alpha binario) ---
|
||||
duration_ms = round(1000 / max(1, int(fps)))
|
||||
loop_count = 0 if loop else 1 # 0 = infinito
|
||||
ext = fmt
|
||||
anim_path = os.path.join(out_dir, f"{name}.{ext}")
|
||||
|
||||
try:
|
||||
if fmt == "webp":
|
||||
frames[0].save(
|
||||
anim_path,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=duration_ms,
|
||||
loop=loop_count,
|
||||
format="WEBP",
|
||||
lossless=True, # no ensucia el pixel-art
|
||||
disposal=2, # limpia entre frames -> transparencia correcta
|
||||
)
|
||||
else: # gif
|
||||
pal_frames = [_rgba_to_p_transparent(im) for im in frames]
|
||||
pal_frames[0].save(
|
||||
anim_path,
|
||||
save_all=True,
|
||||
append_images=pal_frames[1:],
|
||||
duration=duration_ms,
|
||||
loop=loop_count,
|
||||
format="GIF",
|
||||
transparency=255, # indice reservado para el pixel transparente
|
||||
disposal=2,
|
||||
)
|
||||
out["animation_path"] = anim_path
|
||||
out["ok"] = True
|
||||
except (OSError, ValueError) as exc:
|
||||
warnings.append(f"animacion no guardada: {exc}")
|
||||
out["ok"] = False
|
||||
|
||||
out["error"] = "; ".join(warnings)
|
||||
return out
|
||||
|
||||
|
||||
def _rgba_to_p_transparent(im, alpha_threshold: int = 128):
|
||||
"""Convierte un frame RGBA a modo P reservando el indice 255 como transparente.
|
||||
|
||||
GIF solo soporta 1 bit de alpha: cada pixel es opaco o totalmente transparente.
|
||||
Los pixeles con alpha < alpha_threshold se mapean al indice 255 (transparente);
|
||||
el resto se cuantiza a 255 colores (indices 0..254).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
alpha = im.getchannel("A")
|
||||
# Cuantiza el RGB a 255 colores -> indices 0..254 libres, 255 para transparencia.
|
||||
p = im.convert("RGB").quantize(colors=255, method=Image.Quantize.MEDIANCUT)
|
||||
# Mascara de los pixeles "transparentes" (alpha por debajo del umbral).
|
||||
mask = alpha.point(lambda a: 255 if a < alpha_threshold else 0)
|
||||
p.paste(255, (0, 0), mask)
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from PIL import Image as _Image, ImageDraw as _ImageDraw
|
||||
|
||||
# --- Genera 4 frames de prueba: un cuadrado de color que se mueve de izquierda a
|
||||
# derecha sobre un lienzo RGBA transparente de 32x32. ---
|
||||
tmp = tempfile.mkdtemp(prefix="assemble_sprite_demo_")
|
||||
demo_frames: list = []
|
||||
box = 10
|
||||
for i in range(4):
|
||||
frame = _Image.new("RGBA", (32, 32), (0, 0, 0, 0)) # fondo transparente
|
||||
d = _ImageDraw.Draw(frame)
|
||||
x0 = 1 + i * 6 # se desplaza hacia la derecha cada frame
|
||||
d.rectangle([x0, 11, x0 + box, 11 + box], fill=(40, 180, 230, 255))
|
||||
fpath = os.path.join(tmp, f"frame_{i:02d}.png")
|
||||
frame.save(fpath)
|
||||
demo_frames.append(fpath)
|
||||
|
||||
result = assemble_animated_sprite(
|
||||
demo_frames, tmp, name="walk_demo", fps=8, fmt="webp"
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -49,7 +49,9 @@ params:
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
|
||||
tested: false
|
||||
tested: true
|
||||
tests: ["estructura img2img (LoadImage+VAEEncode, sin EmptyLatentImage)", "input_image/prompt reflejados en LoadImage y CLIPTextEncode positivo", "size por defecto inserta ImageScale a 512; size=None lo omite", "denoise se clampa a [0,1]", "filename_prefix/seed/lora opcional reflejados", "input_image o variant vacios -> ValueError", "determinismo: misma entrada -> mismo dict"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py"
|
||||
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
|
||||
---
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ params:
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida del SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames."
|
||||
tested: false
|
||||
tested: true
|
||||
tests: ["sv3d: estructura + orbit (video_frames=directions, size nativa 576)", "orbit_frames override", "zero123: StableZero123_Conditioning_Batched, azimuth equiespaciado, size 256", "cfg/ckpt por defecto segun modelo", "elevation/seed reflejados", "directional_sprite_view_order para 4/8/N", "errores: input vacio, model invalido, directions<1", "determinismo"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: labels
|
||||
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
|
||||
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["grid basico: ok + out_path + cols/rows (ceil(sqrt(N)))", "cols explicito define filas", "cell define dimension del canvas", "labels reservan franja bajo cada celda", "error: lista vacia", "error: ruta inexistente", "determinismo del dict de salida"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_grid.py"
|
||||
file_path: "python/functions/ml/comfyui_build_grid.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ params:
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)."
|
||||
tested: false
|
||||
tested: true
|
||||
tests: ["estructura vae_encode (LoadImage+LoadImageMask+VAEEncodeForInpaint)", "prompt de region + grow_mask reflejados", "grow_mask se clampa a [0,64]", "mode noise_mask degrada a VAEEncode+SetLatentNoiseMask+GrowMask", "size inserta ImageScale a imagen y mascara + ImageToMask", "lora opcional + filename_prefix", "errores: input/mask/prompt vacios, mode invalido", "determinismo"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py"
|
||||
file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py
|
||||
---
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ params:
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad."
|
||||
tested: false
|
||||
tested: true
|
||||
tests: ["estructura outpaint (ImagePadForOutpaint, sin LoadImageMask)", "pad cableado a VAEEncodeForInpaint (pixels<-IMAGE, mask<-MASK)", "extensiones redondeadas a multiplo de 8", "sin extension (todo 0 tras redondear) -> ValueError", "feather y prompt reflejados", "lora opcional + filename_prefix", "errores: input/prompt vacios", "determinismo"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py"
|
||||
file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py
|
||||
---
|
||||
|
||||
|
||||
@@ -78,6 +78,21 @@ CheckpointLoaderSimple -> ... -> KSampler -> VAEDecode --IMAGE--+-> SaveImage (f
|
||||
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
|
||||
```
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_build_parallax_background_workflow import comfyui_build_parallax_background_workflow
|
||||
|
||||
# Fondo apaisado + su mapa de profundidad, 4 bandas de parallax (función pura, sin red).
|
||||
wf = comfyui_build_parallax_background_workflow("forest at dusk, fantasy", layers=4, seed=7)
|
||||
|
||||
# El dict API format trae DOS SaveImage: el fondo y el depth map. Encólalo con submit_workflow.
|
||||
saves = [n for n in wf.values() if n.get("class_type") == "SaveImage"]
|
||||
print(len(saves), "SaveImage (fondo + depth)") # 2
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
|
||||
|
||||
@@ -3,11 +3,11 @@ name: comfyui_build_pixelart_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, filename_prefix: str = \"pixelart\") -> dict"
|
||||
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
||||
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl]
|
||||
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, transparent: bool = True, rembg_model: str = \"u2net\", filename_prefix: str = \"pixelart\") -> dict"
|
||||
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Si transparent (default), inyecta un nodo 'Image Rembg' tras el VAEDecode para recortar el fondo -> sprite con alpha (mismo patron que comfyui_build_item_icon_workflow); transparent=False para tiles/fondos opacos. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
||||
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl, rembg, transparent]
|
||||
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -45,11 +45,15 @@ params:
|
||||
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
||||
- name: transparent
|
||||
desc: "si True (default) inyecta 'Image Rembg' tras VAEDecode y el PNG sale con alpha (fondo recortado) — para sprites de sujeto (personajes/objetos). False deja fondo opaco — para tiles/texturas/fondos. keyword-only."
|
||||
- name: rembg_model
|
||||
desc: "modelo Rembg ('u2net' general, 'isnet-anime' anime). Solo se usa si transparent=True. keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + SaveImage."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + nodo 'Image Rembg' antes del SaveImage si transparent + SaveImage."
|
||||
tested: true
|
||||
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo"]
|
||||
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo", "transparent default inyecta Image Rembg + repunta SaveImage", "transparent=False sin Rembg (SaveImage lee del VAEDecode)", "rembg_model override"]
|
||||
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
||||
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
||||
---
|
||||
@@ -94,3 +98,15 @@ Para tilesets, genera cada tile por separado y ensambla con `comfyui_build_grid`
|
||||
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
||||
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
||||
400 salta al enviar con `comfyui_submit_workflow`.
|
||||
- **transparent=True (default, v1.1.0)**: inyecta el nodo `Image Rembg (Remove
|
||||
Background)`. Requiere el custom node `ComfyUI-Image-Background-Remove` (o equiv.)
|
||||
instalado en el server; si falta, el `submit` devuelve error en el dict (no crashea).
|
||||
El sprite sale RGBA con fondo recortado — ideal para personajes/objetos. Para
|
||||
tiles/texturas/fondos sin contorno usar `transparent=False` (PNG opaco).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-28) — `transparent`/`rembg_model`: inyecta `Image Rembg` tras el
|
||||
VAEDecode (mismo patron que `comfyui_build_item_icon_workflow`) para producir
|
||||
sprites con fondo transparente. Cierra el bug del pipeline pixelart que no podia
|
||||
generar sprites sin fondo (issue sprite-fix).
|
||||
|
||||
@@ -19,6 +19,7 @@ Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -29,6 +30,44 @@ _LCM_DEFAULTS = {"steps": 8, "cfg": 1.5, "sampler_name": "lcm", "scheduler": "sg
|
||||
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
||||
|
||||
|
||||
def _inject_rembg(workflow: dict, model: str) -> dict:
|
||||
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
|
||||
|
||||
Mismo helper que comfyui_build_item_icon_workflow / comfyui_build_sprite_sheet_workflow:
|
||||
el nodo recorta la silueta del sujeto dejando alpha, y se repunta SaveImage.images a
|
||||
la salida del Rembg para que el PNG salga con fondo transparente. No muta el dict de
|
||||
entrada (copia profunda).
|
||||
"""
|
||||
wf = copy.deepcopy(workflow)
|
||||
vaedecode_id = next(
|
||||
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
|
||||
)
|
||||
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
|
||||
if vaedecode_id is None or save_id is None:
|
||||
raise ValueError(
|
||||
"comfyui_build_pixelart_workflow: no se encontro VAEDecode/SaveImage para Rembg"
|
||||
)
|
||||
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
|
||||
wf[rembg_id] = {
|
||||
"class_type": "Image Rembg (Remove Background)",
|
||||
"inputs": {
|
||||
"images": [vaedecode_id, 0],
|
||||
"transparency": True,
|
||||
"model": model,
|
||||
"post_processing": False,
|
||||
"only_mask": False,
|
||||
"alpha_matting": False,
|
||||
"alpha_matting_foreground_threshold": 240,
|
||||
"alpha_matting_background_threshold": 10,
|
||||
"alpha_matting_erode_size": 10,
|
||||
"background_color": "none",
|
||||
},
|
||||
}
|
||||
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
|
||||
return wf
|
||||
|
||||
|
||||
def comfyui_build_pixelart_workflow(
|
||||
positive: str,
|
||||
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
||||
@@ -46,6 +85,8 @@ def comfyui_build_pixelart_workflow(
|
||||
seed: int = 0,
|
||||
sampler_name: str | None = None,
|
||||
scheduler: str | None = None,
|
||||
transparent: bool = True,
|
||||
rembg_model: str = "u2net",
|
||||
filename_prefix: str = "pixelart",
|
||||
) -> dict:
|
||||
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
||||
@@ -70,15 +111,24 @@ def comfyui_build_pixelart_workflow(
|
||||
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
||||
en la Fase 2 con comfyui_pixelize_image).
|
||||
seed: semilla del KSampler.
|
||||
transparent: si True (default) inyecta 'Image Rembg' tras el VAEDecode y el
|
||||
PNG sale con alpha (fondo recortado) — lo habitual para sprites de sujeto
|
||||
(personajes, criaturas, objetos). Si False deja la imagen opaca sobre
|
||||
fondo plano, para tiles/texturas/fondos que no quieren transparencia.
|
||||
keyword-only.
|
||||
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo
|
||||
se usa si transparent=True. keyword-only.
|
||||
filename_prefix: prefijo del PNG en output/.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow, con el
|
||||
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
||||
SDXL_lcm-lora si use_lcm), KSampler con los params del modo y SaveImage.
|
||||
SDXL_lcm-lora si use_lcm), KSampler con los params del modo, un nodo
|
||||
'Image Rembg' antes del SaveImage si transparent, y SaveImage.
|
||||
|
||||
Raises:
|
||||
ValueError: si positive esta vacio.
|
||||
ValueError: si positive esta vacio, o si la base no tiene VAEDecode/SaveImage
|
||||
donde inyectar el Rembg (propagado por el helper, solo si transparent).
|
||||
"""
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
||||
@@ -117,7 +167,12 @@ def comfyui_build_pixelart_workflow(
|
||||
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
||||
)
|
||||
|
||||
return comfyui_inject_multi_lora(base, loras)
|
||||
wf = comfyui_inject_multi_lora(base, loras)
|
||||
|
||||
if transparent:
|
||||
wf = _inject_rembg(wf, rembg_model)
|
||||
|
||||
return wf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -51,7 +51,9 @@ params:
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)."
|
||||
tested: false
|
||||
tested: true
|
||||
tests: ["estructura txt2img + ControlNet (EmptyLatentImage, ControlNetLoader/Apply)", "lineart: preprocesador + modelo por defecto, ControlNetApply consume el mapa de lineas", "canny: preprocesador + modelo", "preprocess=False pasa el boceto directo al ControlNet", "controlnet_name override + strength reflejado", "strength se clampa a [0,2]", "lora opcional", "errores: sketch/subject vacios, control_type invalido", "determinismo"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py"
|
||||
file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: comfyui_build_walk_cycle_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_walk_cycle_workflow(subject: str, pose_skeletons: list, *, ckpt_name: str = \"IMG_dreamshaper_8.safetensors\", char_lora: str | None = None, lora_strength: float = 1.0, controlnet_name: str = \"control_v11p_sd15_openpose_fp16.safetensors\", controlnet_strength: float = 0.7, controlnet_start: float = 0.0, controlnet_end: float = 0.8, transparent: bool = True, rembg_model: str = \"u2net\", negative: str = \"blurry, lowres, extra limbs, deformed\", width: int = 512, height: int = 768, steps: int = 24, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", fps: int = 8, filename_prefix: str = \"walk_cycle\") -> dict"
|
||||
description: "Construye el dict (API format) del workflow de un WALK CYCLE animado: genera N frames de un personaje en N poses OpenPose con la MISMA seed (identidad consistente), los combina en un batch encadenando ImageBatch, recorta el fondo a alpha con Rembg y los exporta como WEBP animado con SaveAnimatedWEBP. Caso 1 del report 0217 (animacion de sprite frame-by-frame pose-driven). Hermano animado de comfyui_build_sprite_sheet_workflow (frame estatico) y de comfyui_build_directional_sprite_workflow (rotacion 3D). Pura, sin red ni I/O. class_types e inputs verificados contra /object_info."
|
||||
tags: [gamedev-2d, comfyui, sprite, animation, walk-cycle, controlnet, openpose]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: subject
|
||||
desc: "Descripcion del personaje (ej. 'pixel art knight'). Se completa con ', full body, game sprite, simple background, walking'. No puede estar vacio."
|
||||
- name: pose_skeletons
|
||||
desc: "Lista (no vacia) de nombres de archivo de esqueletos OpenPose en el dir input/ del servidor, uno por frame del ciclo en orden de animacion. Cada uno debe ser string no vacio. La lista no se muta."
|
||||
- name: ckpt_name
|
||||
desc: "Checkpoint SD1.5 (OpenPose solo instalado en SD1.5; default 'IMG_dreamshaper_8.safetensors'). keyword-only."
|
||||
- name: char_lora
|
||||
desc: "LoRA de personaje/estilo opcional en models/loras (refuerza consistencia de ropa/cuerpo entre frames). None = sin LoRA. keyword-only."
|
||||
- name: lora_strength
|
||||
desc: "Fuerza del char_lora sobre model y clip. keyword-only."
|
||||
- name: controlnet_name
|
||||
desc: "ControlNet OpenPose (default SD1.5 'control_v11p_sd15_openpose_fp16.safetensors'). keyword-only."
|
||||
- name: controlnet_strength
|
||||
desc: "Fuerza del OpenPose (default 0.7). keyword-only."
|
||||
- name: controlnet_start
|
||||
desc: "Inicio de aplicacion del OpenPose (fraccion 0..1). keyword-only."
|
||||
- name: controlnet_end
|
||||
desc: "Fin de aplicacion del OpenPose (end<1.0 deja libres los ultimos pasos para pelo/ropa; default 0.8). keyword-only."
|
||||
- name: transparent
|
||||
desc: "Si True inyecta Rembg para alpha (recomendado para sprites de juego). False = fondo opaco. keyword-only."
|
||||
- name: rembg_model
|
||||
desc: "Modelo Rembg ('u2net' general, 'isnet-anime' para anime). keyword-only."
|
||||
- name: negative
|
||||
desc: "Prompt negativo. keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho en px (512). keyword-only."
|
||||
- name: height
|
||||
desc: "Alto en px (768, vertical, encuadra cuerpo entero). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "CFG del KSampler. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler, FIJA e identica para todos los frames (identidad consistente). keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Sampler del KSampler (default 'dpmpp_2m'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del KSampler (default 'karras'). keyword-only."
|
||||
- name: fps
|
||||
desc: "Frames por segundo del WEBP animado (default 8). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo WEBP en output/ (default 'walk_cycle'). keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow. Claves = node_ids (string); cada valor tiene class_type + inputs. Estructura: CheckpointLoaderSimple (+ LoraLoader si char_lora) + 2x CLIPTextEncode + ControlNetLoader compartido + N x (LoadImage + ControlNetApplyAdvanced + EmptyLatentImage + KSampler + VAEDecode) + cadena de ImageBatch que une los N frames + Rembg (si transparent) + SaveAnimatedWEBP."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_build_walk_cycle_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_walk_cycle_workflow import comfyui_build_walk_cycle_workflow
|
||||
|
||||
# Ciclo de andar de 4 frames: 4 esqueletos OpenPose (en input/ del servidor),
|
||||
# misma seed -> el mismo personaje caminando, no 4 personajes distintos.
|
||||
wf = comfyui_build_walk_cycle_workflow(
|
||||
"pixel art knight",
|
||||
pose_skeletons=[
|
||||
"walk_pose_00.png",
|
||||
"walk_pose_01.png",
|
||||
"walk_pose_02.png",
|
||||
"walk_pose_03.png",
|
||||
],
|
||||
transparent=True,
|
||||
fps=8,
|
||||
seed=0,
|
||||
)
|
||||
# wf es API format -> comfyui_submit_workflow(wf) genera el WEBP animado en output/.
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_walk_cycle_workflow` (imprime nodos + class_types del ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites una **animacion** de un sprite de personaje 2D (no un frame suelto):
|
||||
ciclo de andar, correr, atacar, idle... — cualquier secuencia donde el personaje conserva
|
||||
su identidad y solo cambia la postura. Dibuja los N esqueletos OpenPose de la secuencia,
|
||||
pasalos en orden, fija la `seed` y obtienes un WEBP animado de una sola pasada. Para UN
|
||||
frame estatico usa `comfyui_build_sprite_sheet_workflow`; para rotar el personaje en 3D
|
||||
(vistas direccionales) usa `comfyui_build_directional_sprite_workflow`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo SD1.5 hoy**: el ControlNet OpenPose esta instalado solo en SD1.5. Usa
|
||||
`IMG_dreamshaper_8` u otro checkpoint SD1.5.
|
||||
- **`pose_skeletons` son nombres de archivo en el dir `input/` del servidor**, no rutas
|
||||
locales. Subelas antes (cada LoadImage las lee de ahi). El orden de la lista = el orden
|
||||
de los frames de la animacion.
|
||||
- **La `seed` es FIJA para todos los frames a proposito**: compartir seed + prompt +
|
||||
checkpoint y variar solo el OpenPose es lo que mantiene al mismo personaje entre
|
||||
fotogramas. Una seed por frame haria "parpadear" la identidad (cara/ropa/paleta derivan).
|
||||
- **`ControlNetApplyAdvanced` con `end_percent` 0.8** deja los ultimos pasos libres para
|
||||
que pelo/ropa no queden aplastados contra el esqueleto.
|
||||
- **El batch se construye encadenando `ImageBatch`** (toma 2 imagenes): para N frames hay
|
||||
N-1 nodos ImageBatch. Con N=1 no hay ImageBatch (el unico frame va directo al Save).
|
||||
- `Image Rembg` da matting binario (silueta solida) — ideal para personajes, NO para
|
||||
efectos translucidos (humo/fuego). Con `transparent=False` se omite (fondo opaco).
|
||||
- **El WEBP animado** usa `lossless=True`, `quality=90`, `method="default"`; sube/baja
|
||||
`fps` para la velocidad del ciclo. Verificado que `method` admite `default/fastest/slowest`.
|
||||
- Funcion pura: construye el grafo, NO valida contra el server ni envia nada. El coste GPU
|
||||
esta al enviar con `comfyui_submit_workflow`.
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Construye el workflow ComfyUI de un WALK CYCLE animado (N frames pose-driven -> WEBP).
|
||||
|
||||
Caso 1 del report 0217 ("animacion de sprite frame-by-frame pose-driven"): a partir de
|
||||
N esqueletos OpenPose que describen las poses sucesivas de un ciclo de andar, construye el
|
||||
dict (API format) de un workflow que:
|
||||
|
||||
1. genera un frame por pose con la MISMA seed y el MISMO prompt/checkpoint/LoRA, de modo
|
||||
que el personaje conserva su identidad de un frame al siguiente (la unica variable es
|
||||
el esqueleto OpenPose que dicta la postura);
|
||||
2. combina los N frames en un unico batch encadenando `ImageBatch`;
|
||||
3. recorta el fondo a alpha con `Image Rembg (Remove Background)` (transparencia para el
|
||||
motor del juego);
|
||||
4. los exporta como WEBP animado con `SaveAnimatedWEBP` (un solo archivo reproducible).
|
||||
|
||||
Es el builder PURO equivalente a `comfyui_build_sprite_sheet_workflow` (que produce UN
|
||||
frame estatico) pero orientado a ANIMACION: en vez de devolver un sprite suelto por pose y
|
||||
montar un contact-sheet a posteriori, este grafo produce de una sola pasada el WEBP animado
|
||||
del ciclo. Hermano direccional: `comfyui_build_directional_sprite_workflow` (rota el
|
||||
personaje en 3D); aqui el personaje no rota, camina (mismo angulo de camara, poses 2D).
|
||||
|
||||
Por que ControlNetApplyAdvanced (y no el legacy ControlNetApply): `end_percent` < 1.0 deja
|
||||
los ultimos pasos del sampler libres para que pelo y ropa no queden aplastados contra el
|
||||
esqueleto OpenPose (mismo razonamiento que el sprite sheet, report 0137).
|
||||
|
||||
Por que la seed es FIJA para todos los frames: una seed distinta por frame haria que el
|
||||
personaje "parpadee" de identidad entre fotogramas (ropa/cara/paleta derivan). Compartir la
|
||||
seed + prompt + checkpoint y variar solo el OpenPose es lo que hace que sea el mismo
|
||||
personaje andando, no N personajes distintos en N posturas.
|
||||
|
||||
Funcion PURA: sin red, sin I/O. No muta las entradas (no recibe dicts; copia la lista de
|
||||
poses). Todos los class_types y sus inputs estan verificados contra /object_info del server
|
||||
8GB (CheckpointLoaderSimple, LoraLoader, CLIPTextEncode, ControlNetLoader, LoadImage,
|
||||
ControlNetApplyAdvanced, EmptyLatentImage, KSampler, VAEDecode, ImageBatch,
|
||||
'Image Rembg (Remove Background)', SaveAnimatedWEBP).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def comfyui_build_walk_cycle_workflow(
|
||||
subject: str,
|
||||
pose_skeletons: list,
|
||||
*,
|
||||
ckpt_name: str = "IMG_dreamshaper_8.safetensors",
|
||||
char_lora: str | None = None,
|
||||
lora_strength: float = 1.0,
|
||||
controlnet_name: str = "control_v11p_sd15_openpose_fp16.safetensors",
|
||||
controlnet_strength: float = 0.7,
|
||||
controlnet_start: float = 0.0,
|
||||
controlnet_end: float = 0.8,
|
||||
transparent: bool = True,
|
||||
rembg_model: str = "u2net",
|
||||
negative: str = "blurry, lowres, extra limbs, deformed",
|
||||
width: int = 512,
|
||||
height: int = 768,
|
||||
steps: int = 24,
|
||||
cfg: float = 7.0,
|
||||
seed: int = 0,
|
||||
sampler_name: str = "dpmpp_2m",
|
||||
scheduler: str = "karras",
|
||||
fps: int = 8,
|
||||
filename_prefix: str = "walk_cycle",
|
||||
) -> dict:
|
||||
"""Construye el dict (API format) del workflow de un walk cycle animado.
|
||||
|
||||
Genera un frame por cada esqueleto OpenPose de ``pose_skeletons`` con identidad
|
||||
consistente (misma seed/prompt/checkpoint), los combina en un batch, los recorta a
|
||||
alpha (Rembg) y los guarda como WEBP animado.
|
||||
|
||||
Args:
|
||||
subject: descripcion del personaje (ej. "pixel art knight"). Se completa con
|
||||
", full body, game sprite, simple background, walking". No puede estar vacio.
|
||||
pose_skeletons: lista (no vacia) de nombres de archivo de esqueletos OpenPose en el
|
||||
dir ``input/`` del servidor (uno por frame del ciclo, en orden de animacion). El
|
||||
grafo crea un LoadImage por entrada; cada uno debe ser un string no vacio. La
|
||||
lista no se muta.
|
||||
ckpt_name: checkpoint SD1.5 (OpenPose solo instalado en SD1.5; default
|
||||
'IMG_dreamshaper_8.safetensors'). keyword-only.
|
||||
char_lora: LoRA de personaje/estilo opcional en models/loras (refuerza la
|
||||
consistencia de ropa/cuerpo entre frames). None = sin LoRA. keyword-only.
|
||||
lora_strength: fuerza del char_lora sobre model y clip. keyword-only.
|
||||
controlnet_name: ControlNet OpenPose (default SD1.5). keyword-only.
|
||||
controlnet_strength: fuerza del OpenPose (default 0.7). keyword-only.
|
||||
controlnet_start: inicio de aplicacion del OpenPose (fraccion 0..1). keyword-only.
|
||||
controlnet_end: fin de aplicacion del OpenPose (end<1.0 deja libres los ultimos
|
||||
pasos para pelo/ropa; default 0.8). keyword-only.
|
||||
transparent: si True inyecta Rembg para alpha (recomendado para sprites de juego).
|
||||
False = fondo opaco. keyword-only.
|
||||
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). keyword-only.
|
||||
negative: prompt negativo. keyword-only.
|
||||
width: ancho en px (512). keyword-only.
|
||||
height: alto en px (768, vertical, encuadra cuerpo entero). keyword-only.
|
||||
steps: pasos del KSampler. keyword-only.
|
||||
cfg: CFG del KSampler. keyword-only.
|
||||
seed: semilla del KSampler, FIJA e identica para todos los frames (identidad
|
||||
consistente). keyword-only.
|
||||
sampler_name: sampler del KSampler (default 'dpmpp_2m'). keyword-only.
|
||||
scheduler: scheduler del KSampler (default 'karras'). keyword-only.
|
||||
fps: frames por segundo del WEBP animado (default 8). keyword-only.
|
||||
filename_prefix: prefijo del archivo WEBP en output/ (default 'walk_cycle').
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son node_ids
|
||||
(string) y cada valor tiene class_type + inputs. Estructura: CheckpointLoaderSimple
|
||||
(+ LoraLoader si char_lora) + 2x CLIPTextEncode + ControlNetLoader compartido +
|
||||
N x (LoadImage + ControlNetApplyAdvanced + EmptyLatentImage + KSampler + VAEDecode)
|
||||
+ cadena de ImageBatch que une los N frames + Rembg (si transparent) +
|
||||
SaveAnimatedWEBP.
|
||||
|
||||
Raises:
|
||||
ValueError: si subject esta vacio, pose_skeletons esta vacio, o alguna pose no es un
|
||||
string no vacio.
|
||||
"""
|
||||
if not subject or not subject.strip():
|
||||
raise ValueError("comfyui_build_walk_cycle_workflow: 'subject' no puede estar vacio")
|
||||
if not isinstance(pose_skeletons, (list, tuple)) or len(pose_skeletons) == 0:
|
||||
raise ValueError(
|
||||
"comfyui_build_walk_cycle_workflow: 'pose_skeletons' debe ser una lista no vacia "
|
||||
"de nombres de esqueletos OpenPose en input/ (uno por frame del ciclo)."
|
||||
)
|
||||
poses = list(pose_skeletons)
|
||||
for i, p in enumerate(poses):
|
||||
if not isinstance(p, str) or not p.strip():
|
||||
raise ValueError(
|
||||
"comfyui_build_walk_cycle_workflow: pose_skeletons["
|
||||
f"{i}] debe ser un string no vacio (nombre de archivo en input/)."
|
||||
)
|
||||
|
||||
positive = f"{subject}, full body, game sprite, simple background, walking"
|
||||
|
||||
wf: dict = {}
|
||||
counter = [0]
|
||||
|
||||
def nid() -> str:
|
||||
counter[0] += 1
|
||||
return str(counter[0])
|
||||
|
||||
# 1. Checkpoint -> MODEL(0), CLIP(1), VAE(2).
|
||||
ckpt_id = nid()
|
||||
wf[ckpt_id] = {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
}
|
||||
model_src = [ckpt_id, 0]
|
||||
clip_src = [ckpt_id, 1]
|
||||
vae_src = [ckpt_id, 2]
|
||||
|
||||
# 2. LoRA opcional -> reapunta MODEL/CLIP a su salida.
|
||||
if char_lora:
|
||||
lora_id = nid()
|
||||
wf[lora_id] = {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"model": model_src,
|
||||
"clip": clip_src,
|
||||
"lora_name": char_lora,
|
||||
"strength_model": lora_strength,
|
||||
"strength_clip": lora_strength,
|
||||
},
|
||||
}
|
||||
model_src = [lora_id, 0]
|
||||
clip_src = [lora_id, 1]
|
||||
|
||||
# 3. Prompts positivo y negativo (compartidos por todos los frames).
|
||||
pos_clip_id = nid()
|
||||
wf[pos_clip_id] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": clip_src},
|
||||
}
|
||||
neg_clip_id = nid()
|
||||
wf[neg_clip_id] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": clip_src},
|
||||
}
|
||||
|
||||
# 4. ControlNetLoader compartido (uno solo para todas las poses).
|
||||
cn_loader_id = nid()
|
||||
wf[cn_loader_id] = {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {"control_net_name": controlnet_name},
|
||||
}
|
||||
|
||||
# 5. Por cada pose: LoadImage -> ControlNetApplyAdvanced -> EmptyLatent -> KSampler -> VAEDecode.
|
||||
frame_image_srcs: list = []
|
||||
for pose in poses:
|
||||
load_id = nid()
|
||||
wf[load_id] = {"class_type": "LoadImage", "inputs": {"image": pose}}
|
||||
|
||||
apply_id = nid()
|
||||
wf[apply_id] = {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [pos_clip_id, 0],
|
||||
"negative": [neg_clip_id, 0],
|
||||
"control_net": [cn_loader_id, 0],
|
||||
"image": [load_id, 0],
|
||||
"strength": controlnet_strength,
|
||||
"start_percent": controlnet_start,
|
||||
"end_percent": controlnet_end,
|
||||
},
|
||||
}
|
||||
|
||||
latent_id = nid()
|
||||
wf[latent_id] = {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||
}
|
||||
|
||||
ksampler_id = nid()
|
||||
wf[ksampler_id] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed, # FIJA: misma seed para todos los frames (identidad consistente).
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"model": model_src,
|
||||
"positive": [apply_id, 0],
|
||||
"negative": [apply_id, 1],
|
||||
"latent_image": [latent_id, 0],
|
||||
},
|
||||
}
|
||||
|
||||
vae_id = nid()
|
||||
wf[vae_id] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": [ksampler_id, 0], "vae": vae_src},
|
||||
}
|
||||
frame_image_srcs.append([vae_id, 0])
|
||||
|
||||
# 6. Combinar los N frames en un solo batch encadenando ImageBatch.
|
||||
if len(frame_image_srcs) == 1:
|
||||
batch_src = frame_image_srcs[0]
|
||||
else:
|
||||
batch_src = frame_image_srcs[0]
|
||||
for next_src in frame_image_srcs[1:]:
|
||||
ib_id = nid()
|
||||
wf[ib_id] = {
|
||||
"class_type": "ImageBatch",
|
||||
"inputs": {"image1": batch_src, "image2": next_src},
|
||||
}
|
||||
batch_src = [ib_id, 0]
|
||||
|
||||
# 7. Rembg opcional sobre el batch (alpha para el motor del juego).
|
||||
save_images_src = batch_src
|
||||
if transparent:
|
||||
rembg_id = nid()
|
||||
wf[rembg_id] = {
|
||||
"class_type": "Image Rembg (Remove Background)",
|
||||
"inputs": {
|
||||
"images": batch_src,
|
||||
"transparency": True,
|
||||
"model": rembg_model,
|
||||
"post_processing": False,
|
||||
"only_mask": False,
|
||||
"alpha_matting": False,
|
||||
"alpha_matting_foreground_threshold": 240,
|
||||
"alpha_matting_background_threshold": 10,
|
||||
"alpha_matting_erode_size": 10,
|
||||
"background_color": "none",
|
||||
},
|
||||
}
|
||||
save_images_src = [rembg_id, 0]
|
||||
|
||||
# 8. Exportar el ciclo como WEBP animado.
|
||||
save_id = nid()
|
||||
wf[save_id] = {
|
||||
"class_type": "SaveAnimatedWEBP",
|
||||
"inputs": {
|
||||
"images": save_images_src,
|
||||
"filename_prefix": filename_prefix,
|
||||
"fps": float(fps),
|
||||
"lossless": True,
|
||||
"quality": 90,
|
||||
"method": "default",
|
||||
},
|
||||
}
|
||||
|
||||
return wf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_walk_cycle_workflow(
|
||||
"pixel art knight",
|
||||
pose_skeletons=[
|
||||
"walk_pose_00.png",
|
||||
"walk_pose_01.png",
|
||||
"walk_pose_02.png",
|
||||
"walk_pose_03.png",
|
||||
],
|
||||
)
|
||||
print(json.dumps({
|
||||
"nodes": list(wf),
|
||||
"classes": sorted({n["class_type"] for n in wf.values()}),
|
||||
}, indent=2))
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: token
|
||||
desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only."
|
||||
output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["_extract_json: fence json", "_extract_json: brace plano", "_extract_json: sin objeto -> ValueError", "flujo: veredicto estructurado good", "verdict ambiguo -> bad conservador", "API caida -> ok=False", "respuesta no parseable -> ok=False"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_critique_image_llm.py"
|
||||
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: nsfw
|
||||
desc: "Marca provenance.nsfw. keyword-only."
|
||||
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["_slugify (normaliza y acota a 6 tokens)", "_loras_from_prompt", "_dims_from_prompt + _checkpoint_from_prompt", "_detect_base_workflow (flux/txt2img)", "_from_civitai_meta (mapea steps/cfg/size/modelo/prompts)", "flujo fallback a civitai_meta sin workflow embebido", "slug derivado del prompt", "error: sin workflow ni meta"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py"
|
||||
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_fetch_civitai_image_meta(image_ref, *, token: str | None = None, timeout: float = 15.0) -> dict"
|
||||
signature: "def comfyui_fetch_civitai_image_meta(image_ref, token: str | None = None, timeout: float = 15.0) -> dict"
|
||||
description: "Recupera los detalles de generacion de una imagen de Civitai por su id o URL (civitai.com/images/<id>): prompt, prompt negativo, modelo, sampler, steps, cfg, seed, dimensiones, recursos (checkpoint + LoRAs) y nivel NSFW. Es el paso 'entrar al link y observar como lo hicieron'. Usa los endpoints tRPC image.getGenerationData + image.get que consume la propia web de civitai.com, porque la API v1 publica (GET /api/v1/images) hoy devuelve meta=null y un JPEG recomprimido sin workflow embebido. Si la meta trae un workflow ComfyUI embebido (campo comfy) lo devuelve en API format. NO descarga la imagen ni reconstruye workflow: solo lee. Impura: HTTP a civitai.com + subprocess (pass para el token)."
|
||||
tags: [comfyui, civitai, replicate, ml, metadata, http, stable-diffusion]
|
||||
uses_functions: []
|
||||
|
||||
@@ -128,15 +128,15 @@ def _extract_comfy_workflow(meta):
|
||||
return {}
|
||||
|
||||
|
||||
def comfyui_fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0):
|
||||
def comfyui_fetch_civitai_image_meta(image_ref, token=None, timeout=15.0):
|
||||
"""Recupera los detalles de generación de una imagen de Civitai por id/URL.
|
||||
|
||||
Args:
|
||||
image_ref: id numérico de la imagen (int o str), o su URL
|
||||
`https://civitai.com/images/<id>` (con o sin query string).
|
||||
token: API token de Civitai (header Authorization Bearer). Si None se
|
||||
resuelve de `pass civitai/api-token`. No hardcodear. keyword-only.
|
||||
timeout: timeout HTTP en segundos por petición. keyword-only.
|
||||
resuelve de `pass civitai/api-token`. No hardcodear.
|
||||
timeout: timeout HTTP en segundos por petición.
|
||||
|
||||
Returns:
|
||||
dict {ok, image_id, meta, resources, process, comfy_workflow, width,
|
||||
|
||||
@@ -26,9 +26,9 @@ params:
|
||||
- name: resample
|
||||
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
|
||||
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["aplana transparente sobre blanco -> RGB sin alpha", "color de fondo personalizado", "size redimensiona a cuadrado", "out_path por defecto con sufijo _flat", "error: imagen inexistente", "determinismo (mismos bytes de salida)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py"
|
||||
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ params:
|
||||
- name: timeout
|
||||
desc: "Timeout HTTP en segundos. keyword-only."
|
||||
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["API format se devuelve tal cual (format=api)", "UI graph se normaliza a API (descarta Note, resuelve conexiones)", "JSON invalido -> error", "formato no reconocido -> error", "JSON no es objeto -> error", "archivo inexistente -> error", "determinismo"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_import_workflow_json.py"
|
||||
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict"
|
||||
signature: "def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict"
|
||||
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
|
||||
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
|
||||
uses_functions: []
|
||||
|
||||
@@ -14,12 +14,12 @@ import urllib.request
|
||||
import zlib
|
||||
|
||||
|
||||
def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict:
|
||||
def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict:
|
||||
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
|
||||
|
||||
Args:
|
||||
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
|
||||
timeout: timeout HTTP en segundos (solo si es URL). keyword-only.
|
||||
timeout: timeout HTTP en segundos (solo si es URL).
|
||||
|
||||
Returns:
|
||||
dict {ok, prompt, workflow, format_detected, error}:
|
||||
|
||||
@@ -3,10 +3,10 @@ name: comfyui_interrupt_queue
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_interrupt_queue(server: str = \"127.0.0.1:8188\") -> dict"
|
||||
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y devuelve el estado de la cola (GET /queue). Devuelve {ok, interrupted, queue_running, queue_pending, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
|
||||
signature: "def comfyui_interrupt_queue(clear_pending: bool = False, server: str = \"127.0.0.1:8188\", timeout: float = 10.0) -> dict"
|
||||
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y, si clear_pending=True, vacia ademas la cola de pendientes (POST /queue {\"clear\":true}). Consulta GET /queue al final para reportar queue_remaining. Devuelve {ok, interrupted, cleared, queue_remaining, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes salvo clear_pending. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
|
||||
tags: [comfyui, ml, queue, interrupt, control, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -15,12 +15,16 @@ returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: clear_pending
|
||||
desc: "keyword-only. Si True, ademas de cortar el prompt en ejecucion vacia la cola de pendientes con POST /queue {\"clear\":true}. Default False."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||
output: "dict con ok (bool, True si interrupt + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), queue_running (int, prompts ejecutandose), queue_pending (int, prompts encolados), error (str, vacio si todo OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
desc: "keyword-only. host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||
- name: timeout
|
||||
desc: "keyword-only. Timeout de cada peticion HTTP en segundos (default 10.0)."
|
||||
output: "dict con ok (bool, True si interrupt + clear (si se pidio) + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), cleared (bool, True si clear_pending y POST /queue {clear:true} respondio; False si no se pidio o fallo), queue_remaining (int, queue_running + queue_pending tras la operacion), error (str, vacio si todo OK)."
|
||||
tested: true
|
||||
tests: ["test_interrumpe_sin_vaciar", "test_clear_pending_vacia_cola", "test_clear_pending_cola_vacia_no_rompe", "test_servidor_caido_no_lanza"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_interrupt_queue.py"
|
||||
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
|
||||
---
|
||||
|
||||
@@ -31,30 +35,47 @@ import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
|
||||
|
||||
# Solo cortar el prompt en ejecucion (los pendientes siguen):
|
||||
res = comfyui_interrupt_queue()
|
||||
# {'ok': True, 'interrupted': True, 'queue_running': 0, 'queue_pending': 0, 'error': ''}
|
||||
if res["ok"] and res["interrupted"]:
|
||||
print(f"cortado; pendientes en cola: {res['queue_pending']}")
|
||||
# {'ok': True, 'interrupted': True, 'cleared': False, 'queue_remaining': 3, 'error': ''}
|
||||
|
||||
# Cortar el actual Y vaciar los pendientes de golpe:
|
||||
res = comfyui_interrupt_queue(clear_pending=True)
|
||||
# {'ok': True, 'interrupted': True, 'cleared': True, 'queue_remaining': 0, 'error': ''}
|
||||
if res["ok"]:
|
||||
print(f"cortado; quedan {res['queue_remaining']} en cola")
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_interrupt_queue`.
|
||||
O lanzable directo: `./fn run comfyui_interrupt_queue` · `./fn run comfyui_interrupt_queue --clear`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
|
||||
la prevista, o tras encolar por error un workflow pesado. Tambien para inspeccionar
|
||||
de un vistazo cuanto queda en cola (`queue_running` / `queue_pending`) sin parsear
|
||||
el JSON de /queue a mano. Es el freno de mano del round-trip build -> submit -> wait.
|
||||
la prevista, o tras encolar por error un workflow pesado. Con `clear_pending=True`
|
||||
es el freno de mano completo: corta el actual y borra todo lo encolado en una sola
|
||||
llamada (sin tener que encadenar `comfyui_queue_manage("clear")` despues). Tras la
|
||||
operacion `queue_remaining` dice de un vistazo cuanto queda en cola.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `/interrupt` corta SOLO el prompt en ejecucion; los pendientes (`queue_pending`)
|
||||
siguen y el siguiente arranca de inmediato. Para vaciar la cola entera hay que
|
||||
llamar `POST /queue` con `{"clear": true}` (no lo hace esta funcion — solo corta
|
||||
+ lee).
|
||||
- `/interrupt` corta SOLO el prompt en ejecucion; sin `clear_pending` los pendientes
|
||||
(`queue_pending`) siguen y el siguiente arranca de inmediato. Pasa
|
||||
`clear_pending=True` para vaciar tambien la cola (POST /queue {"clear": true}).
|
||||
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
|
||||
mata. Si la cola esta vacia, el interrupt es inocuo (interrupted=True igual).
|
||||
mata. Si la cola esta vacia, tanto el interrupt como el clear son inocuos
|
||||
(`interrupted=True`/`cleared=True` igual, `queue_remaining=0`).
|
||||
- `queue_remaining` se lee al FINAL (GET /queue tras interrupt+clear): es
|
||||
`queue_running + queue_pending`. Justo tras un interrupt sin clear puede ser >0
|
||||
porque el siguiente pendiente ya arranco.
|
||||
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
|
||||
`ok` antes de fiarte de los conteos.
|
||||
`ok` antes de fiarte de `queue_remaining`.
|
||||
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
|
||||
trabajo pesado (esta funcion no lo hace).
|
||||
trabajo pesado (esta funcion no lo hace; ver el round-trip build -> submit -> wait).
|
||||
- Para operaciones de cola mas finas (borrar UN prompt por id, contar el historial)
|
||||
usa `comfyui_queue_manage`; esta funcion se centra en el interrupt + clear masivo.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-28) — anade flag `clear_pending` (vacia la cola en la misma
|
||||
llamada) + param `timeout`; el output pasa a {ok, interrupted, cleared,
|
||||
queue_remaining, error} y se anaden tests (mock HTTP local).
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola.
|
||||
"""Interrumpe la generacion en curso de ComfyUI y, opcionalmente, vacia la cola.
|
||||
|
||||
Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib.
|
||||
Funcion impura: hace red (HTTP POST /interrupt, POST /queue, GET /queue). Solo
|
||||
stdlib (urllib, json).
|
||||
|
||||
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo (no vacia
|
||||
la cola: los prompts pendientes siguen). GET /queue devuelve queue_running (lo que
|
||||
se ejecuta) y queue_pending (lo encolado). Esta funcion combina ambos en un dict
|
||||
honesto que NO lanza excepcion en fallo de red: devuelve {ok: False, error}.
|
||||
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo: NO vacia
|
||||
los pendientes, solo aborta el actual y el siguiente arranca de inmediato. Para
|
||||
vaciar de golpe los pendientes hay que ademas hacer POST /queue con {"clear": true}
|
||||
(lo que activa el flag clear_pending). GET /queue se consulta al final para reportar
|
||||
cuantos trabajos quedan en cola tras la operacion (queue_remaining).
|
||||
|
||||
NO lanza excepcion en fallo de red: devuelve un dict de estado {ok: False, error}.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
||||
"""Interrumpe la generacion en curso y devuelve el estado de la cola.
|
||||
def comfyui_interrupt_queue(
|
||||
clear_pending: bool = False,
|
||||
server: str = "127.0.0.1:8188",
|
||||
timeout: float = 10.0,
|
||||
) -> dict:
|
||||
"""Corta la generacion en curso de ComfyUI y devuelve el estado de la cola.
|
||||
|
||||
Args:
|
||||
clear_pending: si True, ademas de cortar el prompt en ejecucion vacia la
|
||||
cola de pendientes con POST /queue {"clear": true}. keyword-only.
|
||||
server: host:port del servidor ComfyUI sin esquema (default
|
||||
"127.0.0.1:8188").
|
||||
"127.0.0.1:8188"). keyword-only.
|
||||
timeout: timeout de cada peticion HTTP en segundos (default 10.0).
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si tanto el interrupt como la lectura de la cola
|
||||
tuvieron exito.
|
||||
- ok (bool): True si el interrupt, la lectura de la cola y (si se pidio)
|
||||
el clear tuvieron exito.
|
||||
- interrupted (bool): True si el POST /interrupt respondio sin error.
|
||||
- queue_running (int): numero de prompts ejecutandose ahora mismo.
|
||||
- queue_pending (int): numero de prompts encolados pendientes.
|
||||
- cleared (bool): True si clear_pending era True y el POST /queue
|
||||
{"clear": true} respondio sin error; False si no se pidio o fallo.
|
||||
- queue_remaining (int): trabajos que quedan en cola tras la operacion
|
||||
(queue_running + queue_pending segun GET /queue al final).
|
||||
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {
|
||||
"ok": False,
|
||||
"interrupted": False,
|
||||
"queue_running": 0,
|
||||
"queue_pending": 0,
|
||||
"cleared": False,
|
||||
"queue_remaining": 0,
|
||||
"error": "",
|
||||
}
|
||||
base = f"http://{server}"
|
||||
@@ -40,19 +54,37 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
||||
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
|
||||
try:
|
||||
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
|
||||
with urllib.request.urlopen(req, timeout=10.0):
|
||||
with urllib.request.urlopen(req, timeout=timeout):
|
||||
out["interrupted"] = True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
|
||||
return out
|
||||
|
||||
# 2. GET /queue: estado actual de la cola tras el interrupt.
|
||||
# 2. Opcional: POST /queue {"clear": true} para vaciar los pendientes.
|
||||
if clear_pending:
|
||||
try:
|
||||
payload = json.dumps({"clear": True}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base}/queue",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout):
|
||||
out["cleared"] = True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"clear fallo: no se pudo conectar a {base}/queue: {reason}"
|
||||
return out
|
||||
|
||||
# 3. GET /queue: cuantos trabajos quedan en cola tras la operacion.
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
|
||||
with urllib.request.urlopen(f"{base}/queue", timeout=timeout) as resp:
|
||||
data = json.loads(resp.read())
|
||||
out["queue_running"] = len(data.get("queue_running", []))
|
||||
out["queue_pending"] = len(data.get("queue_pending", []))
|
||||
running = len(data.get("queue_running", []))
|
||||
pending = len(data.get("queue_pending", []))
|
||||
out["queue_remaining"] = running + pending
|
||||
out["ok"] = True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
@@ -63,9 +95,12 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
res = comfyui_interrupt_queue()
|
||||
import sys
|
||||
|
||||
clear = "--clear" in sys.argv[1:]
|
||||
res = comfyui_interrupt_queue(clear_pending=clear)
|
||||
print(
|
||||
f"ok={res['ok']} interrupted={res['interrupted']} "
|
||||
f"running={res['queue_running']} pending={res['queue_pending']} "
|
||||
f"cleared={res['cleared']} queue_remaining={res['queue_remaining']} "
|
||||
f"error={res['error']!r}"
|
||||
)
|
||||
|
||||
@@ -32,9 +32,9 @@ params:
|
||||
- name: venv_python
|
||||
desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only."
|
||||
output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["tres votos good -> verdict good + score medio", "mayoria bad", "empate -> bad conservador", "juez caido se excluye sin crashear", "los tres jueces fallan -> ok=False", "weights afectan score pero no el voto"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_judge_image.py"
|
||||
file_path: "python/functions/ml/comfyui_judge_image.py"
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict"
|
||||
signature: "def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict"
|
||||
description: "Lista las skills ComfyUI guardadas en la libreria de disco con su metadata de resumen: slug, title, base_workflow, version, score_mean/score_n y nsfw (de provenance.nsfw), mas n_versions. Respeta include_nsfw=False (oculta las NSFW por defecto). Libreria inexistente o vacia -> lista vacia sin error. library_dir default ~/ComfyUI/skills_library."
|
||||
error_type: error_go_core
|
||||
tags: [comfyui, comfyui-skill, ml, skill, library]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user