Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dd80c042a | |||
| a9a60cbf2c | |||
| 1fee225bff | |||
| f2278d7027 | |||
| c411df1cfc | |||
| 8a78a70ef6 | |||
| 5a4f82cf76 | |||
| 2ebc9efeb2 | |||
| fbdf80bd71 | |||
| 8408863cfa | |||
| 7273823087 | |||
| 76592e4dc0 | |||
| 26569c7015 | |||
| 44622339fa | |||
| c0d44a6352 | |||
| cab0fbf0a3 | |||
| 7f304adc9c | |||
| a74a5a047f | |||
| 44be1d6b58 | |||
| 64306f3b1c | |||
| f2eb782a5f | |||
| 80d10010f5 | |||
| ecc22d6d57 | |||
| 7bdb8bffb5 | |||
| 4139394326 | |||
| 54a9ab70c7 | |||
| 4773781323 | |||
| ea6678ec23 | |||
| 50c05d126c | |||
| 792b890195 | |||
| 9886e2905d | |||
| bebbd05de5 | |||
| 6fb6ef6cfe | |||
| 857c3d8637 | |||
| 4f1530797e | |||
| 9c1b7dd0f3 | |||
| 6e3c3cf2a2 | |||
| 6a1520f458 |
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: paper-reviewer
|
||||
description: "Revisor académico adversarial (read-only) para los papers del subsistema `papers/`. Recibe el directorio de un paper (`papers/<slug>/`) y su `preregistration.md`, y lo juzga sin piedad: puntúa novedad, rigor, reproducibilidad y validez (0-5 cada uno), intenta REFUTAR cada claim contra la evidencia citada, detecta HARKing contra el pre-registro, y emite un veredicto estructurado (accept|major_revision|reject) con default conservador. Es el gate anti paper-mill: NO modifica el paper, solo lo evalúa."
|
||||
model: opus
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
# Agente Paper-Reviewer — peer review adversarial
|
||||
|
||||
Eres un revisor académico **hostil pero justo**. Tu trabajo NO es ayudar al autor a sentirse bien: es proteger la integridad del registro científico. Asumes la posición de un revisor de conferencia top que ha visto cientos de papers inflados y sabe oler el humo. Por defecto **desconfías** de cada afirmación hasta que la evidencia citada la sostenga. Eres específico, citas líneas y archivos, y no rellenas con elogios.
|
||||
|
||||
Este agente es el **gate anti paper-mill** del subsistema `papers/`. El riesgo que combates: papers que *parecen* rigurosos (estructura IMRaD impecable, lenguaje académico, tablas bonitas) pero sin sustancia — hipótesis que no podían fallar, estadística de teatro, claims que exceden la evidencia, análisis inventados después de ver los datos. Si no hubo riesgo real de refutación, no es un paper.
|
||||
|
||||
---
|
||||
|
||||
## REGLA FUNDAMENTAL: read-only, solo juzgas
|
||||
|
||||
- **Lectura:** `paper.md`, `preregistration.md`, `references.md`/`.bib`, y todo lo que haya en `experiments/`, `data/`, `figures/`, `reviews/` del paper.
|
||||
- **Escritura:** NINGUNA. No tienes Edit ni Write. No modificas el paper, no arreglas su prosa, no corriges sus tablas. Solo emites un veredicto.
|
||||
- **Bash es read-only:** úsalo para inspeccionar evidencia (`ls`, `cat`, `head`, `wc`, `grep`, re-correr un script de análisis que YA exista en `experiments/` para verificar un número reportado, contar filas de un dataset, comprobar que una figura referenciada existe). NUNCA escribas archivos, NUNCA borres, NUNCA mutes estado externo (sin red con efectos, sin deploys).
|
||||
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
Recibes el path de un directorio de paper:
|
||||
|
||||
- `paper_dir` (ej. `papers/0001-bucle-reactivo-calls`). Dentro esperas al menos `paper.md`; idealmente también `preregistration.md`, `experiments/`, `data/`, `figures/`.
|
||||
|
||||
Si falta `paper.md`, reporta que no hay paper que revisar y sal. Si falta `preregistration.md`, NO es excusa para aprobar: la ausencia de pre-registro es en sí misma una **amenaza grave a la validez** (no puedes distinguir análisis confirmatorios de exploratorios) y debe bajar el eje de rigor y reproducibilidad.
|
||||
|
||||
---
|
||||
|
||||
## Algoritmo de revisión
|
||||
|
||||
### 1. Lee todo el material primero
|
||||
|
||||
- `paper.md` completo (frontmatter + cuerpo IMRaD).
|
||||
- `preregistration.md` (H0/H1, plan de análisis congelado, timestamp/hash si lo tiene).
|
||||
- Inventaria la evidencia: `ls -R experiments/ data/ figures/`. Anota qué tablas, figuras, scripts y datasets existen REALMENTE en disco.
|
||||
- Si hay `reviews/` previos, léelos para no repetir y para ver si el autor respondió a críticas anteriores.
|
||||
|
||||
No puntúes nada hasta haber leído el material. Una revisión sin abrir la evidencia es la enfermedad que combates.
|
||||
|
||||
### 2. Extrae y enumera los CLAIMS
|
||||
|
||||
Recorre Results y Discussion. Lista cada **afirmación de resultado** verificable (no las de contexto). Ejemplos de claim: "el método A reduce el error un 23%", "la diferencia es significativa (p<0.01)", "el efecto es grande (d=0.8)", "el patrón se mantiene en los 3 datasets". Para cada claim anota la evidencia que el paper cita (tabla X, figura Y, sección de `experiments/`).
|
||||
|
||||
### 3. Intenta REFUTAR cada claim
|
||||
|
||||
Para cada claim, posición de partida: **"no soportada"**. Solo lo marcas "soportada" si:
|
||||
|
||||
- La evidencia citada EXISTE en disco (la tabla/figura/dato está realmente ahí, no solo mencionada).
|
||||
- El número del texto COINCIDE con el de la evidencia (si puedes re-derivarlo de un script o un CSV en `experiments/`/`data/`, hazlo con Bash y compáralo).
|
||||
- La inferencia es válida: el claim no extrapola más allá de lo que el dato muestra (no confunde correlación con causalidad sin diseño que lo permita; no generaliza fuera de la población muestreada).
|
||||
|
||||
Si la evidencia no aparece, si el número no cuadra, o si no puedes reproducir el cálculo con lo descrito → claim **no soportada**. Apúntala en `claims_unsupported` con el motivo concreto (qué falta, qué no cuadra).
|
||||
|
||||
### 4. Puntúa los 4 ejes (0-5 cada uno)
|
||||
|
||||
Sé tacaño. 5 es excepcional y raro; 3 es "aceptable con reservas"; 0-2 es rechazo en ese eje. Justifica cada número con una frase concreta.
|
||||
|
||||
- **novelty (novedad):** ¿el paper aporta algo que no se sabía? ¿El gap está articulado y la contribución es explícita y real, o es un resultado obvio/ya conocido revestido de novedad? Related work honesto (reconoce lo que ya existe) sube; reinventar la rueda baja.
|
||||
- **rigor:** método reproducible y estadística correcta. Exige: **effect size + intervalos de confianza**, no solo `p<0.05`; **corrección por comparaciones múltiples** (Holm-Bonferroni o similar) si se testean varias hipótesis; N justificado (no insuficiente); ausencia de p-hacking/cherry-picking. Estadística de teatro (p-valor suelto sin tamaño de efecto, "tendencia hacia la significancia", N=3 presentado como concluyente) hunde este eje.
|
||||
- **reproducibility (reproducibilidad):** ¿otra persona puede re-correr el experimento con lo descrito? Exige protocolo, datos accesibles (o su descripción), código en `experiments/`, semillas/versiones. Si tú mismo no podrías reproducirlo con lo que hay, el eje es bajo. Pre-registro presente y seguido sube; ausente baja.
|
||||
- **validity (validez):** las cuatro validez de Shadish/Cook/Campbell — **interna** (¿la causa es realmente la causa, o hay confusores?), **externa** (¿generaliza fuera de esta muestra?), **de constructo** (¿se mide lo que se dice medir?), **estadística** (¿las inferencias estadísticas son legítimas?). El paper debe DECLARAR sus amenazas a la validez. Amenazas no declaradas que tú detectas → bajan el eje y van a `gaps`.
|
||||
|
||||
### 5. Chequea coherencia con el pre-registro (HARKing)
|
||||
|
||||
Compara los análisis REPORTADOS en Results contra los PRE-REGISTRADOS en `preregistration.md`:
|
||||
|
||||
- ¿Los análisis confirmatorios presentados son exactamente los pre-registrados? Si aparecen análisis NO declarados presentados como si fueran confirmatorios → **HARKing** (Hypothesizing After Results are Known). Marca `harking_detected: true`.
|
||||
- ¿Hay análisis pre-registrados que desaparecieron del paper (resultados incómodos enterrados)? Eso es cherry-picking — anótalo en `gaps`.
|
||||
- Análisis exploratorios son legítimos SOLO si el paper los etiqueta honestamente como exploratorios (generan hipótesis, no las confirman). Presentar exploratorio como confirmatorio = HARKing.
|
||||
- Si no hay `preregistration.md`, no puedes verificar esto: anótalo como amenaza grave y trata todos los resultados como potencialmente exploratorios.
|
||||
|
||||
### 6. Verifica honestidad: limitaciones y overclaiming
|
||||
|
||||
- ¿Hay una sección de **limitaciones / amenazas a la validez** declarada honestamente? Su ausencia es una bandera roja: ningún estudio real está libre de limitaciones.
|
||||
- ¿Las **claims ≤ evidencia**? Compara el lenguaje de las conclusiones con lo que los datos permiten. "demostramos que X causa Y" sobre un diseño correlacional = **overclaiming**. "el método es superior" sobre un solo dataset = overclaiming. Lista cada overclaim en `gaps`.
|
||||
|
||||
### 7. Emite el veredicto
|
||||
|
||||
Default conservador. Reglas de decisión:
|
||||
|
||||
- **reject** si: hay claims no soportadas centrales al paper, O HARKing detectado, O rigor ≤ 2, O validez ≤ 2, O no hay riesgo real de refutación (la hipótesis no podía fallar).
|
||||
- **major_revision** si: el núcleo es salvable pero hay gaps serios (evidencia incompleta, estadística mejorable, amenazas no declaradas, pre-registro ausente) — el caso por defecto cuando algo falta pero no es fraude.
|
||||
- **accept** SOLO si: los 4 ejes ≥ 3, cero claims no soportadas centrales, sin HARKing, limitaciones declaradas, claims ≤ evidencia, reproducible. Es raro y hay que ganárselo.
|
||||
|
||||
Ante la duda, baja, no subas. Es preferible un major_revision injusto que dejar pasar un paper-mill.
|
||||
|
||||
---
|
||||
|
||||
## Output (formato obligatorio)
|
||||
|
||||
Devuelve un bloque JSON con EXACTAMENTE esta forma, seguido de un párrafo corto de justificación en prosa (crítico y específico, sin elogios de relleno):
|
||||
|
||||
```json
|
||||
{
|
||||
"scores": {
|
||||
"novelty": 0,
|
||||
"rigor": 0,
|
||||
"reproducibility": 0,
|
||||
"validity": 0
|
||||
},
|
||||
"claims_unsupported": [
|
||||
"Claim '<texto>': <por qué no está soportada — evidencia ausente / número no cuadra / inferencia inválida>"
|
||||
],
|
||||
"harking_detected": false,
|
||||
"gaps": [
|
||||
"<amenaza a la validez no declarada / overclaim / estadística faltante / dato no reproducible>"
|
||||
],
|
||||
"verdict": "reject"
|
||||
}
|
||||
```
|
||||
|
||||
Reglas del output:
|
||||
|
||||
- `scores`: enteros 0-5. Tacaño por defecto.
|
||||
- `claims_unsupported`: una entrada por claim que no superó la refutación, con el motivo concreto. Lista vacía solo si TODAS las claims se sostuvieron contra la evidencia.
|
||||
- `harking_detected`: `true` en cuanto detectes un análisis confirmatorio no pre-registrado, o si la ausencia de pre-registro impide descartarlo (en ese caso explícalo en `gaps`).
|
||||
- `gaps`: amenazas a la validez no declaradas, overclaims, estadística de teatro, datos no reproducibles. Concreto y accionable.
|
||||
- `verdict`: `accept` | `major_revision` | `reject`. Default conservador según las reglas de la sección 7.
|
||||
|
||||
El párrafo de prosa que sigue al JSON resume el veredicto en lenguaje directo: qué hunde el paper o qué falta para subir de nivel. Sin "buen trabajo", sin "interesante contribución" de relleno — solo señal.
|
||||
|
||||
---
|
||||
|
||||
## Tono y anti-patrones
|
||||
|
||||
- **Crítico y específico.** "La tabla 2 reporta p=0.03 pero no da tamaño de efecto ni CI; con N=4 esto no sostiene el claim de la sección 4.2" — no "la estadística podría mejorarse".
|
||||
- **Cita evidencia.** Siempre `archivo:línea` o `tabla/figura X`. Una crítica sin cita es ruido.
|
||||
- **No inventes mérito.** Si el paper no aporta novedad, dilo. El sesgo de complacencia es el que alimenta los paper-mills.
|
||||
- **No arregles el paper.** No es tu trabajo (no tienes Write). Tu trabajo es el veredicto. Sugiere QUÉ falta, no escribas el fix.
|
||||
- **Default a fallar.** Evidencia ausente = claim no soportada. Pre-registro ausente = no se puede descartar HARKing. Duda = baja la nota.
|
||||
|
||||
## Relación con el ecosistema
|
||||
|
||||
- Es la materialización del **paso 9 (peer review)** del proceso de 10 pasos del subsistema `papers/` (ver `reports/0001-2026-06-30-papers-system-design.md`), heredando el patrón de **verificador adversarial** del modo orquestador (`.claude/rules/orchestration.md`): un juez independiente que por defecto refuta y solo aprueba con evidencia.
|
||||
- Sus outputs se guardan en `papers/<slug>/reviews/` para trazar la evolución del paper entre revisiones.
|
||||
- Complementa el `preregister_hypothesis` (rigor experimental, congela la hipótesis antes de los datos) y `render_paper_pdf` (entrega): este agente es el control de calidad que decide si el paper merece convertirse en PDF entregable o volver a revisión.
|
||||
@@ -54,6 +54,13 @@ reports/*
|
||||
!reports/.gitkeep
|
||||
projects/*/reports/
|
||||
|
||||
# Papers — artefacto local: papers académicos reproducibles. En fase interna viven
|
||||
# local y gitignored (como los reports); al promocionar a fase publishable se
|
||||
# vuelven sub-repo Gitea propio (como apps/analyses). Solo el marcador .gitkeep se
|
||||
# versiona. Convención: docs/capabilities/papers.md
|
||||
papers/*
|
||||
!papers/.gitkeep
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"mcpServers": {
|
||||
"registry": {
|
||||
"command": "./apps/registry_mcp/registry_mcp",
|
||||
"args": ["--enable-run", "--enable-write"]
|
||||
"args": [
|
||||
"--enable-run",
|
||||
"--enable-write"
|
||||
]
|
||||
},
|
||||
"orchestrator": {
|
||||
"command": "./apps/orchestrator_mcp/orchestrator_mcp",
|
||||
@@ -10,7 +13,10 @@
|
||||
},
|
||||
"jupyter": {
|
||||
"command": "bash",
|
||||
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
||||
"args": [
|
||||
"-c",
|
||||
"exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""
|
||||
]
|
||||
},
|
||||
"godot": {
|
||||
"type": "http",
|
||||
@@ -19,6 +25,10 @@
|
||||
"ardour": {
|
||||
"command": "/home/enmanuel/audio-tools/ardour-mcp/target/release/ardour_mcp_server",
|
||||
"args": []
|
||||
},
|
||||
"onlyoffice": {
|
||||
"command": "./apps/onlyoffice_mcp/onlyoffice_mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: next_numbered_dir
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: io
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "next_numbered_dir(parent_dir: string, [width: int]) -> string"
|
||||
description: "Calcula el siguiente prefijo numerico NNNN- para un directorio numerado incremental. Escanea los subdirectorios directos de parent_dir cuyo nombre empiece por NNNN- (4+ digitos seguidos de guion), toma el maximo, le suma 1 y lo imprime con zero-padding al ancho width (default 4). Si parent_dir no existe o no tiene subdirs que matcheen, imprime 0001."
|
||||
tags: [papers, io, scaffold]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: parent_dir
|
||||
desc: "directorio padre cuyos subdirectorios numerados (NNNN-...) se escanean; obligatorio"
|
||||
- name: width
|
||||
desc: "ancho del zero-padding del numero impreso (default 4); opcional"
|
||||
output: "el siguiente numero como string con zero-padding a width digitos a stdout (ej. 0003); usage a stderr y exit 1 si falta parent_dir"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/io/next_numbered_dir.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/io/next_numbered_dir.sh
|
||||
|
||||
# Sobre un papers/ que ya contiene 0001-foo y 0002-bar
|
||||
mkdir -p /tmp/papers/{0001-foo,0002-bar}
|
||||
next_numbered_dir /tmp/papers
|
||||
# -> 0003
|
||||
|
||||
# Directorio vacio o inexistente -> primer numero
|
||||
next_numbered_dir /tmp/papers_nuevo
|
||||
# -> 0001
|
||||
|
||||
# Ancho de padding distinto
|
||||
next_numbered_dir /tmp/papers 6
|
||||
# -> 000003
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando scaffoldees un artefacto numerado incremental (papers/, reports/, issues/) y necesites el siguiente NNNN sin colision: escanea lo que ya existe en disco y te da el numero libre listo para crear `<NNNN>-<slug>`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem (estado del directorio en el momento de la llamada). No crea nada — solo calcula e imprime el numero.
|
||||
- **Octal**: los numeros con cero a la izquierda (`08`, `09`) se interpretan como octal en aritmetica bash y romperian el calculo. La funcion fuerza base 10 con `10#$num` para evitarlo.
|
||||
- **Solo subdirectorios**: cuenta unicamente subdirs directos. Archivos sueltos (`.gitkeep`, `notas.md`) y subdirs que no matcheen el patron se ignoran. No es recursivo.
|
||||
- **Patron estricto**: el prefijo debe ser `NNNN-` (minimo 4 digitos seguidos de guion). Un subdir `12-foo` o `0001foo` (sin guion) NO se cuenta.
|
||||
- No hay deteccion de huecos: devuelve `max+1`, no el primer numero libre intermedio. Si tienes `0001` y `0003`, devuelve `0004`, no `0002`.
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# next_numbered_dir — Compute the next NNNN- prefix for a numbered directory.
|
||||
#
|
||||
# Scans the DIRECT subdirectories of <parent_dir> whose names start with a
|
||||
# numeric prefix of the form `NNNN-` (4+ digits followed by a hyphen), takes
|
||||
# the maximum number, adds 1, and prints it zero-padded to <width> (default 4).
|
||||
# If <parent_dir> does not exist or contains no matching subdir, prints the
|
||||
# first number (0001 at default width).
|
||||
|
||||
next_numbered_dir() {
|
||||
local parent_dir="${1:-}"
|
||||
local width="${2:-4}"
|
||||
|
||||
if [[ -z "$parent_dir" ]]; then
|
||||
echo "usage: next_numbered_dir <parent_dir> [width]" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local max=0
|
||||
local entry base num
|
||||
|
||||
if [[ -d "$parent_dir" ]]; then
|
||||
# Iterate only over direct subdirectories. The trailing slash in the
|
||||
# glob ensures files (e.g. .gitkeep) are skipped — only dirs match.
|
||||
for entry in "$parent_dir"/*/; do
|
||||
# If the glob matched nothing it stays literal; guard with -d.
|
||||
[[ -d "$entry" ]] || continue
|
||||
base="$(basename "$entry")"
|
||||
# Require a prefix of 4+ digits followed by a hyphen.
|
||||
if [[ "$base" =~ ^([0-9]{4,})- ]]; then
|
||||
num="${BASH_REMATCH[1]}"
|
||||
# Force base 10 so leading zeros (08, 09) are not read as octal.
|
||||
num=$((10#$num))
|
||||
if (( num > max )); then
|
||||
max=$num
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
printf "%0*d\n" "$width" $(( max + 1 ))
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
next_numbered_dir "$@"
|
||||
fi
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: init_paper
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_paper(slug: string, [--title <t>] [--domain <d>] [--tags <csv>]) -> void"
|
||||
description: "Scaffold de un paper académico reproducible en papers/<NNNN-slug>/. Calcula el siguiente número incremental escaneando papers/, crea las subcarpetas (experiments data figures reviews out), copia las plantillas paper.md (IMRaD) + preregistration.md (anti-HARKing) rellenando el frontmatter (title, slug, date de hoy, phase=question, status=draft) y crea references.md. NO hace git init: el paper arranca en fase interna local (papers/ gitignored). Grupo de capacidad papers."
|
||||
tags: [papers, scaffold, paper, pipeline, bash, launcher]
|
||||
uses_functions:
|
||||
- next_numbered_dir_bash_io
|
||||
- slugify_ascii_py_core
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: slug
|
||||
desc: "identificador legible del paper; se slugifica a ASCII (espacios/acentos se normalizan) y se prefija con el siguiente NNNN incremental"
|
||||
- name: "--title"
|
||||
desc: "título del paper (string); si se omite, usa el slug limpio. No debe contener el carácter '|'"
|
||||
- name: "--domain"
|
||||
desc: "dominio del paper escrito en el frontmatter (default datascience)"
|
||||
- name: "--tags"
|
||||
desc: "tags CSV que se escriben en el frontmatter de paper.md (opcional)"
|
||||
output: "sin salida directa; crea papers/<NNNN-slug>/ con paper.md, preregistration.md, references.md y las subcarpetas experiments/ data/ figures/ reviews/ out/. Imprime el resumen y los pasos siguientes a stdout."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/pipelines/init_paper.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Scaffold de un paper nuevo (numera 0001, 0002, ... automáticamente)
|
||||
fn run init_paper mi-primer-paper --title "Mi primer paper"
|
||||
fn run init_paper reactive-loop-calls --domain datascience --tags registry,telemetria
|
||||
|
||||
# El slug se slugifica: "Áreas de Mejora" -> papers/0003-areas-de-mejora/
|
||||
fn run init_paper "Áreas de Mejora"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando empiezas un paper académico nuevo dentro de `fn_registry` y necesitas el esqueleto del artefacto (`papers/<NNNN-slug>/`) con las plantillas IMRaD y de pre-registro listas para rellenar. Es el paso 1 del grupo de capacidad `papers` (ver `docs/capabilities/papers.md`), antes de la revisión de literatura y del pre-registro de la hipótesis.
|
||||
|
||||
## Flujo
|
||||
|
||||
1. Parsea `<slug>` (posicional) + flags `--title` / `--domain` / `--tags`. Falla con exit ≠ 0 si falta el slug.
|
||||
2. `slugify_ascii` — normaliza el slug a ASCII lowercase sin diacríticos (reutiliza la función del registry, solo stdlib).
|
||||
3. `next_numbered_dir papers/` — calcula el siguiente NNNN de 4 dígitos sin colisión.
|
||||
4. Crea `papers/<NNNN-slug>/` con las subcarpetas `experiments/ data/ figures/ reviews/ out/`.
|
||||
5. Copia `docs/templates/paper.md` + `docs/templates/preregistration.md` y rellena el frontmatter por clave de línea (title, slug, date de hoy, domain, tags; phase=question y status=draft vienen de la plantilla).
|
||||
6. Crea `references.md` vacío.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **NO hace `git init`.** El paper arranca en fase interna local; `papers/` está gitignored en el repo padre (solo `papers/.gitkeep` se versiona). Promocionar a sub-repo Gitea (fase publishable) es manual.
|
||||
- **El `--title` no debe contener el carácter `|`** (se usa como delimitador de sed al rellenar el frontmatter; los `&` y `\` sí se escapan).
|
||||
- **No indexa el paper en `registry.db`** — los artefactos `papers/<slug>/` no se indexan en esta fase (KISS); sí se indexa este pipeline.
|
||||
- Requiere `python3` (del venv del registry o del sistema) para slugificar; `slugify_ascii` solo usa stdlib, así que el venv no es obligatorio.
|
||||
- Idempotencia: si el directorio destino ya existiera, aborta con exit ≠ 0 en vez de sobrescribir.
|
||||
|
||||
## Notas
|
||||
|
||||
Cada paper es un artefacto independiente (mismo patrón que `apps/` y `analysis/`, pero para investigación). El pipeline usa `set -euo pipefail`: cualquier fallo detiene la ejecución. Parte del grupo de capacidad `papers` — diseño completo en `reports/0001-2026-06-30-papers-system-design.md`.
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# init_paper
|
||||
# ----------
|
||||
# Scaffold de un paper académico reproducible en papers/<NNNN-slug>/.
|
||||
#
|
||||
# Calcula el siguiente número incremental escaneando papers/, crea el
|
||||
# directorio con todas las subcarpetas (experiments data figures reviews out),
|
||||
# copia las plantillas paper.md + preregistration.md rellenando el frontmatter
|
||||
# (title, slug, date de hoy, phase=question, status=draft) y crea references.md.
|
||||
#
|
||||
# NO hace `git init`: el paper arranca en fase interna local (papers/ está
|
||||
# gitignored en el repo padre, solo .gitkeep se versiona). La promoción a
|
||||
# sub-repo Gitea (fase publishable) es un paso posterior MANUAL.
|
||||
#
|
||||
# Compone: next_numbered_dir (helper de numeración del registry) +
|
||||
# slugify_ascii (slug ASCII del registry).
|
||||
#
|
||||
# USO:
|
||||
# ./init_paper.sh <slug> [--title "..."] [--domain <d>] [--tags a,b,c]
|
||||
#
|
||||
# EJEMPLOS:
|
||||
# ./init_paper.sh mi-primer-paper --title "Mi primer paper"
|
||||
# ./init_paper.sh reactive-loop-calls --domain datascience --tags registry,telemetria
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Funciones atómicas del registry
|
||||
source "$REGISTRY_ROOT/bash/functions/io/next_numbered_dir.sh"
|
||||
|
||||
# ── Parsing de argumentos ────────────────────────────────────
|
||||
|
||||
SLUG_RAW=""
|
||||
TITLE=""
|
||||
DOMAIN="datascience"
|
||||
TAGS=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title)
|
||||
TITLE="$2"; shift 2 ;;
|
||||
--domain)
|
||||
DOMAIN="$2"; shift 2 ;;
|
||||
--tags)
|
||||
TAGS="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
|
||||
-*)
|
||||
echo "Flag desconocido: $1" >&2 ; exit 1 ;;
|
||||
*)
|
||||
if [ -z "$SLUG_RAW" ]; then
|
||||
SLUG_RAW="$1"
|
||||
else
|
||||
echo "ERROR: argumento posicional inesperado: '$1' (solo se admite un <slug>)." >&2
|
||||
exit 1
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$SLUG_RAW" ]; then
|
||||
echo "ERROR: falta el argumento <slug>." >&2
|
||||
echo "Uso: $0 <slug> [--title \"...\"] [--domain <d>] [--tags a,b,c]" >&2
|
||||
echo " Ejemplo: $0 mi-primer-paper --title \"Mi primer paper\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Slugificar (reutiliza slugify_ascii del registry; solo stdlib) ──
|
||||
|
||||
PYBIN="$REGISTRY_ROOT/python/.venv/bin/python3"
|
||||
[ -x "$PYBIN" ] || PYBIN="$(command -v python3 || true)"
|
||||
if [ -z "$PYBIN" ]; then
|
||||
echo "ERROR: no se encontró python3 para slugificar el slug." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SLUG_CLEAN=$("$PYBIN" -c '
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(sys.argv[2], "python", "functions"))
|
||||
from core.slugify_ascii import slugify_ascii
|
||||
print(slugify_ascii(sys.argv[1], default="paper"))
|
||||
' "$SLUG_RAW" "$REGISTRY_ROOT")
|
||||
|
||||
# ── Resolver número incremental y directorio destino ─────────
|
||||
|
||||
PAPERS_DIR="$REGISTRY_ROOT/papers"
|
||||
mkdir -p "$PAPERS_DIR"
|
||||
|
||||
NUM=$(next_numbered_dir "$PAPERS_DIR")
|
||||
SLUG_FULL="${NUM}-${SLUG_CLEAN}"
|
||||
PAPER_DIR="$PAPERS_DIR/$SLUG_FULL"
|
||||
|
||||
if [ -d "$PAPER_DIR" ]; then
|
||||
echo "ERROR: el directorio del paper ya existe: $PAPER_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
[ -n "$TITLE" ] || TITLE="$SLUG_CLEAN"
|
||||
|
||||
TAGS_YAML="[]"
|
||||
if [ -n "$TAGS" ]; then
|
||||
TAGS_YAML="[$(echo "$TAGS" | sed 's/,/, /g')]"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " INIT PAPER: ${SLUG_FULL}"
|
||||
echo " Título: ${TITLE}"
|
||||
echo " Directorio: ${PAPER_DIR}"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── Crear estructura ─────────────────────────────────────────
|
||||
|
||||
echo "[1/3] Creando estructura..."
|
||||
mkdir -p "$PAPER_DIR"/experiments "$PAPER_DIR"/data "$PAPER_DIR"/figures \
|
||||
"$PAPER_DIR"/reviews "$PAPER_DIR"/out
|
||||
echo " experiments/ data/ figures/ reviews/ out/"
|
||||
|
||||
# ── Copiar plantillas + rellenar frontmatter ─────────────────
|
||||
|
||||
echo "[2/3] Escribiendo paper.md + preregistration.md..."
|
||||
|
||||
# Escapa caracteres especiales del RHS de sed (delimitador |)
|
||||
sed_escape() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; }
|
||||
TITLE_ESC="$(sed_escape "$TITLE")"
|
||||
DOMAIN_ESC="$(sed_escape "$DOMAIN")"
|
||||
|
||||
PAPER_MD="$PAPER_DIR/paper.md"
|
||||
PREREG_MD="$PAPER_DIR/preregistration.md"
|
||||
|
||||
cp "$REGISTRY_ROOT/docs/templates/paper.md" "$PAPER_MD"
|
||||
cp "$REGISTRY_ROOT/docs/templates/preregistration.md" "$PREREG_MD"
|
||||
|
||||
sed -i \
|
||||
-e "s|^title:.*|title: \"${TITLE_ESC}\"|" \
|
||||
-e "s|^slug:.*|slug: ${SLUG_FULL}|" \
|
||||
-e "s|^date:.*|date: ${TODAY}|" \
|
||||
-e "s|^domain:.*|domain: ${DOMAIN_ESC}|" \
|
||||
-e "s|^tags:.*|tags: ${TAGS_YAML}|" \
|
||||
"$PAPER_MD"
|
||||
|
||||
sed -i \
|
||||
-e "s|^paper_slug:.*|paper_slug: ${SLUG_FULL}|" \
|
||||
"$PREREG_MD"
|
||||
|
||||
echo " $PAPER_MD"
|
||||
echo " $PREREG_MD"
|
||||
|
||||
# ── references.md ────────────────────────────────────────────
|
||||
|
||||
echo "[3/3] Escribiendo references.md..."
|
||||
cat > "$PAPER_DIR/references.md" << EOF
|
||||
# References — ${TITLE}
|
||||
|
||||
<!-- Una entrada por referencia. Formato libre (o BibTeX) hasta promocionar a publishable. -->
|
||||
EOF
|
||||
echo " $PAPER_DIR/references.md"
|
||||
|
||||
# ── Resumen ──────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " PAPER '${SLUG_FULL}' LISTO (fase: question, status: draft)"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Pasos siguientes:"
|
||||
echo " 1. Revisión de literatura (skill /deep-research) → Related work."
|
||||
echo " 2. Pre-registro: congela H0/H1 + plan en preregistration.md (preregister_hypothesis)."
|
||||
echo " 3. Experimentos en experiments/ → análisis (grupo eda) → escritura IMRaD en paper.md."
|
||||
echo " 4. render_paper_pdf → out/paper.pdf. Peer review adversarial → reviews/."
|
||||
echo ""
|
||||
echo " papers/ está gitignored: este paper vive local hasta promocionar a publishable."
|
||||
echo ""
|
||||
@@ -41,12 +41,13 @@ reconocido se degrada a `Note`, nunca lanza).
|
||||
| `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 |
|
||||
| `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **si cabe** como texto se parte por filas repitiendo cabecera; **si NO cabe** (demasiadas columnas) se rasteriza entera como imagen de alta resolución para hacer zoom. Ver §11.4 |
|
||||
| `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 |
|
||||
| `Group(blocks, title=None)` | unidad **keep-together**: sus bloques se mantienen juntos | el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. Ver §11 |
|
||||
| `Group(blocks, title=None, page_break_before=False, layout="stack")` | unidad **keep-together**: sus bloques se mantienen juntos | el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. `layout="side_by_side"` coloca tabla+figura en dos columnas (solo PPTX). Ver §11 y §11.4 |
|
||||
| `GlossaryEntry(key, label, definition)` | una entrada del glosario (destino clicable) | la genera el capítulo `glosario`; registra su posición como destino de los términos marcados. Ver §11 |
|
||||
| `TocEntry(label, target_id)` | una entrada de **índice clicable** en la portada | la genera el capítulo `portada`; el renderer la cablea como salto al inicio del capítulo cuyo `id` o `title` coincide con `target_id`. Ver §11.4 |
|
||||
|
||||
`Figure`/`Image` aceptan `height_in` (hint): el renderer **clampa** la figura a esa altura máxima (lo usa `Group` para encoger la figura). Toda figura escala dejando sitio a su caption en la misma página/slide; en PPTX el caption es **siempre** visible (si no se da `caption`, cae al último heading o a "Figura").
|
||||
|
||||
@@ -397,6 +398,65 @@ cabecera con su fondo propio. Es automático en PDF y PPTX; el patrón se mantie
|
||||
cuando una tabla larga se parte y repite cabecera (el índice de fila es lógico, no por
|
||||
página). No hay nada que hacer en los capítulos.
|
||||
|
||||
### 11.4 Calidad de render global: DPI alto, tabla ancha → imagen, figura al lado, índice clicable
|
||||
|
||||
Cuatro capacidades transversales del motor, **todas automáticas salvo `layout`** (que un
|
||||
capítulo activa explícitamente). Aplican a PDF y PPTX salvo donde se indique.
|
||||
|
||||
**(a) DPI alto (automático).** Toda figura/imagen embebida se rasteriza a **220 dpi**
|
||||
(constante `_RASTER_DPI` en ambos renderers; en PDF se aplica también al `savefig` de la
|
||||
página, porque matplotlib re-rasteriza cada `imshow` al escribir la página). Objetivo:
|
||||
ampliar en el móvil y leer detalle (ejes, celdas) sin pixelar. El texto sigue siendo
|
||||
vectorial y seleccionable. No hay nada que hacer en los capítulos.
|
||||
|
||||
**(b) Tabla ancha → imagen de alta resolución (automático).** Cuando un `DataTable` tiene
|
||||
**demasiadas columnas para ser legible como texto** en el ancho útil (criterio
|
||||
`_table_fits_as_text`: ancho mínimo legible por columna × nº de columnas > ancho útil; en
|
||||
la práctica salta sobre tablas tipo `df.head` con muchas columnas), en vez de comprimir las
|
||||
columnas hasta hacerlas ilegibles, la tabla se dibuja **entera como una imagen de alta
|
||||
resolución** (función `render_table_as_figure_py_datascience`: cabecera sombreada + zebra)
|
||||
escalada para caber completa, de modo que el lector hace **zoom** y la lee sin perder datos.
|
||||
Si la tabla **sí cabe**, se mantiene como texto seleccionable (PDF) / tabla nativa (PPTX).
|
||||
Las `KVTable` (2 columnas) caben siempre y se quedan como texto. No hay nada que hacer en
|
||||
los capítulos.
|
||||
|
||||
**(c) Figura al lado de la tabla — `Group(layout="side_by_side")`.** Hint de layout que un
|
||||
capítulo activa para que su **tabla quede a la izquierda y su figura a la derecha** en la
|
||||
misma diapositiva, en lugar de apiladas:
|
||||
|
||||
```python
|
||||
model.Group(
|
||||
layout="side_by_side",
|
||||
blocks=[
|
||||
model.Heading(text=str(name), level=2), # va a ancho completo arriba
|
||||
model.DataTable(header=..., rows=...), # columna IZQUIERDA (~55%)
|
||||
model.Figure(make=_grafico_perezoso(...)), # columna DERECHA (~45%)
|
||||
model.Markdown(text="explicación…"), # va a ancho completo abajo
|
||||
])
|
||||
```
|
||||
|
||||
Contrato exacto del campo:
|
||||
|
||||
| Campo | Valor | Efecto |
|
||||
|---|---|---|
|
||||
| `layout` | `"stack"` (por defecto) | comportamiento histórico: apilado vertical (keep-together). |
|
||||
| `layout` | `"side_by_side"` | **PPTX**: la tabla (rasterizada a imagen) ocupa la columna izquierda (~55% del ancho útil) y la figura la derecha (~45%); cualquier otro bloque (heading, markdown) va a ancho completo arriba/abajo. Si no hay un par tabla+figura, o no caben lado a lado en una slide, **cae automáticamente a apilado**. **PDF**: se trata **igual que `stack`** (el ancho A5 móvil no admite dos columnas legibles). Valores desconocidos degradan a `"stack"`. |
|
||||
|
||||
Es **retrocompatible**: un `Group` sin `layout` (o `layout="stack"`) se comporta exactamente
|
||||
como antes. El capítulo `cat_distr` es el consumidor previsto (gráfico a la derecha de la
|
||||
tabla de categorías en PPT); este motor solo provee el soporte.
|
||||
|
||||
**(d) Índice clicable en la portada — `TocEntry`.** La portada emite un `Heading("Índice")`
|
||||
seguido de un `TocEntry(label, target_id)` por capítulo. El renderer registra la
|
||||
página/slide de inicio de **cada** capítulo (indexado por `id` **y** por `title`) y cablea
|
||||
cada `TocEntry` como un salto real a ese inicio: en **PDF** vía
|
||||
`add_pdf_internal_links_py_datascience` (link GOTO de PyMuPDF), en **PPTX** vía
|
||||
`pptx_link_run_to_slide_py_datascience` (salto a slide nativo). Como la portada solo conoce
|
||||
los **títulos** de los capítulos, el `target_id` se hace coincidir contra el `title` (o el
|
||||
`id`) de destino. Si un destino no resuelve, la entrada se muestra igualmente como texto
|
||||
(en color de enlace), nunca se corta. Es el mismo mecanismo que los términos clicables del
|
||||
glosario (§11.1), reutilizado en sentido portada → capítulo.
|
||||
|
||||
---
|
||||
|
||||
## 10. Integración futura con `profile_table` (siguiente fase)
|
||||
|
||||
@@ -39,6 +39,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [cpp-tables](tql.md) | 9 | Table Query Language C++ puro: filter, group, agg, sort, join, stats, formulas Lua, round-trip emit/apply |
|
||||
| [data-table-renderers](data_table_renderers.md) | 1 | API declarativa de cell renderers para data_table: Badge, Progress, Duration, Icon via TableInput.column_specs |
|
||||
| [scheduler](scheduler.md) | 4 | Cron expression parsing, matching, next-run y traduccion humana (consume `apps/dag_engine`) |
|
||||
| [papers](papers.md) | — | Papers académicos reproducibles en `papers/<NNNN-slug>/`: scaffold del artefacto (`init_paper` + helper `next_numbered_dir`), plantillas IMRaD + pre-registro anti-HARKing, y (en construcción por la flota) congelar hipótesis, funciones estadísticas (effect size/CI/corrección múltiple), render md→PDF y peer-review adversarial. Reutiliza `deep-research`, grupo `eda` y el motor PDF de `datascience`. Diseño: `reports/0001-2026-06-30-papers-system-design.md` |
|
||||
| [extractor](extractor.md) | 15 | Funciones que leen datos de fuentes externas (BD, API, archivos, web). Nodos input de `data_factory` |
|
||||
| [transformer](transformer.md) | 15 | Funciones que clean/dedup/aggregate/feature-engineer datos. Nodos intermedios de `data_factory` |
|
||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
Operar **ONLYOFFICE Desktop Editors** (binario `/usr/bin/onlyoffice-desktopeditors`) en Linux/X11 desde terminal, gestionando la **ventana** de los archivos sin perturbar la instancia personal del usuario.
|
||||
|
||||
Este grupo NO es el ONLYOFFICE **Document Server** (web/Docker) — para eso ver `start_documentserver_bash_infra`, `documentserver_health_go_infra`, `onlyoffice_command_service_go_infra` y compañia. Este grupo es el editor de **escritorio**.
|
||||
Este grupo NO es el ONLYOFFICE **Document Server** (web/Docker/co-editing por navegador): a día de hoy el registry NO tiene funciones de Document Server (las que antes citaba esta página — `start_documentserver_*`, `documentserver_health_*`, `onlyoffice_command_service_*` — nunca se implementaron). Este grupo es el editor de **escritorio**.
|
||||
|
||||
### Edición en vivo desde Claude — app `onlyoffice_bridge`
|
||||
|
||||
Para que Claude **lea y edite el documento abierto en tiempo real** (Word/Cell/Slide) sin cerrar/reabrir, existe la app `apps/onlyoffice_bridge/`: un plugin de sistema instalado dentro de OnlyOffice + un server loopback con long-poll. Es la alternativa "in-place" al ciclo cerrar+reabrir de `reload_onlyoffice_file` (Issue #2313). Ver su `app.md` para instalación, protocolo y limitaciones (el foco de la ventana condiciona el arranque del plugin). Este grupo (`open`/`reload`/`close`/`save`) sigue siendo la vía para gestionar la **ventana**; `onlyoffice_bridge` es la vía para editar el **contenido** en vivo.
|
||||
|
||||
## Convencion de instancia aislada (slot)
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# papers — papers académicos reproducibles
|
||||
|
||||
Grupo de capacidad para producir **papers académicos** dentro de `fn_registry`: investigación con hipótesis falsables, experimentos reproducibles, análisis estadístico honesto y escritura en formato IMRaD. Cada paper es un artefacto nuevo en `papers/<NNNN-slug>/` que reutiliza infraestructura existente (skill `deep-research` para la revisión de literatura, grupo `eda` para el análisis, motor md→PDF de `datascience`, patrón de verificación adversarial del orquestador) y añade lo que falta como funciones del registry.
|
||||
|
||||
Diseño completo y decisiones: `reports/0001-2026-06-30-papers-system-design.md`.
|
||||
|
||||
> **Regla de oro anti paper-mill:** una hipótesis que **podía** fallar + un experimento con riesgo real de refutación + estadística que no es teatro. Si no hay riesgo de refutación, no es un paper. Los claims nunca superan a la evidencia. El antídoto al HARKing es el **pre-registro**: el plan de análisis se congela *antes* de mirar los datos.
|
||||
|
||||
## Estructura del artefacto
|
||||
|
||||
```
|
||||
papers/0001-mi-paper/
|
||||
paper.md # frontmatter (title, slug, authors, date, status, phase, tags, domain, hypothesis_id) + cuerpo IMRaD
|
||||
preregistration.md # H0/H1 + plan de análisis CONGELADO (frozen_at + content_hash) antes de correr
|
||||
references.md # bibliografía
|
||||
experiments/ # código / notebooks por experimento (exp01_*, exp02_*)
|
||||
data/ # crudos + procesados (gitignored si pesa)
|
||||
figures/ # gráficos generados
|
||||
reviews/ # outputs del peer-review adversarial
|
||||
out/ # paper.pdf — entregable final
|
||||
.git/ # SOLO cuando promociona a fase publishable (sub-repo Gitea)
|
||||
```
|
||||
|
||||
`papers/` está gitignored en el repo padre (solo `papers/.gitkeep` se versiona): un paper en fase interna no contamina el repo. Al promocionar a `status: publishable` se vuelve sub-repo Gitea `dataforge/<slug>` (como apps y analyses).
|
||||
|
||||
### Fases (campo `phase` de `paper.md`)
|
||||
|
||||
```
|
||||
question → review → hypothesis → design → running → analysis → writing → internal-review
|
||||
→ [DONE interno] → polish → submitted [solo en fase publishable]
|
||||
```
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Pureza | Estado | Qué hace |
|
||||
|---|---|---|---|
|
||||
| `init_paper_bash_pipelines` | impure | ✅ disponible | Scaffold de `papers/<NNNN-slug>/`: calcula el siguiente NNNN, crea las subcarpetas, copia `paper.md` + `preregistration.md` con el frontmatter relleno (slug, title, date de hoy, `phase: question`, `status: draft`) y `references.md` vacío. NO hace `git init` (el paper arranca en fase interna local). |
|
||||
| `next_numbered_dir_bash_io` | impure | ✅ disponible | Dado un directorio, devuelve el siguiente número incremental de 4 dígitos (`0001`, `0002`, …) escaneando los subdirs con prefijo `NNNN-`. Helper de numeración de `init_paper` (reutilizable por reports/issues). |
|
||||
| `preregister_hypothesis` | impure | 🚧 en construcción (flota) | Congela el `preregistration.md` (H0/H1 + plan de análisis) con `frozen_at` + `content_hash`, pasa `status` a `frozen` y escribe `hypothesis_id` en `paper.md`. Mata el HARKing: tras congelar, el plan no se edita. |
|
||||
| `cohens_d` (effect size) | pure | 🚧 en construcción (flota) | Tamaño del efecto (Cohen's d) entre dos grupos. Reporta magnitud, no solo significancia. |
|
||||
| `confidence_interval` | pure | 🚧 en construcción (flota) | Intervalo de confianza de una métrica (media/diferencia). |
|
||||
| `holm_bonferroni` | pure | 🚧 en construcción (flota) | Corrección de comparaciones múltiples (Holm-Bonferroni / FWER) para el plan de análisis. |
|
||||
| `render_paper_pdf` | impure | 🚧 en construcción (flota) | Markdown IMRaD (`paper.md` + figuras) → `out/paper.pdf`, reutilizando el motor md→PDF del grupo `eda`/`datascience`. |
|
||||
|
||||
> Las funciones estadísticas reutilizan lo que ya exista en `datascience` (p.ej. `fdr_correction_py_datascience` cubre la corrección de comparaciones múltiples por FDR; el agente del rigor experimental decide si añade Holm-Bonferroni o reusa lo existente). Buscar antes de duplicar: `mcp__registry__fn_search query="effect size" domain="datascience"`.
|
||||
|
||||
### Peer review (no es función del registry)
|
||||
|
||||
El agente adversarial `.claude/agents/paper-reviewer.md` (🚧 en construcción por la flota) puntúa novedad, rigor, reproducibilidad y validez, e intenta **refutar** cada claim. Default a "failed" si la evidencia no soporta. Escribe su veredicto en `reviews/`. Es el equivalente al verificador adversarial del orquestador aplicado al paper.
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```bash
|
||||
# 1. Scaffold del paper (fase question, local). Crea papers/0001-mi-paper/.
|
||||
./fn run init_paper mi-paper --title "¿El bucle reactivo reduce las calls inline?" --domain datascience --tags registry,telemetria
|
||||
|
||||
# 2. Revisión de literatura → llena Related work (skill deep-research, fase review).
|
||||
# /deep-research "..."
|
||||
|
||||
# 3. Pre-registro: congela H0/H1 + plan de análisis ANTES de mirar datos (fase hypothesis).
|
||||
./fn run preregister_hypothesis papers/0001-mi-paper # 🚧 en construcción
|
||||
|
||||
# 4. Experimentos en papers/0001-mi-paper/experiments/ (fase running) →
|
||||
# análisis con el grupo `eda` + funciones de effect size / CI / corrección múltiple (fase analysis).
|
||||
|
||||
# 5. Escritura IMRaD en paper.md (fase writing) → render del entregable PDF.
|
||||
./fn run render_paper_pdf papers/0001-mi-paper # 🚧 en construcción → out/paper.pdf
|
||||
|
||||
# 6. Peer review adversarial (fase internal-review).
|
||||
# Agent(subagent_type="paper-reviewer", prompt="Revisa papers/0001-mi-paper ...") # 🚧 en construcción
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO es para reports de trabajo.** Un report (`reports/`) es el entregable escrito de una tarea (resumen + evidencia + gaps); un paper es investigación con hipótesis falsable y experimento. Ver `.claude/rules/reports.md`.
|
||||
- **NO se indexa en `registry.db` en esta fase.** No hay tabla `papers` ni `entity_type` `paper` (KISS); se añadiría con migración propia si se decide. Las *funciones* del grupo sí se indexan (viven en `bash/functions/`, `python/functions/`), pero los artefactos `papers/<slug>/` no.
|
||||
- **NO hace `git init` en el scaffold.** El paper arranca en fase interna local y gitignored. La promoción a sub-repo Gitea (fase publishable) es un paso manual posterior.
|
||||
- **NO soporta LaTeX/arXiv todavía.** Formato elegido: Markdown como fuente + PDF como entregable. El soporte LaTeX se añadiría al promocionar un paper a fase publishable.
|
||||
|
||||
## Estado
|
||||
|
||||
Fase de scaffolding. Disponible: estructura del artefacto, plantillas (`docs/templates/paper.md`, `docs/templates/preregistration.md`), pipeline `init_paper` + helper `next_numbered_dir`, esta página y el bloque gitignore de `papers/`. En construcción por la flota: `preregister_hypothesis`, funciones estadísticas (effect size / CI / corrección múltiple), `render_paper_pdf` y el agente `paper-reviewer`. Validación end-to-end con un paper piloto real: pendiente.
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: "TITULO DEL PAPER"
|
||||
slug: NNNN-slug
|
||||
authors: [Enmanuel]
|
||||
date: 2026-01-01
|
||||
status: draft # draft | internal | publishable
|
||||
phase: question # question -> review -> hypothesis -> design -> running -> analysis -> writing -> internal-review -> polish -> submitted
|
||||
tags: []
|
||||
domain: datascience
|
||||
hypothesis_id: "" # lo rellena preregister_hypothesis al congelar el preregistro
|
||||
---
|
||||
|
||||
<!--
|
||||
Paper académico reproducible (formato IMRaD). Esta es la FUENTE editable en Markdown;
|
||||
el entregable PDF se genera con render_paper_pdf (grupo `papers`).
|
||||
|
||||
Regla de oro anti paper-mill: una hipótesis que PODÍA fallar + un experimento con
|
||||
riesgo real de refutación + estadística que no es teatro. Si no hay riesgo de
|
||||
refutación, no es un paper. Los claims nunca superan a la evidencia.
|
||||
-->
|
||||
|
||||
# {{título del paper}}
|
||||
|
||||
## Abstract
|
||||
|
||||
<!--
|
||||
Resumen estructurado en 4-6 frases: contexto -> gap -> método -> resultados -> conclusión.
|
||||
Sin citas, sin abreviaturas sin definir. Es lo único que mucha gente leerá: que se sostenga solo.
|
||||
-->
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
<!--
|
||||
Embudo en cuatro movimientos:
|
||||
1. Contexto — el área y por qué importa.
|
||||
2. Gap — qué NO se sabe todavía (el hueco que este paper llena).
|
||||
3. Pregunta / hipótesis — formulada de forma falsable (ver preregistration.md).
|
||||
4. Contribución — lista explícita de lo que aporta este trabajo ("Contributions:").
|
||||
-->
|
||||
|
||||
## 2. Related work
|
||||
|
||||
<!--
|
||||
Qué existe ya y por qué no basta. Agrupa por enfoque, no por autor. Cada cita debe
|
||||
justificar por qué el gap sigue abierto. Output de la fase de revisión (skill deep-research).
|
||||
-->
|
||||
|
||||
## 3. Methods
|
||||
|
||||
<!--
|
||||
Diseño REPRODUCIBLE: otra persona lo corre y obtiene lo mismo.
|
||||
- Variables: independiente(s), dependiente(s), control.
|
||||
- Diseño: N, condiciones, muestreo, aleatorización.
|
||||
- Métricas y cómo se miden.
|
||||
- Protocolo paso a paso + dónde vive el código (experiments/) y los datos (data/).
|
||||
Debe ser coherente con el preregistration.md congelado (no se cambia el plan tras ver datos).
|
||||
-->
|
||||
|
||||
## 4. Results
|
||||
|
||||
<!--
|
||||
Datos SIN interpretar. Tablas y figuras (figures/) con su lectura literal.
|
||||
Reporta effect size + intervalos de confianza, no solo p-valores.
|
||||
Incluye también los resultados negativos / no significativos (anti cherry-picking).
|
||||
-->
|
||||
|
||||
## 5. Discussion
|
||||
|
||||
<!--
|
||||
Interpretación de los resultados a la luz de la pregunta. Claims <= evidencia.
|
||||
-->
|
||||
|
||||
### 5.1 Limitaciones
|
||||
|
||||
<!-- Qué no cubre el estudio, supuestos, datos faltantes. Honestidad explícita. -->
|
||||
|
||||
### 5.2 Amenazas a la validez
|
||||
|
||||
<!--
|
||||
- Validez interna — ¿la causa es lo que decimos o hay confusores?
|
||||
- Validez externa — ¿generaliza fuera de esta muestra/condiciones?
|
||||
- Validez de constructo — ¿la métrica mide lo que dice medir?
|
||||
- Validez estadística — ¿N suficiente, supuestos del test cumplidos, comparaciones múltiples corregidas?
|
||||
-->
|
||||
|
||||
## 6. Conclusion + Future work
|
||||
|
||||
<!--
|
||||
Cierre en 2-4 frases: qué se aprendió (sin overclaiming) + las siguientes preguntas que abre.
|
||||
-->
|
||||
|
||||
## References
|
||||
|
||||
<!-- Ver references.md. -->
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
---
|
||||
paper_slug: NNNN-slug
|
||||
frozen_at: "" # timestamp ISO — lo rellena preregister_hypothesis al congelar
|
||||
content_hash: "" # hash del contenido congelado — lo rellena preregister_hypothesis
|
||||
status: draft # draft -> frozen (preregister_hypothesis lo pasa a frozen; tras congelar NO se edita)
|
||||
---
|
||||
|
||||
> **⚠️ ESTE DOCUMENTO SE CONGELA ANTES DE MIRAR LOS DATOS (anti-HARKing).**
|
||||
> El plan de análisis se fija aquí *antes* de ejecutar el experimento. Una vez congelado
|
||||
> (`status: frozen`, con `frozen_at` + `content_hash`), **no se edita**. Inventar o ajustar
|
||||
> la hipótesis después de ver los resultados (HARKing) invalida el paper. Si el plan cambia
|
||||
> tras ver datos, eso es análisis exploratorio y se reporta como tal, no como confirmatorio.
|
||||
|
||||
# Pre-registro — {{título del paper}}
|
||||
|
||||
## 1. Pregunta de investigación
|
||||
|
||||
<!-- La pregunta concreta, en una frase. Debe poder responderse con un experimento. -->
|
||||
|
||||
## 2. Hipótesis
|
||||
|
||||
<!-- Falsable (Popper): una predicción que PODRÍA fallar. -->
|
||||
|
||||
- **H0 (nula):** <!-- no hay efecto / no hay diferencia. Es lo que el test intenta rechazar. -->
|
||||
- **H1 (alternativa):** <!-- el efecto esperado, con dirección si la hay. -->
|
||||
|
||||
## 3. Variables
|
||||
|
||||
- **Independiente(s):** <!-- lo que se manipula. -->
|
||||
- **Dependiente(s):** <!-- lo que se mide (la métrica de resultado). -->
|
||||
- **Control:** <!-- lo que se mantiene fijo / se cubre estadísticamente. -->
|
||||
|
||||
## 4. Diseño
|
||||
|
||||
<!--
|
||||
- N: tamaño de muestra (y justificación / power analysis si aplica).
|
||||
- Condiciones / grupos.
|
||||
- Muestreo y aleatorización.
|
||||
- Criterios de inclusión / exclusión de datos (definidos AHORA, no después).
|
||||
-->
|
||||
|
||||
## 5. Plan de análisis
|
||||
|
||||
<!--
|
||||
El plan estadístico EXACTO, decidido antes de ver los datos:
|
||||
- Test estadístico concreto (p.ej. t-test de Welch, Mann-Whitney U, regresión...).
|
||||
- Métrica de effect size (p.ej. Cohen's d, diferencia de medias, odds ratio).
|
||||
- Criterio de decisión (umbral alpha, qué resultado confirma/refuta H1).
|
||||
- Corrección por comparaciones múltiples (p.ej. Holm-Bonferroni) si hay >1 contraste.
|
||||
- Manejo de supuestos (normalidad, varianzas) y qué se hace si no se cumplen.
|
||||
-->
|
||||
|
||||
## 6. Predicción cuantitativa
|
||||
|
||||
<!--
|
||||
La predicción numérica concreta que el experimento pondrá a prueba.
|
||||
P.ej. "esperamos d >= 0.5 con IC95% que no cruza 0" o "una reducción >= 15% en la métrica X".
|
||||
Cuanto más específica, más falsable.
|
||||
-->
|
||||
File diff suppressed because one or more lines are too long
@@ -3,11 +3,11 @@ name: bq_auth
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def bq_auth(project_id: str = '', credentials_path: str = '') -> BQClient"
|
||||
description: "Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD."
|
||||
tags: [bigquery, gcp, auth, google-cloud, python, pendiente-usar]
|
||||
signature: "def bq_auth(project_id: str = '', credentials_path: str = '', drop_quota_project: bool = False) -> BQClient"
|
||||
description: "Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD. Con drop_quota_project=True descarta el quota project del ADC del usuario (creds.with_quota_project(None)) para evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva un quota_project_id ajeno."
|
||||
tags: [bigquery, gcp, auth, google-cloud, python, forecast, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -19,6 +19,8 @@ params:
|
||||
desc: "ID del proyecto GCP (vacio = detectar de credenciales/entorno)"
|
||||
- name: credentials_path
|
||||
desc: "ruta a archivo JSON de service account (vacio = Application Default Credentials)"
|
||||
- name: drop_quota_project
|
||||
desc: "si True y sin credentials_path, resuelve ADC via google.auth.default y descarta el quota project del ADC (with_quota_project(None)); evita el 403 USER_PROJECT_DENIED cuando el ADC del usuario lleva un quota_project_id ajeno. Default False = comportamiento original"
|
||||
output: "BQClient: cliente autenticado con proyecto resuelto"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -40,6 +42,9 @@ client = bq_auth("my-project-id")
|
||||
# Service account
|
||||
client = bq_auth(credentials_path="/path/to/service-account.json")
|
||||
|
||||
# Sin quota project (evita 403 USER_PROJECT_DENIED con ADC de usuario)
|
||||
client = bq_auth("autingo-159109", drop_quota_project=True)
|
||||
|
||||
# Context manager
|
||||
with bq_auth() as client:
|
||||
# client se cierra automaticamente
|
||||
@@ -48,9 +53,14 @@ with bq_auth() as client:
|
||||
|
||||
## Notas
|
||||
|
||||
Tres modos de autenticacion:
|
||||
Modos de autenticacion:
|
||||
- Sin argumentos: usa Application Default Credentials (ADC) — requiere `gcloud auth application-default login`
|
||||
- Con project_id: usa ADC pero fuerza el proyecto
|
||||
- Con credentials_path: lee el JSON de service account directamente
|
||||
- Con drop_quota_project=True (y sin credentials_path): resuelve ADC via `google.auth.default(scopes=[".../bigquery"])`, aplica `creds.with_quota_project(None)` si el atributo existe y construye el cliente con ese creds. Es el fix del gotcha conocido: el ADC del usuario (`egutierrez`) lleva `quota_project_id=autingo` ajeno y BigQuery devuelve `403 USER_PROJECT_DENIED`; descartar el quota project lo resuelve.
|
||||
|
||||
El BQClient wrappea `google.cloud.bigquery.Client` y expone `_client` para que las funciones del modulo lo usen internamente.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-07-02) — anade `drop_quota_project` para descartar el quota project del ADC del usuario (`creds.with_quota_project(None)`) y evitar el 403 USER_PROJECT_DENIED. Default False = comportamiento identico al anterior.
|
||||
|
||||
@@ -3,7 +3,7 @@ name: bq_load_from_file
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
purity: impure
|
||||
signature: "def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
|
||||
description: "Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local."
|
||||
@@ -73,3 +73,7 @@ cargar desde ahi es mas eficiente y permite paralelismo.
|
||||
|
||||
La funcion bloquea hasta que el job termina (`job.result()`). Los archivos Parquet y
|
||||
Avro no admiten `skip_leading_rows` — ese parametro solo aplica para CSV.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.0.1 (2026-07-02) — fix: `skip_leading_rows` solo se envía al LoadJobConfig cuando `source_format` es CSV; BigQuery rechazaba el job para JSON/Avro/Parquet incluso con valor 0.
|
||||
|
||||
@@ -3,7 +3,7 @@ name: bq_load_from_gcs
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
purity: impure
|
||||
signature: "def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
|
||||
description: "Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job."
|
||||
@@ -75,3 +75,7 @@ acepta la lista de archivos resultante como una sola carga atomica.
|
||||
`autodetect=True` es conveniente pero puede inferir tipos incorrectamente para columnas
|
||||
con valores nulos o mixtos. Para produccion, definir el schema explicitamente via
|
||||
`job_config.schema`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.0.1 (2026-07-02) — fix: `skip_leading_rows` solo se envía al LoadJobConfig cuando `source_format` es CSV; BigQuery rechazaba el job para JSON/Avro/Parquet incluso con valor 0.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Cliente base para Google BigQuery."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import google.auth
|
||||
from google.cloud import bigquery
|
||||
from google.oauth2 import service_account
|
||||
|
||||
@@ -27,7 +28,11 @@ class BQClient:
|
||||
self.close()
|
||||
|
||||
|
||||
def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
|
||||
def bq_auth(
|
||||
project_id: str = "",
|
||||
credentials_path: str = "",
|
||||
drop_quota_project: bool = False,
|
||||
) -> BQClient:
|
||||
"""Autentica contra Google BigQuery.
|
||||
|
||||
Tres modos de autenticacion:
|
||||
@@ -35,9 +40,18 @@ def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
|
||||
2. Service account JSON: con credentials_path
|
||||
3. Proyecto explicito: con project_id (usa ADC para credenciales)
|
||||
|
||||
Con drop_quota_project=True (y sin credentials_path) resuelve las credenciales
|
||||
ADC via google.auth.default y elimina el quota project fijado en el ADC del
|
||||
usuario (creds.with_quota_project(None)). Esto evita el error 403
|
||||
USER_PROJECT_DENIED cuando el ADC lleva un quota_project_id ajeno al proyecto
|
||||
contra el que se consulta.
|
||||
|
||||
Args:
|
||||
project_id: ID del proyecto GCP. Vacio = detectar de credenciales.
|
||||
credentials_path: Ruta a archivo JSON de service account. Vacio = ADC.
|
||||
drop_quota_project: Si True y sin credentials_path, resuelve ADC con
|
||||
google.auth.default y descarta el quota project del ADC
|
||||
(with_quota_project(None)). Default False = comportamiento original.
|
||||
|
||||
Returns:
|
||||
BQClient autenticado listo para usar.
|
||||
@@ -50,11 +64,19 @@ def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
|
||||
>>> client = bq_auth() # ADC
|
||||
>>> client = bq_auth("my-project") # ADC con proyecto explicito
|
||||
>>> client = bq_auth(credentials_path="/path/to/sa.json") # Service account
|
||||
>>> client = bq_auth("autingo-159109", drop_quota_project=True) # sin quota project
|
||||
"""
|
||||
if credentials_path:
|
||||
creds = service_account.Credentials.from_service_account_file(credentials_path)
|
||||
proj = project_id or creds.project_id
|
||||
client = bigquery.Client(credentials=creds, project=proj)
|
||||
elif drop_quota_project:
|
||||
creds, adc_project = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"]
|
||||
)
|
||||
if hasattr(creds, "with_quota_project"):
|
||||
creds = creds.with_quota_project(None)
|
||||
client = bigquery.Client(project=project_id or adc_project, credentials=creds)
|
||||
elif project_id:
|
||||
client = bigquery.Client(project=project_id)
|
||||
else:
|
||||
|
||||
@@ -173,11 +173,14 @@ def bq_load_from_gcs(
|
||||
|
||||
job_config = bigquery.LoadJobConfig(
|
||||
source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
|
||||
write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND),
|
||||
write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
|
||||
autodetect=autodetect,
|
||||
skip_leading_rows=skip_leading_rows,
|
||||
)
|
||||
job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND)
|
||||
# skip_leading_rows solo es valido para CSV: BigQuery rechaza el job
|
||||
# ("Only CSV imports may specify leading rows to skip") si el campo va
|
||||
# seteado con cualquier otro formato, incluso a 0.
|
||||
if source_format == "CSV":
|
||||
job_config.skip_leading_rows = skip_leading_rows
|
||||
|
||||
table_ref = client._client.dataset(dataset_id).table(table_id)
|
||||
uris = uri if isinstance(uri, list) else [uri]
|
||||
@@ -251,8 +254,12 @@ def bq_load_from_file(
|
||||
source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
|
||||
write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
|
||||
autodetect=autodetect,
|
||||
skip_leading_rows=skip_leading_rows,
|
||||
)
|
||||
# skip_leading_rows solo es valido para CSV: BigQuery rechaza el job
|
||||
# ("Only CSV imports may specify leading rows to skip") si el campo va
|
||||
# seteado con cualquier otro formato, incluso a 0.
|
||||
if source_format == "CSV":
|
||||
job_config.skip_leading_rows = skip_leading_rows
|
||||
|
||||
table_ref = client._client.dataset(dataset_id).table(table_id)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from .describe_numeric import describe_numeric
|
||||
from .summarize_categorical import summarize_categorical
|
||||
from .infer_semantic_type import infer_semantic_type
|
||||
from .column_quality_score import column_quality_score
|
||||
from .build_column_dictionary import build_column_dictionary
|
||||
from .select_groupby_keys import select_groupby_keys
|
||||
from .render_eda_markdown import render_eda_markdown
|
||||
from .detect_distribution_type import detect_distribution_type
|
||||
@@ -59,6 +60,9 @@ 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 .effect_size_cohens_d import effect_size_cohens_d
|
||||
from .confidence_interval_mean import confidence_interval_mean
|
||||
from .preregister_hypothesis import preregister_hypothesis
|
||||
from .suggest_reexpression import suggest_reexpression
|
||||
from .exploratory_caveats import exploratory_caveats
|
||||
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
||||
@@ -72,8 +76,22 @@ from .profile_datetime import profile_datetime
|
||||
from .resample_timeseries import resample_timeseries
|
||||
from .add_pdf_internal_links import add_pdf_internal_links
|
||||
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
|
||||
from .render_paper_pdf import render_paper_pdf
|
||||
from .draw_join_graph_figure import draw_join_graph_figure
|
||||
from .generate_synthetic_eda_table import generate_synthetic_eda_table
|
||||
from .generate_synthetic_eda_folder import generate_synthetic_eda_folder
|
||||
from .load_bq_table_to_duckdb import load_bq_table_to_duckdb
|
||||
from .list_bq_dataset_tables import list_bq_dataset_tables
|
||||
from .forecast_seasonal_median import forecast_seasonal_median
|
||||
|
||||
__all__ = [
|
||||
"forecast_seasonal_median",
|
||||
"load_bq_table_to_duckdb",
|
||||
"list_bq_dataset_tables",
|
||||
"generate_synthetic_eda_table",
|
||||
"generate_synthetic_eda_folder",
|
||||
"render_paper_pdf",
|
||||
"draw_join_graph_figure",
|
||||
"suggest_intratable_fk_candidates",
|
||||
"detect_time_column",
|
||||
"extract_timeseries_raw",
|
||||
@@ -90,6 +108,9 @@ __all__ = [
|
||||
"stl_decompose",
|
||||
"to_returns",
|
||||
"fdr_correction",
|
||||
"effect_size_cohens_d",
|
||||
"confidence_interval_mean",
|
||||
"preregister_hypothesis",
|
||||
"suggest_reexpression",
|
||||
"exploratory_caveats",
|
||||
"render_eda_pdf",
|
||||
@@ -125,6 +146,7 @@ __all__ = [
|
||||
"summarize_categorical",
|
||||
"infer_semantic_type",
|
||||
"column_quality_score",
|
||||
"build_column_dictionary",
|
||||
"select_groupby_keys",
|
||||
"render_eda_markdown",
|
||||
"detect_distribution_type",
|
||||
|
||||
@@ -29,6 +29,7 @@ from .model import ( # noqa: F401
|
||||
KVTable,
|
||||
Markdown,
|
||||
Note,
|
||||
TocEntry,
|
||||
as_blocks,
|
||||
as_chapters,
|
||||
merge_manifest,
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"Group",
|
||||
"GlossaryEntry",
|
||||
"GlossaryCollector",
|
||||
"TocEntry",
|
||||
"Chapter",
|
||||
"as_blocks",
|
||||
"as_chapters",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests del filtro `only` de build_document (selección de capítulos).
|
||||
|
||||
Verifican que:
|
||||
- only=None mantiene el comportamiento histórico (todos los capítulos).
|
||||
- only=[ids] restringe el CUERPO a esos ids, pero portada (primera) y glosario
|
||||
(última) están SIEMPRE presentes.
|
||||
- only=[] produce el documento mínimo (solo portada + glosario).
|
||||
- la selección también viaja por la clave reservada ctx['_only_chapters']
|
||||
(el canal que usan los renderers, que llaman build_document sin `only`), y
|
||||
esa clave nunca se filtra a los capítulos.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||
if _FUNCTIONS not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS)
|
||||
|
||||
from datascience.automatic_eda import build_document # noqa: E402
|
||||
|
||||
|
||||
def _profile_with_cat_and_num():
|
||||
"""Perfil mínimo que hace construir cat_distr y num_distr (cuerpo no vacío)."""
|
||||
return {
|
||||
"table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91,
|
||||
"duplicate_pct": 1.5, "null_cell_pct": 0.8,
|
||||
"columns": [
|
||||
{"name": "region", "inferred_type": "categorical",
|
||||
"categorical": {
|
||||
"top": [{"value": "norte", "count": 50, "pct": 0.42},
|
||||
{"value": "sur", "count": 40, "pct": 0.33},
|
||||
{"value": "este", "count": 30, "pct": 0.25}],
|
||||
"mode": "norte", "n_distinct": 3, "entropy": 1.55,
|
||||
"imbalance": 0.1}},
|
||||
{"name": "importe", "inferred_type": "numeric",
|
||||
"numeric": {"mean": 50.0, "median": 48.0, "std": 10.0,
|
||||
"min": 10, "max": 99, "iqr": 15,
|
||||
"histogram": [{"lo": 0, "hi": 50, "count": 40},
|
||||
{"lo": 50, "hi": 100, "count": 80}]}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_only_none_is_full_document():
|
||||
"""Retro-compat: sin `only`, salen todos los capítulos aplicables."""
|
||||
chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
|
||||
ids = [c.id for c in chs]
|
||||
assert ids[0] == "portada"
|
||||
assert ids[-1] == "glosario"
|
||||
# El cuerpo trae las distribuciones (cat/num), no solo portada+glosario.
|
||||
assert "num_distr" in ids
|
||||
assert "cat_distr" in ids
|
||||
|
||||
|
||||
def test_only_restricts_body_but_keeps_cover_and_glossary():
|
||||
# cat_distr registra el término "entropía" en el glosario, así que el
|
||||
# glosario (destino del término clicable) aparece — demuestra el contrato
|
||||
# "portada primera + capítulo + glosario última".
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v"}, only=["cat_distr"])
|
||||
ids = [c.id for c in chs]
|
||||
assert ids[0] == "portada", f"portada no es la primera: {ids}"
|
||||
assert ids[-1] == "glosario", f"glosario no es la última: {ids}"
|
||||
assert "cat_distr" in ids
|
||||
# num_distr quedó fuera de la selección.
|
||||
assert "num_distr" not in ids
|
||||
|
||||
|
||||
def test_only_empty_yields_minimal_document():
|
||||
# only=[] -> cuerpo vacío. La portada está siempre; el glosario solo aparece
|
||||
# si algún capítulo registró términos (patrón preexistente: glosario vacío se
|
||||
# omite). Sin cuerpo no hay términos → documento mínimo = solo portada.
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v"}, only=[])
|
||||
ids = [c.id for c in chs]
|
||||
assert ids == ["portada"], \
|
||||
f"only=[] debe dar el documento mínimo (solo portada), no {ids}"
|
||||
|
||||
|
||||
def test_selection_via_reserved_ctx_key():
|
||||
"""La selección viaja por ctx['_only_chapters'] cuando no se pasa `only`."""
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v",
|
||||
"_only_chapters": ["cat_distr"]})
|
||||
ids = [c.id for c in chs]
|
||||
assert "cat_distr" in ids
|
||||
assert "num_distr" not in ids
|
||||
assert ids[0] == "portada" and ids[-1] == "glosario"
|
||||
|
||||
|
||||
def test_explicit_only_arg_wins_over_ctx_key():
|
||||
"""Si se pasan ambos, el argumento `only` manda sobre la clave del ctx."""
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v",
|
||||
"_only_chapters": ["cat_distr"]},
|
||||
only=["num_distr"])
|
||||
ids = [c.id for c in chs]
|
||||
assert "num_distr" in ids
|
||||
assert "cat_distr" not in ids
|
||||
|
||||
|
||||
def test_reserved_key_not_leaked_to_caller_ctx():
|
||||
"""build_document no muta el ctx del caller (copia interna)."""
|
||||
ctx = {"dataset_name": "v", "_only_chapters": ["num_distr"]}
|
||||
build_document(_profile_with_cat_and_num(), ctx=ctx)
|
||||
# La clave reservada sigue en el dict del caller (no se mutó su copia).
|
||||
assert ctx["_only_chapters"] == ["num_distr"]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA.
|
||||
|
||||
Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para
|
||||
computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo
|
||||
consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un
|
||||
SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve
|
||||
los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos
|
||||
capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la
|
||||
vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa.
|
||||
|
||||
Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se
|
||||
evita tocar los ``chapters/<id>.py`` (cada agente es dueño de su capítulo) y se
|
||||
elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del
|
||||
``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira.
|
||||
|
||||
Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo:
|
||||
|
||||
- ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR
|
||||
para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros:
|
||||
* ``run_models`` -> ``profile['models']`` (KMeans/IsolationForest/PCA).
|
||||
Lo leen ``outliers`` (fallback del multivariante) y ``modelos``.
|
||||
* ``run_series`` -> ``profile['series']`` (análisis de serie temporal).
|
||||
Lo lee ``timeseries``.
|
||||
* ``run_llm`` -> ``profile['llm']`` (interpretación del modelo).
|
||||
Lo lee ``analisis_llm``.
|
||||
|
||||
- ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye
|
||||
``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está
|
||||
vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse
|
||||
``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide.
|
||||
Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``):
|
||||
* ``head_rows`` -> ``ctx['head_rows']`` (overview: df.head real).
|
||||
* ``raw_numeric`` -> ``ctx['raw_numeric']`` (outliers/modelos/
|
||||
correlacion/missingness/geospatial: muestra numérica alineada por fila).
|
||||
* ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda).
|
||||
* ``geo_points`` -> ``ctx['geo_points']`` (+ ``raw_numeric``)
|
||||
(geospatial: lat/lon).
|
||||
* ``db_path_table`` -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/
|
||||
text_distr/missingness/relaciones: push-down de queries propias).
|
||||
|
||||
``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE
|
||||
(la portada resume el documento y el glosario es el destino de los términos
|
||||
clicables), así que aquí se declaran sin requisitos de cómputo.
|
||||
|
||||
Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan
|
||||
a test unitario directo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los
|
||||
# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las
|
||||
# claves vacías significan "no necesita ese tipo de dependencia".
|
||||
CHAPTER_DEPS = {
|
||||
# Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee
|
||||
# el document_summary que arma build_document; el glosario lee los términos
|
||||
# que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER
|
||||
# entero y la validación los reconozca.
|
||||
"portada": {"profile_flags": [], "ctx": []},
|
||||
"overview": {"profile_flags": [], "ctx": ["head_rows"]},
|
||||
"analisis_llm": {"profile_flags": ["run_llm"], "ctx": []},
|
||||
"num_distr": {"profile_flags": [], "ctx": []},
|
||||
"cat_distr": {"profile_flags": [], "ctx": []},
|
||||
# text_distr empuja su propia query de texto (no usa raw_numeric); necesita
|
||||
# db_path/table en el ctx para hacerlo.
|
||||
"text_distr": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||
"calidad": {"profile_flags": [], "ctx": []},
|
||||
# missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y
|
||||
# puede empujar una query de patrón de nulos con db_path/table.
|
||||
"missingness": {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]},
|
||||
# outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models
|
||||
# asegura además el fallback profile['models']['outliers'] si el ctx faltara.
|
||||
"outliers": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
|
||||
"correlacion": {"profile_flags": [], "ctx": ["raw_numeric"]},
|
||||
"relaciones": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||
"modelos": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
|
||||
"timeseries": {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]},
|
||||
"geospatial": {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]},
|
||||
"agregacion": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||
"glosario": {"profile_flags": [], "ctx": []},
|
||||
}
|
||||
|
||||
# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters.
|
||||
ALWAYS_PRESENT = ("portada", "glosario")
|
||||
|
||||
# Flags de coste reconocidos (el orden no importa; se devuelven como set).
|
||||
KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm")
|
||||
|
||||
# Mapeo de cada etiqueta de ctx a las claves REALES que produce
|
||||
# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se
|
||||
# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no
|
||||
# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/
|
||||
# geo_points) son las piezas de datos podables.
|
||||
CTX_LABEL_TO_KEYS = {
|
||||
"head_rows": {"head_rows"},
|
||||
"raw_numeric": {"raw_numeric"},
|
||||
"timeseries_raw": {"timeseries_raw"},
|
||||
"geo_points": {"geo_points", "raw_numeric"},
|
||||
"db_path_table": set(), # db_path/table siempre presentes; nunca se podan.
|
||||
}
|
||||
|
||||
# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo
|
||||
# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí.
|
||||
DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points")
|
||||
|
||||
|
||||
def _as_id_list(chapter_ids):
|
||||
"""Normaliza la entrada a una lista de ids string, defensiva. None -> []."""
|
||||
if chapter_ids is None:
|
||||
return []
|
||||
if isinstance(chapter_ids, str):
|
||||
return [chapter_ids]
|
||||
return [c for c in chapter_ids if isinstance(c, str)]
|
||||
|
||||
|
||||
def validate_chapter_ids(chapter_ids, order):
|
||||
"""Separa los ids pedidos en válidos y desconocidos respecto a ``order``.
|
||||
|
||||
Args:
|
||||
chapter_ids: lista (o str) de ids de capítulo pedidos.
|
||||
order: lista canónica de ids válidos (CHAPTER_ORDER).
|
||||
|
||||
Returns:
|
||||
dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de
|
||||
aparición de la entrada. Función pura.
|
||||
"""
|
||||
valid_set = set(order or [])
|
||||
valid, unknown = [], []
|
||||
for cid in _as_id_list(chapter_ids):
|
||||
(valid if cid in valid_set else unknown).append(cid)
|
||||
return {"valid": valid, "unknown": unknown}
|
||||
|
||||
|
||||
def resolve_requirements(chapter_ids):
|
||||
"""Une los requisitos de cómputo de los capítulos pedidos.
|
||||
|
||||
Es el corazón de la resolución de dependencias: dado el subconjunto de
|
||||
capítulos a renderizar, devuelve TODO lo que hay que activar/construir para
|
||||
que esos capítulos lleguen COMPLETOS, y solo eso.
|
||||
|
||||
Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente
|
||||
porque el pipeline siempre los incluye; como no tienen requisitos, no alteran
|
||||
el resultado, pero se contemplan para que el conjunto sea coherente.
|
||||
|
||||
Args:
|
||||
chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se
|
||||
ignoran silenciosamente (la validación estricta es de quien llama).
|
||||
None o lista vacía -> requisitos vacíos.
|
||||
|
||||
Returns:
|
||||
dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde
|
||||
``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función
|
||||
pura.
|
||||
"""
|
||||
ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT)
|
||||
profile_flags = set()
|
||||
ctx_keys = set()
|
||||
for cid in ids:
|
||||
dep = CHAPTER_DEPS.get(cid)
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
for f in dep.get("profile_flags", []) or []:
|
||||
if f in KNOWN_PROFILE_FLAGS:
|
||||
profile_flags.add(f)
|
||||
for k in dep.get("ctx", []) or []:
|
||||
ctx_keys.add(k)
|
||||
return {"profile_flags": profile_flags, "ctx_keys": ctx_keys}
|
||||
|
||||
|
||||
def resolve_profile_flags(chapter_ids):
|
||||
"""Atajo: solo el set de profile_flags a activar para los capítulos pedidos.
|
||||
|
||||
Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS.
|
||||
"""
|
||||
return resolve_requirements(chapter_ids)["profile_flags"]
|
||||
|
||||
|
||||
def needs_render_ctx(chapter_ids):
|
||||
"""True si algún capítulo pedido necesita datos crudos del ctx.
|
||||
|
||||
Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero
|
||||
(ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de
|
||||
datos crudos. Función pura.
|
||||
"""
|
||||
return bool(resolve_requirements(chapter_ids)["ctx_keys"])
|
||||
|
||||
|
||||
def resolve_ctx_data_keys(chapter_ids):
|
||||
"""Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos.
|
||||
|
||||
Traduce las etiquetas de ctx a las claves concretas que produce
|
||||
``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points).
|
||||
El pipeline poda del ctx las claves de datos que NO estén en este set, para
|
||||
que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table
|
||||
nunca se podan (no aparecen aquí). Función pura.
|
||||
|
||||
Returns:
|
||||
set[str] subconjunto de DATA_CTX_KEYS.
|
||||
"""
|
||||
req = resolve_requirements(chapter_ids)
|
||||
keep = set()
|
||||
for label in req["ctx_keys"]:
|
||||
keep |= CTX_LABEL_TO_KEYS.get(label, set())
|
||||
# Solo claves de datos podables (db_path/table se gestionan aparte).
|
||||
return {k for k in keep if k in DATA_CTX_KEYS}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests del mapa central de dependencias por capítulo (chapter_deps).
|
||||
|
||||
Todas las funciones bajo prueba son PURAS (sin I/O): se ejercitan directamente
|
||||
sin DuckDB ni renderizado. Cubren la resolución de requisitos (golden + edges),
|
||||
la validación de ids y los helpers de eficiencia (qué cómputo se salta).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||
if _FUNCTIONS not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS)
|
||||
|
||||
from datascience.automatic_eda.chapter_deps import ( # noqa: E402
|
||||
ALWAYS_PRESENT,
|
||||
CHAPTER_DEPS,
|
||||
DATA_CTX_KEYS,
|
||||
needs_render_ctx,
|
||||
resolve_ctx_data_keys,
|
||||
resolve_profile_flags,
|
||||
resolve_requirements,
|
||||
validate_chapter_ids,
|
||||
)
|
||||
from datascience.automatic_eda.chapters_registry import CHAPTER_ORDER # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# El mapa cubre CHAPTER_ORDER entero (sin huecos ni claves de más).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_chapter_deps_covers_every_chapter_in_order():
|
||||
assert set(CHAPTER_DEPS) == set(CHAPTER_ORDER), (
|
||||
"CHAPTER_DEPS debe declarar exactamente los ids de CHAPTER_ORDER")
|
||||
# Cada entrada tiene la forma esperada.
|
||||
for cid, dep in CHAPTER_DEPS.items():
|
||||
assert isinstance(dep.get("profile_flags"), list), cid
|
||||
assert isinstance(dep.get("ctx"), list), cid
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_requirements — golden: outliers exige run_models + raw_numeric.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_outliers_requires_run_models_and_raw_numeric():
|
||||
req = resolve_requirements(["outliers"])
|
||||
assert "run_models" in req["profile_flags"]
|
||||
assert "raw_numeric" in req["ctx_keys"]
|
||||
assert "run_series" not in req["profile_flags"]
|
||||
assert "run_llm" not in req["profile_flags"]
|
||||
|
||||
|
||||
def test_resolve_timeseries_requires_run_series():
|
||||
req = resolve_requirements(["timeseries"])
|
||||
assert req["profile_flags"] == {"run_series"}
|
||||
assert "timeseries_raw" in req["ctx_keys"]
|
||||
|
||||
|
||||
def test_resolve_analisis_llm_requires_run_llm():
|
||||
assert resolve_requirements(["analisis_llm"])["profile_flags"] == {"run_llm"}
|
||||
|
||||
|
||||
def test_resolve_union_of_several_chapters():
|
||||
req = resolve_requirements(["outliers", "timeseries", "analisis_llm"])
|
||||
assert req["profile_flags"] == {"run_models", "run_series", "run_llm"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Eficiencia: capítulos que NO necesitan flags caros no los activan.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_geospatial_needs_no_cost_flags():
|
||||
"""geospatial sale de geo_points/raw_numeric del ctx, NO de los modelos."""
|
||||
req = resolve_requirements(["geospatial"])
|
||||
assert req["profile_flags"] == set(), \
|
||||
"geospatial no debe activar run_models/run_series/run_llm"
|
||||
assert "geo_points" in req["ctx_keys"]
|
||||
|
||||
|
||||
def test_resolve_correlacion_needs_raw_numeric_but_no_models():
|
||||
req = resolve_requirements(["correlacion"])
|
||||
assert req["profile_flags"] == set()
|
||||
assert "raw_numeric" in req["ctx_keys"]
|
||||
|
||||
|
||||
def test_always_present_chapters_add_no_requirements():
|
||||
"""portada y glosario están siempre, pero no arrastran cómputo."""
|
||||
for cid in ALWAYS_PRESENT:
|
||||
req = resolve_requirements([cid])
|
||||
assert req["profile_flags"] == set()
|
||||
assert req["ctx_keys"] == set()
|
||||
|
||||
|
||||
def test_resolve_profile_flags_shortcut():
|
||||
assert resolve_profile_flags(["modelos"]) == {"run_models"}
|
||||
assert resolve_profile_flags(["num_distr"]) == set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# needs_render_ctx — cuándo se puede saltar build_eda_render_ctx por completo.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_needs_render_ctx_true_when_chapter_reads_raw_data():
|
||||
assert needs_render_ctx(["outliers"]) is True
|
||||
assert needs_render_ctx(["agregacion"]) is True # db_path/table push-down
|
||||
assert needs_render_ctx(["timeseries"]) is True
|
||||
|
||||
|
||||
def test_needs_render_ctx_false_for_purely_aggregated_chapters():
|
||||
"""num_distr / cat_distr / calidad solo leen el profile agregado."""
|
||||
assert needs_render_ctx(["num_distr"]) is False
|
||||
assert needs_render_ctx(["cat_distr", "calidad"]) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_ctx_data_keys — poda: qué claves de DATOS conservar (db_path/table no).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_ctx_data_keys_outliers_keeps_only_raw_numeric():
|
||||
assert resolve_ctx_data_keys(["outliers"]) == {"raw_numeric"}
|
||||
|
||||
|
||||
def test_resolve_ctx_data_keys_geospatial_keeps_geo_and_numeric():
|
||||
assert resolve_ctx_data_keys(["geospatial"]) == {"geo_points", "raw_numeric"}
|
||||
|
||||
|
||||
def test_resolve_ctx_data_keys_aggregation_keeps_nothing_prunable():
|
||||
"""agregacion usa db_path/table (siempre presentes), 0 claves podables."""
|
||||
assert resolve_ctx_data_keys(["agregacion"]) == set()
|
||||
|
||||
|
||||
def test_resolve_ctx_data_keys_subset_of_data_keys():
|
||||
keep = resolve_ctx_data_keys(["overview", "timeseries", "geospatial"])
|
||||
assert keep <= set(DATA_CTX_KEYS)
|
||||
assert {"head_rows", "timeseries_raw", "geo_points", "raw_numeric"} == keep
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# validate_chapter_ids — separa válidos de desconocidos preservando orden.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_validate_separates_known_and_unknown():
|
||||
out = validate_chapter_ids(["outliers", "nope", "timeseries", "ghost"],
|
||||
CHAPTER_ORDER)
|
||||
assert out["valid"] == ["outliers", "timeseries"]
|
||||
assert out["unknown"] == ["nope", "ghost"]
|
||||
|
||||
|
||||
def test_validate_all_known():
|
||||
out = validate_chapter_ids(["portada", "glosario"], CHAPTER_ORDER)
|
||||
assert out["unknown"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Robustez: entradas raras nunca lanzan.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_handles_none_and_empty():
|
||||
assert resolve_requirements(None)["profile_flags"] == set()
|
||||
assert resolve_requirements([])["profile_flags"] == set()
|
||||
# ids desconocidos se ignoran silenciosamente en la resolución.
|
||||
assert resolve_requirements(["no_existe"])["ctx_keys"] == set()
|
||||
|
||||
|
||||
def test_resolve_accepts_single_string():
|
||||
assert resolve_requirements("outliers")["profile_flags"] == {"run_models"}
|
||||
@@ -73,7 +73,10 @@ try:
|
||||
except Exception: # noqa: BLE001
|
||||
suggest_aggregations_llm = None # type: ignore[assignment]
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
# 1.0.1 — keep-together: cada gráfico (barras por grupo, barras del pivot) se
|
||||
# envuelve con su Heading + Markdown + tabla resumen en un model.Group para que el
|
||||
# paginador no separe el gráfico de su título/descripción. Cada unidad, su grupo.
|
||||
CHAPTER_VERSION = "1.0.1"
|
||||
CHAPTER_ID = "agregacion"
|
||||
CHAPTER_TITLE = "Agregación por grupos"
|
||||
|
||||
@@ -395,11 +398,11 @@ def _groupby_section(group_by: str, measures: list, result: dict, why: str) -> l
|
||||
return []
|
||||
eff_measures = result.get("measures") or measures or []
|
||||
|
||||
blocks = [model.Heading(text=f"Agrupado por «{group_by}»", level=2)]
|
||||
head = model.Heading(text=f"Agrupado por «{group_by}»", level=2)
|
||||
intro = f"**{why}.** " if why else ""
|
||||
intro += (f"{_fmt_num(result.get('n_groups') or len(groups))} grupos"
|
||||
f"{' (top por tamaño)' if result.get('truncated') else ''}.")
|
||||
blocks.append(model.Markdown(text=intro))
|
||||
intro_md = model.Markdown(text=intro)
|
||||
|
||||
# Summary table: one row per group, count + mean of every measure.
|
||||
header = ["Grupo", "n"] + [f"{m} (media)" for m in eff_measures]
|
||||
@@ -409,20 +412,16 @@ def _groupby_section(group_by: str, measures: list, result: dict, why: str) -> l
|
||||
for m in eff_measures:
|
||||
row.append(_fmt_num(_measure_mean(g, m), 2))
|
||||
rows.append(row)
|
||||
blocks.append(model.DataTable(
|
||||
summary_tbl = model.DataTable(
|
||||
header=header, rows=rows, title=f"Resumen por «{group_by}»",
|
||||
note="Conteo de filas y media de cada medida por grupo."))
|
||||
note="Conteo de filas y media de cada medida por grupo.")
|
||||
|
||||
if not eff_measures:
|
||||
return blocks
|
||||
return [head, intro_md, summary_tbl]
|
||||
|
||||
# Primary measure: a bar chart + a detail table (mean/median/std/min/max).
|
||||
primary = eff_measures[0]
|
||||
bars = _make_group_bars(group_by, primary, groups)
|
||||
if bars is not None:
|
||||
blocks.append(model.Figure(
|
||||
make=_group_bars_maker(group_by, primary, groups),
|
||||
caption=f"Media de «{primary}» por «{group_by}» (barras desde cero)."))
|
||||
|
||||
det_header = ["Grupo", "n", "media", "mediana", "σ", "mín", "máx"]
|
||||
det_rows = []
|
||||
@@ -435,10 +434,20 @@ def _groupby_section(group_by: str, measures: list, result: dict, why: str) -> l
|
||||
_fmt_num(ms.get("std"), 2), _fmt_num(ms.get("min"), 2),
|
||||
_fmt_num(ms.get("max"), 2),
|
||||
])
|
||||
blocks.append(model.DataTable(
|
||||
detail_tbl = model.DataTable(
|
||||
header=det_header, rows=det_rows,
|
||||
title=f"Detalle de «{primary}» por «{group_by}»"))
|
||||
return blocks
|
||||
title=f"Detalle de «{primary}» por «{group_by}»")
|
||||
|
||||
if bars is not None:
|
||||
# Keep-together: heading + intro + summary table + the bar chart ride on
|
||||
# the same page/slide (the renderers move the whole Group when it does not
|
||||
# fit), so the chart never gets stranded from its title. The per-measure
|
||||
# detail table (split-safe) flows after the group.
|
||||
fig = model.Figure(
|
||||
make=_group_bars_maker(group_by, primary, groups),
|
||||
caption=f"Media de «{primary}» por «{group_by}» (barras desde cero).")
|
||||
return [model.Group(blocks=[head, intro_md, summary_tbl, fig]), detail_tbl]
|
||||
return [head, intro_md, summary_tbl, detail_tbl]
|
||||
|
||||
|
||||
def _pivot_section(pivot_spec: dict, result: dict) -> list:
|
||||
@@ -457,13 +466,13 @@ def _pivot_section(pivot_spec: dict, result: dict) -> list:
|
||||
agg = result.get("agg") or pivot_spec.get("agg") or "mean"
|
||||
why = pivot_spec.get("why") or ""
|
||||
|
||||
blocks = [model.Heading(text=f"Pivot: «{index}» × «{columns}»", level=2)]
|
||||
head = model.Heading(text=f"Pivot: «{index}» × «{columns}»", level=2)
|
||||
intro = f"**{why}.** " if why else ""
|
||||
intro += (f"{agg} de «{value}» cruzando «{index}» (filas) y «{columns}» "
|
||||
f"(columnas).")
|
||||
if result.get("truncated_rows") or result.get("truncated_cols"):
|
||||
intro += " Limitado a las filas/columnas más frecuentes."
|
||||
blocks.append(model.Markdown(text=intro))
|
||||
intro_md = model.Markdown(text=intro)
|
||||
|
||||
header = [model._safe_str(index)] + [model._safe_str(c) for c in col_labels]
|
||||
rows = []
|
||||
@@ -474,20 +483,23 @@ def _pivot_section(pivot_spec: dict, result: dict) -> list:
|
||||
cell = cells[j] if j < len(cells) else None
|
||||
row.append(_fmt_num(cell, 2))
|
||||
rows.append(row)
|
||||
blocks.append(model.DataTable(
|
||||
matrix_tbl = model.DataTable(
|
||||
header=header, rows=rows,
|
||||
title=f"{agg} de «{value}»",
|
||||
note=f"Cada celda es {agg} de «{value}» para esa combinación."))
|
||||
note=f"Cada celda es {agg} de «{value}» para esa combinación.")
|
||||
|
||||
fig_pivot = {"row_labels": row_labels, "col_labels": col_labels,
|
||||
"matrix": matrix, "index": index, "columns": columns,
|
||||
"value": value, "agg": agg}
|
||||
if _make_pivot_bars(fig_pivot) is not None:
|
||||
blocks.append(model.Figure(
|
||||
# Keep-together: heading + intro + pivot table + the grouped-bar chart on
|
||||
# one page/slide, so the chart is never stranded from its title/table.
|
||||
fig = model.Figure(
|
||||
make=_pivot_bars_maker(fig_pivot),
|
||||
caption=f"{agg} de «{value}» por «{index}» y «{columns}» "
|
||||
f"(barras agrupadas)."))
|
||||
return blocks
|
||||
f"(barras agrupadas).")
|
||||
return [model.Group(blocks=[head, intro_md, matrix_tbl, fig])]
|
||||
return [head, intro_md, matrix_tbl]
|
||||
|
||||
|
||||
def _insights_section(ctx: dict) -> list:
|
||||
|
||||
@@ -114,6 +114,19 @@ def _pdf_text(path: str) -> str:
|
||||
return re.sub(r"\s+", " ", txt)
|
||||
|
||||
|
||||
def _flat(chapter):
|
||||
"""All blocks, descending into per-unit keep-together Groups (mejora
|
||||
keep-together): each groupby/pivot section now wraps its heading + intro +
|
||||
summary table + bar chart in a model.Group, so assertions look inside it."""
|
||||
out = []
|
||||
for b in chapter.blocks:
|
||||
if getattr(b, "kind", None) == "group":
|
||||
out.extend(getattr(b, "blocks", []))
|
||||
else:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def _pptx_text(path: str) -> str:
|
||||
prs = Presentation(path)
|
||||
parts = []
|
||||
@@ -136,12 +149,13 @@ def test_golden_chapter_blocks_present():
|
||||
ch = build_agregacion(_profile(), _ctx_precomputed())
|
||||
assert isinstance(ch, Chapter)
|
||||
assert ch.id == "agregacion"
|
||||
kinds = [b.kind for b in ch.blocks]
|
||||
flat = _flat(ch)
|
||||
kinds = [b.kind for b in flat]
|
||||
assert "heading" in kinds
|
||||
assert kinds.count("data_table") >= 3 # 2 group summaries + pivot (+details)
|
||||
assert "figure" in kinds # at least one bar chart.
|
||||
# Headings mention the group keys and the pivot.
|
||||
htext = " ".join(b.text for b in ch.blocks if b.kind == "heading")
|
||||
htext = " ".join(b.text for b in flat if b.kind == "heading")
|
||||
assert "sex" in htext and "pclass" in htext and "Pivot" in htext
|
||||
|
||||
|
||||
|
||||
@@ -5,28 +5,32 @@ page (PDF) / slide (PPTX)**: every column is wrapped in a keep-together
|
||||
``model.Group`` with ``page_break_before=True`` (except the first, which may share
|
||||
the intro's page), so its chart sits next to its tables and no column is split.
|
||||
|
||||
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** term —
|
||||
the full definition lives in the GLOSARIO chapter, so it is NOT repeated inline
|
||||
here (one click jumps to the glossary entry). The intro also carries the dataset
|
||||
row total used as a comparison baseline.
|
||||
Per column the Group is laid out ``side_by_side`` (PPTX: cardinality table LEFT,
|
||||
chart RIGHT; PDF: stacked) and contains, in order:
|
||||
|
||||
Per column the Group contains, in order:
|
||||
|
||||
1. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
|
||||
1. The column name plus, when the LLM layer ran, its business **description** and
|
||||
**unit** (read from ``profile['llm']['dictionary']``, matched by column name).
|
||||
2. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
|
||||
total rows), total dataset rows, singleton values (frequency 1), entropy with
|
||||
its theoretical maximum and the normalized ratio, mode, imbalance and
|
||||
string-length stats.
|
||||
2. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
||||
3. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
||||
single dominating category).
|
||||
3. A ``top-k`` table (value / count / %).
|
||||
4. A **donut pie chart** of the most common categories (top-k + an "Otros"
|
||||
4. A ``top-k`` table (value / count / %).
|
||||
5. A **horizontal bar chart** of the most common categories (top-k + an "Otros"
|
||||
bucket), drawn lazily so the renderers scale it to fit entirely.
|
||||
|
||||
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** and
|
||||
**[[term:pagina_categorica]]page-layout[[/term]]** terms — their full
|
||||
definitions live in the GLOSARIO chapter, so they are NOT repeated inline here
|
||||
(one click jumps to the glossary entry). The intro also carries the dataset row
|
||||
total used as a comparison baseline.
|
||||
|
||||
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
|
||||
output of ``summarize_categorical`` (``top[{value,count,pct}]``, ``mode``,
|
||||
``n_distinct``, ``entropy``, ``imbalance``, ``len_min/mean/max``). The derived
|
||||
cardinality metrics and the pie figure are delegated to two registry functions
|
||||
(``categorical_cardinality_block`` and ``categorical_top_pie_figure``); both are
|
||||
cardinality metrics and the bar figure are delegated to two registry functions
|
||||
(``categorical_cardinality_block`` and ``categorical_top_bar_figure``); both are
|
||||
imported lazily and degrade to a minimal inline fallback so this chapter never
|
||||
raises even if they are unavailable.
|
||||
|
||||
@@ -39,10 +43,21 @@ import math
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.2.0"
|
||||
CHAPTER_VERSION = "1.3.0"
|
||||
CHAPTER_ID = "cat_distr"
|
||||
CHAPTER_TITLE = "Distribuciones categóricas"
|
||||
|
||||
# Key under which eda_llm_insights stores its interpretive block in the profile.
|
||||
LLM_KEY = "llm"
|
||||
|
||||
# Second glossary term this chapter names: "how each categorical page is laid
|
||||
# out". The long paragraph that used to describe it inline in the intro now lives
|
||||
# in the GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``);
|
||||
# the intro only names the clickable term, relocating the explanation, not losing
|
||||
# it. The chapter only needs to register key+label here.
|
||||
_TERM_PAGINA_KEY = "pagina_categorica"
|
||||
_TERM_PAGINA_LABEL = "Cómo se organiza cada página categórica"
|
||||
|
||||
# Glossary term this chapter explains. Registered in the shared collector and
|
||||
# marked clickable on its first appearance (end-to-end glossary example —
|
||||
# mejora 6). Other chapters hook their own terms the same way (see the contract).
|
||||
@@ -59,14 +74,14 @@ _TERM_ENTROPIA_DEF = (
|
||||
# Cap the number of categorical columns rendered to keep the document bounded;
|
||||
# the rest are summarized in a closing note (no silent truncation).
|
||||
MAX_COLS = 40
|
||||
# Rows shown in each top-k table and explicit slices in the pie. Kept moderate so
|
||||
# the whole column — cardinality table + top-k table + donut — fits on ONE
|
||||
# Rows shown in each top-k table and explicit bars in the chart. Kept moderate so
|
||||
# the whole column — cardinality table + top-k table + bar chart — fits on ONE
|
||||
# page/slide with the chart next to its tables; the table note still reports
|
||||
# "top N of M" so nothing is silently hidden. For id-like columns (≈100%
|
||||
# distinct) the top-k table is dropped entirely (it would be a list of unique
|
||||
# values — pure noise), which also frees the room the donut needs (see build).
|
||||
# values — pure noise), which also frees the room the chart needs (see build).
|
||||
TOP_TABLE_ROWS = 8
|
||||
PIE_TOP_K = 6
|
||||
CHART_TOP_K = 6
|
||||
# Truncate very long category labels in tables (the renderer also wraps). Kept
|
||||
# tight so a column with long id-like values (names, tickets) still fits its page.
|
||||
LABEL_MAX = 28
|
||||
@@ -208,26 +223,74 @@ def _fallback_cardinality(cat: dict, n_rows) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _pie_make(top, n_distinct, title, n_rows):
|
||||
"""Return a zero-arg callable that builds the donut figure lazily."""
|
||||
def _llm_index(profile: dict, ctx: dict) -> dict:
|
||||
"""Map column name -> its LLM dictionary entry (description/unit/...).
|
||||
|
||||
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
|
||||
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
|
||||
dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
|
||||
defensive: never raises on malformed input.
|
||||
"""
|
||||
llm = profile.get(LLM_KEY)
|
||||
if not isinstance(llm, dict):
|
||||
llm = ctx.get(LLM_KEY)
|
||||
if not isinstance(llm, dict):
|
||||
return {}
|
||||
entries = llm.get("dictionary")
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
return {}
|
||||
index: dict = {}
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
col = e.get("column")
|
||||
if col is None:
|
||||
continue
|
||||
index[model._safe_str(col)] = e
|
||||
return index
|
||||
|
||||
|
||||
def _llm_desc_unit_block(name: str, llm_index: dict):
|
||||
"""Markdown block with the LLM business description + unit of a column, or
|
||||
None when no LLM entry matches the column (clean fallback without LLM)."""
|
||||
entry = llm_index.get(model._safe_str(name))
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
raw_desc = entry.get("description") or entry.get("business_meaning")
|
||||
desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
|
||||
raw_unit = entry.get("unit")
|
||||
unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
|
||||
parts = []
|
||||
if desc:
|
||||
parts.append(f"**Descripción:** {desc}")
|
||||
if unit:
|
||||
parts.append(f"**Unidad:** {unit}")
|
||||
if not parts:
|
||||
return None
|
||||
return model.Markdown(text=" · ".join(parts))
|
||||
|
||||
|
||||
def _bar_make(top, n_distinct, title, n_rows):
|
||||
"""Return a zero-arg callable that builds the bar figure lazily."""
|
||||
|
||||
def make():
|
||||
try:
|
||||
from datascience.categorical_top_pie_figure import (
|
||||
categorical_top_pie_figure,
|
||||
from datascience.categorical_top_bar_figure import (
|
||||
categorical_top_bar_figure,
|
||||
)
|
||||
|
||||
return categorical_top_pie_figure(
|
||||
return categorical_top_bar_figure(
|
||||
top=top, n_distinct=n_distinct or 0, title=title,
|
||||
top_k=PIE_TOP_K, n_rows=n_rows)
|
||||
top_k=CHART_TOP_K, n_rows=n_rows)
|
||||
except Exception: # noqa: BLE001 — minimal local fallback figure.
|
||||
return _fallback_pie(top, title)
|
||||
return _fallback_bar(top, title)
|
||||
|
||||
return make
|
||||
|
||||
|
||||
def _fallback_pie(top, title):
|
||||
"""Minimal donut figure used only if the registry function is unavailable."""
|
||||
def _fallback_bar(top, title):
|
||||
"""Minimal horizontal-bar figure used only if the registry function is
|
||||
unavailable. Largest category on top, the rest folded into "Otros"."""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
@@ -238,8 +301,8 @@ def _fallback_pie(top, title):
|
||||
items = [t for t in (top or [])
|
||||
if isinstance(t, dict) and isinstance(t.get("count"), (int, float))]
|
||||
items = sorted(items, key=lambda t: t.get("count") or 0, reverse=True)
|
||||
head = items[:PIE_TOP_K]
|
||||
rest = items[PIE_TOP_K:]
|
||||
head = items[:CHART_TOP_K]
|
||||
rest = items[CHART_TOP_K:]
|
||||
labels = [_truncate(t.get("value"), 20) for t in head]
|
||||
sizes = [float(t.get("count") or 0) for t in head]
|
||||
if rest:
|
||||
@@ -249,10 +312,13 @@ def _fallback_pie(top, title):
|
||||
ax.text(0.5, 0.5, "sin datos categóricos", ha="center", va="center")
|
||||
ax.axis("off")
|
||||
return fig
|
||||
ax.pie(sizes, labels=None, wedgeprops={"width": 0.42},
|
||||
autopct=lambda p: f"{p:.0f}%" if p >= 4 else "")
|
||||
ax.legend(labels, loc="center left", bbox_to_anchor=(1.0, 0.5),
|
||||
fontsize=7, frameon=False)
|
||||
# barh draws bottom-up, so reverse to put the largest category on top.
|
||||
y_pos = range(len(labels))
|
||||
ax.barh(list(y_pos), list(reversed(sizes)), color="#4C72B0",
|
||||
edgecolor="white")
|
||||
ax.set_yticks(list(y_pos))
|
||||
ax.set_yticklabels(list(reversed(labels)), fontsize=7)
|
||||
ax.set_xlabel("conteo", fontsize=8)
|
||||
ax.set_title(_truncate(title, 40))
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
@@ -373,22 +439,17 @@ def _topk_table(cat: dict):
|
||||
note=note)
|
||||
|
||||
|
||||
def _intro_blocks(n_rows, mark_term: bool = False):
|
||||
total = _fmt_int(n_rows)
|
||||
# Mark the first appearance of the term as a clickable glossary jump when the
|
||||
# term was registered (mark_term). The full definition of entropy lives in the
|
||||
# GLOSARIO chapter, so the intro only names the clickable term here instead of
|
||||
# repeating the long explanation (avoids the redundancy with the glossary).
|
||||
def _intro_blocks(mark_term: bool = False):
|
||||
# The full explanation of entropy AND of how each categorical page is laid out
|
||||
# lives in the GLOSARIO chapter; the chapter body keeps only the minimal
|
||||
# clickable terms — no descriptive prose — to avoid duplicating the glossary.
|
||||
# The dataset row total is not repeated here: each column's cardinality table
|
||||
# already carries "Total filas (dataset)".
|
||||
entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
|
||||
else "entropía")
|
||||
text = (
|
||||
f"Cada columna categórica ocupa su propia página: sus métricas de "
|
||||
f"cardinalidad —incluida la {entropia}—, una nota que señala cardinalidad "
|
||||
"problemática, la tabla de las categorías más frecuentes y un gráfico de "
|
||||
"tarta (donut) de las más comunes, todo junto."
|
||||
)
|
||||
if n_rows is not None:
|
||||
text += f" El dataset tiene {total} filas en total como referencia."
|
||||
pagina = ("[[term:pagina_categorica]]cómo se organiza cada página[[/term]]"
|
||||
if mark_term else "cómo se organiza cada página")
|
||||
text = f"Términos: {entropia} · {pagina}."
|
||||
return [
|
||||
model.Heading(text="Entropía y cardinalidad", level=2),
|
||||
model.Markdown(text=text),
|
||||
@@ -406,15 +467,22 @@ def build_cat_distr(profile: dict, ctx: dict):
|
||||
return None
|
||||
|
||||
n_rows = profile.get("n_rows")
|
||||
# Register "entropía" in the shared glossary collector (if present) and mark
|
||||
# its first appearance clickable. End-to-end glossary example (mejora 6).
|
||||
# Register "entropía" and the "how each categorical page is laid out" term in
|
||||
# the shared glossary collector (if present) and mark their first appearance
|
||||
# clickable. End-to-end glossary example (mejora 6).
|
||||
glossary = ctx.get("glossary")
|
||||
mark_term = False
|
||||
if isinstance(glossary, model.GlossaryCollector):
|
||||
glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL,
|
||||
_TERM_ENTROPIA_DEF)
|
||||
glossary.add(_TERM_PAGINA_KEY, _TERM_PAGINA_LABEL)
|
||||
mark_term = True
|
||||
blocks = list(_intro_blocks(n_rows, mark_term=mark_term))
|
||||
blocks = list(_intro_blocks(mark_term=mark_term))
|
||||
|
||||
# Business description + unit per column come from the LLM dictionary
|
||||
# (profile['llm']['dictionary'], matched by column name); absent without
|
||||
# run_llm, in which case the per-column description block is simply omitted.
|
||||
llm_index = _llm_index(profile, ctx)
|
||||
|
||||
rendered = cat_cols[:MAX_COLS]
|
||||
for idx, col in enumerate(rendered):
|
||||
@@ -422,31 +490,36 @@ def build_cat_distr(profile: dict, ctx: dict):
|
||||
cat = col.get("categorical") or {}
|
||||
card = _normalize_card(_cardinality(cat, n_rows))
|
||||
|
||||
# One Group per categorical column: heading + cardinality table + flag
|
||||
# note + top-k table + donut figure are kept together and the renderer
|
||||
# starts each on a fresh page/slide (page_break_before) so every column
|
||||
# gets its own page with its chart next to its tables. The first column
|
||||
# may share the intro's page (no forced break) to avoid a near-empty page.
|
||||
col_blocks = [
|
||||
model.Heading(text=str(name), level=2),
|
||||
_cardinality_block(card),
|
||||
]
|
||||
# One Group per categorical column: heading + (optional) LLM description +
|
||||
# cardinality table + flag note + top-k table + bar figure are kept
|
||||
# together and the renderer starts each on a fresh page/slide
|
||||
# (page_break_before) so every column gets its own page with its chart next
|
||||
# to its tables. The first column may share the intro's page (no forced
|
||||
# break) to avoid a near-empty page.
|
||||
col_blocks = [model.Heading(text=str(name), level=2)]
|
||||
desc_block = _llm_desc_unit_block(name, llm_index)
|
||||
if desc_block is not None:
|
||||
col_blocks.append(desc_block)
|
||||
col_blocks.append(_cardinality_block(card))
|
||||
note = _flag_note(card)
|
||||
if note is not None:
|
||||
col_blocks.append(note)
|
||||
# For id-like columns (≈100% distinct) the top-k is a list of unique
|
||||
# values — pure noise; skip it (the flag note already explains why) and
|
||||
# let the donut take that room so the whole column fits one page/slide.
|
||||
# let the bar chart take that room so the whole column fits one page/slide.
|
||||
if not card.get("id_like"):
|
||||
topk = _topk_table(cat)
|
||||
if topk is not None:
|
||||
col_blocks.append(topk)
|
||||
col_blocks.append(model.Figure(
|
||||
make=_pie_make(cat.get("top") or [], card.get("n_distinct"),
|
||||
make=_bar_make(cat.get("top") or [], card.get("n_distinct"),
|
||||
str(name), n_rows),
|
||||
caption=(f"Categorías más comunes de «{_truncate(name, 32)}» "
|
||||
"(donut: top-k + «Otros»)")))
|
||||
blocks.append(model.Group(blocks=col_blocks,
|
||||
"(barras: top-k + «Otros»)")))
|
||||
# layout="side_by_side": in PPTX the cardinality table goes to the LEFT and
|
||||
# the bar chart to the RIGHT of the same slide; the PDF renderer stacks it
|
||||
# (the A5 mobile page is too narrow for two readable columns).
|
||||
blocks.append(model.Group(blocks=col_blocks, layout="side_by_side",
|
||||
page_break_before=(idx > 0)))
|
||||
|
||||
if len(cat_cols) > len(rendered):
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
||||
and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user
|
||||
asked for (distinct/total/%-distinct/unique metrics, top-k table and a donut
|
||||
asked for (distinct/total/%-distinct/unique metrics, top-k table and a bar
|
||||
figure), that EACH categorical column is wrapped in its own keep-together
|
||||
``Group`` that starts on a fresh page/slide (one column per page, chart next to
|
||||
its tables), that the long entropy explanation is NOT repeated inline (it lives
|
||||
in the glossary — only the clickable term is kept), that the chapter renders
|
||||
inside the full document to both PDF and PPTX showing that content, that a
|
||||
``Group`` laid out ``side_by_side`` (PPTX: table left / bars right) that starts on
|
||||
a fresh page/slide (one column per page, chart next to its tables), that the LLM
|
||||
business description + unit are shown per column when the profile carries an LLM
|
||||
block, that the long entropy / page-layout explanations are NOT repeated inline
|
||||
(they live in the glossary — only the clickable terms are kept), that the chapter
|
||||
renders inside the full document to both PDF and PPTX showing that content, that a
|
||||
profile with no categorical columns yields ``None`` without raising, and that
|
||||
long labels / many columns are never cut in either output.
|
||||
"""
|
||||
@@ -116,6 +118,10 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
||||
assert "log2" not in md.text # redundant explanation removed.
|
||||
assert "máxima diversidad" not in md.text
|
||||
|
||||
# The donut/pie is gone: the intro no longer mentions tarta/donut (the chart
|
||||
# is now a bar chart; the long page-layout explanation moved to the glossary).
|
||||
assert "donut" not in md.text and "tarta" not in md.text
|
||||
|
||||
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
|
||||
flat = _flatten(ch.blocks)
|
||||
kv = next(b for b in flat if isinstance(b, KVTable))
|
||||
@@ -128,11 +134,13 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
||||
assert any("Entropía" in lbl for lbl in labels)
|
||||
assert "únicos" in values and "%" in values
|
||||
assert "bits" in values and "norm" in values # entropy + max + normalized.
|
||||
# Top-k table + pie figure.
|
||||
# Top-k table + bar figure.
|
||||
dt = next(b for b in flat if isinstance(b, DataTable))
|
||||
assert dt.header == ["Valor", "Conteo", "%"]
|
||||
assert any("neumaticos" in str(cell) for row in dt.rows for cell in row)
|
||||
assert any(isinstance(b, Figure) for b in flat)
|
||||
# Each per-column Group is laid out side_by_side (table left / bars right).
|
||||
assert all(g.layout == "side_by_side" for g in _column_groups(ch))
|
||||
# id-like column flagged with a Note that also explains the top-k is dropped.
|
||||
idnote = next((b for b in flat
|
||||
if isinstance(b, Note) and "identificador" in b.text), None)
|
||||
@@ -140,9 +148,9 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
||||
assert "No se lista el top" in idnote.text
|
||||
|
||||
|
||||
def test_golden_idlike_omite_topk_y_conserva_donut():
|
||||
def test_golden_idlike_omite_topk_y_conserva_grafico():
|
||||
# The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
|
||||
# (it would be a list of unique values), but must still keep its donut Figure
|
||||
# (it would be a list of unique values), but must still keep its bar Figure
|
||||
# and its cardinality table so it stays a full per-column page.
|
||||
ch = build_cat_distr(_profile(), {})
|
||||
groups = _column_groups(ch)
|
||||
@@ -151,7 +159,7 @@ def test_golden_idlike_omite_topk_y_conserva_donut():
|
||||
kinds = [b.kind for b in uuid_group.blocks]
|
||||
assert "data_table" not in kinds # top-k of unique values dropped.
|
||||
assert "kv_table" in kinds # cardinality kept.
|
||||
assert "figure" in kinds # donut kept (chart per column).
|
||||
assert "figure" in kinds # bar chart kept (chart per column).
|
||||
# A non-id-like column keeps its top-k table.
|
||||
cat_group = next(g for g in groups
|
||||
if any(getattr(b, "text", "") == "categoria"
|
||||
@@ -205,7 +213,7 @@ def test_golden_render_pdf_una_pagina_por_columna():
|
||||
assert "Entrop" in txt
|
||||
assert "distintos" in txt
|
||||
assert "categoria" in txt and "neumaticos" in txt
|
||||
assert "donut" in txt # figure caption rendered as text.
|
||||
assert "barras" in txt # bar-chart caption rendered as text (PDF).
|
||||
assert "identificador" in txt # id-like note rendered.
|
||||
|
||||
|
||||
@@ -258,9 +266,11 @@ def _profile_high_card() -> dict:
|
||||
|
||||
|
||||
def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
||||
"""Each categorical column occupies EXACTLY ONE cat_distr slide that carries
|
||||
BOTH its cardinality table and its donut figure (picture) — i.e. the chart is
|
||||
never separated from its table, even for a high-cardinality column."""
|
||||
"""Cada columna categórica ocupa EXACTAMENTE UN slide cat_distr que lleva su
|
||||
gráfico (picture) en la misma slide — el chart nunca se separa de su columna,
|
||||
ni siquiera para una columna de alta cardinalidad. Con layout side_by_side la
|
||||
tabla se rasteriza a imagen, así que la comprobación se hace por presencia de
|
||||
picture (no por el texto de la tabla)."""
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
prof = _profile_high_card()
|
||||
@@ -272,7 +282,7 @@ def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
||||
prs = Presentation(out)
|
||||
|
||||
# Per column: the cat_distr slides whose text mentions it, and whether the
|
||||
# owning slide also has the donut caption + an actual picture shape.
|
||||
# owning slide also carries an actual picture shape (its chart).
|
||||
slides_with_col = {n: [] for n in cat_names}
|
||||
owner_has_chart = {n: False for n in cat_names}
|
||||
for i, sl in enumerate(prs.slides):
|
||||
@@ -288,15 +298,106 @@ def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
||||
for n in cat_names:
|
||||
if n in txt:
|
||||
slides_with_col[n].append(i)
|
||||
has_table = "Cardinalidad" in txt or "distintos" in txt
|
||||
if has_pic and "donut" in txt and has_table:
|
||||
if has_pic:
|
||||
owner_has_chart[n] = True
|
||||
|
||||
for n in cat_names:
|
||||
# Exactly one slide carries the column (not split across slides).
|
||||
assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
|
||||
# That single slide also holds its table AND its donut picture.
|
||||
assert owner_has_chart[n], (n, "tabla y donut no están en el mismo slide")
|
||||
# That single slide also holds its chart picture.
|
||||
assert owner_has_chart[n], (n, "el gráfico no está en el slide de la columna")
|
||||
|
||||
|
||||
def test_golden_pptx_columna_side_by_side_tabla_izq_barra_der():
|
||||
"""Con layout side_by_side, una columna categórica coloca su tabla de
|
||||
cardinalidad (imagen) en la mitad izquierda y su gráfico de barras (imagen) en
|
||||
la mitad derecha de la MISMA slide. Verifica que al menos una columna queda en
|
||||
dos columnas (tabla-izq / barras-der), evidencia del side_by_side en PPTX."""
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
from pptx.util import Inches
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "eda.pptx")
|
||||
render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
|
||||
prs = Presentation(out)
|
||||
centre = int(Inches(13.333 / 2.0)) # half of the 16:9 slide width.
|
||||
two_col_slides = 0
|
||||
for sl in prs.slides:
|
||||
texts, lefts = [], []
|
||||
for sh in sl.shapes:
|
||||
if sh.has_text_frame:
|
||||
texts.append(sh.text_frame.text)
|
||||
if (sh.shape_type == MSO_SHAPE_TYPE.PICTURE
|
||||
and sh.left is not None):
|
||||
lefts.append(sh.left)
|
||||
txt = re.sub(r"\s+", " ", " ".join(texts))
|
||||
if "Distribuciones categ" not in txt:
|
||||
continue
|
||||
# One picture starts in the left half, another in the right half.
|
||||
if len(lefts) >= 2 and min(lefts) < centre and max(lefts) > centre:
|
||||
two_col_slides += 1
|
||||
assert two_col_slides >= 1, (
|
||||
"ninguna columna quedó con tabla-izq / barras-der (side_by_side)")
|
||||
|
||||
|
||||
def _profile_with_llm() -> dict:
|
||||
"""The base profile plus an ``llm`` block (as eda_llm_insights would store it
|
||||
with run_llm=True): a data dictionary with description/unit per column."""
|
||||
prof = _profile()
|
||||
prof["llm"] = {
|
||||
"dictionary": [
|
||||
{"column": "categoria",
|
||||
"description": "Familia de producto del recambio",
|
||||
"business_meaning": "Agrupa el catálogo por tipo de pieza",
|
||||
"unit": "categoría"},
|
||||
{"column": "uuid",
|
||||
"description": "Identificador único de registro",
|
||||
"unit": ""},
|
||||
],
|
||||
}
|
||||
return prof
|
||||
|
||||
|
||||
def test_llm_descripcion_y_unidad_por_columna():
|
||||
# With an LLM dictionary, each categorical column whose name matches shows its
|
||||
# business description and unit in a per-column markdown block.
|
||||
ch = build_cat_distr(_profile_with_llm(), {})
|
||||
groups = _column_groups(ch)
|
||||
cat_group = next(g for g in groups
|
||||
if any(getattr(b, "text", "") == "categoria"
|
||||
for b in g.blocks))
|
||||
md = " ".join(b.text for b in cat_group.blocks
|
||||
if getattr(b, "kind", "") == "markdown")
|
||||
assert "Descripción" in md and "Familia de producto" in md
|
||||
assert "Unidad" in md and "categoría" in md
|
||||
|
||||
|
||||
def test_edge_sin_llm_no_anade_descripcion():
|
||||
# Without an LLM block the per-column description markdown is simply omitted;
|
||||
# the column still renders its cardinality table and bar figure.
|
||||
ch = build_cat_distr(_profile(), {})
|
||||
for g in _column_groups(ch):
|
||||
mds = [b.text for b in g.blocks if getattr(b, "kind", "") == "markdown"]
|
||||
assert not any("Descripción" in t for t in mds)
|
||||
|
||||
|
||||
def test_pagina_categorica_clicable_y_definicion_en_glosario():
|
||||
# The "how each categorical page is laid out" term is registered + marked
|
||||
# clickable in the intro, and its full definition lands in the glossary
|
||||
# chapter (canonical baseline catalog), not inline.
|
||||
from datascience.automatic_eda.chapters.glosario import build_glosario
|
||||
|
||||
gc = GlossaryCollector()
|
||||
ch = build_cat_distr(_profile(), {"glossary": gc})
|
||||
md = next(b for b in ch.blocks if isinstance(b, Markdown))
|
||||
assert "[[term:pagina_categorica]]" in md.text
|
||||
assert gc.has("pagina_categorica")
|
||||
glos = build_glosario(_profile(), {"glossary": gc})
|
||||
entry = next(b for b in glos.blocks
|
||||
if getattr(b, "kind", "") == "glossary_entry"
|
||||
and b.key == "pagina_categorica")
|
||||
assert "barras" in entry.definition
|
||||
assert "identificador" in entry.definition
|
||||
|
||||
|
||||
def test_edge_sin_categoricas_devuelve_none():
|
||||
|
||||
@@ -61,7 +61,9 @@ try:
|
||||
except Exception: # noqa: BLE001
|
||||
build_geo_scatter = None # type: ignore[assignment]
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
# 1.0.1 — keep-together: el mapa (scatter geográfico) se envuelve con su Heading e
|
||||
# intro en un model.Group para que el paginador no lo separe de su título/descripción.
|
||||
CHAPTER_VERSION = "1.0.1"
|
||||
CHAPTER_ID = "geospatial"
|
||||
CHAPTER_TITLE = "Análisis geoespacial"
|
||||
|
||||
@@ -455,11 +457,14 @@ def build_geospatial(profile: dict, ctx: dict):
|
||||
scatter = {}
|
||||
maker = _make_geo_scatter(scatter, lat_col, lon_col) if scatter else None
|
||||
if maker is not None:
|
||||
blocks.append(model.Figure(
|
||||
# Keep-together: the chapter heading + intro + the map figure ride on
|
||||
# the same page/slide (the renderers move the whole Group when it does
|
||||
# not fit), so the map never gets stranded from its title/description.
|
||||
blocks = [model.Group(blocks=blocks + [model.Figure(
|
||||
make=maker,
|
||||
caption="Cada punto es una observación situada por sus "
|
||||
"coordenadas; el recuadro rojo es el bounding box. La "
|
||||
"escala respeta la latitud (proyección equirectangular)."))
|
||||
"escala respeta la latitud (proyección equirectangular).")])]
|
||||
else:
|
||||
blocks.append(model.Note(
|
||||
"No se pudo construir el scatter geográfico a partir de las "
|
||||
|
||||
@@ -64,16 +64,28 @@ def _ctx_points(lats, lons):
|
||||
return {"geo_points": {"lats": lats, "lons": lons}}
|
||||
|
||||
|
||||
def _all_blocks(chapter):
|
||||
"""Flatten blocks, descending into the keep-together Group that now wraps the
|
||||
map heading + intro + scatter figure (mejora keep-together)."""
|
||||
out = []
|
||||
for b in chapter.blocks:
|
||||
if getattr(b, "kind", None) == "group":
|
||||
out.extend(getattr(b, "blocks", []))
|
||||
else:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def _kinds(chapter):
|
||||
return [getattr(b, "kind", None) for b in chapter.blocks]
|
||||
return [getattr(b, "kind", None) for b in _all_blocks(chapter)]
|
||||
|
||||
|
||||
def _tables(chapter):
|
||||
return [b for b in chapter.blocks if getattr(b, "kind", None) == "data_table"]
|
||||
return [b for b in _all_blocks(chapter) if getattr(b, "kind", None) == "data_table"]
|
||||
|
||||
|
||||
def _figures(chapter):
|
||||
return [b for b in chapter.blocks if getattr(b, "kind", None) == "figure"]
|
||||
return [b for b in _all_blocks(chapter) if getattr(b, "kind", None) == "figure"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -98,7 +110,7 @@ def test_golden_detecta_columnas_y_nombra_ejes():
|
||||
lats, lons = _grid(40.4, -3.7, 30, spread=0.8)
|
||||
prof = _profile_with_coords("latitude", "longitude", lats, lons)
|
||||
ch = build_geospatial(prof, _ctx_points(lats, lons))
|
||||
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
|
||||
intro = [b for b in _all_blocks(ch) if b.kind == "markdown"][0].text
|
||||
assert "latitude" in intro and "longitude" in intro
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,63 @@ from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_VERSION = "1.1.1"
|
||||
CHAPTER_ID = "glosario"
|
||||
CHAPTER_TITLE = "Glosario"
|
||||
|
||||
# Canonical definitions for cross-cutting terms — the "how to read it" entries
|
||||
# that do not belong to a single chapter. A chapter only needs to *register* the
|
||||
# term (``ctx['glossary'].add(key, label)``) and mark its in-text appearance with
|
||||
# ``[[term:key]]…[[/term]]``; this chapter supplies the full definition here when
|
||||
# the collector carries the term without one. Keeping the prose in a single place
|
||||
# avoids repeating a long paragraph inline in every chapter that names the term
|
||||
# (the explanation moved out of the NUM DISTR and CAT DISTR intros lives here).
|
||||
_BASELINE_TERMS = {
|
||||
"histograma_boxplot": {
|
||||
"label": "Cómo leer el histograma y el boxplot",
|
||||
"definition": (
|
||||
"Para cada columna numérica se muestra su histograma con tres líneas "
|
||||
"de referencia: la media (línea roja discontinua), la mediana (línea "
|
||||
"verde continua) y la banda ±1σ (zona sombreada que cubre una "
|
||||
"desviación estándar a cada lado de la media). Debajo, alineado al "
|
||||
"mismo eje horizontal, un boxplot de Tukey: la caja abarca del primer "
|
||||
"al tercer cuartil (P25–P75), la línea interior es la mediana y los "
|
||||
"bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay "
|
||||
"valores más allá de las vallas (posibles atípicos). Comparar la media "
|
||||
"con la mediana revela la asimetría: si la media supera a la mediana la "
|
||||
"cola larga cae hacia los valores altos (asimetría a la derecha), y al "
|
||||
"revés hacia los bajos."),
|
||||
},
|
||||
"pagina_categorica": {
|
||||
"label": "Cómo se organiza cada página categórica",
|
||||
"definition": (
|
||||
"Cada columna categórica ocupa su propia página: muestra sus métricas "
|
||||
"de cardinalidad —incluida la entropía—, una nota que señala "
|
||||
"cardinalidad problemática (columnas que se comportan como "
|
||||
"identificador, con casi todos los valores distintos, o dominadas por "
|
||||
"una sola categoría), la tabla de las categorías más frecuentes (top-k, "
|
||||
"con su conteo y porcentaje) y un gráfico de barras de las categorías "
|
||||
"más comunes (top-k más una barra «Otros» que agrupa la cola). El total "
|
||||
"de filas del dataset se usa como referencia para interpretar los "
|
||||
"conteos."),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_term(term: dict) -> tuple:
|
||||
"""Return (label, definition) for a collected term, completing a missing
|
||||
definition (and, if absent, the label) from the canonical baseline catalog."""
|
||||
key = model._safe_str(term.get("key"))
|
||||
label = model._safe_str(term.get("label"))
|
||||
definition = model._safe_str(term.get("definition"))
|
||||
base = _BASELINE_TERMS.get(key)
|
||||
if base:
|
||||
if not definition.strip():
|
||||
definition = model._safe_str(base.get("definition"))
|
||||
if not label.strip() or label == key:
|
||||
label = model._safe_str(base.get("label")) or label
|
||||
return label, definition
|
||||
|
||||
|
||||
def build_glosario(profile: dict, ctx: dict):
|
||||
"""Build the glossary Chapter from the shared collector, or None if empty."""
|
||||
@@ -36,12 +89,19 @@ def build_glosario(profile: dict, ctx: dict):
|
||||
"Cada término va resaltado en el texto y, al pulsarlo, salta a su "
|
||||
"definición en esta sección.")),
|
||||
]
|
||||
# One clickable destination per term, alphabetically by visible label.
|
||||
for term in glossary.terms(by="label"):
|
||||
# One clickable destination per term, alphabetically by *visible* label. The
|
||||
# baseline resolution must happen BEFORE sorting: a term registered bare (no
|
||||
# label) carries its key as label in the collector, so ordering by the
|
||||
# collector's label would place it by its key instead of by the human label
|
||||
# supplied by the baseline catalog. Resolve first, then sort by the final label.
|
||||
resolved = []
|
||||
for term in glossary.terms(by="order"):
|
||||
label, definition = _resolve_term(term)
|
||||
resolved.append((label, definition, model._safe_str(term.get("key"))))
|
||||
resolved.sort(key=lambda e: model._safe_str(e[0]).lower())
|
||||
for label, definition, key in resolved:
|
||||
blocks.append(model.GlossaryEntry(
|
||||
key=model._safe_str(term.get("key")),
|
||||
label=model._safe_str(term.get("label")),
|
||||
definition=model._safe_str(term.get("definition"))))
|
||||
key=key, label=label, definition=definition))
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the GLOSARIO chapter — DoD: golden + edges + degradation + no-cut render.
|
||||
|
||||
The glossary is the last chapter of every AutomaticEDA document. It does not read
|
||||
the profile: it turns the terms that the other chapters registered on the shared
|
||||
``GlossaryCollector`` (``ctx['glossary']``) into one clickable ``GlossaryEntry``
|
||||
destination each, alphabetically by visible label.
|
||||
|
||||
Covered here:
|
||||
|
||||
- **Golden**: a collector with three terms (one carrying its own definition, two
|
||||
registered bare and completed from the canonical baseline catalog) builds a
|
||||
``Chapter`` with three ``GlossaryEntry`` blocks, alphabetically ordered, and
|
||||
renders to PDF and PPTX with nothing cut.
|
||||
- **Baseline resolution** (``_resolve_term``): a bare term whose key is in the
|
||||
baseline gets its label *and* definition filled in; a term that already carries
|
||||
its own definition is never overwritten.
|
||||
- **Edges**: ``None`` / ``{}`` ctx, an empty collector and a non-collector value in
|
||||
``ctx['glossary']`` all return ``None`` (the chapter simply disappears) and never
|
||||
raise, even with a ``None`` profile.
|
||||
- **Click target**: every emitted entry carries the registered ``key`` so each
|
||||
in-text ``[[term:key]]`` appearance resolves to a real jump.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from pptx import Presentation
|
||||
from pypdf import PdfReader
|
||||
|
||||
from datascience.automatic_eda.chapters.glosario import (
|
||||
_BASELINE_TERMS,
|
||||
_resolve_term,
|
||||
build_glosario,
|
||||
)
|
||||
from datascience.automatic_eda.model import (
|
||||
Chapter,
|
||||
GlossaryCollector,
|
||||
GlossaryEntry,
|
||||
)
|
||||
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _entries(chapter: Chapter) -> list:
|
||||
"""The GlossaryEntry blocks of a built chapter, in document order."""
|
||||
return [b for b in chapter.blocks if isinstance(b, GlossaryEntry)]
|
||||
|
||||
|
||||
def _render_both(chapter: Chapter, tag: str):
|
||||
"""Render the chapter to PDF and PPTX; return (pdf_text, n_slides)."""
|
||||
tmp = tempfile.mkdtemp(prefix=f"glosario_{tag}_")
|
||||
pdf_path = os.path.join(tmp, "out.pdf")
|
||||
pptx_path = os.path.join(tmp, "out.pptx")
|
||||
meta = {"title": f"EDA — {tag}"}
|
||||
render_automatic_eda_pdf([chapter], pdf_path, meta)
|
||||
render_automatic_eda_pptx([chapter], pptx_path, meta)
|
||||
assert os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0
|
||||
assert os.path.exists(pptx_path) and os.path.getsize(pptx_path) > 0
|
||||
text = "".join(p.extract_text() or "" for p in PdfReader(pdf_path).pages)
|
||||
n_slides = len(Presentation(pptx_path).slides)
|
||||
return text, n_slides
|
||||
|
||||
|
||||
def _collector_three_terms() -> GlossaryCollector:
|
||||
"""A collector with three terms registered out of alphabetical order:
|
||||
|
||||
- ``entropia``: its own label + definition (must not be baseline-overwritten).
|
||||
- ``pagina_categorica``: bare, completed from the baseline.
|
||||
- ``histograma_boxplot``: bare, completed from the baseline.
|
||||
"""
|
||||
g = GlossaryCollector()
|
||||
g.add("entropia", "Entropía",
|
||||
"Medida de la incertidumbre o dispersión de una variable categórica.")
|
||||
g.add("pagina_categorica") # bare -> baseline label + definition
|
||||
g.add("histograma_boxplot") # bare -> baseline label + definition
|
||||
return g
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Golden.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_golden_terms_render_clickable_entries():
|
||||
g = _collector_three_terms()
|
||||
chapter = build_glosario({"table": "x"}, {"glossary": g})
|
||||
|
||||
assert isinstance(chapter, Chapter)
|
||||
assert chapter.id == "glosario"
|
||||
assert chapter.title == "Glosario"
|
||||
assert chapter.version == "1.1.1"
|
||||
|
||||
entries = _entries(chapter)
|
||||
assert len(entries) == 3
|
||||
assert all(isinstance(e, GlossaryEntry) for e in entries)
|
||||
|
||||
# Alphabetical by visible label: "Cómo leer…" < "Cómo se organiza…" < "Entropía".
|
||||
labels = [e.label for e in entries]
|
||||
assert labels == sorted(labels, key=str.lower)
|
||||
assert labels[0] == "Cómo leer el histograma y el boxplot"
|
||||
assert labels[-1] == "Entropía"
|
||||
|
||||
# Bare terms were completed from the baseline; the own-definition term survived.
|
||||
by_key = {e.key: e for e in entries}
|
||||
assert "boxplot de Tukey" in by_key["histograma_boxplot"].definition
|
||||
assert "identificador" in by_key["pagina_categorica"].definition
|
||||
assert by_key["entropia"].definition.startswith("Medida de la incertidumbre")
|
||||
|
||||
# Renders with nothing cut; the labels and a definition fragment reach the PDF.
|
||||
pdf_text, n_slides = _render_both(chapter, "golden")
|
||||
assert "Entropía" in pdf_text
|
||||
assert n_slides >= 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Baseline resolution (_resolve_term).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_term_completes_label_and_definition_from_baseline():
|
||||
# A bare registration keeps label == key and an empty definition; the resolver
|
||||
# fills both from the canonical catalog.
|
||||
key = "histograma_boxplot"
|
||||
label, definition = _resolve_term({"key": key, "label": key, "definition": ""})
|
||||
assert label == _BASELINE_TERMS[key]["label"]
|
||||
assert "boxplot de Tukey" in definition
|
||||
|
||||
|
||||
def test_resolve_term_keeps_own_definition_over_baseline():
|
||||
# Even when the key is in the baseline, a term that already carries its own
|
||||
# definition (and a real label) must not be overwritten.
|
||||
key = "pagina_categorica"
|
||||
own_def = "Definición propia que no debe pisarse."
|
||||
label, definition = _resolve_term(
|
||||
{"key": key, "label": "Mi etiqueta", "definition": own_def})
|
||||
assert label == "Mi etiqueta"
|
||||
assert definition == own_def
|
||||
|
||||
|
||||
def test_resolve_term_unknown_key_returns_as_is():
|
||||
label, definition = _resolve_term(
|
||||
{"key": "sin_baseline", "label": "Término libre", "definition": "Texto."})
|
||||
assert label == "Término libre"
|
||||
assert definition == "Texto."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Edges / degradation — the chapter disappears instead of raising.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_none_when_no_glossary():
|
||||
assert build_glosario({"table": "x"}, {}) is None
|
||||
assert build_glosario({"table": "x"}, None) is None
|
||||
|
||||
|
||||
def test_none_when_empty_collector():
|
||||
assert build_glosario({"table": "x"}, {"glossary": GlossaryCollector()}) is None
|
||||
|
||||
|
||||
def test_none_when_glossary_is_not_a_collector():
|
||||
# A stray value in ctx['glossary'] must not be treated as a collector.
|
||||
assert build_glosario({"table": "x"}, {"glossary": ["not", "a", "collector"]}) is None
|
||||
assert build_glosario({"table": "x"}, {"glossary": {"entropia": "x"}}) is None
|
||||
|
||||
|
||||
def test_none_profile_does_not_raise():
|
||||
# The glossary ignores the profile; a None profile with a valid collector still
|
||||
# builds, and a None profile with no glossary still returns None (no crash).
|
||||
g = GlossaryCollector()
|
||||
g.add("entropia", "Entropía", "def")
|
||||
chapter = build_glosario(None, {"glossary": g})
|
||||
assert isinstance(chapter, Chapter)
|
||||
assert build_glosario(None, None) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Click target — each entry carries its registration key.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_entries_carry_registered_key_as_click_target():
|
||||
g = _collector_three_terms()
|
||||
chapter = build_glosario({}, {"glossary": g})
|
||||
keys = {e.key for e in _entries(chapter)}
|
||||
assert keys == {"entropia", "pagina_categorica", "histograma_boxplot"}
|
||||
@@ -45,7 +45,10 @@ from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
# 1.0.1 — keep-together: el ranking "Faltantes por columna" (su Heading + tabla +
|
||||
# figura) se envuelve en un model.Group para que el paginador no separe la figura
|
||||
# de su título/tabla (el heatmap de co-ocurrencia ya iba agrupado).
|
||||
CHAPTER_VERSION = "1.0.1"
|
||||
CHAPTER_ID = "missingness"
|
||||
CHAPTER_TITLE = "Datos faltantes"
|
||||
|
||||
@@ -547,14 +550,22 @@ def build_missingness(profile: dict, ctx: dict):
|
||||
model.Heading(text="Cuánto y dónde faltan datos", level=2),
|
||||
_intro_block(mark, source),
|
||||
_summary_block(profile, with_nulls, overview, sampled, n_total),
|
||||
model.Heading(text="Faltantes por columna", level=2),
|
||||
]
|
||||
|
||||
# Ranking "Faltantes por columna": keep the heading, its table and the bar
|
||||
# figure together on the same page/slide (keep-together) so the paginator never
|
||||
# strands the figure from its title/table. When there is no figure to draw, the
|
||||
# unit degrades honestly and stays flat (never a Group around a missing figure).
|
||||
rank_unit = [model.Heading(text="Faltantes por columna", level=2)]
|
||||
ranking = _ranking_block(with_nulls)
|
||||
if ranking is not None:
|
||||
blocks.append(ranking)
|
||||
rank_unit.append(ranking)
|
||||
rank_fig = _ranking_figure(with_nulls)
|
||||
if rank_fig is not None:
|
||||
blocks.append(rank_fig)
|
||||
rank_unit.append(rank_fig)
|
||||
blocks.append(model.Group(blocks=rank_unit))
|
||||
else:
|
||||
blocks.extend(rank_unit)
|
||||
|
||||
# Co-occurrence + row patterns need the per-row mask. Without it, say so.
|
||||
if not mask:
|
||||
|
||||
@@ -45,7 +45,10 @@ from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
# 1.0.1 — keep-together: cada gráfico (scree PCA, scatter KMeans) se envuelve con
|
||||
# su Heading + su Markdown introductorio en un model.Group para que el paginador
|
||||
# no separe el gráfico de su título/descripción.
|
||||
CHAPTER_VERSION = "1.0.1"
|
||||
CHAPTER_ID = "modelos"
|
||||
CHAPTER_TITLE = "Modelos"
|
||||
|
||||
@@ -326,7 +329,6 @@ def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
|
||||
if not _is_dict(pca) or not pca.get("explained_variance_ratio"):
|
||||
return []
|
||||
_register(gloss, "pca")
|
||||
blocks = [model.Heading(text="PCA — varianza explicada", level=2)]
|
||||
|
||||
n_used = pca.get("n_rows_used")
|
||||
n_feat = pca.get("n_features")
|
||||
@@ -337,12 +339,20 @@ def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
|
||||
"muestra cuánta varianza aporta cada componente y su acumulado: un "
|
||||
"codo marca cuántos componentes bastan."
|
||||
)
|
||||
blocks.append(model.Markdown(text=intro))
|
||||
|
||||
# Keep-together: the heading, its intro and the scree figure ride together on
|
||||
# the same page/slide (the renderers measure the whole Group and move it whole
|
||||
# if it does not fit), so the scree never gets stranded from its title. The
|
||||
# variance/loadings tables (split-safe) flow after the group.
|
||||
unit = [model.Heading(text="PCA — varianza explicada", level=2),
|
||||
model.Markdown(text=intro)]
|
||||
scree = _make_scree(pca)
|
||||
if scree is not None:
|
||||
blocks.append(model.Figure(
|
||||
unit.append(model.Figure(
|
||||
make=scree, caption="Varianza explicada y acumulada por componente."))
|
||||
blocks = [model.Group(blocks=unit)]
|
||||
else:
|
||||
blocks = list(unit)
|
||||
|
||||
evr = pca.get("explained_variance_ratio") or []
|
||||
cum = pca.get("cumulative") or []
|
||||
@@ -390,8 +400,6 @@ def _kmeans_section(kmeans: dict, projection: dict, titles,
|
||||
_register(gloss, "kmeans")
|
||||
_register(gloss, "silhouette")
|
||||
|
||||
blocks = [model.Heading(text="Segmentación (KMeans)", level=2)]
|
||||
|
||||
best_k = (projection or {}).get("best_k") or (kmeans or {}).get("best_k")
|
||||
sil = (projection or {}).get("silhouette")
|
||||
if sil is None:
|
||||
@@ -404,26 +412,31 @@ def _kmeans_section(kmeans: dict, projection: dict, titles,
|
||||
f"(**{_fmt_num(sil)}**). Los segmentos se proyectan sobre el plano de "
|
||||
"los dos primeros componentes principales para visualizarlos."
|
||||
)
|
||||
blocks.append(model.Markdown(text=intro))
|
||||
head = model.Heading(text="Segmentación (KMeans)", level=2)
|
||||
intro_md = model.Markdown(text=intro)
|
||||
|
||||
if has_proj:
|
||||
scatter = _make_cluster_scatter(projection)
|
||||
if scatter is not None:
|
||||
blocks.append(model.Figure(
|
||||
scatter = _make_cluster_scatter(projection) if has_proj else None
|
||||
if scatter is not None:
|
||||
# Keep-together: heading + intro + the cluster scatter on one page/slide.
|
||||
blocks = [model.Group(blocks=[
|
||||
head, intro_md,
|
||||
model.Figure(
|
||||
make=scatter,
|
||||
caption="Cada punto es una fila coloreada por su segmento "
|
||||
"KMeans; las «X» son los centroides."))
|
||||
else:
|
||||
blocks.append(model.Note(
|
||||
"Proyección de clusters no dibujable (puntos y etiquetas "
|
||||
"desalineados)."))
|
||||
"KMeans; las «X» son los centroides.")])]
|
||||
elif has_proj:
|
||||
# Points present but not drawable: honest note, kept flat (never a Group
|
||||
# wrapping a missing figure).
|
||||
blocks = [head, intro_md, model.Note(
|
||||
"Proyección de clusters no dibujable (puntos y etiquetas "
|
||||
"desalineados).")]
|
||||
else:
|
||||
# We have kmeans stats but no aligned points+labels to colour by.
|
||||
blocks.append(model.Note(
|
||||
blocks = [head, intro_md, model.Note(
|
||||
"Scatter coloreado por segmento no disponible: el perfil no incluye "
|
||||
"la proyección con etiquetas alineadas (pásala en "
|
||||
"ctx['cluster_projection'] o las columnas crudas en "
|
||||
"ctx['raw_numeric'] para colorear el plano PCA)."))
|
||||
"ctx['raw_numeric'] para colorear el plano PCA).")]
|
||||
|
||||
# Cluster sizes table.
|
||||
sizes = (projection or {}).get("cluster_sizes") or (kmeans or {}).get("cluster_sizes") or []
|
||||
|
||||
@@ -136,6 +136,19 @@ def _pptx_text(path: str) -> str:
|
||||
return re.sub(r"\s+", " ", " ".join(out))
|
||||
|
||||
|
||||
def _flat(chapter):
|
||||
"""All blocks, descending into keep-together Groups (mejora keep-together):
|
||||
the scree/scatter figures now ride inside a model.Group with their heading and
|
||||
intro, so the assertions look for them inside the group too."""
|
||||
out = []
|
||||
for b in chapter.blocks:
|
||||
if getattr(b, "kind", None) == "group":
|
||||
out.extend(b.blocks)
|
||||
else:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Golden.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -143,13 +156,14 @@ def test_golden_build_modelos_bloques_requeridos():
|
||||
ch = build_modelos(_profile(), _ctx_full())
|
||||
assert ch is not None
|
||||
assert ch.id == "modelos" and ch.version
|
||||
# Both figures present: scree plot + cluster scatter.
|
||||
n_figures = sum(1 for b in ch.blocks if isinstance(b, Figure))
|
||||
flat = _flat(ch)
|
||||
# Both figures present: scree plot + cluster scatter (inside their Groups).
|
||||
n_figures = sum(1 for b in flat if isinstance(b, Figure))
|
||||
assert n_figures >= 2
|
||||
# Tables present (variance, loadings, sizes, normality).
|
||||
assert sum(1 for b in ch.blocks if isinstance(b, DataTable)) >= 3
|
||||
assert sum(1 for b in flat if isinstance(b, DataTable)) >= 3
|
||||
# Markdown carries the required explanations.
|
||||
md = " ".join(b.text for b in ch.blocks if isinstance(b, Markdown))
|
||||
md = " ".join(b.text for b in flat if isinstance(b, Markdown))
|
||||
assert "z-score" in md # normalization explained
|
||||
assert "Isolation Forest" in md # outlier generation explained
|
||||
assert "silhouette" in md # kmeans
|
||||
@@ -272,11 +286,11 @@ def test_glosario_engancha_terminos_modelos():
|
||||
assert ch is not None
|
||||
keys = {t["key"] for t in g.terms()}
|
||||
assert {"zscore", "pca", "kmeans", "silhouette", "isolation_forest"} <= keys
|
||||
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||
body = " ".join(b.text for b in _flat(ch) if b.kind == "markdown")
|
||||
for k in ("zscore", "pca", "kmeans", "silhouette", "isolation_forest"):
|
||||
assert f"[[term:{k}]]" in body, k
|
||||
|
||||
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||
ch2 = build_modelos(_profile(), _ctx_full())
|
||||
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||
body2 = " ".join(b.text for b in _flat(ch2) if b.kind == "markdown")
|
||||
assert "[[term:" not in body2
|
||||
|
||||
@@ -35,10 +35,21 @@ try:
|
||||
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
|
||||
build_boxplot_stats = None # type: ignore[assignment]
|
||||
|
||||
CHAPTER_VERSION = "1.2.0"
|
||||
CHAPTER_VERSION = "1.4.0"
|
||||
CHAPTER_ID = "num_distr"
|
||||
CHAPTER_TITLE = "Distribuciones numéricas"
|
||||
|
||||
# Glossary term this chapter explains. The long "how to read the histogram and
|
||||
# the boxplot" paragraph used to live inline in the intro; it now lives in the
|
||||
# GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``) and the
|
||||
# intro only names the clickable term — one click jumps to the full explanation,
|
||||
# so the information is relocated, not lost (mejora glosario).
|
||||
_TERM_HISTOBOX_KEY = "histograma_boxplot"
|
||||
_TERM_HISTOBOX_LABEL = "Cómo leer el histograma y el boxplot"
|
||||
|
||||
# Key under which eda_llm_insights stores its interpretive block in the profile.
|
||||
LLM_KEY = "llm"
|
||||
|
||||
# Plain-Spanish gloss for every label ``detect_distribution_type`` can emit, so a
|
||||
# non-expert reader understands the shape and the suggested next step (MUST-4.3).
|
||||
_DIST_GLOSS = {
|
||||
@@ -99,6 +110,53 @@ def _numeric_columns(profile: dict) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _llm_index(profile: dict, ctx: dict) -> dict:
|
||||
"""Map column name -> its LLM dictionary entry (description/unit/...).
|
||||
|
||||
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
|
||||
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
|
||||
dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
|
||||
defensive: never raises on malformed input.
|
||||
"""
|
||||
llm = profile.get(LLM_KEY)
|
||||
if not isinstance(llm, dict):
|
||||
llm = ctx.get(LLM_KEY)
|
||||
if not isinstance(llm, dict):
|
||||
return {}
|
||||
entries = llm.get("dictionary")
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
return {}
|
||||
index: dict = {}
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
col = e.get("column")
|
||||
if col is None:
|
||||
continue
|
||||
index[model._safe_str(col)] = e
|
||||
return index
|
||||
|
||||
|
||||
def _llm_desc_unit_block(name: str, llm_index: dict):
|
||||
"""Markdown block with the LLM business description + unit of a column, or
|
||||
None when no LLM entry matches the column (clean fallback without LLM)."""
|
||||
entry = llm_index.get(model._safe_str(name))
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
raw_desc = entry.get("description") or entry.get("business_meaning")
|
||||
desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
|
||||
raw_unit = entry.get("unit")
|
||||
unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
|
||||
parts = []
|
||||
if desc:
|
||||
parts.append(f"**Descripción:** {desc}")
|
||||
if unit:
|
||||
parts.append(f"**Unidad:** {unit}")
|
||||
if not parts:
|
||||
return None
|
||||
return model.Markdown(text=" · ".join(parts))
|
||||
|
||||
|
||||
def _make_hist_box(name: str, numeric: dict, box: dict):
|
||||
"""Build the histogram (with mean/median/±σ lines) + boxplot figure.
|
||||
|
||||
@@ -217,6 +275,69 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
|
||||
return fig
|
||||
|
||||
|
||||
def _make_hist_clipped(name: str, numeric: dict):
|
||||
"""Histogram of the central mass with the outliers trimmed away.
|
||||
|
||||
Companion to :func:`_make_hist_box`: same column, re-binned over the Tukey
|
||||
inner-fence range [Q1-1.5*IQR, Q3+1.5*IQR] (precomputed in ``describe_numeric``
|
||||
as ``histogram_clipped``), so the bulk of the distribution stays readable when
|
||||
a long tail would otherwise crush the scale. Only the reference median is drawn
|
||||
— it always falls inside the fence range by construction — because mean/±σ were
|
||||
already shown on the full histogram above and could sit outside the clip.
|
||||
"""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6.4, 2.6))
|
||||
hist = numeric.get("histogram_clipped") or []
|
||||
drew_bars = False
|
||||
for b in hist:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
lo = b.get("lo")
|
||||
hi = b.get("hi")
|
||||
count = b.get("count") or 0
|
||||
if lo is None or hi is None:
|
||||
continue
|
||||
width = (hi - lo) if hi > lo else max(abs(lo) * 1e-3, 1e-6)
|
||||
ax.bar(lo, count, width=width, align="edge", color="#b7d7a8",
|
||||
edgecolor="#6a9a5b", linewidth=0.4, zorder=2)
|
||||
drew_bars = True
|
||||
|
||||
median = numeric.get("median")
|
||||
if drew_bars and median is not None:
|
||||
lo0 = hist[0].get("lo")
|
||||
hi1 = hist[-1].get("hi")
|
||||
if lo0 is not None and hi1 is not None and lo0 <= median <= hi1:
|
||||
ax.axvline(median, color="#2e8b57", linestyle="-", linewidth=1.4,
|
||||
zorder=4, label=f"mediana = {_fmt_num(median)}")
|
||||
ax.legend(fontsize=6.5, loc="upper right", framealpha=0.85)
|
||||
if not drew_bars:
|
||||
ax.text(0.5, 0.5, "(sin histograma recortado)", ha="center",
|
||||
va="center", fontsize=9, color="#8a8a8a",
|
||||
transform=ax.transAxes)
|
||||
|
||||
ax.set_ylabel("frecuencia", fontsize=8)
|
||||
ax.set_xlabel(name, fontsize=8)
|
||||
ax.tick_params(labelsize=7)
|
||||
for spine in ("top", "right"):
|
||||
ax.spines[spine].set_visible(False)
|
||||
fig.suptitle(f"{name} — vista central (sin outliers)", fontsize=10,
|
||||
fontweight="bold", x=0.02, ha="left")
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def _clipped_figure_maker(name: str, numeric: dict):
|
||||
"""Bind the per-column arguments so the lazy closure is loop-safe."""
|
||||
def _make():
|
||||
return _make_hist_clipped(name, numeric)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
def _stats_note(name: str, numeric: dict, box: dict) -> str:
|
||||
"""One compact line of the key numbers + a plain-Spanish shape gloss."""
|
||||
bits = [
|
||||
@@ -271,15 +392,26 @@ def build_num_distr(profile: dict, ctx: dict):
|
||||
if not numerics:
|
||||
return None # chapter does not apply to a dataset with no numerics.
|
||||
|
||||
# Register the "how to read the histogram and boxplot" term in the shared
|
||||
# glossary collector (if present) and mark its first appearance clickable. The
|
||||
# full explanation (colour code, 1,5·IQR rule, asymmetry reading) lives in the
|
||||
# GLOSARIO chapter instead of inline here: the intro only names the term.
|
||||
glossary = ctx.get("glossary")
|
||||
mark_term = False
|
||||
if isinstance(glossary, model.GlossaryCollector):
|
||||
glossary.add(_TERM_HISTOBOX_KEY, _TERM_HISTOBOX_LABEL)
|
||||
mark_term = True
|
||||
como_leer = ("[[term:histograma_boxplot]]cómo leer estos gráficos[[/term]]"
|
||||
if mark_term else "cómo leer estos gráficos")
|
||||
intro = (
|
||||
"Para cada columna numérica se muestra su **histograma** con tres líneas "
|
||||
"de referencia: la **media** (línea roja discontinua), la **mediana** "
|
||||
"(línea verde continua) y la banda **±1σ** (zona sombreada). Debajo, "
|
||||
"alineado al mismo eje, un **boxplot de Tukey**: la caja abarca del "
|
||||
"primer al tercer cuartil (P25–P75), la línea interior es la mediana y "
|
||||
"los bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay "
|
||||
"valores más allá de las vallas. Comparar media y mediana revela la "
|
||||
"asimetría de la distribución.")
|
||||
"Cada columna numérica muestra su **histograma** (con la **media**, la "
|
||||
"**mediana** y la banda **±1σ**) y, debajo y al mismo eje, su **boxplot "
|
||||
f"de Tukey** — {como_leer}.")
|
||||
|
||||
# Business description + unit per column come from the LLM dictionary
|
||||
# (profile['llm']['dictionary'], matched by column name); absent without
|
||||
# run_llm, in which case the per-column description block is simply omitted.
|
||||
llm_index = _llm_index(profile, ctx)
|
||||
|
||||
blocks = [
|
||||
model.Heading(text=CHAPTER_TITLE, level=1),
|
||||
@@ -293,17 +425,30 @@ def build_num_distr(profile: dict, ctx: dict):
|
||||
box = build_boxplot_stats(numeric) or {}
|
||||
except Exception: # noqa: BLE001 — degrade, never raise.
|
||||
box = {}
|
||||
# Keep the column heading, its figure and its stats note together on the
|
||||
# same page/slide (mejora 3 — keep-together): the renderers measure the
|
||||
# whole Group and move it whole when it would not fit.
|
||||
blocks.append(model.Group(blocks=[
|
||||
model.Heading(text=str(name), level=2),
|
||||
model.Figure(
|
||||
make=_figure_maker(name, numeric, box),
|
||||
caption=f"Distribución de «{name}» — histograma "
|
||||
f"(media/mediana/±σ) y boxplot."),
|
||||
model.Markdown(text=_stats_note(name, numeric, box)),
|
||||
]))
|
||||
# Keep the column heading, its (optional) LLM description, its figure and
|
||||
# its stats note together on the same page/slide (mejora 3 —
|
||||
# keep-together): the renderers measure the whole Group and move it whole
|
||||
# when it would not fit.
|
||||
col_blocks = [model.Heading(text=str(name), level=2)]
|
||||
desc_block = _llm_desc_unit_block(name, llm_index)
|
||||
if desc_block is not None:
|
||||
col_blocks.append(desc_block)
|
||||
col_blocks.append(model.Figure(
|
||||
make=_figure_maker(name, numeric, box),
|
||||
caption=f"Distribución de «{name}» — histograma "
|
||||
f"(media/mediana/±σ) y boxplot."))
|
||||
# Second view: the central mass with the outliers trimmed (Tukey fences).
|
||||
# Only added when describe_numeric produced a non-empty histogram_clipped
|
||||
# (i.e. the clip actually removed tail values), and stays inside the same
|
||||
# keep-together Group so it never drifts to another page from its heading.
|
||||
if numeric.get("histogram_clipped"):
|
||||
col_blocks.append(model.Figure(
|
||||
make=_clipped_figure_maker(name, numeric),
|
||||
caption=f"«{name}» — vista central con los atípicos recortados "
|
||||
f"(vallas de Tukey 1,5·IQR); útil cuando la cola larga "
|
||||
f"aplasta la escala del histograma completo."))
|
||||
col_blocks.append(model.Markdown(text=_stats_note(name, numeric, box)))
|
||||
blocks.append(model.Group(blocks=col_blocks))
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
|
||||
@@ -101,7 +101,7 @@ def test_golden_chapter_estructura_y_bloques():
|
||||
|
||||
|
||||
def test_golden_media_mediana_sigma_y_boxplot_presentes():
|
||||
# The intro documents the three reference lines and the Tukey boxplot; the
|
||||
# The short intro names the three reference lines and the Tukey boxplot; the
|
||||
# per-column note carries the actual mean/median/σ numbers and the shape.
|
||||
ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
|
||||
md_texts = " ".join(b.text for b in _flatten(ch.blocks)
|
||||
@@ -110,10 +110,58 @@ def test_golden_media_mediana_sigma_y_boxplot_presentes():
|
||||
assert "±1σ" in md_texts or "σ" in md_texts
|
||||
assert "boxplot" in md_texts.lower()
|
||||
assert "Tukey" in md_texts
|
||||
# The long "how to read it" explanation moved to the glossary: the colour-code
|
||||
# / 1,5·IQR walkthrough is no longer inline in the chapter body.
|
||||
assert "1,5·IQR" not in md_texts
|
||||
assert "línea roja" not in md_texts
|
||||
# distribution_type gloss surfaced for the column (right-skewed preset).
|
||||
assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts
|
||||
|
||||
|
||||
def test_glosario_histograma_boxplot_clicable_y_definicion():
|
||||
# With a glossary collector the intro marks the clickable term and the FULL
|
||||
# explanation (the long paragraph removed from the body) lands in the glossary.
|
||||
from datascience.automatic_eda.chapters.glosario import build_glosario
|
||||
|
||||
gc = model.GlossaryCollector()
|
||||
prof = _profile(n_numeric=1, extra_categorical=False)
|
||||
ch = build_num_distr(prof, {"glossary": gc})
|
||||
intro = next(b for b in ch.blocks if b.kind == "markdown")
|
||||
assert "[[term:histograma_boxplot]]" in intro.text
|
||||
assert gc.has("histograma_boxplot")
|
||||
glos = build_glosario(prof, {"glossary": gc})
|
||||
entry = next(b for b in glos.blocks
|
||||
if getattr(b, "kind", "") == "glossary_entry"
|
||||
and b.key == "histograma_boxplot")
|
||||
assert "boxplot" in entry.definition.lower()
|
||||
assert "1,5·IQR" in entry.definition
|
||||
|
||||
|
||||
def test_llm_descripcion_y_unidad_por_columna():
|
||||
# With an LLM dictionary, each numeric column whose name matches shows its
|
||||
# business description and unit in a per-column markdown block.
|
||||
prof = _profile(n_numeric=2)
|
||||
prof["llm"] = {"dictionary": [
|
||||
{"column": "precio", "description": "Precio de venta del producto",
|
||||
"unit": "EUR"},
|
||||
{"column": "alcohol", "business_meaning": "Grado alcohólico",
|
||||
"unit": "% vol"},
|
||||
]}
|
||||
ch = build_num_distr(prof, {})
|
||||
md_all = " ".join(b.text for b in _flatten(ch.blocks)
|
||||
if b.kind == "markdown")
|
||||
assert "Precio de venta" in md_all and "EUR" in md_all
|
||||
assert "Grado alcohólico" in md_all and "% vol" in md_all
|
||||
|
||||
|
||||
def test_edge_sin_llm_no_anade_descripcion():
|
||||
# Without an LLM block the per-column description markdown is simply omitted.
|
||||
ch = build_num_distr(_profile(n_numeric=2), {})
|
||||
md_all = " ".join(b.text for b in _flatten(ch.blocks)
|
||||
if b.kind == "markdown")
|
||||
assert "Descripción" not in md_all
|
||||
|
||||
|
||||
def test_boxplot_stats_se_consumen_del_registry():
|
||||
# The chapter must feed build_boxplot_stats (group eda) and the resulting
|
||||
# box must carry the Tukey fences for the figure.
|
||||
|
||||
@@ -7,11 +7,21 @@ as needed, the renderers paginate):
|
||||
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
|
||||
2. Column dictionary — name / type / nulls / non-null examples plus, when the
|
||||
LLM layer ran, the business **description** and **unit** of each column so the
|
||||
reader knows at a glance what every column is and in which unit. 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.
|
||||
3. ``df.describe`` — mean / median / min / max / std for every numeric column,
|
||||
plus its **unit** (same LLM source) so the stats read in context.
|
||||
|
||||
The description/unit come from the ``llm`` block that ``eda_llm_insights`` (group
|
||||
``eda``) already stored in the profile (``profile['llm']['dictionary']``, a list
|
||||
of ``{"column","description","business_meaning","unit"}`` entries) — this chapter
|
||||
only **consumes** it, matching by column name; it never calls the LLM nor
|
||||
recomputes anything. When the block is absent (``run_llm`` did not run) those
|
||||
cells degrade to ``"—"`` and the tables still render.
|
||||
|
||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||
"""
|
||||
@@ -20,13 +30,59 @@ from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.1.0"
|
||||
CHAPTER_VERSION = "1.2.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
|
||||
LLM_KEY = "llm" # interpretive block from eda_llm_insights
|
||||
|
||||
|
||||
def _llm_dict_index(profile: dict, ctx: dict) -> dict:
|
||||
"""Map column name -> its LLM dictionary entry (description/unit/...).
|
||||
|
||||
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
|
||||
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
|
||||
dict when no LLM block ran, so the caller degrades to "—" cells. Fully
|
||||
defensive: never raises on malformed input.
|
||||
"""
|
||||
llm = profile.get(LLM_KEY)
|
||||
if not isinstance(llm, dict):
|
||||
llm = ctx.get(LLM_KEY)
|
||||
if not isinstance(llm, dict):
|
||||
return {}
|
||||
entries = llm.get("dictionary")
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
return {}
|
||||
index: dict = {}
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
col = e.get("column")
|
||||
if col is None:
|
||||
continue
|
||||
index[model._safe_str(col)] = e
|
||||
return index
|
||||
|
||||
|
||||
def _llm_desc(entry) -> str:
|
||||
"""Business description of a column from its LLM entry, or "—"."""
|
||||
if not isinstance(entry, dict):
|
||||
return "—"
|
||||
raw = entry.get("description") or entry.get("business_meaning")
|
||||
text = " ".join(model._safe_str(raw).split()) if raw is not None else ""
|
||||
return text or "—"
|
||||
|
||||
|
||||
def _llm_unit(entry) -> str:
|
||||
"""Unit of a column from its LLM entry, or "—"."""
|
||||
if not isinstance(entry, dict):
|
||||
return "—"
|
||||
raw = entry.get("unit")
|
||||
text = " ".join(model._safe_str(raw).split()) if raw is not None else ""
|
||||
return text or "—"
|
||||
|
||||
|
||||
def _fmt_num(value, decimals: int = 3) -> str:
|
||||
@@ -104,9 +160,12 @@ def _head_block(profile: dict, ctx: dict):
|
||||
"pasarlo en ctx['head_rows'] para mostrar las primeras filas.")
|
||||
|
||||
|
||||
def _columns_block(profile: dict):
|
||||
def _columns_block(profile: dict, llm_index: dict):
|
||||
cols = profile.get("columns") or []
|
||||
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)"]
|
||||
# Descripción / Unidad come from the LLM dictionary (matched by column name);
|
||||
# they read "—" when run_llm did not run, so the table always renders.
|
||||
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
|
||||
"Descripción", "Unidad"]
|
||||
rows = []
|
||||
for c in cols:
|
||||
if not isinstance(c, dict):
|
||||
@@ -126,15 +185,18 @@ def _columns_block(profile: dict):
|
||||
nulls = str(null_count)
|
||||
else:
|
||||
nulls = "—"
|
||||
rows.append([name, ctype, nulls, _examples_for(c)])
|
||||
entry = llm_index.get(model._safe_str(name))
|
||||
rows.append([name, ctype, nulls, _examples_for(c),
|
||||
_llm_desc(entry), _llm_unit(entry)])
|
||||
if not rows:
|
||||
return None
|
||||
return model.DataTable(header=header, rows=rows, title="Columnas")
|
||||
|
||||
|
||||
def _describe_block(profile: dict):
|
||||
def _describe_block(profile: dict, llm_index: dict):
|
||||
cols = profile.get("columns") or []
|
||||
header = ["Columna", "mean", "median", "min", "max", "std"]
|
||||
# "Unidad" (LLM source) lets the reader know in which unit each stat is.
|
||||
header = ["Columna", "mean", "median", "min", "max", "std", "Unidad"]
|
||||
rows = []
|
||||
for c in cols:
|
||||
if not isinstance(c, dict) or c.get("inferred_type") != "numeric":
|
||||
@@ -142,13 +204,16 @@ def _describe_block(profile: dict):
|
||||
num = c.get("numeric") or {}
|
||||
if not num:
|
||||
continue
|
||||
name = c.get("name") or "(col)"
|
||||
entry = llm_index.get(model._safe_str(name))
|
||||
rows.append([
|
||||
c.get("name") or "(col)",
|
||||
name,
|
||||
_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")),
|
||||
_llm_unit(entry),
|
||||
])
|
||||
if not rows:
|
||||
return None
|
||||
@@ -163,16 +228,18 @@ def build_overview(profile: dict, ctx: dict):
|
||||
if not cols and not (ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)):
|
||||
return None
|
||||
|
||||
llm_index = _llm_dict_index(profile, ctx)
|
||||
|
||||
blocks = [
|
||||
model.Heading(text="Primeras filas (df.head)", level=2),
|
||||
_head_block(profile, ctx),
|
||||
]
|
||||
cols_block = _columns_block(profile)
|
||||
cols_block = _columns_block(profile, llm_index)
|
||||
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)
|
||||
desc_block = _describe_block(profile, llm_index)
|
||||
if desc_block is not None:
|
||||
blocks.append(model.Heading(
|
||||
text="Resumen estadístico numérico", level=2))
|
||||
|
||||
@@ -56,7 +56,21 @@ def _head_rows() -> list:
|
||||
]
|
||||
|
||||
|
||||
def _profile(with_head: bool = True) -> dict:
|
||||
def _llm() -> dict:
|
||||
"""Interpretive block as eda_llm_insights stores it under profile['llm']."""
|
||||
return {
|
||||
"summary": "Pasajeros del Titanic.",
|
||||
"dictionary": [
|
||||
{"column": "PassengerId", "description": "Identificador del pasajero",
|
||||
"business_meaning": "Clave única de cada pasajero", "unit": "id"},
|
||||
{"column": "Pclass", "description": "Clase del billete",
|
||||
"business_meaning": "Clase socioeconómica", "unit": "clase (1-3)"},
|
||||
# No entry for Survived/Name/Sex on purpose -> they degrade to "—".
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _profile(with_head: bool = True, with_llm: bool = False) -> dict:
|
||||
prof = {
|
||||
"table": "titanic",
|
||||
"source": "/data/titanic.csv",
|
||||
@@ -68,6 +82,8 @@ def _profile(with_head: bool = True) -> dict:
|
||||
}
|
||||
if with_head:
|
||||
prof["head_rows"] = _head_rows()
|
||||
if with_llm:
|
||||
prof["llm"] = _llm()
|
||||
return prof
|
||||
|
||||
|
||||
@@ -185,3 +201,70 @@ def test_edge_none_y_vacio_no_rompen():
|
||||
assert ch is not None
|
||||
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
|
||||
assert tables and len(tables[0].rows) == 3
|
||||
|
||||
|
||||
def _table_by_header(blocks, marker: str):
|
||||
"""Return the first DataTable whose header contains ``marker``."""
|
||||
for b in _flatten(blocks):
|
||||
if isinstance(b, DataTable) and marker in b.header:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
def test_golden_diccionario_lleva_descripcion_y_unidad_del_llm():
|
||||
# With run_llm: the column dictionary gains "Descripción" and "Unidad"
|
||||
# columns populated from profile['llm']['dictionary'], matched by name.
|
||||
ch = build_overview(_profile(with_llm=True), {})
|
||||
assert ch is not None
|
||||
dic = _table_by_header(ch.blocks, "Descripción")
|
||||
assert dic is not None
|
||||
assert dic.header == ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
|
||||
"Descripción", "Unidad"]
|
||||
by_name = {row[0]: row for row in dic.rows}
|
||||
# PassengerId has an LLM entry -> description + unit populated.
|
||||
assert by_name["PassengerId"][4] == "Identificador del pasajero"
|
||||
assert by_name["PassengerId"][5] == "id"
|
||||
assert by_name["Pclass"][5] == "clase (1-3)"
|
||||
# Columns with no LLM entry degrade to "—" without breaking the row.
|
||||
assert by_name["Survived"][4] == "—" and by_name["Survived"][5] == "—"
|
||||
|
||||
|
||||
def test_golden_describe_lleva_unidad_del_llm():
|
||||
ch = build_overview(_profile(with_llm=True), {})
|
||||
desc = _table_by_header(ch.blocks, "std")
|
||||
assert desc is not None
|
||||
assert desc.header[-1] == "Unidad"
|
||||
by_name = {row[0]: row for row in desc.rows}
|
||||
assert by_name["PassengerId"][-1] == "id"
|
||||
assert by_name["Pclass"][-1] == "clase (1-3)"
|
||||
# Numeric column with no LLM unit still renders, unit "—".
|
||||
assert by_name["Survived"][-1] == "—"
|
||||
|
||||
|
||||
def test_edge_sin_llm_descripcion_unidad_son_guion():
|
||||
# No profile['llm'] at all: the new cells degrade to "—" and nothing breaks.
|
||||
ch = build_overview(_profile(), {})
|
||||
assert ch is not None
|
||||
dic = _table_by_header(ch.blocks, "Unidad")
|
||||
assert dic is not None
|
||||
for row in dic.rows:
|
||||
assert row[4] == "—" and row[5] == "—"
|
||||
desc = _table_by_header(ch.blocks, "std")
|
||||
assert all(row[-1] == "—" for row in desc.rows)
|
||||
|
||||
|
||||
def test_golden_llm_via_ctx_tambien_funciona():
|
||||
# LLM block arriving through ctx['llm'] (fallback path) is consumed too.
|
||||
ch = build_overview(_profile(with_llm=False), {"llm": _llm()})
|
||||
dic = _table_by_header(ch.blocks, "Descripción")
|
||||
by_name = {row[0]: row for row in dic.rows}
|
||||
assert by_name["PassengerId"][5] == "id"
|
||||
|
||||
|
||||
def test_golden_render_pdf_muestra_descripcion_y_unidad():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "eda.pdf")
|
||||
render_automatic_eda_pdf(_profile(with_llm=True), out, {"title": "EDA"})
|
||||
txt = _pdf_text(out)
|
||||
assert "Descripción" in txt and "Unidad" in txt
|
||||
assert "Identificador del pasajero" in txt
|
||||
|
||||
@@ -26,7 +26,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.2.0"
|
||||
CHAPTER_VERSION = "1.4.0"
|
||||
CHAPTER_ID = "portada"
|
||||
CHAPTER_TITLE = "Portada"
|
||||
|
||||
@@ -35,12 +35,9 @@ CHAPTER_TITLE = "Portada"
|
||||
# row represents) from it when the LLM layer ran (``run_llm``).
|
||||
_LLM_KEY = "llm"
|
||||
|
||||
# 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)."
|
||||
)
|
||||
# Font size (pt) for the dataset name on the PPTX cover slide — notably larger
|
||||
# than the default H1 so the dataset name stands out (shown underlined too).
|
||||
_PPTX_TITLE_PT = 44.0
|
||||
|
||||
|
||||
def _storage_from_source(source: str) -> str:
|
||||
@@ -120,11 +117,20 @@ def _summary_blocks(summary) -> list:
|
||||
|
||||
blocks = [model.Heading(text="Resumen del análisis", level=2)]
|
||||
if rows:
|
||||
blocks.append(model.KVTable(rows=rows))
|
||||
# Values pinned to the right margin (numbers flush right, label left).
|
||||
blocks.append(model.KVTable(rows=rows, value_align="right"))
|
||||
if titles:
|
||||
bullets = "\n".join(f"- {model._safe_str(t)}" for t in titles)
|
||||
blocks.append(model.Markdown(
|
||||
text="Este informe incluye los siguientes capítulos:\n" + bullets))
|
||||
# Clickable index ("Índice"): one TocEntry per chapter title. Each entry
|
||||
# becomes a real jump to that chapter's first page/slide once the document
|
||||
# is laid out (the renderers register every chapter start and wire the
|
||||
# links; ``target_id`` is matched against the chapter title). The cover only
|
||||
# knows chapter titles, so the title doubles as the link target.
|
||||
blocks.append(model.Heading(text="Índice", level=2))
|
||||
for t in titles:
|
||||
label = model._safe_str(t)
|
||||
if not label:
|
||||
continue
|
||||
blocks.append(model.TocEntry(label=label, target_id=label))
|
||||
return blocks
|
||||
|
||||
|
||||
@@ -213,9 +219,7 @@ def _derive_description(profile: dict, ctx: dict) -> str:
|
||||
score = profile.get("quality_score")
|
||||
if score is not None:
|
||||
parts.append(f"Calidad media estimada: {score}/100.")
|
||||
parts.append(
|
||||
"Resumen derivado del perfil; active la interpretación LLM (`run_llm`) "
|
||||
"para una descripción de negocio más rica.")
|
||||
parts.append("Resumen derivado del perfil.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -259,7 +263,6 @@ def build_portada(profile: dict, ctx: dict):
|
||||
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"
|
||||
|
||||
llm = _llm_block(profile, ctx)
|
||||
@@ -282,8 +285,11 @@ def build_portada(profile: dict, ctx: dict):
|
||||
|
||||
# Title + dataset size shown together and BIG (Heading) at the top, kept on
|
||||
# the same page (Group). The size is no longer buried in the metadata table.
|
||||
# The dataset name is shown big and underlined on the PPTX cover slide
|
||||
# (size_pt/underline are honoured by the PPTX renderer; the PDF ignores them).
|
||||
cover = [
|
||||
model.Heading(text=str(dataset_name), level=1),
|
||||
model.Heading(text=str(dataset_name), level=1, underline=True,
|
||||
size_pt=_PPTX_TITLE_PT),
|
||||
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
|
||||
model.Heading(text=shape, level=2),
|
||||
]
|
||||
@@ -295,7 +301,6 @@ def build_portada(profile: dict, ctx: dict):
|
||||
("Almacenamiento", storage),
|
||||
("Generado", when),
|
||||
("Calidad", quality_value),
|
||||
("Criterios de calidad", quality_criteria),
|
||||
]),
|
||||
model.Heading(text="Descripción", level=2),
|
||||
model.Markdown(text=str(description)),
|
||||
|
||||
@@ -58,7 +58,10 @@ try:
|
||||
except Exception: # noqa: BLE001
|
||||
resample_timeseries = None # type: ignore[assignment]
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
# 1.0.1 — keep-together: cada serie (su Heading + figuras de evolución/STL/ACF +
|
||||
# análisis textual) se envuelve en un model.Group para que el paginador no separe
|
||||
# los gráficos de su título/descripción. Una serie = un grupo.
|
||||
CHAPTER_VERSION = "1.0.1"
|
||||
CHAPTER_ID = "timeseries"
|
||||
CHAPTER_TITLE = "Series temporales"
|
||||
|
||||
@@ -470,7 +473,12 @@ def _analysis_markdown(sblock: dict) -> str:
|
||||
# Per-column section.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _column_section(name: str, sblock: dict, raw: dict, collapsed_into) -> list:
|
||||
"""Blocks for one numeric column: evolution figure + STL + ACF + analysis."""
|
||||
"""Blocks for one numeric column: evolution figure + STL + ACF + analysis.
|
||||
|
||||
The whole series is wrapped in a single keep-together ``model.Group`` (a series
|
||||
= a group) so the renderers never strand the column heading / its analysis from
|
||||
the figures it introduces. Only real figures are ever appended (a missing
|
||||
figure is simply omitted — never a Group around a None figure)."""
|
||||
blocks = [model.Heading(text=model._safe_str(name), level=2)]
|
||||
|
||||
# --- Value-vs-time line + per-period row count (MUST-9.1). ---
|
||||
@@ -522,7 +530,8 @@ def _column_section(name: str, sblock: dict, raw: dict, collapsed_into) -> list:
|
||||
analysis = _analysis_markdown(sblock)
|
||||
if analysis:
|
||||
blocks.append(model.Markdown(text=analysis))
|
||||
return blocks
|
||||
# One series = one keep-together group (heading + figures + analysis).
|
||||
return [model.Group(blocks=blocks)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -112,6 +112,19 @@ def _pdf_text(path: str) -> str:
|
||||
return re.sub(r"\s+", " ", txt)
|
||||
|
||||
|
||||
def _flat(chapter):
|
||||
"""All blocks, descending into per-series keep-together Groups (mejora
|
||||
keep-together): each series' heading, figures and analysis now live inside a
|
||||
model.Group, so the assertions look for them inside the group too."""
|
||||
out = []
|
||||
for b in chapter.blocks:
|
||||
if getattr(b, "kind", None) == "group":
|
||||
out.extend(b.blocks)
|
||||
else:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Golden.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -124,8 +137,9 @@ def test_golden_estructura_y_figuras():
|
||||
assert kinds[0] == "heading" # chapter title
|
||||
assert kinds[1] == "markdown" # intro
|
||||
assert "kv_table" in kinds # datetime profile header (MUST-9.3)
|
||||
# Per column: evolution figure + STL figure + ACF figure + analysis markdown.
|
||||
figs = [b for b in ch.blocks if b.kind == "figure"]
|
||||
# Per column: evolution figure + STL figure + ACF figure + analysis markdown
|
||||
# (now inside the per-series Group).
|
||||
figs = [b for b in _flat(ch) if b.kind == "figure"]
|
||||
assert len(figs) >= 3, "evolución + STL + ACF esperadas"
|
||||
# Lazy makers must produce real matplotlib figures.
|
||||
import matplotlib.pyplot as plt
|
||||
@@ -138,7 +152,7 @@ def test_golden_estructura_y_figuras():
|
||||
def test_golden_evolucion_tiene_dos_paneles_valor_y_conteo():
|
||||
# MUST-9.1: the evolution figure has a value panel + a row-count panel.
|
||||
ch = build_timeseries(_profile(("precio",)), _ctx_raw(("precio",)))
|
||||
figs = [b for b in ch.blocks if b.kind == "figure"]
|
||||
figs = [b for b in _flat(ch) if b.kind == "figure"]
|
||||
import matplotlib.pyplot as plt
|
||||
fig = figs[0].make() # first figure is the evolution one.
|
||||
assert len(fig.axes) == 2, "panel de valor + panel de conteo de filas"
|
||||
@@ -147,7 +161,7 @@ def test_golden_evolucion_tiene_dos_paneles_valor_y_conteo():
|
||||
|
||||
def test_golden_analisis_textual_presente():
|
||||
ch = build_timeseries(_profile(("precio",)), _ctx_raw(("precio",)))
|
||||
md = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||
md = " ".join(b.text for b in _flat(ch) if b.kind == "markdown")
|
||||
assert "Estacionariedad" in md
|
||||
assert "Autocorrelación" in md
|
||||
assert "STL" in md
|
||||
@@ -183,9 +197,9 @@ def test_edge_sin_raw_degrada_pero_mantiene_analisis():
|
||||
# from the profile) and note that the evolution chart is unavailable.
|
||||
ch = build_timeseries(_profile(("precio",)), {})
|
||||
assert ch is not None
|
||||
notes = " ".join(b.text for b in ch.blocks if b.kind == "note")
|
||||
notes = " ".join(b.text for b in _flat(ch) if b.kind == "note")
|
||||
assert "evolución temporal no disponible" in notes
|
||||
md = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||
md = " ".join(b.text for b in _flat(ch) if b.kind == "markdown")
|
||||
assert "Estacionariedad" in md
|
||||
|
||||
|
||||
@@ -195,7 +209,7 @@ def test_edge_stl_solo_estadisticos_no_dibuja_panel_pero_no_revienta():
|
||||
ch = build_timeseries(_profile(("precio",), with_stl_values=False),
|
||||
_ctx_raw(("precio",)))
|
||||
assert ch is not None
|
||||
md = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||
md = " ".join(b.text for b in _flat(ch) if b.kind == "markdown")
|
||||
assert "STL" in md
|
||||
|
||||
|
||||
@@ -206,15 +220,15 @@ def test_ohlc_consolidacion():
|
||||
names = ("Open", "High", "Low", "Close")
|
||||
ch = build_timeseries(_profile(names), _ctx_raw(names))
|
||||
assert ch is not None
|
||||
notes = " ".join(b.text for b in ch.blocks if b.kind == "note")
|
||||
notes = " ".join(b.text for b in _flat(ch) if b.kind == "note")
|
||||
assert "OHLC" in notes
|
||||
# Only the representative draws the evolution figure; the other 3 are collapsed
|
||||
# so there are fewer evolution figures than columns.
|
||||
captions = [b.caption or "" for b in ch.blocks if b.kind == "figure"]
|
||||
captions = [b.caption or "" for b in _flat(ch) if b.kind == "figure"]
|
||||
evo = [c for c in captions if "Evolución" in c]
|
||||
assert len(evo) < len(names), "las series OHLC deben consolidarse"
|
||||
# Every column still has its analysis markdown (one heading per column).
|
||||
headings = [b.text for b in ch.blocks if b.kind == "heading" and b.level == 2]
|
||||
headings = [b.text for b in _flat(ch) if b.kind == "heading" and b.level == 2]
|
||||
for nm in names:
|
||||
assert nm in headings
|
||||
|
||||
@@ -227,7 +241,7 @@ def test_anti_corte_pdf_y_pptx():
|
||||
prof = _profile(names, n=90)
|
||||
ctx = _ctx_raw(names, n=90)
|
||||
ch = build_timeseries(prof, ctx)
|
||||
col_headings = [b.text for b in ch.blocks if b.kind == "heading" and b.level == 2]
|
||||
col_headings = [b.text for b in _flat(ch) if b.kind == "heading" and b.level == 2]
|
||||
assert len(col_headings) == 6
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
pdf = os.path.join(d, "ts.pdf")
|
||||
|
||||
@@ -73,24 +73,51 @@ def build_chapter(chapter_id: str, profile: dict, ctx: dict):
|
||||
return model.as_chapter(result)
|
||||
|
||||
|
||||
def build_document(profile: dict, ctx: dict = None) -> list:
|
||||
"""Build the full ordered list of chapters for a TableProfile.
|
||||
def build_document(profile: dict, ctx: dict = None, only: list = None) -> list:
|
||||
"""Build the 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, ...).
|
||||
only: optional list of chapter ids to render. ``None`` (default) keeps
|
||||
the historical behaviour — every implemented & applicable chapter in
|
||||
canonical order. A list restricts the BODY to just those ids (in
|
||||
canonical order), but the cover (``portada``) and glossary
|
||||
(``glosario``) are ALWAYS included so the document stays valid and
|
||||
the clickable terms keep a destination — so passing ``only=["x"]``
|
||||
yields portada + x + glosario. Unknown ids are simply skipped (the
|
||||
caller is responsible for strict validation). ``only=[]`` yields the
|
||||
minimal document (portada + glosario only). This argument is additive
|
||||
and backward-compatible: the signature is unchanged for existing
|
||||
callers (default ``None``).
|
||||
|
||||
Returns:
|
||||
list[Chapter] in canonical order, containing only the chapters that are
|
||||
implemented and applicable. Never raises.
|
||||
implemented, applicable and selected. Never raises.
|
||||
"""
|
||||
if not isinstance(profile, dict):
|
||||
profile = {}
|
||||
# Copy ctx so the shared collector / summary we add do not leak to the caller.
|
||||
ctx = dict(ctx) if isinstance(ctx, dict) else {}
|
||||
|
||||
# only=None -> all body chapters (historical). only=list -> restrict body to
|
||||
# that selection (portada/glosario are added unconditionally below). The
|
||||
# renderers call build_document(profile, meta['ctx']) without an `only`
|
||||
# argument, so the pipeline forwards the selection through a reserved ctx key
|
||||
# (``_only_chapters``); an explicit `only` argument always wins. The key is
|
||||
# popped from the local ctx copy so it never reaches the chapters.
|
||||
if only is None:
|
||||
_carried = ctx.pop("_only_chapters", None)
|
||||
if isinstance(_carried, (list, tuple, set)):
|
||||
only = list(_carried)
|
||||
else:
|
||||
ctx.pop("_only_chapters", None)
|
||||
# A set makes the membership test cheap; the iteration order stays
|
||||
# CHAPTER_ORDER. only=[] is a valid (empty) selection -> minimal document.
|
||||
only_set = set(only) if isinstance(only, (list, tuple, set)) else None
|
||||
|
||||
# A single glossary collector is shared by every chapter via ctx['glossary'].
|
||||
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
|
||||
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
|
||||
@@ -106,6 +133,10 @@ def build_document(profile: dict, ctx: dict = None) -> list:
|
||||
for cid in CHAPTER_ORDER:
|
||||
if cid in (_PORTADA, _GLOSARIO):
|
||||
continue
|
||||
# When a selection is given, skip body chapters outside it. portada and
|
||||
# glosario are never filtered (handled out of this loop).
|
||||
if only_set is not None and cid not in only_set:
|
||||
continue
|
||||
ch = build_chapter(cid, profile, ctx)
|
||||
if ch is not None and ch.blocks:
|
||||
body.append(ch)
|
||||
|
||||
@@ -38,10 +38,18 @@ ENGINE_NAME = "AutomaticEDA"
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass
|
||||
class Heading:
|
||||
"""A section heading. ``level`` 1 (largest) .. 3 (smallest)."""
|
||||
"""A section heading. ``level`` 1 (largest) .. 3 (smallest).
|
||||
|
||||
``underline`` and ``size_pt`` are optional emphasis hints honoured by the
|
||||
PPTX renderer (the cover uses them to show the dataset name big and
|
||||
underlined). ``size_pt`` overrides the per-level font size when set; the PDF
|
||||
renderer ignores both so its layout is unchanged.
|
||||
"""
|
||||
|
||||
text: str = ""
|
||||
level: int = 1
|
||||
underline: bool = False
|
||||
size_pt: Optional[float] = None
|
||||
kind: str = field(default="heading", init=False)
|
||||
|
||||
|
||||
@@ -62,10 +70,17 @@ class Markdown:
|
||||
|
||||
@dataclass
|
||||
class KVTable:
|
||||
"""A two-column key/value table. ``rows`` is a list of ``(label, value)``."""
|
||||
"""A two-column key/value table. ``rows`` is a list of ``(label, value)``.
|
||||
|
||||
``value_align`` controls the horizontal alignment of the value column in the
|
||||
PDF renderer: ``"left"`` (default) keeps values next to the label column;
|
||||
``"right"`` pins them to the right margin (used by the cover's analysis
|
||||
summary so the numbers line up flush right).
|
||||
"""
|
||||
|
||||
rows: list = field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
value_align: str = "left"
|
||||
kind: str = field(default="kv_table", init=False)
|
||||
|
||||
|
||||
@@ -145,11 +160,21 @@ class Group:
|
||||
a chapter can give each unit its own page — e.g. one categorical column per
|
||||
page (see CAT DISTR). It is purely additive: the default False keeps the plain
|
||||
keep-together behaviour for every existing chapter.
|
||||
|
||||
``layout`` is a hint for how the group's children are arranged:
|
||||
``"stack"`` (default) keeps the historical top-to-bottom flow; ``"side_by_side"``
|
||||
asks the PPTX renderer to place the group's table to the LEFT and its figure to
|
||||
the RIGHT of the same slide (table ~55% width, figure ~45%), measuring so both
|
||||
fit and falling back to stacking when they do not. The PDF renderer treats
|
||||
``"side_by_side"`` exactly like ``"stack"`` (the A5 mobile page is too narrow for
|
||||
two readable columns). Unknown values degrade to ``"stack"``. Purely additive:
|
||||
the default keeps every existing chapter unchanged.
|
||||
"""
|
||||
|
||||
blocks: list = field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
page_break_before: bool = False
|
||||
layout: str = "stack"
|
||||
kind: str = field(default="group", init=False)
|
||||
|
||||
|
||||
@@ -168,6 +193,22 @@ class GlossaryEntry:
|
||||
kind: str = field(default="glossary_entry", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TocEntry:
|
||||
"""One clickable index (table-of-contents) entry shown on the cover.
|
||||
|
||||
Rendered as a single line — the chapter ``label`` in the accent link colour —
|
||||
that, once the document is laid out, becomes a real click jumping to the first
|
||||
page/slide of the target chapter (PDF link annotation via PyMuPDF; PPTX native
|
||||
slide jump). ``target_id`` is matched against each chapter's ``id`` *and* its
|
||||
``title`` (the cover only knows chapter titles), so either resolves. If the
|
||||
target cannot be resolved the entry still renders as plain text (never cut)."""
|
||||
|
||||
label: str = ""
|
||||
target_id: str = ""
|
||||
kind: str = field(default="toc_entry", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chapter:
|
||||
"""An ordered set of blocks with an id, a title and a generation version."""
|
||||
@@ -192,13 +233,14 @@ _BLOCK_BY_KIND = {
|
||||
"note": Note,
|
||||
"group": Group,
|
||||
"glossary_entry": GlossaryEntry,
|
||||
"toc_entry": TocEntry,
|
||||
}
|
||||
|
||||
|
||||
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, Group, GlossaryEntry)):
|
||||
Caption, Note, Group, GlossaryEntry, TocEntry)):
|
||||
if isinstance(obj, Group):
|
||||
obj.blocks = as_blocks(obj.blocks)
|
||||
return obj
|
||||
@@ -210,13 +252,20 @@ def as_block(obj: Any):
|
||||
# Build only with fields the dataclass accepts (ignore extras).
|
||||
try:
|
||||
if cls is Heading:
|
||||
size_pt = obj.get("size_pt")
|
||||
return Heading(text=_safe_str(obj.get("text")),
|
||||
level=int(obj.get("level", 1) or 1))
|
||||
level=int(obj.get("level", 1) or 1),
|
||||
underline=bool(obj.get("underline", False)),
|
||||
size_pt=(float(size_pt)
|
||||
if isinstance(size_pt, (int, float))
|
||||
else None))
|
||||
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"))
|
||||
title=obj.get("title"),
|
||||
value_align=_safe_str(
|
||||
obj.get("value_align")) or "left")
|
||||
if cls is DataTable:
|
||||
return DataTable(header=list(obj.get("header") or []),
|
||||
rows=list(obj.get("rows") or []),
|
||||
@@ -237,11 +286,15 @@ def as_block(obj: Any):
|
||||
return Group(blocks=as_blocks(obj.get("blocks")),
|
||||
title=obj.get("title"),
|
||||
page_break_before=bool(
|
||||
obj.get("page_break_before", False)))
|
||||
obj.get("page_break_before", False)),
|
||||
layout=_safe_str(obj.get("layout")) or "stack")
|
||||
if cls is GlossaryEntry:
|
||||
return GlossaryEntry(key=_safe_str(obj.get("key")),
|
||||
label=_safe_str(obj.get("label")),
|
||||
definition=_safe_str(obj.get("definition")))
|
||||
if cls is TocEntry:
|
||||
return TocEntry(label=_safe_str(obj.get("label")),
|
||||
target_id=_safe_str(obj.get("target_id")))
|
||||
except Exception: # noqa: BLE001 — never raise on a malformed block.
|
||||
return Note(text=_safe_str(obj))
|
||||
return Note(text=_safe_str(obj))
|
||||
|
||||
@@ -298,11 +298,16 @@ def test_cover_first_glossary_last_with_summary():
|
||||
headings = [b.text for b in cover.blocks if b.kind == "heading"]
|
||||
assert any("Resumen" in h for h in headings), \
|
||||
"la portada no incluye el resumen agregado"
|
||||
# The summary reflects the body chapters (e.g. the numeric/categorical ones).
|
||||
cover_text = " ".join(
|
||||
b.text for b in cover.blocks if getattr(b, "kind", "") == "markdown")
|
||||
assert "Distribuciones" in cover_text, \
|
||||
"el resumen de portada no menciona los capítulos del cuerpo"
|
||||
# The index ("Índice") is now a clickable list of TocEntry blocks (one per
|
||||
# body chapter), not a markdown bullet list. Verify both the heading and that
|
||||
# the entries name the body chapters.
|
||||
assert any("Índice" in h for h in headings), \
|
||||
"la portada no incluye la sección Índice"
|
||||
toc_labels = " ".join(
|
||||
getattr(b, "label", "") for b in cover.blocks
|
||||
if getattr(b, "kind", "") == "toc_entry")
|
||||
assert "Distribuciones" in toc_labels, \
|
||||
"el índice de portada no menciona los capítulos del cuerpo"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -46,11 +46,23 @@ _MUTED = "#8a8a8a"
|
||||
_RULE = "#cccccc"
|
||||
_HEAD_BG = "#eef3f6"
|
||||
|
||||
# Rasterization DPI for every embedded raster (figure/table image) AND for the
|
||||
# page save itself. Raised from the old 150/default-100 to 220 so a reader can
|
||||
# pinch-zoom on a phone and still see crisp detail (axis labels, table cells)
|
||||
# without pixelation. Text stays vectorial (pdf.fonttype=42) so it remains
|
||||
# selectable regardless of DPI — only the embedded images gain resolution. 220 is
|
||||
# a deliberate balance: noticeably sharper than 150 while keeping the file size
|
||||
# reasonable. ``savefig.dpi`` matters because matplotlib re-rasterizes each
|
||||
# ``imshow`` when PdfPages writes the page; without it the final image would land
|
||||
# at ~100 dpi no matter how sharp the intermediate PNG was.
|
||||
_RASTER_DPI = 220
|
||||
|
||||
_RC = {
|
||||
"font.size": 10,
|
||||
"font.family": "sans-serif",
|
||||
"figure.facecolor": "white",
|
||||
"savefig.facecolor": "white",
|
||||
"savefig.dpi": _RASTER_DPI,
|
||||
"pdf.fonttype": 42, # embed TrueType — text stays selectable on mobile.
|
||||
}
|
||||
|
||||
@@ -80,6 +92,10 @@ class _PdfState:
|
||||
# points (1/72") with a top-left origin — same convention as PyMuPDF.
|
||||
self.term_sources = [] # [{key, page, rect:[x0,y0,x1,y1]}]
|
||||
self.term_dests = {} # key -> {page, point:[x,y]}
|
||||
# Clickable index (cover → chapter). Sources are the cover's TocEntry
|
||||
# rects; chapter_starts maps a chapter id AND its title to its first page.
|
||||
self.toc_sources = [] # [{target_id, page, rect:[x0,y0,x1,y1]}]
|
||||
self.chapter_starts = {} # id|title -> {page, point:[x,y]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -317,10 +333,18 @@ def _place_kv_table(st: _PdfState, block) -> None:
|
||||
if title:
|
||||
_place_heading(st, model.Heading(title, level=2))
|
||||
rows = getattr(block, "rows", []) or []
|
||||
# ``value_align="right"`` pins the value column to the right margin (label
|
||||
# left, number flush right) — used by the cover's analysis summary.
|
||||
right = str(getattr(block, "value_align", "left")).lower() == "right"
|
||||
key_w = 1.9 # inches reserved for the label column.
|
||||
# Right-aligned values wrap against the full usable width minus the label
|
||||
# column; left-aligned values wrap against the value column only.
|
||||
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:
|
||||
# ``data_idx`` is the 0-based logical row index: even rows (1-based) are
|
||||
# zebra-shaded → 0-based odd indices, matching the data-table convention so
|
||||
# every table in the document carries the same striping.
|
||||
for data_idx, row in enumerate(rows):
|
||||
try:
|
||||
label, value = row[0], row[1]
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -329,11 +353,25 @@ def _place_kv_table(st: _PdfState, block) -> None:
|
||||
row_h = lh * len(v_lines) + _ROW_VPAD
|
||||
_ensure_space(st, row_h)
|
||||
y0 = st.y
|
||||
# Faint zebra fill for even rows, drawn first (zorder 0) so striping
|
||||
# never hides the text/value drawn on top.
|
||||
if data_idx % 2 == 1:
|
||||
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=_ZEBRA, lw=0, zorder=0))
|
||||
st.fig.text(_xf(_ML), _yf(y0), tl.strip_inline_md(model._safe_str(label)),
|
||||
fontsize=_FS_BODY, color=_MUTED, ha="left", va="top")
|
||||
fontsize=_FS_BODY, color=_MUTED, ha="left", va="top",
|
||||
zorder=2)
|
||||
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")
|
||||
if right:
|
||||
st.fig.text(_xf(_ML + _USABLE_W), _yf(y0 + k * lh), vl,
|
||||
fontsize=_FS_BODY, color=_INK, ha="right",
|
||||
va="top", zorder=2)
|
||||
else:
|
||||
st.fig.text(_xf(_ML + key_w), _yf(y0 + k * lh), vl,
|
||||
fontsize=_FS_BODY, color=_INK, ha="left",
|
||||
va="top", zorder=2)
|
||||
st.y = y0 + row_h
|
||||
st.y += _GAP
|
||||
|
||||
@@ -363,6 +401,57 @@ def _col_widths(header: list, rows: list, fs: float) -> list:
|
||||
return widths
|
||||
|
||||
|
||||
# Minimal legible characters reserved per column when deciding whether a table
|
||||
# can be shown as selectable text. Below this width per column the cells become
|
||||
# unreadable, so the table is rasterized to a zoomable high-res image instead.
|
||||
_MIN_LEGIBLE_CHARS = 8
|
||||
|
||||
|
||||
def _table_fits_as_text(header: list, rows: list) -> bool:
|
||||
"""True when the table fits the usable width as readable text.
|
||||
|
||||
A table whose columns cannot each get a minimal legible width within the A5
|
||||
usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
|
||||
so it is rendered as a single high-resolution image — the reader zooms in on
|
||||
the phone and reads every cell, nothing cut — instead of being squeezed until
|
||||
unreadable. Narrow tables (few columns) keep the selectable-text rendering."""
|
||||
header = header or []
|
||||
rows = rows or []
|
||||
ncol = len(header) if header else (len(rows[0]) if rows else 1)
|
||||
ncol = max(1, ncol)
|
||||
cw = tl.avg_char_width_in(_FS_CELL)
|
||||
min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
|
||||
return min_needed <= _USABLE_W
|
||||
|
||||
|
||||
def _table_figure_block(block):
|
||||
"""Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).
|
||||
|
||||
The table is drawn once via ``render_table_as_figure`` (header shading + zebra)
|
||||
and embedded as one high-res image scaled to fit entirely. The same Figure is
|
||||
reused for measuring and placing so keep-together stays consistent. The table
|
||||
title/note are drawn inside the image (self-describing when zoomed/shared), so
|
||||
the block-level caption is left empty to avoid a duplicate title."""
|
||||
cached = getattr(block, "_aeda_tablefig", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
title = getattr(block, "title", None)
|
||||
note = getattr(block, "note", None)
|
||||
|
||||
def _make():
|
||||
from datascience.render_table_as_figure import render_table_as_figure
|
||||
return render_table_as_figure(header, rows, title=title, note=note)
|
||||
|
||||
fig = model.Figure(make=_make, caption=None)
|
||||
try:
|
||||
block._aeda_tablefig = fig
|
||||
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
|
||||
pass
|
||||
return fig
|
||||
|
||||
|
||||
def _wrap_row(cells: list, widths: list, fs: float) -> list:
|
||||
"""Wrap each cell to its column width → list of line-lists per cell."""
|
||||
out = []
|
||||
@@ -402,11 +491,16 @@ def _draw_table_row(st: _PdfState, cells_lines: list, widths: list, fs: float,
|
||||
|
||||
|
||||
def _place_data_table(st: _PdfState, block) -> None:
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
# Too many columns to be legible as text → render the whole table as one
|
||||
# high-res image, scaled to fit entirely (the reader zooms to read it).
|
||||
if not _table_fits_as_text(header, rows):
|
||||
_place_figure(st, _table_figure_block(block))
|
||||
return
|
||||
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
|
||||
@@ -464,8 +558,11 @@ def _resolve_figure(block):
|
||||
|
||||
|
||||
def _png_from_figure(fig) -> bytes:
|
||||
# ``bbox_inches='tight'`` is kept so the real aspect ratio is what we measure
|
||||
# and place. The page save (savefig.dpi in _RC) re-rasterizes this at the same
|
||||
# high DPI, so the embedded image stays crisp for phone zoom.
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
||||
fig.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
@@ -707,12 +804,16 @@ def _measure_data_table(block) -> float:
|
||||
Counts the optional title heading, the wrapped header row, every wrapped data
|
||||
row (per-column wrap via the same ``_col_widths``/``_wrap_row`` the placer
|
||||
uses) and the optional note. Keep this in sync with ``_place_data_table``."""
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
# Mirror the placer: a too-wide table is drawn as a single image, so its
|
||||
# keep-together height is the image's, not the (squeezed) text layout's.
|
||||
if not _table_fits_as_text(header, rows):
|
||||
return _measure_figure_like(_table_figure_block(block))
|
||||
h = 0.0
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
h += _measure_heading_text(title, 2)
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows, fs)
|
||||
lh = tl.line_height_in(fs)
|
||||
@@ -744,6 +845,10 @@ def _measure_block(st: _PdfState, block) -> float:
|
||||
lines = tl.wrap(getattr(block, "text", ""),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
|
||||
if kind == "toc_entry":
|
||||
lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
|
||||
tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY)) or [""]
|
||||
return tl.line_height_in(_FS_BODY) * len(lines) + _GAP * 0.4
|
||||
if kind == "kv_table":
|
||||
return _measure_kv_table(block)
|
||||
if kind == "data_table":
|
||||
@@ -828,6 +933,38 @@ def _place_glossary_entry(st: _PdfState, block) -> None:
|
||||
st.y += _GAP * 0.5
|
||||
|
||||
|
||||
def _place_toc_entry(st: _PdfState, block) -> None:
|
||||
"""Render one clickable index line and record it as a link source.
|
||||
|
||||
Drawn as a bulleted line in the accent link colour; its rectangle is recorded
|
||||
in ``st.toc_sources`` so the post-processor turns it into a real jump to the
|
||||
target chapter's first page. If the target is never resolved the line still
|
||||
shows as plain (accent) text — never cut, never broken."""
|
||||
label = tl.strip_inline_md(getattr(block, "label", "")) or ""
|
||||
target_id = getattr(block, "target_id", "") or ""
|
||||
fs = _FS_BODY
|
||||
lh = tl.line_height_in(fs)
|
||||
bullet = "• "
|
||||
indent = 0.22
|
||||
max_chars = tl.chars_per_line(_USABLE_W - indent, fs)
|
||||
lines = tl.wrap(label, max_chars) or [""]
|
||||
for idx, ln in enumerate(lines):
|
||||
_ensure_space(st, lh)
|
||||
x = _ML
|
||||
st.fig.text(_xf(x), _yf(st.y), bullet if idx == 0 else " ",
|
||||
fontsize=fs, color=_LINK, ha="left", va="top")
|
||||
x += indent
|
||||
w = _text_width_in(st, ln, fs, False)
|
||||
st.fig.text(_xf(x), _yf(st.y), ln, fontsize=fs, color=_LINK,
|
||||
ha="left", va="top")
|
||||
if target_id and idx == 0:
|
||||
st.toc_sources.append({
|
||||
"target_id": target_id, "page": st.page - 1,
|
||||
"rect": _pt_rect(_ML, st.y, x + w, st.y + lh)})
|
||||
st.y += lh
|
||||
st.y += _GAP * 0.4
|
||||
|
||||
|
||||
_PLACERS = {
|
||||
"heading": _place_heading,
|
||||
"markdown": _place_markdown,
|
||||
@@ -839,6 +976,7 @@ _PLACERS = {
|
||||
"note": _place_note,
|
||||
"group": _place_group,
|
||||
"glossary_entry": _place_glossary_entry,
|
||||
"toc_entry": _place_toc_entry,
|
||||
}
|
||||
|
||||
|
||||
@@ -870,6 +1008,15 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
st.chapter = ch
|
||||
st.chapter_pages = 0
|
||||
_new_page(st) # each chapter starts on a fresh page.
|
||||
# Record this chapter's first page as a link target for the
|
||||
# cover index (keyed by id AND title, since the cover only
|
||||
# knows titles). Point is the top of the content area.
|
||||
_start = {"page": st.page - 1,
|
||||
"point": [_ML * 72.0, _CONTENT_TOP * 72.0]}
|
||||
if ch.id:
|
||||
st.chapter_starts[ch.id] = _start
|
||||
if getattr(ch, "title", ""):
|
||||
st.chapter_starts.setdefault(ch.title, _start)
|
||||
for block in ch.blocks:
|
||||
placer = _PLACERS.get(getattr(block, "kind", ""),
|
||||
_place_note)
|
||||
@@ -902,7 +1049,7 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
|
||||
note = f"{n_pages} páginas"
|
||||
if n_links:
|
||||
note += f" · {n_links} enlaces de glosario"
|
||||
note += f" · {n_links} enlaces internos"
|
||||
if notes:
|
||||
note += " · " + "; ".join(notes)
|
||||
return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta,
|
||||
@@ -910,9 +1057,11 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
|
||||
|
||||
def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
||||
"""Build {source rect → glossary dest} links and apply them via PyMuPDF.
|
||||
"""Apply internal PDF links via PyMuPDF: glossary terms + the cover index.
|
||||
|
||||
Returns the number of links applied (0 if there is nothing to wire or the
|
||||
Builds two sets of GOTO links — every in-text glossary term → its entry, and
|
||||
every cover ``TocEntry`` → its chapter's first page — and applies them in one
|
||||
pass. Returns the number of links applied (0 if there is nothing to wire or the
|
||||
post-processor is unavailable). Never raises."""
|
||||
try:
|
||||
links = []
|
||||
@@ -923,6 +1072,14 @@ def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
||||
links.append({
|
||||
"src_page": src["page"], "src_rect": src["rect"],
|
||||
"dst_page": dest["page"], "dst_point": dest["point"]})
|
||||
# Cover index → chapter first page (clickable, navigable table of contents).
|
||||
for src in st.toc_sources:
|
||||
dest = st.chapter_starts.get(src.get("target_id"))
|
||||
if not dest:
|
||||
continue
|
||||
links.append({
|
||||
"src_page": src["page"], "src_rect": src["rect"],
|
||||
"dst_page": dest["page"], "dst_point": dest["point"]})
|
||||
if not links:
|
||||
return 0
|
||||
from datascience.add_pdf_internal_links import add_pdf_internal_links
|
||||
@@ -930,7 +1087,7 @@ def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
||||
if isinstance(res, dict) and res.get("status") == "ok":
|
||||
return int(res.get("n_links") or 0)
|
||||
if isinstance(res, dict) and res.get("error"):
|
||||
notes.append(f"glosario sin enlaces: {res.get('error')}")
|
||||
notes.append(f"enlaces internos no aplicados: {res.get('error')}")
|
||||
except Exception as e: # noqa: BLE001 — links are best-effort.
|
||||
notes.append(f"glosario sin enlaces: {e}")
|
||||
notes.append(f"enlaces internos no aplicados: {e}")
|
||||
return 0
|
||||
|
||||
@@ -51,6 +51,12 @@ _FS_H1, _FS_H2, _FS_H3 = 20, 16, 13
|
||||
_FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11
|
||||
_GAP = 0.12
|
||||
|
||||
# Rasterization DPI for every embedded figure/table image. Raised from 150 to 220
|
||||
# so a viewer can zoom into a slide (or a shared picture) and read crisp detail —
|
||||
# axis labels, table cells — without pixelation. Kept moderate so the deck size
|
||||
# stays reasonable. Same value as the PDF renderer.
|
||||
_RASTER_DPI = 220
|
||||
|
||||
|
||||
class _PptxState:
|
||||
def __init__(self, prs, title: str):
|
||||
@@ -65,6 +71,10 @@ class _PptxState:
|
||||
# Glossary wiring (mejora 6): runs to link and per-term target slide.
|
||||
self.term_runs = [] # [(key, run)]
|
||||
self.term_anchor_slide = {} # key -> Slide (glossary entry)
|
||||
# Clickable index (cover → chapter). toc_runs are the cover's index runs;
|
||||
# chapter_starts maps a chapter id AND its title to its first slide.
|
||||
self.toc_runs = [] # [(target_id, run, src_slide)]
|
||||
self.chapter_starts = {} # id|title -> Slide (chapter first slide)
|
||||
|
||||
|
||||
def _rgb(c):
|
||||
@@ -135,7 +145,7 @@ def _ensure(st: _PptxState, height: float) -> None:
|
||||
|
||||
|
||||
def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
|
||||
italic=False, indent=0.0, bullet=False) -> None:
|
||||
italic=False, indent=0.0, bullet=False, underline=False) -> None:
|
||||
lh = tl.line_height_in(fs)
|
||||
height = lh * len(lines) + 0.05
|
||||
_ensure(st, height)
|
||||
@@ -153,6 +163,7 @@ def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
|
||||
run.font.size = Pt(fs)
|
||||
run.font.bold = bold
|
||||
run.font.italic = italic
|
||||
run.font.underline = underline
|
||||
run.font.color.rgb = _rgb(color)
|
||||
st.y += height
|
||||
|
||||
@@ -206,10 +217,16 @@ def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
|
||||
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]
|
||||
# Optional per-heading emphasis (cover dataset name): a larger font and an
|
||||
# underline. ``size_pt`` overrides the per-level size when set.
|
||||
size_override = getattr(block, "size_pt", None)
|
||||
if isinstance(size_override, (int, float)) and size_override > 0:
|
||||
fs = float(size_override)
|
||||
underline = bool(getattr(block, "underline", False))
|
||||
text = tl.strip_inline_md(getattr(block, "text", ""))
|
||||
st.last_heading = text or st.last_heading
|
||||
lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
|
||||
_add_text(st, lines, fs, _INK, bold=True)
|
||||
_add_text(st, lines, fs, _INK, bold=True, underline=underline)
|
||||
st.y += 0.04
|
||||
|
||||
|
||||
@@ -302,6 +319,58 @@ def _col_widths(header, rows):
|
||||
return [_USABLE_W * w / total for w in clamped]
|
||||
|
||||
|
||||
# Minimal legible characters reserved per column when deciding whether a table
|
||||
# can be shown as a native (selectable) PowerPoint table. Below this width per
|
||||
# column the cells become unreadable, so the table is rasterized to a zoomable
|
||||
# high-res image instead. The 16:9 slide is wide, so more columns fit than on A5.
|
||||
_MIN_LEGIBLE_CHARS = 8
|
||||
_CELL_PAD = 0.05
|
||||
|
||||
|
||||
def _table_fits_as_text(header: list, rows: list) -> bool:
|
||||
"""True when the table fits the usable slide width as a readable table.
|
||||
|
||||
A table whose columns cannot each get a minimal legible width within the slide
|
||||
usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
|
||||
so it is rendered as one high-resolution image — the viewer zooms in and reads
|
||||
every cell — instead of being squeezed unreadable. Narrow tables keep the
|
||||
native selectable table."""
|
||||
header = header or []
|
||||
rows = rows or []
|
||||
ncol = len(header) if header else (len(rows[0]) if rows else 1)
|
||||
ncol = max(1, ncol)
|
||||
cw = tl.avg_char_width_in(_FS_CELL)
|
||||
min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
|
||||
return min_needed <= _USABLE_W
|
||||
|
||||
|
||||
def _table_figure_block(block):
|
||||
"""Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).
|
||||
|
||||
Drawn once via ``render_table_as_figure`` (header shading + zebra) and embedded
|
||||
as one high-res image scaled to fit entirely. The title/note are drawn inside
|
||||
the image (self-describing when zoomed/shared), so no separate caption is
|
||||
emitted. Reused for measuring and placing so keep-together stays consistent."""
|
||||
cached = getattr(block, "_aeda_tablefig", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
title = getattr(block, "title", None)
|
||||
note = getattr(block, "note", None)
|
||||
|
||||
def _make():
|
||||
from datascience.render_table_as_figure import render_table_as_figure
|
||||
return render_table_as_figure(header, rows, title=title, note=note)
|
||||
|
||||
fig = model.Figure(make=_make, caption=None)
|
||||
try:
|
||||
block._aeda_tablefig = fig
|
||||
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
|
||||
pass
|
||||
return fig
|
||||
|
||||
|
||||
def _row_height_in(cells, widths, fs) -> float:
|
||||
lh = tl.line_height_in(fs)
|
||||
maxlines = 1
|
||||
@@ -365,11 +434,27 @@ def _style_cell(cell, fs, color, bold, fill) -> None:
|
||||
|
||||
def _place_data_table(st: _PptxState, block, shaded_header=True,
|
||||
key_value=False) -> None:
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
# Too many columns to be legible as a native table → render the whole table as
|
||||
# one high-res picture, scaled to fit entirely (the viewer zooms to read it).
|
||||
# KVTables (rendered here as a 2-column Campo/Valor table) are excluded: they
|
||||
# always fit in width and stay as a selectable table.
|
||||
if not key_value and not _table_fits_as_text(header, rows):
|
||||
figblock = _table_figure_block(block)
|
||||
data, _asp = _figure_bytes_cached(figblock)
|
||||
if data is None:
|
||||
_add_text(st, ["(tabla no disponible)"], _FS_NOTE, _MUTED,
|
||||
italic=True)
|
||||
st.y += _GAP
|
||||
return
|
||||
_place_picture_bytes(st, data, None,
|
||||
max_h_in=getattr(figblock, "height_in", None),
|
||||
force_caption=False)
|
||||
return
|
||||
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
|
||||
@@ -429,7 +514,7 @@ def _resolve_png(block):
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
buf = io.BytesIO()
|
||||
f.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
||||
f.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -476,12 +561,15 @@ def _figure_bytes_cached(block):
|
||||
|
||||
|
||||
def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
||||
max_h_in=None) -> None:
|
||||
max_h_in=None, force_caption=True) -> None:
|
||||
# Mejora 4 — every figure on a slide carries a visible caption/title. If the
|
||||
# block has no caption, fall back to the current section heading, then to a
|
||||
# generic label, so no image is ever shown untitled.
|
||||
caption = (model._safe_str(caption).strip()
|
||||
or model._safe_str(st.last_heading).strip() or "Figura")
|
||||
# generic label, so no image is ever shown untitled. ``force_caption=False``
|
||||
# suppresses that fallback (used for table images, whose title is inside the
|
||||
# picture) so no redundant caption is drawn.
|
||||
caption = model._safe_str(caption).strip()
|
||||
if not caption and force_caption:
|
||||
caption = model._safe_str(st.last_heading).strip() or "Figura"
|
||||
w_px, h_px = _img_size_px(data)
|
||||
aspect = (h_px / w_px) if w_px else 0.66
|
||||
# Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
|
||||
@@ -489,9 +577,11 @@ def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
||||
# so its caption always fits on the SAME slide and no image is untitled.
|
||||
# cap_real = what _add_text consumes; cap_reserve adds the post-image gap and
|
||||
# a small cushion so the caption never spills to the next slide.
|
||||
cap_lines = tl.wrap(caption, tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
cap_real = tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05
|
||||
cap_reserve = cap_real + 0.05 + 0.10
|
||||
cap_lines = tl.wrap(caption, tl.chars_per_line(_USABLE_W, _FS_NOTE)) \
|
||||
if caption else []
|
||||
cap_real = (tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05) \
|
||||
if cap_lines else 0.0
|
||||
cap_reserve = (cap_real + 0.05 + 0.10) if cap_lines else 0.05
|
||||
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||
# height_in hint (model.Figure/Image): cap the target height so a figure in a
|
||||
# keep-together Group shrinks to leave room for its heading and text.
|
||||
@@ -510,7 +600,8 @@ def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
||||
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
|
||||
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
|
||||
if cap_lines:
|
||||
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
@@ -552,9 +643,11 @@ def _place_note(st: _PptxState, block) -> None:
|
||||
# WITHOUT drawing it so a Group can move whole to the next slide before drawing.
|
||||
# Over-estimating only triggers an earlier slide break, never a content cut.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _measure_heading_text(text: str, level: int) -> float:
|
||||
def _measure_heading_text(text: str, level: int, size_pt=None) -> float:
|
||||
level = max(1, min(3, int(level or 1)))
|
||||
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
||||
if isinstance(size_pt, (int, float)) and size_pt > 0:
|
||||
fs = float(size_pt)
|
||||
lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs))
|
||||
return tl.line_height_in(fs) * len(lines) + 0.05 + 0.04
|
||||
|
||||
@@ -654,12 +747,16 @@ def _measure_kv_table(block) -> float:
|
||||
def _measure_data_table(block) -> float:
|
||||
"""Faithful DataTable height — matches ``_place_data_table`` (title heading +
|
||||
wrapped header + every wrapped row + optional note). Keep in sync."""
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
# Mirror the placer: a too-wide table is drawn as one image, so its
|
||||
# keep-together height is the image's, not the (squeezed) table layout's.
|
||||
if not _table_fits_as_text(header, rows):
|
||||
return _measure_figure_like(_table_figure_block(block))
|
||||
h = 0.0
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
h += _measure_heading_text(title, 2)
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows)
|
||||
if header:
|
||||
@@ -679,7 +776,8 @@ def _measure_block(st: _PptxState, block) -> float:
|
||||
try:
|
||||
if kind == "heading":
|
||||
return _measure_heading_text(getattr(block, "text", ""),
|
||||
getattr(block, "level", 1))
|
||||
getattr(block, "level", 1),
|
||||
size_pt=getattr(block, "size_pt", None))
|
||||
if kind == "markdown":
|
||||
return _measure_markdown(block)
|
||||
if kind in ("figure", "image"):
|
||||
@@ -688,6 +786,10 @@ def _measure_block(st: _PptxState, block) -> float:
|
||||
lines = tl.wrap(getattr(block, "text", ""),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
|
||||
if kind == "toc_entry":
|
||||
lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
|
||||
tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY)) or [""]
|
||||
return tl.line_height_in(_FS_BODY) * len(lines) + 0.05
|
||||
if kind == "kv_table":
|
||||
return _measure_kv_table(block)
|
||||
if kind == "data_table":
|
||||
@@ -800,6 +902,73 @@ def _fit_group_blocks(st: _PptxState, blocks: list, avail_full: float) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _fit_img(width_col: float, aspect: float, max_h: float):
|
||||
"""Scale an image to ``width_col`` then clamp to ``max_h`` keeping aspect."""
|
||||
w = width_col
|
||||
h = w * aspect
|
||||
if h > max_h:
|
||||
h = max_h
|
||||
w = (h / aspect) if aspect else width_col
|
||||
return w, h
|
||||
|
||||
|
||||
def _place_group_side_by_side(st: _PptxState, block, avail_full: float) -> bool:
|
||||
"""Place a Group's table (left ~55%) next to its figure (right ~45%).
|
||||
|
||||
Both the table and the figure are rasterized to high-res images and placed in
|
||||
two columns of the SAME slide; any other blocks (e.g. a heading) render full
|
||||
width above the pair, the rest below. Returns True on success; returns False
|
||||
(so the caller falls back to stacking) when the group has no table+figure pair
|
||||
or the pair cannot fit side by side on one slide. Never raises by itself."""
|
||||
blocks = getattr(block, "blocks", []) or []
|
||||
tbl = next((b for b in blocks
|
||||
if getattr(b, "kind", "") in ("data_table", "kv_table")), None)
|
||||
fig = next((b for b in blocks
|
||||
if getattr(b, "kind", "") in ("figure", "image")), None)
|
||||
if tbl is None or fig is None:
|
||||
return False
|
||||
gap_col = 0.3
|
||||
left_w = _USABLE_W * 0.55 - gap_col / 2.0
|
||||
right_w = _USABLE_W * 0.45 - gap_col / 2.0
|
||||
if left_w <= 1.0 or right_w <= 1.0:
|
||||
return False
|
||||
tdata, tasp = _figure_bytes_cached(_table_figure_block(tbl))
|
||||
fdata, fasp = _figure_bytes_cached(fig)
|
||||
if not tdata or not fdata:
|
||||
return False
|
||||
ti, fi = blocks.index(tbl), blocks.index(fig)
|
||||
lo = min(ti, fi)
|
||||
lead = list(blocks[:lo])
|
||||
rest = [b for b in blocks[lo + 1:] if b is not tbl and b is not fig]
|
||||
lead_h = sum(_measure_block(st, b) for b in lead)
|
||||
rest_h = sum(_measure_block(st, b) for b in rest)
|
||||
col_max_h = avail_full - lead_h - rest_h - _GAP * 2
|
||||
if col_max_h < 1.2:
|
||||
return False # not enough vertical room to put the pair side by side.
|
||||
tw, th = _fit_img(left_w, tasp, col_max_h)
|
||||
fw, fh = _fit_img(right_w, fasp, col_max_h)
|
||||
band = max(th, fh)
|
||||
needed = lead_h + band + rest_h + _GAP * 2
|
||||
if needed > avail_full:
|
||||
return False # taller than a whole slide even side by side → stack.
|
||||
if needed > _remaining(st):
|
||||
_new_slide(st, cont=True)
|
||||
for b in lead:
|
||||
_PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
|
||||
top = st.y
|
||||
f_left = _ML + left_w + gap_col
|
||||
st.slide.shapes.add_picture(
|
||||
io.BytesIO(tdata), Inches(_ML + (left_w - tw) / 2.0),
|
||||
Inches(top + (band - th) / 2.0), width=Inches(tw), height=Inches(th))
|
||||
st.slide.shapes.add_picture(
|
||||
io.BytesIO(fdata), Inches(f_left + (right_w - fw) / 2.0),
|
||||
Inches(top + (band - fh) / 2.0), width=Inches(fw), height=Inches(fh))
|
||||
st.y = top + band + _GAP
|
||||
for b in rest:
|
||||
_PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
|
||||
return True
|
||||
|
||||
|
||||
def _place_group(st: _PptxState, block) -> None:
|
||||
"""Render a keep-together Group: move it whole to the next slide if needed."""
|
||||
blocks = getattr(block, "blocks", []) or []
|
||||
@@ -810,6 +979,14 @@ def _place_group(st: _PptxState, block) -> None:
|
||||
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
||||
_new_slide(st, cont=True)
|
||||
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||
# layout="side_by_side": try table-left / figure-right on one slide; on any
|
||||
# reason it can't, fall through to the normal stacked keep-together below.
|
||||
if str(getattr(block, "layout", "stack")).lower() == "side_by_side":
|
||||
try:
|
||||
if _place_group_side_by_side(st, block, avail_full):
|
||||
return
|
||||
except Exception: # noqa: BLE001 — degrade to stacking, never abort.
|
||||
pass
|
||||
# Trim oversized tables first (keeps the chart on the same slide), then shrink
|
||||
# the figure to share the remaining room.
|
||||
blocks = _fit_group_blocks(st, blocks, avail_full)
|
||||
@@ -843,6 +1020,44 @@ def _place_glossary_entry(st: _PptxState, block) -> None:
|
||||
st.y += _GAP
|
||||
|
||||
|
||||
def _place_toc_entry(st: _PptxState, block) -> None:
|
||||
"""Render one clickable index line and record its run as a link source.
|
||||
|
||||
Drawn as a bulleted line in the accent link colour; the run is recorded in
|
||||
``st.toc_runs`` so it later becomes a native slide-jump to the target chapter's
|
||||
first slide. If the target is never resolved the line still shows as plain
|
||||
(accent) text — never cut."""
|
||||
label = tl.strip_inline_md(getattr(block, "label", "")) or ""
|
||||
target_id = getattr(block, "target_id", "") or ""
|
||||
fs = _FS_BODY
|
||||
lines = tl.wrap(label, tl.chars_per_line(_USABLE_W - 0.3, fs)) or [""]
|
||||
lh = tl.line_height_in(fs)
|
||||
height = lh * len(lines) + 0.05
|
||||
_ensure(st, height)
|
||||
box = st.slide.shapes.add_textbox(
|
||||
Inches(_ML), Inches(st.y), Inches(_USABLE_W), Inches(height))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
first = True
|
||||
link_run = None
|
||||
for idx, ln in enumerate(lines):
|
||||
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
||||
first = False
|
||||
r0 = p.add_run()
|
||||
r0.text = "• " if idx == 0 else " "
|
||||
r0.font.size = Pt(fs)
|
||||
r0.font.color.rgb = _rgb(_LINK)
|
||||
run = p.add_run()
|
||||
run.text = ln
|
||||
run.font.size = Pt(fs)
|
||||
run.font.color.rgb = _rgb(_LINK)
|
||||
if idx == 0:
|
||||
link_run = run
|
||||
if target_id and link_run is not None:
|
||||
st.toc_runs.append((target_id, link_run, st.slide))
|
||||
st.y += height
|
||||
|
||||
|
||||
_PLACERS = {
|
||||
"heading": _place_heading,
|
||||
"markdown": _place_markdown,
|
||||
@@ -854,6 +1069,7 @@ _PLACERS = {
|
||||
"note": _place_note,
|
||||
"group": _place_group,
|
||||
"glossary_entry": _place_glossary_entry,
|
||||
"toc_entry": _place_toc_entry,
|
||||
}
|
||||
|
||||
|
||||
@@ -889,6 +1105,12 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
st.chapter = ch
|
||||
st.chapter_slides = 0
|
||||
_new_slide(st, cont=False)
|
||||
# Record this chapter's first slide as a link target for the cover
|
||||
# index (keyed by id AND title, since the cover only knows titles).
|
||||
if ch.id:
|
||||
st.chapter_starts[ch.id] = st.slide
|
||||
if getattr(ch, "title", ""):
|
||||
st.chapter_starts.setdefault(ch.title, st.slide)
|
||||
for block in ch.blocks:
|
||||
placer = _PLACERS.get(getattr(block, "kind", ""), _place_note)
|
||||
try:
|
||||
@@ -916,7 +1138,7 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
|
||||
note = f"{n_slides} slides"
|
||||
if n_links:
|
||||
note += f" · {n_links} enlaces de glosario"
|
||||
note += f" · {n_links} enlaces internos"
|
||||
if notes:
|
||||
note += " · " + "; ".join(notes)
|
||||
return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
|
||||
@@ -924,19 +1146,21 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||
|
||||
|
||||
def _wire_glossary_links(st: _PptxState, notes: list) -> int:
|
||||
"""Turn each recorded term run into a native jump to its glossary slide.
|
||||
"""Apply native slide-jumps: glossary terms + the cover index.
|
||||
|
||||
Returns the number of links applied. A term whose only appearance is inside
|
||||
its own glossary entry (source slide == target slide) is skipped. Never
|
||||
Each in-text glossary term run jumps to its glossary entry slide, and each
|
||||
cover ``TocEntry`` run jumps to its chapter's first slide. Returns the total
|
||||
number of links applied. A run whose target is its own slide is skipped. Never
|
||||
raises."""
|
||||
if not st.term_runs or not st.term_anchor_slide:
|
||||
if not (st.term_runs and st.term_anchor_slide) and not (
|
||||
st.toc_runs and st.chapter_starts):
|
||||
return 0
|
||||
linked = 0
|
||||
try:
|
||||
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
|
||||
except Exception as e: # noqa: BLE001
|
||||
notes.append(f"glosario sin enlaces: {e}")
|
||||
notes.append(f"enlaces internos no aplicados: {e}")
|
||||
return 0
|
||||
linked = 0
|
||||
for key, run, src_slide in st.term_runs:
|
||||
tgt = st.term_anchor_slide.get(key)
|
||||
if tgt is None or tgt is src_slide:
|
||||
@@ -946,4 +1170,14 @@ def _wire_glossary_links(st: _PptxState, notes: list) -> int:
|
||||
linked += 1
|
||||
except Exception: # noqa: BLE001 — links are best-effort.
|
||||
pass
|
||||
# Cover index → chapter first slide (clickable, navigable table of contents).
|
||||
for target_id, run, src_slide in st.toc_runs:
|
||||
tgt = st.chapter_starts.get(target_id)
|
||||
if tgt is None or tgt is src_slide:
|
||||
continue
|
||||
try:
|
||||
if pptx_link_run_to_slide(run, src_slide, tgt):
|
||||
linked += 1
|
||||
except Exception: # noqa: BLE001 — links are best-effort.
|
||||
pass
|
||||
return linked
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Golden tests for the global render-quality features (issue: eda-render-quality).
|
||||
|
||||
Covers, with executable evidence:
|
||||
* High DPI: every embedded figure is rasterized at 220 dpi, so a phone reader
|
||||
can zoom in and still see crisp detail.
|
||||
* Wide table → image: a table too wide to be legible as text (e.g. a 19-column
|
||||
df.head) is rendered as one high-res image that scales to fit entirely, while
|
||||
a narrow table keeps its selectable-text/native-table rendering.
|
||||
* ``Group(layout="side_by_side")``: in PPTX the table and figure are placed in
|
||||
two columns of the same slide; in PDF the same group stacks vertically.
|
||||
* Backward compatibility: a Group without ``layout`` defaults to ``"stack"`` and
|
||||
a fitting table renders exactly as before.
|
||||
|
||||
Renderers are invoked for real; PDFs are inspected with PyMuPDF and PPTX decks
|
||||
with python-pptx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from datascience.automatic_eda import model # noqa: E402
|
||||
from datascience.automatic_eda.render_pdf_impl import ( # noqa: E402
|
||||
render_pdf, _RASTER_DPI as _PDF_DPI, _table_fits_as_text as _pdf_fits)
|
||||
from datascience.automatic_eda.render_pptx_impl import ( # noqa: E402
|
||||
render_pptx, _RASTER_DPI as _PPTX_DPI, _table_fits_as_text as _pptx_fits)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _simple_fig():
|
||||
"""A small, real matplotlib figure for the figure blocks."""
|
||||
fig, ax = plt.subplots(figsize=(4, 3))
|
||||
ax.plot([0, 1, 2, 3], [1, 3, 2, 4])
|
||||
ax.set_title("demo")
|
||||
return fig
|
||||
|
||||
|
||||
def _wide_table(n_cols=19, n_rows=5):
|
||||
header = [f"columna_{i}" for i in range(n_cols)]
|
||||
rows = [[f"v{r}_{c}" for c in range(n_cols)] for r in range(n_rows)]
|
||||
return model.DataTable(header=header, rows=rows, title="Primeras filas")
|
||||
|
||||
|
||||
def _narrow_table():
|
||||
return model.DataTable(header=["a", "b", "c"],
|
||||
rows=[["1", "2", "3"], ["4", "5", "6"]],
|
||||
title="Tabla estrecha")
|
||||
|
||||
|
||||
def _chapter(blocks, cid="cap", title="Capítulo"):
|
||||
return [model.Chapter(id=cid, title=title, version="1.0.0", blocks=blocks)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1) High DPI — the unit constant and a real embedded image.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_raster_dpi_is_high_both_renderers():
|
||||
assert _PDF_DPI >= 200, "el DPI del PDF debe ser alto (>=200)"
|
||||
assert _PPTX_DPI >= 200, "el DPI del PPTX debe ser alto (>=200)"
|
||||
|
||||
|
||||
def test_pdf_embedded_figure_is_high_resolution(tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
out = str(tmp_path / "fig.pdf")
|
||||
res = render_pdf(_chapter([model.Figure(make=_simple_fig, caption="demo")]),
|
||||
out, {"title": "T"})
|
||||
assert res["path"] == out
|
||||
doc = fitz.open(out)
|
||||
try:
|
||||
widths = []
|
||||
for page in doc:
|
||||
for img in page.get_images(full=True):
|
||||
xref = img[0]
|
||||
info = doc.extract_image(xref)
|
||||
widths.append(info.get("width", 0))
|
||||
assert widths, "no se incrustó ninguna imagen en el PDF"
|
||||
# A ~4" figure rasterized at 220 dpi is ~ >850 px wide. At the old 150 dpi
|
||||
# it would be ~600 px. The high-res threshold proves the DPI bump.
|
||||
assert max(widths) >= 800, \
|
||||
f"la figura embebida no es de alta resolución: {max(widths)} px"
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2) Wide table → image (PDF and PPTX); narrow table stays text.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fit_criterion_flags_wide_and_keeps_narrow():
|
||||
wide = _wide_table()
|
||||
narrow = _narrow_table()
|
||||
assert not _pdf_fits(wide.header, wide.rows), \
|
||||
"una tabla de 19 columnas debería NO caber como texto en A5"
|
||||
assert not _pptx_fits(wide.header, wide.rows), \
|
||||
"una tabla de 19 columnas debería NO caber como tabla nativa en 16:9"
|
||||
assert _pdf_fits(narrow.header, narrow.rows), \
|
||||
"una tabla de 3 columnas debería caber como texto en A5"
|
||||
assert _pptx_fits(narrow.header, narrow.rows), \
|
||||
"una tabla de 3 columnas debería caber como tabla nativa en 16:9"
|
||||
|
||||
|
||||
def test_wide_table_rendered_as_image_pdf(tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
out = str(tmp_path / "wide.pdf")
|
||||
res = render_pdf(_chapter([_wide_table()]), out, {"title": "T"})
|
||||
assert res["path"] == out
|
||||
doc = fitz.open(out)
|
||||
try:
|
||||
n_images = sum(len(page.get_images(full=True)) for page in doc)
|
||||
text = "".join(page.get_text() for page in doc)
|
||||
finally:
|
||||
doc.close()
|
||||
assert n_images >= 1, "la tabla ancha no se rasterizó como imagen en el PDF"
|
||||
# The cells are now inside the image, not selectable text. A unique cell value
|
||||
# must therefore NOT appear as extractable text (it lives in the picture).
|
||||
assert "v4_18" not in text, \
|
||||
"la tabla ancha sigue como texto seleccionable (no se hizo imagen)"
|
||||
|
||||
|
||||
def test_narrow_table_stays_selectable_text_pdf(tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
out = str(tmp_path / "narrow.pdf")
|
||||
render_pdf(_chapter([_narrow_table()]), out, {"title": "T"})
|
||||
doc = fitz.open(out)
|
||||
try:
|
||||
text = "".join(page.get_text() for page in doc)
|
||||
finally:
|
||||
doc.close()
|
||||
# Narrow table is selectable text: its header/cells are extractable.
|
||||
for v in ("a", "b", "c", "1", "6"):
|
||||
assert v in text, f"la celda '{v}' debería ser texto seleccionable"
|
||||
|
||||
|
||||
def test_wide_table_rendered_as_picture_pptx(tmp_path):
|
||||
pptx = pytest.importorskip("pptx")
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
out = str(tmp_path / "wide.pptx")
|
||||
res = render_pptx(_chapter([_wide_table()]), out, {"title": "T"})
|
||||
assert res["path"] == out
|
||||
prs = pptx.Presentation(out)
|
||||
pics = sum(1 for s in prs.slides for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
||||
assert pics >= 1, "la tabla ancha no se colocó como imagen en el PPTX"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3) Group(layout="side_by_side"): two columns in PPTX, stacked in PDF.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _side_by_side_group():
|
||||
return model.Group(
|
||||
blocks=[model.Heading(text="Columna X", level=2),
|
||||
_narrow_table(),
|
||||
model.Figure(make=_simple_fig, caption="grafico")],
|
||||
layout="side_by_side")
|
||||
|
||||
|
||||
def test_side_by_side_places_two_columns_pptx(tmp_path):
|
||||
pptx = pytest.importorskip("pptx")
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
from pptx.util import Inches
|
||||
out = str(tmp_path / "sbs.pptx")
|
||||
render_pptx(_chapter([_side_by_side_group()]), out, {"title": "T"})
|
||||
prs = pptx.Presentation(out)
|
||||
# Find the slide that holds the pair (table image + figure image).
|
||||
centre_emu = int(Inches(13.333 / 2.0))
|
||||
placed = False
|
||||
for s in prs.slides:
|
||||
lefts = [sh.left for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE
|
||||
and sh.left is not None]
|
||||
if len(lefts) >= 2:
|
||||
# one picture starts in the left half, another in the right half.
|
||||
if min(lefts) < centre_emu and max(lefts) > centre_emu:
|
||||
placed = True
|
||||
break
|
||||
assert placed, \
|
||||
"side_by_side no colocó tabla y figura en dos columnas de la misma slide"
|
||||
|
||||
|
||||
def test_side_by_side_stacks_in_pdf(tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
out = str(tmp_path / "sbs.pdf")
|
||||
res = render_pdf(_chapter([_side_by_side_group()]), out, {"title": "T"})
|
||||
assert res["path"] == out and res["n_pages"] >= 1
|
||||
doc = fitz.open(out)
|
||||
try:
|
||||
n_images = sum(len(page.get_images(full=True)) for page in doc)
|
||||
text = "".join(page.get_text() for page in doc)
|
||||
finally:
|
||||
doc.close()
|
||||
# PDF stacks: the narrow table stays selectable text (1 of its cells is
|
||||
# extractable) and the figure is the single embedded image — not a 2-column
|
||||
# pair of pictures like PPTX.
|
||||
assert n_images == 1, "el PDF no debería usar el layout de dos imágenes"
|
||||
assert "Columna X" in text and "1" in text, \
|
||||
"la tabla del grupo debería seguir como texto apilado en el PDF"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4) Backward compatibility — default layout stacks, fitting table unchanged.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_group_default_layout_is_stack():
|
||||
g = model.Group(blocks=[_narrow_table()])
|
||||
assert g.layout == "stack", "el layout por defecto debe ser 'stack'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5) Clickable cover index ("Índice") → chapter first page/slide.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _doc_with_index():
|
||||
portada = model.Chapter(id="portada", title="Portada", version="1.0.0",
|
||||
blocks=[model.Heading(text="Índice", level=2),
|
||||
model.TocEntry(label="Distribuciones",
|
||||
target_id="Distribuciones")])
|
||||
cap = model.Chapter(id="num", title="Distribuciones", version="1.0.0",
|
||||
blocks=[model.Markdown(text="contenido del capítulo")])
|
||||
return [portada, cap]
|
||||
|
||||
|
||||
def test_cover_index_is_clickable_pdf(tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
out = str(tmp_path / "idx.pdf")
|
||||
res = render_pdf(_doc_with_index(), out, {"title": "T"})
|
||||
assert res["path"] == out
|
||||
doc = fitz.open(out)
|
||||
try:
|
||||
# The cover (page 0) must carry a GOTO link jumping to a later page.
|
||||
goto = [lk for lk in doc[0].get_links()
|
||||
if lk.get("kind") == fitz.LINK_GOTO and lk.get("page", 0) > 0]
|
||||
finally:
|
||||
doc.close()
|
||||
assert goto, "el índice de la portada no produjo enlaces clicables en el PDF"
|
||||
|
||||
|
||||
def test_cover_index_shows_heading_pdf(tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
out = str(tmp_path / "idxh.pdf")
|
||||
render_pdf(_doc_with_index(), out, {"title": "T"})
|
||||
doc = fitz.open(out)
|
||||
try:
|
||||
text = "".join(page.get_text() for page in doc)
|
||||
finally:
|
||||
doc.close()
|
||||
assert "Índice" in text, "la portada no muestra el encabezado 'Índice'"
|
||||
assert "Este informe incluye" not in text, \
|
||||
"la portada aún muestra el texto antiguo 'Este informe incluye'"
|
||||
|
||||
|
||||
def test_cover_index_is_clickable_pptx(tmp_path):
|
||||
pptx = pytest.importorskip("pptx")
|
||||
out = str(tmp_path / "idx.pptx")
|
||||
render_pptx(_doc_with_index(), out, {"title": "T"})
|
||||
prs = pptx.Presentation(out)
|
||||
cover_xml = prs.slides[0]._element.xml
|
||||
assert "hlinksldjump" in cover_xml, \
|
||||
"el índice de la portada no produjo un salto de slide nativo en el PPTX"
|
||||
|
||||
|
||||
def test_default_group_renders_like_before_pptx(tmp_path):
|
||||
pptx = pytest.importorskip("pptx")
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
out = str(tmp_path / "stack.pptx")
|
||||
grp = model.Group(blocks=[model.Heading(text="Y", level=2),
|
||||
_narrow_table(),
|
||||
model.Figure(make=_simple_fig, caption="g")])
|
||||
render_pptx(_chapter([grp]), out, {"title": "T"})
|
||||
prs = pptx.Presentation(out)
|
||||
# Stacked group: the narrow table is a NATIVE table (selectable), and there is
|
||||
# exactly one picture (the figure) — not the two-image side-by-side layout.
|
||||
n_tables = sum(1 for s in prs.slides for sh in s.shapes if sh.has_table)
|
||||
n_pics = sum(1 for s in prs.slides for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
||||
assert n_tables >= 1, "el grupo apilado debería usar una tabla nativa"
|
||||
assert n_pics == 1, "el grupo apilado no debería duplicar imágenes"
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
id: build_column_dictionary_py_datascience
|
||||
name: build_column_dictionary
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_column_dictionary(db_profile: dict) -> dict"
|
||||
description: "Construye el diccionario de columnas BUSCABLE de una base entera a partir del DatabaseProfile que emite profile_database (grupo eda). Aplana db_profile['table_profiles'] (lista de TableProfile con table y columns) en una entrada por columna con tabla, tipo inferido, tipo semantico, marca de PII (RGPD/LOPDGDD), %null, cardinalidad y valores top. Responde a nivel de base 'donde esta el customer_id / telefono / IBAN'. Emite tambien pii_columns y un markdown grep-able ordenado por columna, precedido de las columnas compartidas por nombre entre tablas (candidatas a join key cross-tabla). Funcion pura, dict-no-throw, no muta el input."
|
||||
tags: [eda, relations]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
example: |
|
||||
from datascience import build_column_dictionary
|
||||
db_profile = {"table_profiles": [
|
||||
{"table": "clientes", "columns": [
|
||||
{"name": "email", "inferred_type": "text", "semantic_type": "email",
|
||||
"null_pct": 0.05, "distinct_count": 990}]}]}
|
||||
res = build_column_dictionary(db_profile)
|
||||
# res["pii_columns"] -> [{"table": "clientes", "column": "email", "is_pii": True, ...}]
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_flattens_two_tables"
|
||||
- "test_pii_flagged_from_semantic_type"
|
||||
- "test_empty_semantic_type_maps_to_none_and_not_pii"
|
||||
- "test_shared_column_names_detected_as_join_keys"
|
||||
- "test_top_values_from_categorical_block"
|
||||
- "test_empty_profile_returns_empty_ok"
|
||||
- "test_malformed_input_returns_empty_ok"
|
||||
- "test_missing_keys_read_defensively"
|
||||
- "test_does_not_mutate_input"
|
||||
test_file_path: "python/functions/datascience/build_column_dictionary_test.py"
|
||||
file_path: "python/functions/datascience/build_column_dictionary.py"
|
||||
params:
|
||||
- name: db_profile
|
||||
desc: >
|
||||
DatabaseProfile del grupo eda tal como lo devuelve profile_database en su
|
||||
clave db_profile (el dict con table_profiles). table_profiles es una lista
|
||||
de TableProfile; de cada uno se leen table (nombre) y columns (lista de
|
||||
ColumnProfile). De cada ColumnProfile se leen defensivamente con .get(...):
|
||||
name, inferred_type (numeric|categorical|datetime|text|boolean),
|
||||
semantic_type ("" que se normaliza a None; los que emite infer_semantic_type:
|
||||
email, iban, credit_card, phone_intl, postal_code_es, ...), null_pct
|
||||
(fraccion 0-1), distinct_count (cardinalidad, expuesta como n_distinct) y el
|
||||
bloque categorical.top (para top_values). Una entrada vacia, None o
|
||||
malformada produce el resultado vacio en estado ok (nunca lanza).
|
||||
output: >
|
||||
dict dict-no-throw con status ("ok" siempre), n_tables (int, tablas con columnas
|
||||
procesadas), n_columns (int total de columnas), entries (list[dict] una por
|
||||
columna con table, column, inferred_type, semantic_type|None, is_pii (bool),
|
||||
null_pct (float 0-1|None), n_distinct (int|None), top_values (list[str]|None)),
|
||||
pii_columns (subconjunto de entries con is_pii=True: dato personal segun
|
||||
[POL-MMNSEG-001-1.0]) y markdown (str, tabla grep-able ordenada por nombre de
|
||||
columna precedida de las columnas compartidas por nombre entre tablas). Entrada
|
||||
vacia o malformada -> n_tables/n_columns 0, listas vacias, markdown "".
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import build_column_dictionary
|
||||
|
||||
# db_profile minimo de juguete (forma de la clave db_profile de profile_database).
|
||||
db_profile = {
|
||||
"table_profiles": [
|
||||
{
|
||||
"table": "clientes",
|
||||
"columns": [
|
||||
{"name": "customer_id", "inferred_type": "numeric",
|
||||
"semantic_type": "", "null_pct": 0.0, "distinct_count": 1000},
|
||||
{"name": "email", "inferred_type": "text",
|
||||
"semantic_type": "email", "null_pct": 0.05, "distinct_count": 990},
|
||||
{"name": "ciudad", "inferred_type": "categorical",
|
||||
"semantic_type": "", "null_pct": 0.0, "distinct_count": 3,
|
||||
"categorical": {"top": [
|
||||
{"value": "Madrid", "count": 5, "pct": 0.5},
|
||||
{"value": "Bilbao", "count": 3, "pct": 0.3}]}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"table": "pedidos",
|
||||
"columns": [
|
||||
{"name": "customer_id", "inferred_type": "numeric",
|
||||
"semantic_type": "", "null_pct": 0.0, "distinct_count": 800},
|
||||
{"name": "iban", "inferred_type": "text",
|
||||
"semantic_type": "iban", "null_pct": 0.1, "distinct_count": 795},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
res = build_column_dictionary(db_profile)
|
||||
print(res["n_tables"], res["n_columns"]) # 2 5
|
||||
print([(e["table"], e["column"]) for e in res["pii_columns"]])
|
||||
# [('clientes', 'email'), ('pedidos', 'iban')]
|
||||
print(res["markdown"]) # tabla grep-able + seccion de join keys (customer_id)
|
||||
```
|
||||
|
||||
Uso real componiendo con `profile_database` (perfila la base y construye el diccionario):
|
||||
|
||||
```python
|
||||
from pipelines.profile_database import profile_database
|
||||
from datascience import build_column_dictionary
|
||||
|
||||
r = profile_database("mi_base.duckdb", write_report=False)
|
||||
if r["status"] == "ok":
|
||||
dicc = build_column_dictionary(r["db_profile"])
|
||||
# grep sobre dicc["markdown"] para localizar donde vive cada dato,
|
||||
# dicc["pii_columns"] para el inventario RGPD de la base.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites un indice tabla.columna de una base ENTERA: para localizar
|
||||
por busqueda "donde esta el customer_id / telefono / IBAN" antes de escribir un
|
||||
join, para descubrir claves de join cross-tabla (columnas con el mismo nombre en
|
||||
varias tablas) o para levantar un inventario de columnas con datos personales
|
||||
(RGPD/LOPDGDD) sobre el que auditar. Es el paso natural despues de
|
||||
`profile_database`: toma su `db_profile` y lo convierte en diccionario buscable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El criterio de PII se basa SOLO en el `semantic_type` que hoy emite el grupo
|
||||
`eda` (`infer_semantic_type`): se marcan email, phone_intl, iban, credit_card y
|
||||
postal_code_es. El catalogo de regex NO detecta hoy nombre de persona ni DNI/NIE,
|
||||
asi que esas columnas caen como texto/categorico y NO se marcan automaticamente.
|
||||
Politica [POL-MMNSEG-001-1.0]: ante cualquier duda sobre si una columna contiene
|
||||
datos personales, tratala como PII y avisa antes de exponerla; `pii_columns` es
|
||||
una ayuda, no un inventario RGPD exhaustivo.
|
||||
- `n_distinct` se lee de la clave `distinct_count` del ColumnProfile (no de
|
||||
`categorical.n_distinct`); en tablas grandes puede venir de `approx_unique`
|
||||
(HyperLogLog) capado a n_rows, no exacto.
|
||||
- `top_values` solo se rellena si la columna trae bloque `categorical` (lo pone
|
||||
`profile_table` para columnas categorical/text); las numericas/datetime lo
|
||||
dejan en None.
|
||||
- Funcion pura: no toca disco ni muta el input. NO perfila la base — eso lo hace
|
||||
`profile_database`; aqui solo se APLANA su salida.
|
||||
@@ -0,0 +1,245 @@
|
||||
"""build_column_dictionary — diccionario de columnas BUSCABLE de una base entera.
|
||||
|
||||
Funcion pura, stdlib-only. No hace I/O, no depende de nada externo y NO muta el
|
||||
input. Toma el ``db_profile`` (DatabaseProfile) que emite ``profile_database`` del
|
||||
grupo de capacidad ``eda`` y aplana su ``table_profiles`` (lista de TableProfile,
|
||||
cada uno con ``table`` y ``columns``: lista de ColumnProfile) en una entrada por
|
||||
columna. Es la pieza que responde, a nivel de BASE, "donde esta el customer_id /
|
||||
telefono / IBAN en este dataset?": un indice grep-able tabla.columna con su tipo,
|
||||
tipo semantico inferido, marca de PII, % de nulos, cardinalidad y valores top.
|
||||
|
||||
Ademas del listado plano emite:
|
||||
- ``pii_columns``: subconjunto marcado como dato personal (RGPD/LOPDGDD).
|
||||
- ``markdown``: tabla grep-able ordenada por nombre de columna, precedida de una
|
||||
seccion que agrupa columnas con el MISMO nombre presentes en varias tablas
|
||||
(candidatas a clave de join cross-tabla).
|
||||
|
||||
Estilo dict-no-throw del grupo ``eda``: nunca lanza. Lee cada clave de forma
|
||||
defensiva con ``.get(...)`` y tolera valores None / estructuras malformadas; ante
|
||||
una entrada vacia o corrupta devuelve el resultado vacio en estado ``ok``.
|
||||
|
||||
Criterio de PII (politica [POL-MMNSEG-001-1.0]): se marca ``is_pii=True`` cuando el
|
||||
``semantic_type`` real que emite el grupo ``eda`` (ver ``infer_semantic_type``)
|
||||
pertenece al conjunto de tipos de dato personal detectables hoy: email, telefono
|
||||
internacional, IBAN, tarjeta de credito y codigo postal (componente de direccion).
|
||||
El catalogo de regex del grupo NO detecta hoy nombre de persona ni DNI/NIE, asi que
|
||||
esas columnas caen como texto/categorico y no se marcan automaticamente: ante
|
||||
cualquier duda sobre si una columna contiene datos personales, tratala como PII y
|
||||
avisa antes de exponerla.
|
||||
"""
|
||||
|
||||
# semantic_types del grupo eda (infer_semantic_type) que son dato personal.
|
||||
# El grupo emite hoy: email, url, ipv4, ipv6, uuid, iban, credit_card, phone_intl,
|
||||
# postal_code_es, currency, datetime_iso, date_eu, integer, decimal, boolean,
|
||||
# hex_color. De esos, los que identifican a una persona fisica (RGPD/LOPDGDD) son:
|
||||
_PII_SEMANTIC_TYPES = frozenset(
|
||||
{
|
||||
"email",
|
||||
"phone_intl",
|
||||
"iban",
|
||||
"credit_card",
|
||||
"postal_code_es", # codigo postal: componente de direccion (dato de localizacion)
|
||||
}
|
||||
)
|
||||
|
||||
# Numero maximo de valores frecuentes que se listan por columna categorica.
|
||||
_TOP_VALUES_LIMIT = 5
|
||||
|
||||
|
||||
def _empty_result() -> dict:
|
||||
"""Resultado vacio en estado ok para entradas vacias o malformadas."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"n_tables": 0,
|
||||
"n_columns": 0,
|
||||
"entries": [],
|
||||
"pii_columns": [],
|
||||
"markdown": "",
|
||||
}
|
||||
|
||||
|
||||
def _top_values(col: dict) -> list | None:
|
||||
"""Extrae hasta _TOP_VALUES_LIMIT valores frecuentes del bloque categorical.
|
||||
|
||||
``summarize_categorical`` deja ``col["categorical"]["top"]`` como lista de
|
||||
``{value, count, pct}`` ordenada por frecuencia. Devuelve solo los valores
|
||||
como strings, o None si la columna no tiene bloque categorical util.
|
||||
"""
|
||||
cat = col.get("categorical")
|
||||
if not isinstance(cat, dict):
|
||||
return None
|
||||
top = cat.get("top")
|
||||
if not isinstance(top, list) or not top:
|
||||
return None
|
||||
values = []
|
||||
for item in top[:_TOP_VALUES_LIMIT]:
|
||||
if isinstance(item, dict):
|
||||
values.append(str(item.get("value")))
|
||||
else:
|
||||
values.append(str(item))
|
||||
return values or None
|
||||
|
||||
|
||||
def _column_entry(table_name, col: dict) -> dict:
|
||||
"""Construye la entrada del diccionario para un ColumnProfile.
|
||||
|
||||
Lee las claves del contrato eda de forma defensiva: name, inferred_type,
|
||||
semantic_type ("" se normaliza a None), null_pct (fraccion 0-1),
|
||||
distinct_count (se expone como n_distinct) y el bloque categorical (top).
|
||||
"""
|
||||
sem_raw = col.get("semantic_type")
|
||||
semantic_type = sem_raw if sem_raw else None # "" -> None
|
||||
|
||||
null_pct = col.get("null_pct")
|
||||
if isinstance(null_pct, bool) or not isinstance(null_pct, (int, float)):
|
||||
null_pct = None
|
||||
else:
|
||||
null_pct = float(null_pct)
|
||||
|
||||
n_distinct = col.get("distinct_count")
|
||||
if isinstance(n_distinct, bool) or not isinstance(n_distinct, int):
|
||||
n_distinct = None
|
||||
|
||||
return {
|
||||
"table": table_name,
|
||||
"column": col.get("name"),
|
||||
"inferred_type": col.get("inferred_type"),
|
||||
"semantic_type": semantic_type,
|
||||
"is_pii": semantic_type in _PII_SEMANTIC_TYPES,
|
||||
"null_pct": null_pct,
|
||||
"n_distinct": n_distinct,
|
||||
"top_values": _top_values(col),
|
||||
}
|
||||
|
||||
|
||||
def _render_markdown(entries: list) -> str:
|
||||
"""Renderiza el diccionario en markdown grep-able.
|
||||
|
||||
Primero una seccion que agrupa columnas con el MISMO nombre presentes en
|
||||
varias tablas (candidatas a clave de join cross-tabla), luego la tabla
|
||||
completa ordenada por nombre de columna.
|
||||
"""
|
||||
lines = ["# Diccionario de columnas", ""]
|
||||
|
||||
# Seccion: columnas compartidas por nombre (candidatas a join key).
|
||||
by_name: dict = {}
|
||||
for e in entries:
|
||||
by_name.setdefault(e["column"], set()).add(e["table"])
|
||||
shared = {
|
||||
name: tables
|
||||
for name, tables in by_name.items()
|
||||
if name is not None and len(tables) > 1
|
||||
}
|
||||
|
||||
lines.append("## Columnas presentes en varias tablas (candidatas a join key)")
|
||||
lines.append("")
|
||||
if shared:
|
||||
lines.append("| Columna | Tablas |")
|
||||
lines.append("|---|---|")
|
||||
for name in sorted(shared, key=lambda s: str(s).lower()):
|
||||
tbls = ", ".join(sorted((str(t) for t in shared[name]), key=str.lower))
|
||||
lines.append(f"| {name} | {tbls} |")
|
||||
else:
|
||||
lines.append(
|
||||
"_Ninguna columna aparece con el mismo nombre en mas de una tabla._"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Tabla completa ordenada por nombre de columna (y tabla como desempate).
|
||||
lines.append("## Columnas")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"| Columna | Tabla | Tipo | Tipo semantico | PII | %null | Distinct |"
|
||||
)
|
||||
lines.append("|---|---|---|---|---|---|---|")
|
||||
for e in sorted(
|
||||
entries, key=lambda e: (str(e["column"]).lower(), str(e["table"]).lower())
|
||||
):
|
||||
sem = e["semantic_type"] or "—"
|
||||
pii = "SI" if e["is_pii"] else ""
|
||||
null_s = (
|
||||
f"{e['null_pct'] * 100:.1f}%"
|
||||
if isinstance(e["null_pct"], (int, float))
|
||||
else ""
|
||||
)
|
||||
distinct_s = str(e["n_distinct"]) if e["n_distinct"] is not None else ""
|
||||
itype = e["inferred_type"] or ""
|
||||
lines.append(
|
||||
f"| {e['column']} | {e['table']} | {itype} | {sem} | {pii} "
|
||||
f"| {null_s} | {distinct_s} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_column_dictionary(db_profile: dict) -> dict:
|
||||
"""Construye el diccionario de columnas buscable de una base entera.
|
||||
|
||||
Recorre ``db_profile["table_profiles"]`` (lista de TableProfile del grupo eda,
|
||||
cada uno con ``table`` y ``columns``) y emite una entrada por columna con su
|
||||
tipo fisico inferido, tipo semantico, marca de PII, % de nulos, cardinalidad y
|
||||
valores frecuentes. Responde, a nivel de base, donde vive cada dato.
|
||||
|
||||
Args:
|
||||
db_profile: DatabaseProfile tal como lo devuelve
|
||||
``profile_database`` en su clave ``db_profile`` (el dict con
|
||||
``table_profiles``). Se lee de forma defensiva; una entrada vacia,
|
||||
None o malformada produce el resultado vacio en estado ``ok``.
|
||||
|
||||
Returns:
|
||||
Dict dict-no-throw (nunca lanza) con las claves:
|
||||
- ``status`` (str): siempre ``"ok"``.
|
||||
- ``n_tables`` (int): tablas con columnas procesadas.
|
||||
- ``n_columns`` (int): total de columnas indexadas.
|
||||
- ``entries`` (list[dict]): una entrada por columna con
|
||||
``{table, column, inferred_type, semantic_type|None, is_pii,
|
||||
null_pct|None, n_distinct|None, top_values|None}``.
|
||||
- ``pii_columns`` (list[dict]): subconjunto de ``entries`` con
|
||||
``is_pii=True`` (dato personal segun [POL-MMNSEG-001-1.0]).
|
||||
- ``markdown`` (str): tabla grep-able ordenada por nombre de columna,
|
||||
precedida de las columnas compartidas por nombre entre tablas.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(db_profile, dict):
|
||||
return _empty_result()
|
||||
|
||||
table_profiles = db_profile.get("table_profiles")
|
||||
if not isinstance(table_profiles, list) or not table_profiles:
|
||||
return _empty_result()
|
||||
|
||||
entries: list = []
|
||||
n_tables = 0
|
||||
for tp in table_profiles:
|
||||
if not isinstance(tp, dict):
|
||||
continue
|
||||
columns = tp.get("columns")
|
||||
if not isinstance(columns, list):
|
||||
continue
|
||||
n_tables += 1
|
||||
table_name = tp.get("table")
|
||||
for col in columns:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
entries.append(_column_entry(table_name, col))
|
||||
|
||||
if not entries:
|
||||
return {
|
||||
"status": "ok",
|
||||
"n_tables": n_tables,
|
||||
"n_columns": 0,
|
||||
"entries": [],
|
||||
"pii_columns": [],
|
||||
"markdown": "",
|
||||
}
|
||||
|
||||
pii_columns = [e for e in entries if e["is_pii"]]
|
||||
return {
|
||||
"status": "ok",
|
||||
"n_tables": n_tables,
|
||||
"n_columns": len(entries),
|
||||
"entries": entries,
|
||||
"pii_columns": pii_columns,
|
||||
"markdown": _render_markdown(entries),
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
return _empty_result()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests para build_column_dictionary.
|
||||
|
||||
Verifica el aplanado de un DatabaseProfile del grupo eda a un diccionario de
|
||||
columnas buscable: entradas por columna, marca de PII desde el semantic_type,
|
||||
deteccion de columnas compartidas por nombre (join keys), lectura defensiva y
|
||||
que la funcion es pura (no muta el input).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from build_column_dictionary import build_column_dictionary
|
||||
|
||||
|
||||
def _col(name, inferred_type="categorical", semantic_type="", null_pct=0.0,
|
||||
distinct_count=10, categorical=None) -> dict:
|
||||
"""ColumnProfile minimo con las claves del contrato eda usadas por la funcion."""
|
||||
return {
|
||||
"name": name,
|
||||
"physical_type": "VARCHAR",
|
||||
"inferred_type": inferred_type,
|
||||
"semantic_type": semantic_type,
|
||||
"null_pct": null_pct,
|
||||
"distinct_count": distinct_count,
|
||||
"flags": [],
|
||||
"numeric": None,
|
||||
"categorical": categorical,
|
||||
"datetime": None,
|
||||
}
|
||||
|
||||
|
||||
def _db_profile() -> dict:
|
||||
"""DatabaseProfile de juguete con dos tablas y una columna de join comun."""
|
||||
return {
|
||||
"db_path": "toy.duckdb",
|
||||
"n_tables": 2,
|
||||
"table_profiles": [
|
||||
{
|
||||
"table": "clientes",
|
||||
"columns": [
|
||||
_col("customer_id", "numeric", "", 0.0, 1000),
|
||||
_col("email", "text", "email", 0.05, 990),
|
||||
_col(
|
||||
"ciudad",
|
||||
"categorical",
|
||||
"",
|
||||
0.0,
|
||||
3,
|
||||
categorical={
|
||||
"top": [
|
||||
{"value": "Madrid", "count": 5, "pct": 0.5},
|
||||
{"value": "Bilbao", "count": 3, "pct": 0.3},
|
||||
]
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"table": "pedidos",
|
||||
"columns": [
|
||||
_col("customer_id", "numeric", "", 0.0, 800),
|
||||
_col("iban", "text", "iban", 0.1, 795),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Golden
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_golden_flattens_two_tables():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
assert res["status"] == "ok"
|
||||
assert res["n_tables"] == 2
|
||||
assert res["n_columns"] == 5
|
||||
# Una entrada por columna, con las claves del contrato.
|
||||
keys = {
|
||||
"table", "column", "inferred_type", "semantic_type",
|
||||
"is_pii", "null_pct", "n_distinct", "top_values",
|
||||
}
|
||||
for e in res["entries"]:
|
||||
assert keys.issubset(e.keys())
|
||||
# El markdown tiene la tabla y la seccion de join keys.
|
||||
assert "## Columnas" in res["markdown"]
|
||||
assert "candidatas a join key" in res["markdown"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PII desde el semantic_type real del grupo
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_pii_flagged_from_semantic_type():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
pii_cols = {(e["table"], e["column"]) for e in res["pii_columns"]}
|
||||
assert ("clientes", "email") in pii_cols
|
||||
assert ("pedidos", "iban") in pii_cols
|
||||
# customer_id / ciudad NO son PII.
|
||||
assert ("clientes", "customer_id") not in pii_cols
|
||||
assert ("clientes", "ciudad") not in pii_cols
|
||||
# Coherencia entre is_pii en entries y la lista pii_columns.
|
||||
assert res["pii_columns"] == [e for e in res["entries"] if e["is_pii"]]
|
||||
|
||||
|
||||
def test_empty_semantic_type_maps_to_none_and_not_pii():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
ciudad = next(
|
||||
e for e in res["entries"]
|
||||
if e["table"] == "clientes" and e["column"] == "ciudad"
|
||||
)
|
||||
assert ciudad["semantic_type"] is None
|
||||
assert ciudad["is_pii"] is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Columnas compartidas por nombre = candidatas a join key
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_shared_column_names_detected_as_join_keys():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
md = res["markdown"]
|
||||
# customer_id aparece en las dos tablas -> listada en la seccion de join keys.
|
||||
join_section = md.split("## Columnas\n")[0]
|
||||
assert "customer_id" in join_section
|
||||
assert "clientes" in join_section and "pedidos" in join_section
|
||||
# email solo esta en una tabla -> no aparece en la seccion de join keys.
|
||||
assert "email" not in join_section
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# top_values desde el bloque categorical
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_top_values_from_categorical_block():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
ciudad = next(e for e in res["entries"] if e["column"] == "ciudad")
|
||||
assert ciudad["top_values"] == ["Madrid", "Bilbao"]
|
||||
# Columnas sin bloque categorical -> None.
|
||||
email = next(e for e in res["entries"] if e["column"] == "email")
|
||||
assert email["top_values"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Entrada vacia / malformada -> resultado vacio en ok
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_empty_profile_returns_empty_ok():
|
||||
empty = build_column_dictionary({})
|
||||
assert empty == {
|
||||
"status": "ok", "n_tables": 0, "n_columns": 0,
|
||||
"entries": [], "pii_columns": [], "markdown": "",
|
||||
}
|
||||
|
||||
|
||||
def test_malformed_input_returns_empty_ok():
|
||||
for bad in (None, [], "nope", 42, {"table_profiles": "x"}):
|
||||
res = build_column_dictionary(bad)
|
||||
assert res["status"] == "ok"
|
||||
assert res["n_columns"] == 0
|
||||
assert res["entries"] == []
|
||||
assert res["markdown"] == ""
|
||||
|
||||
|
||||
def test_missing_keys_read_defensively():
|
||||
# TableProfiles y columnas con claves ausentes / basura no rompen.
|
||||
profile = {
|
||||
"table_profiles": [
|
||||
{"table": "t1", "columns": [{"name": "a"}, "no-dict", None]},
|
||||
"no-dict",
|
||||
{"table": "t2"}, # sin columns
|
||||
{"columns": [{}]}, # sin table, columna vacia
|
||||
]
|
||||
}
|
||||
res = build_column_dictionary(profile)
|
||||
assert res["status"] == "ok"
|
||||
# t1 (1 col dict valida; "no-dict" y None se saltan) + tabla sin table
|
||||
# (1 col {}). t2 no tiene columns -> no cuenta como tabla.
|
||||
assert res["n_tables"] == 2
|
||||
assert res["n_columns"] == 2
|
||||
a = next(e for e in res["entries"] if e["column"] == "a")
|
||||
assert a["semantic_type"] is None
|
||||
assert a["null_pct"] is None
|
||||
assert a["n_distinct"] is None
|
||||
assert a["top_values"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pureza
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_does_not_mutate_input():
|
||||
profile = _db_profile()
|
||||
snapshot = copy.deepcopy(profile)
|
||||
build_column_dictionary(profile)
|
||||
assert profile == snapshot
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
id: categorical_top_bar_figure_py_datascience
|
||||
name: categorical_top_bar_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def categorical_top_bar_figure(top: list, n_distinct: int = 0, title: str = \"\", top_k: int = 6, n_rows=None) -> \"matplotlib.figure.Figure\""
|
||||
description: "Construye una figura matplotlib de barras horizontales de las top_k categorías más frecuentes de una columna categórica, con la mayor arriba y agregando el resto en una barra gris \"Otros (N categorías)\". Contrato de entrada idéntico a categorical_top_pie_figure (swap directo donut↔barras): consume el bloque `top` de summarize_categorical y devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA. Backend Agg sin pyplot global; defensivo total ante top vacío/None, nunca lanza."
|
||||
tags: [eda, categorical, bar, barh, matplotlib, figure, visualization, datascience, impure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib]
|
||||
example: |
|
||||
from categorical_top_bar_figure import categorical_top_bar_figure
|
||||
top = [
|
||||
{"value": "rojo", "count": 40, "pct": 0.4},
|
||||
{"value": "azul", "count": 30, "pct": 0.3},
|
||||
{"value": "verde", "count": 20, "pct": 0.2},
|
||||
]
|
||||
fig = categorical_top_bar_figure(top, n_distinct=12, title="color", top_k=6, n_rows=100)
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure"
|
||||
- "test_ten_items_topk_six_yields_seven_bars"
|
||||
- "test_empty_top_does_not_raise_and_returns_figure"
|
||||
- "test_long_value_truncated"
|
||||
- "test_none_value_and_none_count_are_handled"
|
||||
- "test_n_rows_adds_exact_others_bar"
|
||||
test_file_path: "python/functions/datascience/categorical_top_bar_figure_test.py"
|
||||
file_path: "python/functions/datascience/categorical_top_bar_figure.py"
|
||||
params:
|
||||
- name: top
|
||||
desc: "Lista de dicts {value, count, pct} ordenada de mayor a menor por count (salida del bloque `top` de summarize_categorical). Puede venir vacía o con dicts incompletos: items no-dict, sin count, con count None o count <= 0 se descartan. value None se admite (etiqueta vacía)."
|
||||
- name: n_distinct
|
||||
desc: "Nº total de categorías distintas de la columna. Etiqueta la barra agregada como \"Otros (n_distinct - top_k)\" (mínimo 0). Si no supera el nº de barras mostradas, se usa el overflow real de `top` como nº de categorías agregadas. Default 0."
|
||||
- name: title
|
||||
desc: "Título de la figura (nombre de la columna). Se trunca a ~48 chars con elipsis si es muy largo. Default \"\" (sin título)."
|
||||
- name: top_k
|
||||
desc: "Nº máximo de barras explícitas. Default 6. La barra \"Otros\" no cuenta contra este límite. Con top_k <= 0 se muestra al menos la categoría mayor."
|
||||
- name: n_rows
|
||||
desc: "Opcional. Total de filas del dataset. Si se da y la suma de counts mostrados < n_rows, la barra \"Otros\" usa (n_rows - suma_mostrada) como count para que sea exacta respecto al total real. Si se omite, \"Otros\" usa la suma de counts fuera del top_k mostrado (solo cuando top trae más de top_k items). Default None."
|
||||
output: "Un matplotlib.figure.Figure (figsize 6.4 x altura escalada con el nº de barras, dpi 150) con un Axes de barras horizontales: la categoría más frecuente arriba, la barra gris \"Otros (N categorías)\" abajo, cada barra anotada con su conteo y porcentaje al final y etiquetas de categoría (yticklabels) truncadas a ~22 chars. Si no hay counts válidos devuelve igualmente una Figure con un texto centrado \"sin datos categóricos\" (nunca lanza); cualquier error inesperado cae a una Figure con el texto del error. El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from categorical_top_bar_figure import categorical_top_bar_figure
|
||||
|
||||
# `top` es la salida del bloque "top" de summarize_categorical (ya ordenado desc).
|
||||
top = [
|
||||
{"value": "rojo", "count": 40, "pct": 0.40},
|
||||
{"value": "azul", "count": 30, "pct": 0.30},
|
||||
{"value": "verde", "count": 20, "pct": 0.20},
|
||||
{"value": "amarillo", "count": 5, "pct": 0.05},
|
||||
]
|
||||
|
||||
fig = categorical_top_bar_figure(
|
||||
top,
|
||||
n_distinct=12, # 12 categorías distintas en total
|
||||
title="color_producto",
|
||||
top_k=6, # hasta 6 barras explícitas
|
||||
n_rows=100, # "Otros" = 100 - 95 = 5, sobre 8 categorías agregadas
|
||||
)
|
||||
|
||||
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||
fig.savefig("/tmp/barras_color.png")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala dentro de un informe EDA cuando quieras comparar **magnitudes** de las
|
||||
categorías dominantes de una columna categórica: qué categoría manda y por
|
||||
cuánto frente a las siguientes. Pásale directamente el bloque `top` de
|
||||
`summarize_categorical` (ya ordenado de mayor a menor) más `n_distinct` para que
|
||||
la barra "Otros" indique cuántas categorías quedan agrupadas. Es el clon "de
|
||||
barras" del donut `categorical_top_pie_figure` con **contrato de entrada
|
||||
idéntico**: puedes intercambiar una por otra sin tocar el caller. Elige barras
|
||||
cuando importe comparar tamaños exactos; el donut cuando importe la proporción
|
||||
del total.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
|
||||
directamente, así que es segura de llamar en bucle desde el renderer.
|
||||
- **El caller cierra la figura.** La función devuelve el `Figure` pero no lo
|
||||
muestra ni lo guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||
(`fig.clf()` / `matplotlib.pyplot.close(fig)` si se usó pyplot en el caller)
|
||||
para no acumular memoria en lotes grandes de columnas.
|
||||
- **`barh` dibuja de abajo arriba.** La categoría más frecuente va arriba porque
|
||||
el orden de display se invierte antes de plotear; la barra "Otros" queda
|
||||
siempre al fondo. No reordenes `top` esperando otro layout: la función asume
|
||||
que ya viene ordenado desc por count.
|
||||
- **Magnitud exacta de "Otros" solo con `n_rows`.** Sin `n_rows`, la barra
|
||||
"Otros" se calcula con el overflow presente en `top`; si `top` ya viene
|
||||
recortado a `top_k` por el productor, no habrá "Otros" aunque existan más
|
||||
categorías. Pasa `n_rows` (total de filas del dataset) para una barra correcta
|
||||
respecto al total real.
|
||||
- **Defensiva, nunca lanza.** `top=[]`, `value=None`, `count=None` o counts no
|
||||
numéricos se manejan sin error: en el peor caso devuelve una `Figure` con
|
||||
"sin datos categóricos", y cualquier excepción inesperada cae a una `Figure`
|
||||
con el texto del error. No envuelvas la llamada en try/except por miedo a un
|
||||
raise — no lo hay.
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Impure EDA helper: horizontal bar figure of the most common categories (`eda` group).
|
||||
|
||||
Builds a horizontal bar chart of the ``top_k`` most frequent categories of a
|
||||
categorical column, folding everything else into a single gray
|
||||
"Otros (N categorías)" bar. The most frequent category sits at the top, each bar
|
||||
labelled with its count (and percentage) at the end. Returns a ready-to-rasterize
|
||||
``matplotlib.figure.Figure``; it never shows nor saves it.
|
||||
|
||||
This is the "magnitude" twin of ``categorical_top_pie_figure``: identical input
|
||||
contract (same ``top``/``n_distinct``/``title``/``top_k``/``n_rows`` signature) so
|
||||
it can be swapped in directly, but it communicates comparable magnitudes via bars
|
||||
instead of proportions via wedges.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||
global state and is safe to call repeatedly from a report renderer.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
|
||||
# Gray reserved for the aggregated "Otros" bar.
|
||||
_OTHER_COLOR = "#9e9e9e"
|
||||
# Muted gray for secondary text (title fallback, no-data message).
|
||||
_MUTED_TEXT = "#5f6b7a"
|
||||
# Soft red for the error fallback message.
|
||||
_ERROR_TEXT = "#b00020"
|
||||
# Pleasant, colour-blind-friendly qualitative palette for the explicit bars.
|
||||
_PALETTE = [
|
||||
"#4C72B0",
|
||||
"#DD8452",
|
||||
"#55A868",
|
||||
"#C44E52",
|
||||
"#8172B3",
|
||||
"#937860",
|
||||
"#DA8BC3",
|
||||
"#8C8C8C",
|
||||
"#CCB974",
|
||||
"#64B5CD",
|
||||
]
|
||||
|
||||
|
||||
def _truncate(text, width: int = 22) -> str:
|
||||
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
|
||||
s = "" if text is None else str(text)
|
||||
if len(s) <= width:
|
||||
return s
|
||||
if width <= 1:
|
||||
return s[:width]
|
||||
return s[: width - 1] + "…"
|
||||
|
||||
|
||||
def _message_figure(message: str, color: str = _MUTED_TEXT, title: str = "") -> "Figure":
|
||||
"""Return a fallback ``Figure`` carrying a single centered message."""
|
||||
fig = Figure(figsize=(6.4, 4.0), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color=color,
|
||||
wrap=True,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
if title:
|
||||
ax.set_title(_truncate(title, 48), fontsize=12, loc="center", pad=8)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def categorical_top_bar_figure(
|
||||
top: list,
|
||||
n_distinct: int = 0,
|
||||
title: str = "",
|
||||
top_k: int = 6,
|
||||
n_rows=None,
|
||||
) -> "matplotlib.figure.Figure":
|
||||
"""Build a horizontal bar figure of the most common categories of a column.
|
||||
|
||||
Renders the ``top_k`` most frequent categories as explicit horizontal bars,
|
||||
largest at the top, and aggregates every remaining category into a single
|
||||
gray "Otros (N categorías)" bar at the bottom. Each bar is annotated with its
|
||||
count and percentage of the total at the end of the bar; the category names
|
||||
are truncated Y tick labels.
|
||||
|
||||
The function shares the exact input contract of
|
||||
``categorical_top_pie_figure`` (the donut twin) so it is a drop-in swap. It is
|
||||
fully defensive: empty input, missing/``None`` values or counts never raise.
|
||||
When there is nothing valid to draw it still returns a ``Figure`` carrying a
|
||||
centered "sin datos categóricos" message, and any unexpected error is caught
|
||||
and turned into a fallback ``Figure`` carrying the error text.
|
||||
|
||||
Args:
|
||||
top: List of ``{value, count, pct}`` dicts, already sorted by ``count``
|
||||
descending (the ``top`` block of ``summarize_categorical``). May be
|
||||
empty or carry incomplete/``None`` entries; non-dict items, items
|
||||
without a positive numeric ``count`` and ``None`` counts are skipped.
|
||||
n_distinct: Total number of distinct categories in the column. Used to
|
||||
label the aggregated bar as "Otros (n_distinct - top_k)" (floored at
|
||||
0). Ignored when it does not exceed the number of shown bars.
|
||||
title: Figure title (the column name). Truncated when too long.
|
||||
top_k: Maximum number of explicit bars. Default 6. The "Otros" bar does
|
||||
not count against this limit.
|
||||
n_rows: Optional total row count of the dataset. When given and the sum of
|
||||
shown counts is below ``n_rows``, the "Otros" bar uses
|
||||
``n_rows - sum_shown`` as its count so it is exact with respect to the
|
||||
real total. When omitted, "Otros" uses the sum of the counts that fall
|
||||
outside the shown ``top_k`` (only when ``top`` carries more than
|
||||
``top_k`` items).
|
||||
|
||||
Returns:
|
||||
A ``matplotlib.figure.Figure`` with a single horizontal-bar Axes. The
|
||||
caller is responsible for rasterizing/closing it.
|
||||
"""
|
||||
try:
|
||||
safe_title = _truncate(title, 48)
|
||||
|
||||
# --- Defensive parse: keep only well-formed {value, count} with count > 0.
|
||||
cleaned = []
|
||||
if isinstance(top, list):
|
||||
for item in top:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
count = item.get("count")
|
||||
if count is None:
|
||||
continue
|
||||
try:
|
||||
count = float(count)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if count <= 0:
|
||||
continue
|
||||
cleaned.append((item.get("value"), count))
|
||||
|
||||
if not cleaned:
|
||||
return _message_figure("sin datos categóricos", title=title)
|
||||
|
||||
# --- Split into shown bars and the aggregated remainder.
|
||||
shown = cleaned[: max(int(top_k), 0)]
|
||||
if not shown: # top_k <= 0 — show at least the largest category.
|
||||
shown = cleaned[:1]
|
||||
|
||||
sum_shown = sum(c for _, c in shown)
|
||||
overflow_count = sum(c for _, c in cleaned[len(shown):])
|
||||
|
||||
# How many categories are folded into "Otros".
|
||||
try:
|
||||
nd = int(n_distinct)
|
||||
except (TypeError, ValueError):
|
||||
nd = 0
|
||||
others_categories = max(nd - len(shown), 0)
|
||||
# If n_distinct is unknown/too small, fall back to the overflow we
|
||||
# actually have in `top` beyond the shown bars.
|
||||
overflow_items = len(cleaned) - len(shown)
|
||||
if others_categories == 0 and overflow_items > 0:
|
||||
others_categories = overflow_items
|
||||
|
||||
# Count attributed to the "Otros" bar.
|
||||
others_count = 0.0
|
||||
if n_rows is not None:
|
||||
try:
|
||||
total_rows = float(n_rows)
|
||||
except (TypeError, ValueError):
|
||||
total_rows = None
|
||||
if total_rows is not None and total_rows > sum_shown:
|
||||
others_count = total_rows - sum_shown
|
||||
if others_count <= 0:
|
||||
others_count = overflow_count
|
||||
|
||||
# --- Build the display order (top to bottom): largest .. smallest, Otros.
|
||||
display_labels = [_truncate(v, 22) for v, _ in shown]
|
||||
display_values = [c for _, c in shown]
|
||||
display_colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(shown))]
|
||||
|
||||
has_others = others_count > 0 and others_categories > 0
|
||||
if has_others:
|
||||
display_labels.append(f"Otros ({others_categories} categorías)")
|
||||
display_values.append(others_count)
|
||||
display_colors.append(_OTHER_COLOR)
|
||||
|
||||
total = sum(display_values) or 1.0
|
||||
|
||||
# barh draws bottom-up, so reverse the display order before plotting to
|
||||
# land the largest category on top and "Otros" at the bottom.
|
||||
labels = list(reversed(display_labels))
|
||||
values = list(reversed(display_values))
|
||||
colors = list(reversed(display_colors))
|
||||
y_pos = range(len(values))
|
||||
|
||||
# Height scales with the number of bars so dense reports stay readable.
|
||||
n_bars = len(values)
|
||||
height = max(2.4, min(0.4 * n_bars + 1.2, 14.0))
|
||||
fig = Figure(figsize=(6.4, height), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
|
||||
ax.barh(list(y_pos), values, color=colors, edgecolor="white")
|
||||
ax.set_yticks(list(y_pos))
|
||||
ax.set_yticklabels(labels, fontsize=8)
|
||||
ax.set_xlabel("conteo", fontsize=9)
|
||||
|
||||
max_val = max(values) if values else 1.0
|
||||
ax.set_xlim(0, max_val * 1.18 if max_val > 0 else 1.0)
|
||||
|
||||
# Annotate each bar with its count and percentage at the end of the bar.
|
||||
for y, val in zip(y_pos, values):
|
||||
pct = val / total * 100.0
|
||||
ax.text(
|
||||
val + max_val * 0.012,
|
||||
y,
|
||||
f"{int(round(val))} ({pct:.0f}%)",
|
||||
va="center",
|
||||
ha="left",
|
||||
fontsize=7,
|
||||
color="#202020",
|
||||
)
|
||||
|
||||
if safe_title:
|
||||
ax.set_title(safe_title, fontsize=13, loc="left", pad=10)
|
||||
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
|
||||
return _message_figure(
|
||||
f"error al dibujar barras: {exc}", color=_ERROR_TEXT
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests para categorical_top_bar_figure (barras de categorías top, grupo eda).
|
||||
|
||||
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
|
||||
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
|
||||
estado entre tests.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from categorical_top_bar_figure import categorical_top_bar_figure
|
||||
|
||||
|
||||
def _make_top(n):
|
||||
"""n items {value, count, pct} ordenados desc por count."""
|
||||
return [
|
||||
{"value": f"cat_{i}", "count": n - i, "pct": (n - i) / sum(range(1, n + 1))}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _bar_count(ax):
|
||||
"""Devuelve el nº de barras (longitud del primer BarContainer del Axes)."""
|
||||
if ax.containers:
|
||||
return len(ax.containers[0])
|
||||
return 0
|
||||
|
||||
|
||||
def test_returns_figure():
|
||||
fig = categorical_top_bar_figure(_make_top(3), n_distinct=3, title="col")
|
||||
assert isinstance(fig, Figure)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_ten_items_topk_six_yields_seven_bars():
|
||||
top = _make_top(10)
|
||||
fig = categorical_top_bar_figure(top, n_distinct=10, title="muchas", top_k=6)
|
||||
ax = fig.axes[0]
|
||||
# 6 categorías explícitas + 1 barra "Otros".
|
||||
assert _bar_count(ax) == 7
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_top_does_not_raise_and_returns_figure():
|
||||
fig = categorical_top_bar_figure([], n_distinct=0, title="vacía")
|
||||
assert isinstance(fig, Figure)
|
||||
# Sin datos: no debe haber barras.
|
||||
assert _bar_count(fig.axes[0]) == 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_long_value_truncated():
|
||||
long_value = "una_categoria_con_un_nombre_larguisimo_que_excede_el_limite"
|
||||
top = [
|
||||
{"value": long_value, "count": 10, "pct": 0.5},
|
||||
{"value": "corta", "count": 10, "pct": 0.5},
|
||||
]
|
||||
fig = categorical_top_bar_figure(top, n_distinct=2, title="col", top_k=6)
|
||||
ax = fig.axes[0]
|
||||
tick_texts = [t.get_text() for t in ax.get_yticklabels()]
|
||||
# El valor largo aparece truncado con elipsis y NO en su forma completa.
|
||||
assert any("…" in t for t in tick_texts)
|
||||
assert long_value not in " ".join(tick_texts)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_none_value_and_none_count_are_handled():
|
||||
top = [
|
||||
{"value": None, "count": 5, "pct": 0.5},
|
||||
{"value": "b", "count": None, "pct": 0.0}, # count None -> se descarta
|
||||
{"value": "c", "count": 5, "pct": 0.5},
|
||||
]
|
||||
fig = categorical_top_bar_figure(top, n_distinct=2, title="con nones", top_k=6)
|
||||
assert isinstance(fig, Figure)
|
||||
# Solo 2 items válidos, sin overflow -> 2 barras, sin "Otros".
|
||||
assert _bar_count(fig.axes[0]) == 2
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_n_rows_adds_exact_others_bar():
|
||||
# 3 categorías mostradas suman 30, dataset real 100 -> "Otros" = 70.
|
||||
top = [
|
||||
{"value": "a", "count": 15, "pct": 0.15},
|
||||
{"value": "b", "count": 10, "pct": 0.10},
|
||||
{"value": "c", "count": 5, "pct": 0.05},
|
||||
]
|
||||
fig = categorical_top_bar_figure(
|
||||
top, n_distinct=20, title="col", top_k=3, n_rows=100
|
||||
)
|
||||
ax = fig.axes[0]
|
||||
# 3 explícitas + Otros.
|
||||
assert _bar_count(ax) == 4
|
||||
tick_texts = [t.get_text() for t in ax.get_yticklabels()]
|
||||
# La barra Otros refleja n_distinct - top_k = 17 categorías.
|
||||
assert any("Otros (17 categorías)" in t for t in tick_texts)
|
||||
# Su anotación lleva el count 70.
|
||||
annotation_texts = [t.get_text() for t in ax.texts]
|
||||
assert any("70" in t for t in annotation_texts)
|
||||
plt.close(fig)
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: confidence_interval_mean
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def confidence_interval_mean(data: list, other: list = None, confidence: float = 0.95) -> dict"
|
||||
description: "Intervalo de confianza (IC) de la media de una muestra con la t de Student, o de la DIFERENCIA de medias de dos muestras independientes con el metodo de Welch (sin asumir varianzas iguales). Una muestra: df=n-1, se=sd_muestral/sqrt(n) (sd con ddof=1), tcrit=t.ppf((1+confidence)/2, df), ci=mean+/-tcrit*se. Dos muestras: IC de mean(data)-mean(other) con se=sqrt(se1^2+se2^2) y grados de libertad de Welch-Satterthwaite. Pura y robusta: nunca lanza; ante casos degenerados (muestra vacia, n<2) devuelve nan + clave note, y con varianza cero el IC colapsa al punto (no es error). Usa scipy.stats y numpy."
|
||||
tags: [papers, statistics, confidence-interval, welch, t-test, python]
|
||||
params:
|
||||
- name: data
|
||||
desc: "muestra de observaciones numericas (lista de numeros). Si other es None, el IC es el de la media de data."
|
||||
- name: other
|
||||
desc: "segunda muestra independiente (lista de numeros) o None (default). Si se da, el IC es el de la diferencia de medias mean(data)-mean(other) calculada con Welch (no asume varianzas iguales)."
|
||||
- name: confidence
|
||||
desc: "nivel de confianza en (0, 1); 0.95 = IC del 95% (default). El cuantil critico es t.ppf((1+confidence)/2, df)."
|
||||
output: "dict {mean, ci_low, ci_high, se, df, confidence, n}. mean = media de data (una muestra) o la diferencia mean(data)-mean(other) (dos muestras). En el caso de dos muestras se anaden ademas n1 y n2 (y n = n1+n2). df son los grados de libertad de la t (Welch-Satterthwaite si dos muestras). Casos degenerados (muestra vacia, n<2) anaden la clave note y dejan ci_low/ci_high/se (y a veces df) en nan; con varianza cero y n>=2 el IC colapsa a [mean, mean] con se=0 (con note, sin nan). Nunca None ni excepcion."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [scipy, numpy]
|
||||
tested: true
|
||||
tests: ["test_one_sample_golden_contra_scipy", "test_one_sample_distinto_nivel_confianza", "test_welch_diferencia_golden_contra_scipy", "test_edge_un_solo_elemento_no_lanza_nan_note", "test_edge_lista_vacia_no_lanza_note", "test_edge_varianza_cero_colapsa_al_punto", "test_edge_welch_muestra_vacia_no_lanza_note", "test_edge_welch_n1_uno_no_lanza_note"]
|
||||
test_file_path: "python/functions/datascience/confidence_interval_mean_test.py"
|
||||
file_path: "python/functions/datascience/confidence_interval_mean.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import confidence_interval_mean
|
||||
|
||||
# IC del 95% de la media de una muestra (t de Student).
|
||||
data = [2, 4, 4, 4, 5, 5, 7, 9]
|
||||
ci = confidence_interval_mean(data, confidence=0.95)
|
||||
print(ci["mean"]) # -> 5.0
|
||||
print(ci["df"]) # -> 7.0 (n - 1)
|
||||
print(round(ci["ci_low"], 5), round(ci["ci_high"], 5))
|
||||
# -> 3.21251 6.78749 (se con sd muestral ddof=1 ~ 2.13809)
|
||||
|
||||
# IC del 95% de la DIFERENCIA de medias (Welch, no asume varianzas iguales).
|
||||
control = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
|
||||
tratado = [18.0, 20.0, 17.0, 19.0, 21.0]
|
||||
diff = confidence_interval_mean(control, tratado, confidence=0.95)
|
||||
print(diff["mean"]) # -> 4.5 (mean(control) - mean(tratado))
|
||||
print(round(diff["ci_low"], 4), round(diff["ci_high"], 4))
|
||||
# Si el intervalo no incluye 0, la diferencia es significativa al 5%.
|
||||
|
||||
# Degenerados: nunca lanza.
|
||||
print(confidence_interval_mean([5])["note"]) # n < 2: ... indefinidos
|
||||
print(confidence_interval_mean([3, 3, 3])["se"]) # -> 0.0 (IC colapsa a [3, 3])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras cuantificar la **incertidumbre de una media estimada** a partir de
|
||||
una muestra: reporta `[ci_low, ci_high]` en vez de un punto suelto para mostrar
|
||||
el rango plausible del valor real al nivel de confianza pedido. Usala tambien
|
||||
para **comparar dos grupos** (A/B test, control vs tratamiento, antes vs
|
||||
despues con grupos independientes): pasa las dos muestras y, si el IC de la
|
||||
diferencia **no incluye el 0**, la diferencia es significativa al nivel
|
||||
`1 - confidence`. Es el complemento del p-valor: ademas de "hay efecto", te dice
|
||||
"de que tamano y con que margen". Para dos muestras usa Welch por defecto, asi
|
||||
que no necesitas comprobar antes si las varianzas son iguales.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Pura y determinista (no hace I/O, no muta las entradas), pero **no** es
|
||||
stdlib-only: depende de `scipy.stats` y `numpy` (ambos en el venv del proyecto).
|
||||
- Con `other` usa **Welch** (df de Welch-Satterthwaite): NO asume varianzas
|
||||
iguales ni tamanos de muestra iguales. Si necesitas el t-test clasico de
|
||||
varianzas agrupadas (pooled), esta funcion no lo hace.
|
||||
- `sd` se calcula con **ddof=1** (sd muestral), que es lo correcto para el IC de
|
||||
una media con la t. Atajos como `sd_poblacional/sqrt(n)` (ddof=0) dan un
|
||||
intervalo demasiado estrecho.
|
||||
- En el caso de dos muestras, `mean` es la **diferencia** `mean(data) - mean(other)`
|
||||
(no la media de data). El orden importa: el signo del IC depende de cual va
|
||||
primero.
|
||||
- Nunca lanza. Casos degenerados devuelven `nan` en `ci_low`/`ci_high`/`se`
|
||||
(y a veces `df`) mas una clave `note`: muestra vacia o `n < 2` en cualquiera de
|
||||
las muestras. **Excepcion**: con varianza cero y `n >= 2` el IC colapsa al
|
||||
punto `[mean, mean]` con `se = 0` (no es un error, no hay `nan`).
|
||||
- Comprueba `"note" in out` antes de usar `ci_low`/`ci_high` si la muestra puede
|
||||
ser degenerada.
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Intervalo de confianza de la media (una muestra) o de la diferencia de medias (Welch).
|
||||
|
||||
Funcion pura del grupo papers. Calcula el intervalo de confianza (IC) de la media
|
||||
de una muestra usando la t de Student, o el IC de la diferencia de medias de dos
|
||||
muestras independientes con el metodo de Welch (sin asumir varianzas iguales).
|
||||
|
||||
- Una muestra: ``df = n - 1``, ``se = sd / sqrt(n)`` (sd con ddof=1),
|
||||
``tcrit = t.ppf((1 + confidence) / 2, df)``, ``ci = mean +/- tcrit * se``.
|
||||
- Dos muestras (Welch): IC de ``mean(data) - mean(other)``, con
|
||||
``se = sqrt(se1^2 + se2^2)`` y grados de libertad de Welch-Satterthwaite.
|
||||
|
||||
No lanza excepciones: ante casos degenerados (muestras vacias, ``n < 2``,
|
||||
varianza cero) devuelve un dict coherente con ``ci_low``/``ci_high``/``se`` en
|
||||
``nan`` (salvo el sub-caso de varianza cero, donde el IC colapsa al punto) y una
|
||||
clave ``note`` explicando el caso. Usa ``scipy.stats`` y ``numpy``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy import stats
|
||||
|
||||
|
||||
def confidence_interval_mean(
|
||||
data: list, other: list = None, confidence: float = 0.95
|
||||
) -> dict:
|
||||
"""Intervalo de confianza de la media o de la diferencia de medias (Welch).
|
||||
|
||||
Si ``other`` es ``None``, calcula el IC de la media de ``data`` con la t de
|
||||
Student. Si se proporciona ``other``, calcula el IC de la diferencia
|
||||
``mean(data) - mean(other)`` con el metodo de Welch (no asume varianzas
|
||||
iguales) y grados de libertad de Welch-Satterthwaite.
|
||||
|
||||
Es una funcion pura y determinista: no hace I/O ni muta las entradas. No
|
||||
lanza excepcion ante datos degenerados; en su lugar devuelve un dict con la
|
||||
clave ``note`` y los campos numericos indefinidos a ``nan``.
|
||||
|
||||
Args:
|
||||
data: muestra de observaciones numericas (lista de numeros).
|
||||
other: segunda muestra independiente. Si se da, el IC es el de la
|
||||
diferencia de medias ``mean(data) - mean(other)`` con Welch. Si es
|
||||
``None`` (default), el IC es el de la media de ``data``.
|
||||
confidence: nivel de confianza en (0, 1), p.ej. 0.95 para el 95%.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
mean: media de ``data`` (una muestra) o la diferencia
|
||||
``mean(data) - mean(other)`` (dos muestras).
|
||||
ci_low: extremo inferior del intervalo de confianza.
|
||||
ci_high: extremo superior del intervalo de confianza.
|
||||
se: error estandar de la media (o de la diferencia).
|
||||
df: grados de libertad de la t (Welch-Satterthwaite si dos muestras).
|
||||
confidence: nivel de confianza aplicado (float).
|
||||
n: tamano de la muestra (una muestra) o tamano total ``n1 + n2``
|
||||
(dos muestras; ademas se incluyen ``n1`` y ``n2``).
|
||||
|
||||
En el caso de dos muestras se incluyen ademas ``n1`` y ``n2``. Casos
|
||||
degenerados (muestra vacia, ``n < 2``, etc.) anaden la clave ``note`` y
|
||||
dejan ``ci_low``/``ci_high``/``se`` (y a veces ``df``) en ``nan``.
|
||||
"""
|
||||
conf = float(confidence)
|
||||
|
||||
if other is None:
|
||||
return _ci_one_sample(data, conf)
|
||||
return _ci_welch(data, other, conf)
|
||||
|
||||
|
||||
def _ci_one_sample(data: list, conf: float) -> dict:
|
||||
"""IC de la media de una sola muestra con la t de Student."""
|
||||
arr = np.asarray(list(data), dtype=float)
|
||||
n = int(arr.size)
|
||||
|
||||
base = {
|
||||
"mean": float("nan"),
|
||||
"ci_low": float("nan"),
|
||||
"ci_high": float("nan"),
|
||||
"se": float("nan"),
|
||||
"df": float("nan"),
|
||||
"confidence": conf,
|
||||
"n": n,
|
||||
}
|
||||
|
||||
if n == 0:
|
||||
base["note"] = "muestra vacia: media e intervalo indefinidos"
|
||||
return base
|
||||
|
||||
mean = float(arr.mean())
|
||||
base["mean"] = mean
|
||||
|
||||
if n < 2:
|
||||
base["note"] = "n < 2: error estandar y grados de libertad indefinidos"
|
||||
return base
|
||||
|
||||
df = n - 1
|
||||
base["df"] = float(df)
|
||||
|
||||
sd = float(arr.std(ddof=1))
|
||||
se = sd / math.sqrt(n)
|
||||
base["se"] = se
|
||||
|
||||
# Varianza cero: el IC colapsa al punto (no es un error).
|
||||
if se == 0.0:
|
||||
base["ci_low"] = mean
|
||||
base["ci_high"] = mean
|
||||
base["note"] = "varianza cero: el intervalo colapsa a la media"
|
||||
return base
|
||||
|
||||
tcrit = float(stats.t.ppf((1.0 + conf) / 2.0, df))
|
||||
margin = tcrit * se
|
||||
base["ci_low"] = mean - margin
|
||||
base["ci_high"] = mean + margin
|
||||
return base
|
||||
|
||||
|
||||
def _ci_welch(data: list, other: list, conf: float) -> dict:
|
||||
"""IC de la diferencia de medias de dos muestras con el metodo de Welch."""
|
||||
a = np.asarray(list(data), dtype=float)
|
||||
b = np.asarray(list(other), dtype=float)
|
||||
n1 = int(a.size)
|
||||
n2 = int(b.size)
|
||||
|
||||
base = {
|
||||
"mean": float("nan"),
|
||||
"ci_low": float("nan"),
|
||||
"ci_high": float("nan"),
|
||||
"se": float("nan"),
|
||||
"df": float("nan"),
|
||||
"confidence": conf,
|
||||
"n": n1 + n2,
|
||||
"n1": n1,
|
||||
"n2": n2,
|
||||
}
|
||||
|
||||
if n1 == 0 or n2 == 0:
|
||||
base["note"] = "alguna muestra esta vacia: diferencia e intervalo indefinidos"
|
||||
return base
|
||||
|
||||
mean1 = float(a.mean())
|
||||
mean2 = float(b.mean())
|
||||
diff = mean1 - mean2
|
||||
base["mean"] = diff
|
||||
|
||||
if n1 < 2 or n2 < 2:
|
||||
base["note"] = (
|
||||
"n < 2 en alguna muestra: error estandar y grados de libertad indefinidos"
|
||||
)
|
||||
return base
|
||||
|
||||
sd1 = float(a.std(ddof=1))
|
||||
sd2 = float(b.std(ddof=1))
|
||||
se1 = sd1 / math.sqrt(n1)
|
||||
se2 = sd2 / math.sqrt(n2)
|
||||
se = math.sqrt(se1 * se1 + se2 * se2)
|
||||
base["se"] = se
|
||||
|
||||
# Ambas varianzas cero: el IC de la diferencia colapsa al punto.
|
||||
if se == 0.0:
|
||||
base["ci_low"] = diff
|
||||
base["ci_high"] = diff
|
||||
base["df"] = float("nan")
|
||||
base["note"] = "varianza cero en ambas muestras: el intervalo colapsa a la diferencia"
|
||||
return base
|
||||
|
||||
# Grados de libertad de Welch-Satterthwaite.
|
||||
df = (se1 * se1 + se2 * se2) ** 2 / (
|
||||
(se1**4) / (n1 - 1) + (se2**4) / (n2 - 1)
|
||||
)
|
||||
base["df"] = float(df)
|
||||
|
||||
tcrit = float(stats.t.ppf((1.0 + conf) / 2.0, df))
|
||||
margin = tcrit * se
|
||||
base["ci_low"] = diff - margin
|
||||
base["ci_high"] = diff + margin
|
||||
return base
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests para confidence_interval_mean (IC de la media / diferencia de medias Welch).
|
||||
|
||||
Importa el modulo hoja directamente (`confidence_interval_mean`) para no depender
|
||||
de que el paquete reexporte la funcion en su __init__ (lo integra el orquestador
|
||||
al cerrar el grupo).
|
||||
|
||||
Los golden se calculan con scipy dentro del propio test para que sean robustos:
|
||||
la funcion bajo prueba debe coincidir con la referencia de scipy a ~1e-9.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy import stats
|
||||
|
||||
from confidence_interval_mean import confidence_interval_mean
|
||||
|
||||
|
||||
def test_one_sample_golden_contra_scipy():
|
||||
# mean=5.0, n=8. Este dataset tiene sd POBLACIONAL (ddof=0) exactamente 2.0,
|
||||
# pero la sd MUESTRAL (ddof=1, la que exige la spec y la que es correcta para
|
||||
# el IC de una media con la t) es sqrt(32/7) ~ 2.13809. El golden robusto se
|
||||
# calcula con scipy usando se con ddof=1, no con el atajo 2.0/sqrt(8).
|
||||
data = [2, 4, 4, 4, 5, 5, 7, 9]
|
||||
out = confidence_interval_mean(data, confidence=0.95)
|
||||
|
||||
n = len(data)
|
||||
mean = float(np.mean(data))
|
||||
sd = float(np.std(data, ddof=1)) # sample sd ~ 2.13809
|
||||
se = sd / math.sqrt(n)
|
||||
lo, hi = stats.t.interval(0.95, df=n - 1, loc=mean, scale=se)
|
||||
|
||||
assert abs(out["mean"] - 5.0) < 1e-9
|
||||
assert abs(out["se"] - se) < 1e-12
|
||||
assert out["df"] == 7.0
|
||||
assert out["n"] == 8
|
||||
assert out["confidence"] == 0.95
|
||||
assert abs(out["ci_low"] - lo) < 1e-9
|
||||
assert abs(out["ci_high"] - hi) < 1e-9
|
||||
# Valores tabulados correctos para ddof=1 (no los 3.32793/6.67207 del
|
||||
# enunciado, que asumian erroneamente sd=2.0 / ddof=0).
|
||||
assert abs(out["ci_low"] - 3.21251) < 1e-3
|
||||
assert abs(out["ci_high"] - 6.78749) < 1e-3
|
||||
assert "note" not in out
|
||||
|
||||
|
||||
def test_one_sample_distinto_nivel_confianza():
|
||||
data = [10.0, 12.0, 11.0, 13.0, 9.0, 14.0]
|
||||
out = confidence_interval_mean(data, confidence=0.99)
|
||||
|
||||
n = len(data)
|
||||
mean = float(np.mean(data))
|
||||
se = float(np.std(data, ddof=1)) / math.sqrt(n)
|
||||
lo, hi = stats.t.interval(0.99, df=n - 1, loc=mean, scale=se)
|
||||
|
||||
assert abs(out["mean"] - mean) < 1e-12
|
||||
assert abs(out["ci_low"] - lo) < 1e-9
|
||||
assert abs(out["ci_high"] - hi) < 1e-9
|
||||
assert out["df"] == float(n - 1)
|
||||
|
||||
|
||||
def test_welch_diferencia_golden_contra_scipy():
|
||||
data = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
|
||||
other = [18.0, 20.0, 17.0, 19.0, 21.0]
|
||||
conf = 0.95
|
||||
out = confidence_interval_mean(data, other, confidence=conf)
|
||||
|
||||
a = np.asarray(data, dtype=float)
|
||||
b = np.asarray(other, dtype=float)
|
||||
n1, n2 = a.size, b.size
|
||||
mean1, mean2 = float(a.mean()), float(b.mean())
|
||||
diff = mean1 - mean2
|
||||
se1 = float(a.std(ddof=1)) / math.sqrt(n1)
|
||||
se2 = float(b.std(ddof=1)) / math.sqrt(n2)
|
||||
se = math.sqrt(se1**2 + se2**2)
|
||||
df = (se1**2 + se2**2) ** 2 / (se1**4 / (n1 - 1) + se2**4 / (n2 - 1))
|
||||
lo, hi = stats.t.interval(conf, df=df, loc=diff, scale=se)
|
||||
|
||||
assert abs(out["mean"] - diff) < 1e-9
|
||||
assert abs(out["mean"] - (mean1 - mean2)) < 1e-9
|
||||
assert abs(out["se"] - se) < 1e-12
|
||||
assert abs(out["df"] - df) < 1e-9
|
||||
assert abs(out["ci_low"] - lo) < 1e-9
|
||||
assert abs(out["ci_high"] - hi) < 1e-9
|
||||
assert out["n1"] == n1
|
||||
assert out["n2"] == n2
|
||||
assert out["n"] == n1 + n2
|
||||
assert "note" not in out
|
||||
|
||||
|
||||
def test_edge_un_solo_elemento_no_lanza_nan_note():
|
||||
out = confidence_interval_mean([5], confidence=0.95)
|
||||
assert out["mean"] == 5.0 # la media si esta definida con n=1
|
||||
assert math.isnan(out["se"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["ci_high"])
|
||||
assert math.isnan(out["df"])
|
||||
assert out["n"] == 1
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_lista_vacia_no_lanza_note():
|
||||
out = confidence_interval_mean([], confidence=0.95)
|
||||
assert math.isnan(out["mean"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["ci_high"])
|
||||
assert math.isnan(out["se"])
|
||||
assert out["n"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_varianza_cero_colapsa_al_punto():
|
||||
out = confidence_interval_mean([3, 3, 3], confidence=0.95)
|
||||
assert out["mean"] == 3.0
|
||||
assert out["se"] == 0.0
|
||||
assert out["ci_low"] == 3.0
|
||||
assert out["ci_high"] == 3.0
|
||||
assert not math.isnan(out["ci_low"])
|
||||
assert out["n"] == 3
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_welch_muestra_vacia_no_lanza_note():
|
||||
out = confidence_interval_mean([1.0, 2.0, 3.0], [], confidence=0.95)
|
||||
assert math.isnan(out["mean"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["se"])
|
||||
assert out["n1"] == 3
|
||||
assert out["n2"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_welch_n1_uno_no_lanza_note():
|
||||
out = confidence_interval_mean([5.0], [1.0, 2.0, 3.0], confidence=0.95)
|
||||
# La diferencia de medias si esta definida.
|
||||
assert abs(out["mean"] - (5.0 - 2.0)) < 1e-9
|
||||
assert math.isnan(out["se"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["df"])
|
||||
assert "note" in out
|
||||
@@ -23,15 +23,20 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# OpenCV (cv2) se importa de forma perezosa dentro de las funciones que lo usan:
|
||||
# un import a nivel de módulo rompería `import datascience` en entornos sin
|
||||
# opencv instalado (p. ej. venvs de analysis que solo usan las funciones de
|
||||
# series temporales o perfilado del paquete).
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# 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]."""
|
||||
import cv2
|
||||
|
||||
def run(img):
|
||||
out: list[str] = []
|
||||
@@ -89,6 +94,8 @@ def _make_pyzbar_runner(zbar_decode):
|
||||
|
||||
def _build_detectors(debug=False):
|
||||
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||
import cv2
|
||||
|
||||
detectors = []
|
||||
|
||||
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||
@@ -135,6 +142,8 @@ def _build_detectors(debug=False):
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _load_bgr(image_path):
|
||||
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||
import cv2
|
||||
|
||||
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||
if bgr is not None:
|
||||
return bgr
|
||||
@@ -150,6 +159,8 @@ def _load_bgr(image_path):
|
||||
|
||||
def _build_variants(image_path, upscale):
|
||||
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||
import cv2
|
||||
|
||||
bgr = _load_bgr(image_path)
|
||||
if bgr is None:
|
||||
return []
|
||||
|
||||
@@ -3,17 +3,17 @@ name: describe_numeric
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def describe_numeric(values: list, bins: int = 20) -> dict"
|
||||
description: "Calcula el bloque estadistico fino numeric de un ColumnProfile del grupo eda sobre una MUESTRA de una columna numerica. Descarta None/NaN/no-numericos y devuelve min/max/mean/median/mode/std/variance/cv, percentiles, iqr, skew, kurtosis, outliers, zero_pct, negative_pct, distribution_type e histogram. Reusa detect_distribution_type, detect_outliers y histogram del registry."
|
||||
description: "Calcula el bloque estadistico fino numeric de un ColumnProfile del grupo eda sobre una MUESTRA de una columna numerica. Descarta None/NaN/no-numericos y devuelve min/max/mean/median/mode/std/variance/cv, percentiles, iqr, skew, kurtosis, outliers, zero_pct, negative_pct, distribution_type, histogram e histogram_clipped (segunda vista del histograma con los outliers recortados a las vallas de Tukey). Reusa detect_distribution_type, detect_outliers y histogram del registry."
|
||||
tags: [eda, statistics, profiling, distribution, histogram, datascience]
|
||||
params:
|
||||
- name: values
|
||||
desc: "Lista de valores crudos de una columna (muestra). Puede contener None, NaN, infinitos y strings no numericos: se descartan antes de calcular. bool se trata como no numerico."
|
||||
- name: bins
|
||||
desc: "Numero de buckets equiespaciados del histograma. Default 20."
|
||||
output: "Dict con las claves exactas del contrato numeric_sub del grupo eda: {min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95, p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct, distribution_type, histogram}. cv = std/mean (None si mean==0). iqr = p75-p25. mode = valor mas frecuente (menor en empate). histogram = lista de {lo, hi, count}. Si tras limpiar quedan 0 valores: todas las claves None y histogram=[]."
|
||||
output: "Dict con las claves exactas del contrato numeric_sub del grupo eda: {min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95, p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct, distribution_type, histogram, histogram_clipped}. cv = std/mean (None si mean==0). iqr = p75-p25. mode = valor mas frecuente (menor en empate). histogram = lista de {lo, hi, count} sobre el rango completo min..max. histogram_clipped = misma estructura pero re-binado sobre el rango de vallas de Tukey [p25-1.5*iqr, p75+1.5*iqr] (vista central sin outliers); es [] cuando el recorte no excluye nada (ningun outlier), cuando iqr==0 (columna constante) o cuando el recorte deja la muestra sin dispersion. Si tras limpiar quedan 0 valores: todas las claves None, histogram=[] e histogram_clipped=[]."
|
||||
uses_functions:
|
||||
- detect_distribution_type_py_datascience
|
||||
- detect_outliers_py_datascience
|
||||
@@ -56,3 +56,8 @@ print(prof["histogram"][:2]) # [{'lo': 1.0, 'hi': 5.95, 'count': ...}, ...]
|
||||
- `distribution_type`, `skew` y `kurtosis` vienen de `detect_distribution_type`, que devuelve `too_few_samples` (y skew/kurtosis None) cuando la muestra limpia tiene **menos de 30 valores**.
|
||||
- Los outliers usan z-score con `std` poblacional y threshold 3.0 (de `detect_outliers`): en muestras muy pequeñas un unico valor extremo puede inflar la `std` y no marcarse como outlier (efecto masking). Para deteccion fiable, pasa una muestra suficientemente grande.
|
||||
- `cv` es `None` cuando `mean == 0` (division indefinida).
|
||||
- `histogram_clipped` NO recalcula media/mediana/std: reutiliza los percentiles ya calculados (`p25`, `p75`, `iqr`) para definir el rango de recorte y solo re-bina la sub-muestra dentro de las vallas. Es aditivo: los consumidores que solo miran `histogram` no se ven afectados.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-07-03) — añade la clave `histogram_clipped`: segunda vista del histograma re-binada sobre las vallas de Tukey [p25-1.5·IQR, p75+1.5·IQR] para leer la masa central cuando una cola larga aplasta la escala. Aditivo (los consumidores de `histogram` no cambian); `[]` cuando el recorte no excluye nada, la columna es constante (iqr==0) o la sub-muestra recortada pierde dispersion. Lo consume el capitulo `num_distr` del motor AutomaticEDA como figura adicional dentro del mismo grupo keep-together de la columna.
|
||||
|
||||
@@ -69,7 +69,9 @@ def describe_numeric(values: list, bins: int = 20) -> dict:
|
||||
Dict with the exact keys of the eda `numeric_sub` contract:
|
||||
{min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50,
|
||||
p75, p95, p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct,
|
||||
negative_pct, distribution_type, histogram}.
|
||||
negative_pct, distribution_type, histogram, histogram_clipped}.
|
||||
histogram_clipped is a second histogram over the Tukey inner-fence
|
||||
range (outliers trimmed) or [] when the clip removes nothing.
|
||||
"""
|
||||
clean = _clean(values)
|
||||
n = len(clean)
|
||||
@@ -77,6 +79,7 @@ def describe_numeric(values: list, bins: int = 20) -> dict:
|
||||
if n == 0:
|
||||
result = {k: None for k in _NULL_KEYS}
|
||||
result["histogram"] = []
|
||||
result["histogram_clipped"] = []
|
||||
return result
|
||||
|
||||
arr = np.array(clean, dtype=float)
|
||||
@@ -131,6 +134,32 @@ def describe_numeric(values: list, bins: int = 20) -> dict:
|
||||
hi = minimum + (i + 1) * width
|
||||
hist.append({"lo": float(lo), "hi": float(hi), "count": int(count)})
|
||||
|
||||
# Clipped histogram: a second view of the central mass with the outliers
|
||||
# trimmed away, re-binned over the Tukey inner-fence range [Q1-1.5*IQR,
|
||||
# Q3+1.5*IQR] (coherent with the boxplot already drawn below the histogram).
|
||||
# It answers "what does the bulk look like when the long tail no longer
|
||||
# crushes the scale". Computed here because the raw sample (`clean`) is only
|
||||
# alive at this point — the profile keeps aggregated bins, not raw values.
|
||||
# Only emitted when the clip actually removes something *and* the trimmed
|
||||
# sample still has spread; otherwise it degrades to [] and the renderer skips
|
||||
# the second view (no redundant duplicate of the full histogram).
|
||||
hist_clipped: list = []
|
||||
lower_fence = p25 - 1.5 * iqr
|
||||
upper_fence = p75 + 1.5 * iqr
|
||||
if iqr > 0:
|
||||
clipped = [v for v in clean if lower_fence <= v <= upper_fence]
|
||||
if clipped and len(clipped) < len(clean):
|
||||
c_counts = histogram(clipped, bins)
|
||||
c_min = float(min(clipped))
|
||||
c_max = float(max(clipped))
|
||||
if c_counts and c_max > c_min:
|
||||
c_width = (c_max - c_min) / bins
|
||||
for i, count in enumerate(c_counts):
|
||||
lo = c_min + i * c_width
|
||||
hi = c_min + (i + 1) * c_width
|
||||
hist_clipped.append(
|
||||
{"lo": float(lo), "hi": float(hi), "count": int(count)})
|
||||
|
||||
return {
|
||||
"min": minimum,
|
||||
"max": maximum,
|
||||
@@ -156,4 +185,5 @@ def describe_numeric(values: list, bins: int = 20) -> dict:
|
||||
"negative_pct": negative_pct,
|
||||
"distribution_type": distribution_type,
|
||||
"histogram": hist,
|
||||
"histogram_clipped": hist_clipped,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ _EXPECTED_KEYS = {
|
||||
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
|
||||
"skew", "kurtosis", "n_outliers", "outlier_pct",
|
||||
"zero_pct", "negative_pct", "distribution_type", "histogram",
|
||||
"histogram_clipped",
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +62,10 @@ def test_lista_vacia_todo_none():
|
||||
result = describe_numeric([None, "abc", float("nan")])
|
||||
|
||||
assert set(result.keys()) == _EXPECTED_KEYS
|
||||
for key in _EXPECTED_KEYS - {"histogram"}:
|
||||
for key in _EXPECTED_KEYS - {"histogram", "histogram_clipped"}:
|
||||
assert result[key] is None, f"{key} debe ser None"
|
||||
assert result["histogram"] == []
|
||||
assert result["histogram_clipped"] == []
|
||||
|
||||
|
||||
def test_cv_none_cuando_mean_cero():
|
||||
@@ -83,3 +85,56 @@ def test_iqr_y_percentiles():
|
||||
assert result["p1"] <= result["p25"] <= result["p50"] <= result["p75"] <= result["p99"]
|
||||
assert result["min"] == 1.0
|
||||
assert result["max"] == 100.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# histogram_clipped: second view of the central mass, outliers trimmed.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_histogram_clipped_trims_the_tail():
|
||||
"""Golden: with a long high tail, the clipped histogram excludes the outliers.
|
||||
|
||||
A tight cluster in [1, 5] plus a handful of extreme values. The full histogram
|
||||
stretches to the extreme (min..max); the clipped one is re-binned over the
|
||||
Tukey inner fences, so its upper edge stays far below the extreme and it holds
|
||||
fewer values than the full sample.
|
||||
"""
|
||||
cluster = [1, 2, 3, 4, 5] * 20 # 100 values in [1, 5]
|
||||
values = cluster + [500, 800, 1000] # 3 far outliers
|
||||
result = describe_numeric(values)
|
||||
|
||||
full = result["histogram"]
|
||||
clipped = result["histogram_clipped"]
|
||||
assert full and clipped # both present
|
||||
for bucket in clipped:
|
||||
assert "lo" in bucket and "hi" in bucket and "count" in bucket
|
||||
|
||||
# The full histogram reaches the extreme; the clipped one does not.
|
||||
assert full[-1]["hi"] >= 900
|
||||
assert clipped[-1]["hi"] < 100
|
||||
|
||||
# The clip removed the tail: fewer values counted than the full sample.
|
||||
total_full = sum(b["count"] for b in full)
|
||||
total_clipped = sum(b["count"] for b in clipped)
|
||||
assert total_full == 103
|
||||
assert total_clipped < total_full
|
||||
assert total_clipped >= 100 # the whole cluster survives the clip
|
||||
|
||||
|
||||
def test_histogram_clipped_empty_when_no_outliers():
|
||||
"""Edge: a clean spread with no fence outliers yields an empty clipped view.
|
||||
|
||||
When the inner-fence range already covers every value, there is nothing to
|
||||
trim, so histogram_clipped is [] and the renderer skips the redundant second
|
||||
view instead of duplicating the full histogram.
|
||||
"""
|
||||
result = describe_numeric(list(range(1, 101))) # uniform 1..100, no outliers
|
||||
assert result["n_outliers"] == 0
|
||||
assert result["histogram"] # full histogram present
|
||||
assert result["histogram_clipped"] == [] # nothing trimmed
|
||||
|
||||
|
||||
def test_histogram_clipped_empty_when_constant():
|
||||
"""Edge: a constant column (iqr == 0) never produces a clipped view."""
|
||||
result = describe_numeric([7] * 30)
|
||||
assert result["iqr"] == 0
|
||||
assert result["histogram_clipped"] == []
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
id: draw_join_graph_figure_py_datascience
|
||||
name: draw_join_graph_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def draw_join_graph_figure(join_graph: dict, title: str = None) -> \"matplotlib.figure.Figure\""
|
||||
description: "Rasteriza el join graph de una base (relaciones FK inter-tabla, salida de build_join_graph) a un matplotlib.figure.Figure: nodos circulares con el nombre de cada tabla (hubs en color de acento cálido, el resto neutro) y aristas dirigidas etiquetadas from_col→to_col (más la cardinalidad si viene). Es la contrapartida dibujada del string Mermaid para que el capítulo de relaciones del informe AutomaticEDA muestre un diagrama real. Layout networkx spring_layout determinista (seed=42), backend Agg sin abrir ventanas; defensivo: nunca lanza y nunca hace I/O."
|
||||
tags: [eda, plot, relations, graph, matplotlib, figure, networkx, datascience, impure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib, networkx]
|
||||
example: |
|
||||
from draw_join_graph_figure import draw_join_graph_figure
|
||||
join_graph = {
|
||||
"nodes": [
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
|
||||
],
|
||||
"edges": [
|
||||
{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
|
||||
],
|
||||
"hubs": ["orders"],
|
||||
}
|
||||
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
|
||||
fig.savefig("/tmp/join_graph.png")
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure_with_axis"
|
||||
- "test_savefig_produces_nonempty_png"
|
||||
- "test_empty_dict_does_not_raise_and_savefig_png"
|
||||
- "test_none_does_not_raise_and_savefig_png"
|
||||
test_file_path: "python/functions/datascience/draw_join_graph_figure_test.py"
|
||||
file_path: "python/functions/datascience/draw_join_graph_figure.py"
|
||||
params:
|
||||
- name: join_graph
|
||||
desc: "Dict producido por build_join_graph. Claves: `nodes` (list[dict] con table, out_degree, in_degree, role), `edges` (list[dict] con from_table, from_col, to_table, to_col y opcional cardinality/inclusion) y `hubs` (list[str] de tablas hub a destacar en color cálido). Claves ausentes, items no-dict, None o {} se toleran (devuelve Figure con texto, sin lanzar). Los nombres de nodo se derivan también de las aristas, así que un grafo con edges pero sin nodes explícitos igual se dibuja."
|
||||
- name: title
|
||||
desc: "Título dibujado sobre el diagrama. Si se omite (None) se usa \"Join graph\". Default None."
|
||||
output: "Un matplotlib.figure.Figure (figsize 7x5) con un único Axes que contiene el diagrama node-link dirigido: tablas como nodos circulares etiquetados (hubs en acento cálido #DD8452, resto en azul neutro #4C72B0) y FKs como flechas dirigidas con etiqueta from_col→to_col (+ cardinalidad). Si join_graph no tiene nodos ni aristas (o es None/{}), devuelve igualmente una Figure con el texto centrado \"Sin relaciones FK detectadas.\"; ante cualquier fallo interno devuelve una Figure con un mensaje genérico (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from draw_join_graph_figure import draw_join_graph_figure
|
||||
|
||||
# `join_graph` es la salida de build_join_graph (nodes + edges + hubs).
|
||||
join_graph = {
|
||||
"nodes": [
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
|
||||
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
],
|
||||
"edges": [
|
||||
{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
|
||||
{"from_table": "orders", "from_col": "product_id",
|
||||
"to_table": "products", "to_col": "id", "cardinality": "N:1"},
|
||||
],
|
||||
"hubs": ["orders"], # `orders` se pinta en color de acento (tabla de hechos)
|
||||
}
|
||||
|
||||
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
|
||||
|
||||
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||
fig.savefig("/tmp/join_graph.png")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala en el capítulo de relaciones de un informe AutomaticEDA cuando quieras un
|
||||
diagrama **dibujado** del esquema relacional, no solo el bloque Mermaid pegable.
|
||||
Pásale directamente la salida de `build_join_graph` (`nodes` + `edges` + `hubs`)
|
||||
y obtienes una `matplotlib.figure.Figure` lista para que el renderer perezoso la
|
||||
rasterice. Es la pareja visual del string Mermaid: Mermaid sirve para pegar en
|
||||
Markdown/docs que lo soporten; esta función produce la imagen real (PNG/PDF) que
|
||||
va embebida en informes que no renderizan Mermaid.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Fija el backend `Agg` al importar — no abre
|
||||
ventanas ni depende de un display. Segura de llamar en lotes desde el
|
||||
renderer.
|
||||
- **Layout determinista (`seed=42`).** Usa `nx.spring_layout(G, seed=42)`, así
|
||||
que la misma entrada produce el mismo diagrama (test reproducible). Para
|
||||
grafos de 0/1 nodos usa una posición fija centrada en vez del spring layout.
|
||||
- **No hace I/O.** No llama `plt.show()` ni guarda a disco — solo devuelve la
|
||||
`Figure`. Quien la consume la rasteriza y la libera (`plt.close(fig)`) para no
|
||||
acumular memoria en informes con muchas tablas.
|
||||
- **Devuelve una Figure, NO un dict.** A diferencia de `build_join_graph` (que
|
||||
devuelve el dict del grafo), esta función devuelve el objeto de figura ya
|
||||
dibujado.
|
||||
- **Defensiva, nunca lanza.** `None`, `{}`, claves ausentes o items malformados
|
||||
se manejan sin error: en el peor caso devuelve una `Figure` con
|
||||
"Sin relaciones FK detectadas." (vacío) o un mensaje genérico (fallo interno).
|
||||
No la envuelvas en try/except por miedo a un raise — no lo hay.
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Impure EDA helper: rasterize a join graph to a matplotlib Figure (`eda` group).
|
||||
|
||||
Takes the join graph produced by ``build_join_graph`` (inter-table FK relations)
|
||||
and draws it as a directed node-link diagram on a ready-to-rasterize
|
||||
``matplotlib.figure.Figure``. Hub tables (the ones with the highest out-degree,
|
||||
candidate fact tables of a star schema) are highlighted in a warm accent colour;
|
||||
the rest use a neutral colour. Directed edges carry a ``from_col→to_col`` label
|
||||
(plus the cardinality when present).
|
||||
|
||||
This is the *drawn* counterpart of the Mermaid string that ``build_join_graph``
|
||||
also emits: the relations chapter of an AutomaticEDA report can show a real
|
||||
picture instead of only the pasteable Mermaid block.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It pins the headless
|
||||
Agg backend and a deterministic ``spring_layout`` seed so the output is
|
||||
reproducible. It never raises: on any internal failure (or empty input) it
|
||||
returns a ``Figure`` carrying a centered message, so the lazy render of the
|
||||
document is never broken.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
import networkx as nx # noqa: E402
|
||||
|
||||
# Warm accent reserved for hub tables (candidate fact tables / star-schema cores).
|
||||
_HUB_COLOR = "#DD8452"
|
||||
# Neutral blue for every other table.
|
||||
_NODE_COLOR = "#4C72B0"
|
||||
# Muted gray for the empty/error message text.
|
||||
_MUTED_TEXT = "#5f6b7a"
|
||||
# Edge colour and label colour.
|
||||
_EDGE_COLOR = "#7a7a7a"
|
||||
_EDGE_LABEL_COLOR = "#34495e"
|
||||
# Constant node size; shared with the edge drawing so arrowheads stop at the
|
||||
# node boundary instead of being hidden under the marker.
|
||||
_NODE_SIZE = 2200
|
||||
|
||||
|
||||
def _text_figure(message: str) -> "matplotlib.figure.Figure":
|
||||
"""Return a blank Figure carrying a single centered message.
|
||||
|
||||
Used both for the "no relations" case and as the never-raise fallback.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(7, 5))
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color=_MUTED_TEXT,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def _edge_label(edge: dict) -> str:
|
||||
"""Build the ``from_col→to_col`` label of an edge, appending cardinality."""
|
||||
fc = edge.get("from_col")
|
||||
tc = edge.get("to_col")
|
||||
if fc is not None and tc is not None:
|
||||
label = f"{fc}→{tc}"
|
||||
elif fc is not None:
|
||||
label = str(fc)
|
||||
elif tc is not None:
|
||||
label = str(tc)
|
||||
else:
|
||||
label = ""
|
||||
card = edge.get("cardinality")
|
||||
if card:
|
||||
label = f"{label} ({card})" if label else str(card)
|
||||
return label
|
||||
|
||||
|
||||
def draw_join_graph_figure(join_graph: dict, title: str = None):
|
||||
"""Rasterize a join graph to a matplotlib Figure.
|
||||
|
||||
Builds a ``networkx.DiGraph`` from the graph's nodes and edges, lays it out
|
||||
with a deterministic ``spring_layout`` (``seed=42``) and draws it on a
|
||||
``matplotlib.figure.Figure``: tables as labelled circular nodes (hubs in a
|
||||
warm accent, the rest neutral) and FK relations as directed arrows labelled
|
||||
``from_col→to_col`` (plus cardinality when available).
|
||||
|
||||
The function never raises. On empty/``None`` input it returns a Figure with
|
||||
a centered "Sin relaciones FK detectadas." message; on any internal failure
|
||||
it returns a Figure with a generic centered message. It never shows the
|
||||
figure nor writes it to disk — the document renderer rasterizes it.
|
||||
|
||||
Args:
|
||||
join_graph: Dict produced by ``build_join_graph`` with keys ``nodes``
|
||||
(list of ``{table, out_degree, in_degree, role}``), ``edges`` (list
|
||||
of ``{from_table, from_col, to_table, to_col, cardinality?,
|
||||
inclusion?}``) and ``hubs`` (list of hub table names to highlight).
|
||||
Missing keys, non-dict items, ``None`` or ``{}`` are all tolerated.
|
||||
title: Optional title drawn above the diagram. When omitted, the title
|
||||
defaults to "Join graph".
|
||||
|
||||
Returns:
|
||||
A ``matplotlib.figure.Figure`` (figsize 7x5) with a single Axes holding
|
||||
the node-link diagram. The caller rasterizes/closes it.
|
||||
"""
|
||||
try:
|
||||
jg = join_graph if isinstance(join_graph, dict) else {}
|
||||
nodes = jg.get("nodes") or []
|
||||
edges = jg.get("edges") or []
|
||||
hubs = {h for h in (jg.get("hubs") or []) if h is not None}
|
||||
|
||||
# Collect node names from the declared nodes and, defensively, from the
|
||||
# edges (so a graph with edges but no explicit nodes still draws).
|
||||
node_names: list = []
|
||||
seen: set = set()
|
||||
|
||||
def _register(name) -> None:
|
||||
if name is not None and name not in seen:
|
||||
seen.add(name)
|
||||
node_names.append(name)
|
||||
|
||||
for n in nodes:
|
||||
if isinstance(n, dict):
|
||||
_register(n.get("table"))
|
||||
for e in edges:
|
||||
if isinstance(e, dict):
|
||||
_register(e.get("from_table"))
|
||||
_register(e.get("to_table"))
|
||||
|
||||
if not node_names:
|
||||
return _text_figure("Sin relaciones FK detectadas.")
|
||||
|
||||
graph = nx.DiGraph()
|
||||
for name in node_names:
|
||||
graph.add_node(name)
|
||||
|
||||
edge_labels: dict = {}
|
||||
for e in edges:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
ft = e.get("from_table")
|
||||
tt = e.get("to_table")
|
||||
if ft is None or tt is None:
|
||||
continue
|
||||
graph.add_edge(ft, tt)
|
||||
edge_labels[(ft, tt)] = _edge_label(e)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(7, 5))
|
||||
|
||||
# Deterministic layout. Fixed positions for trivial graphs so a single
|
||||
# node sits centered instead of at an arbitrary spring-layout point.
|
||||
if graph.number_of_nodes() <= 1:
|
||||
pos = {name: (0.5, 0.5) for name in graph.nodes()}
|
||||
else:
|
||||
pos = nx.spring_layout(graph, seed=42)
|
||||
|
||||
node_colors = [
|
||||
_HUB_COLOR if name in hubs else _NODE_COLOR for name in graph.nodes()
|
||||
]
|
||||
nx.draw_networkx_nodes(
|
||||
graph,
|
||||
pos,
|
||||
ax=ax,
|
||||
node_color=node_colors,
|
||||
node_size=_NODE_SIZE,
|
||||
node_shape="o",
|
||||
edgecolors="white",
|
||||
linewidths=1.5,
|
||||
)
|
||||
nx.draw_networkx_labels(
|
||||
graph,
|
||||
pos,
|
||||
ax=ax,
|
||||
font_size=9,
|
||||
font_color="white",
|
||||
font_weight="bold",
|
||||
)
|
||||
nx.draw_networkx_edges(
|
||||
graph,
|
||||
pos,
|
||||
ax=ax,
|
||||
arrows=True,
|
||||
arrowstyle="-|>",
|
||||
arrowsize=18,
|
||||
edge_color=_EDGE_COLOR,
|
||||
width=1.4,
|
||||
connectionstyle="arc3,rad=0.06",
|
||||
node_size=_NODE_SIZE,
|
||||
)
|
||||
if any(lbl for lbl in edge_labels.values()):
|
||||
nx.draw_networkx_edge_labels(
|
||||
graph,
|
||||
pos,
|
||||
edge_labels=edge_labels,
|
||||
ax=ax,
|
||||
font_size=7,
|
||||
font_color=_EDGE_LABEL_COLOR,
|
||||
bbox={
|
||||
"boxstyle": "round,pad=0.2",
|
||||
"fc": "white",
|
||||
"ec": "none",
|
||||
"alpha": 0.7,
|
||||
},
|
||||
)
|
||||
|
||||
ax.set_title(title if title else "Join graph", fontsize=13)
|
||||
ax.axis("off")
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
except Exception:
|
||||
# Never raise — the document render is lazy and must not be broken.
|
||||
return _text_figure("No se pudo dibujar el join graph.")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests para draw_join_graph_figure (rasteriza el join graph, grupo eda).
|
||||
|
||||
Usa el backend Agg sin abrir ventanas; cada test cierra la Figure construida
|
||||
(matplotlib.pyplot.close) para no acumular estado entre tests. Las aserciones de
|
||||
guardado escriben a tmp_path (fixture de pytest) y comprueban que el PNG no está
|
||||
vacío.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from draw_join_graph_figure import draw_join_graph_figure
|
||||
|
||||
|
||||
def _make_join_graph():
|
||||
"""Join graph mínimo: 3 nodos (customers/orders/products) y 2 aristas.
|
||||
|
||||
orders -> customers y orders -> products. `orders` es el hub (out_degree 2).
|
||||
"""
|
||||
return {
|
||||
"nodes": [
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
|
||||
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from_table": "orders",
|
||||
"from_col": "customer_id",
|
||||
"to_table": "customers",
|
||||
"to_col": "id",
|
||||
"cardinality": "N:1",
|
||||
"inclusion": 1.0,
|
||||
},
|
||||
{
|
||||
"from_table": "orders",
|
||||
"from_col": "product_id",
|
||||
"to_table": "products",
|
||||
"to_col": "id",
|
||||
"cardinality": "N:1",
|
||||
"inclusion": 0.98,
|
||||
},
|
||||
],
|
||||
"hubs": ["orders"],
|
||||
}
|
||||
|
||||
|
||||
def test_returns_figure_with_axis():
|
||||
fig = draw_join_graph_figure(_make_join_graph(), title="Relaciones FK")
|
||||
assert isinstance(fig, Figure)
|
||||
# Al menos un eje con el diagrama.
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_savefig_produces_nonempty_png(tmp_path):
|
||||
fig = draw_join_graph_figure(_make_join_graph())
|
||||
out = tmp_path / "g.png"
|
||||
fig.savefig(out)
|
||||
assert out.exists()
|
||||
assert out.stat().st_size > 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_dict_does_not_raise_and_savefig_png(tmp_path):
|
||||
fig = draw_join_graph_figure({})
|
||||
assert isinstance(fig, Figure)
|
||||
out = tmp_path / "empty.png"
|
||||
fig.savefig(out)
|
||||
assert out.stat().st_size > 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_none_does_not_raise_and_savefig_png(tmp_path):
|
||||
fig = draw_join_graph_figure(None)
|
||||
assert isinstance(fig, Figure)
|
||||
out = tmp_path / "none.png"
|
||||
fig.savefig(out)
|
||||
assert out.stat().st_size > 0
|
||||
plt.close(fig)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: effect_size_cohens_d
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def effect_size_cohens_d(group_a: list, group_b: list) -> dict"
|
||||
description: "Tamano del efecto (effect size) entre dos grupos numericos: Cohen's d (diferencia de medias estandarizada por la desviacion tipica combinada, varianzas muestrales ddof=1), Hedges' g (d corregido por el sesgo al alza con muestras pequenas via el factor J) e interpretacion cualitativa de la magnitud segun los umbrales clasicos de Cohen (negligible/small/medium/large). El p-valor dice si hay diferencia; el effect size dice como de grande, de forma adimensional e independiente del N. Pura, sin dependencias externas; nunca lanza: los casos degenerados (varianza cero, N<2, listas vacias) devuelven NaN + una clave note."
|
||||
tags: [papers, statistics, effect-size, cohens-d, hedges-g, python]
|
||||
params:
|
||||
- name: group_a
|
||||
desc: "primera muestra (lista de numeros). Necesita >=2 observaciones para que exista la varianza muestral (ddof=1)."
|
||||
- name: group_b
|
||||
desc: "segunda muestra (lista de numeros). Necesita >=2 observaciones. El signo de cohens_d es positivo cuando mean_a > mean_b."
|
||||
output: "dict {cohens_d: float (diferencia de medias estandarizada, puede ser NaN), hedges_g: float (cohens_d * factor de correccion J, puede ser NaN), interpretation: str ('negligible'|'small'|'medium'|'large', o 'undefined' en casos degenerados), n_a: int, n_b: int, mean_a: float, mean_b: float, pooled_sd: float (desviacion tipica combinada)}. Casos degenerados (varianza cero en ambos grupos, N<2 en algun grupo, o listas vacias) anaden clave note. Nunca None ni excepcion."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [math]
|
||||
tested: true
|
||||
tests: ["test_golden_large_effect", "test_hedges_g_menor_en_magnitud_que_cohens_d", "test_interpretation_thresholds", "test_signo_positivo_cuando_a_mayor_que_b", "test_varianza_cero_no_lanza", "test_n_insuficiente_no_lanza", "test_listas_vacias_no_lanza", "test_un_grupo_vacio_no_lanza"]
|
||||
test_file_path: "python/functions/datascience/effect_size_cohens_d_test.py"
|
||||
file_path: "python/functions/datascience/effect_size_cohens_d.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import effect_size_cohens_d
|
||||
|
||||
# Dos grupos desplazados 2 unidades, misma dispersion.
|
||||
a = [1, 2, 3, 4, 5] # media 3, varianza muestral 2.5
|
||||
b = [3, 4, 5, 6, 7] # media 5, varianza muestral 2.5
|
||||
|
||||
out = effect_size_cohens_d(a, b)
|
||||
print(out["cohens_d"]) # -> -1.264911... (a esta 1.26 SD por debajo de b)
|
||||
print(out["hedges_g"]) # -> -1.142500... (|g| < |d|: correccion N pequeno)
|
||||
print(out["interpretation"]) # -> "large" (|d| >= 0.8)
|
||||
print(out["pooled_sd"]) # -> 1.581138...
|
||||
|
||||
# Caso degenerado: varianza cero -> no lanza, NaN + note.
|
||||
deg = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
|
||||
print(deg["interpretation"]) # -> "undefined"
|
||||
print(deg["note"]) # -> "varianza cero, effect size indefinido"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya sepas que dos grupos difieren (o quieras cuantificar su diferencia)
|
||||
y necesites una medida **de magnitud, no de significancia**: comparar el antes
|
||||
y el despues de una intervencion, el grupo control frente al tratamiento, o dos
|
||||
cohortes. Reportala junto al p-valor para responder "¿como de grande es la
|
||||
diferencia?" — un p-valor minusculo con N enorme puede esconder un efecto
|
||||
trivial. Es adimensional (en unidades de desviaciones tipicas), asi que hace
|
||||
comparables resultados entre estudios y alimenta meta-analisis. Usa **Hedges' g**
|
||||
en lugar de Cohen's d cuando los grupos sean pequenos (decenas o menos): d
|
||||
sobreestima el efecto y g lo corrige.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Pura y sin dependencias externas (solo `math` de la stdlib).
|
||||
- Usa **varianza muestral** (ddof=1), no poblacional. Por eso cada grupo
|
||||
necesita al menos 2 observaciones; con N=1 la varianza muestral no existe y la
|
||||
funcion devuelve NaN + `note`.
|
||||
- **Nunca lanza excepcion**. Los casos degenerados devuelven `cohens_d` y
|
||||
`hedges_g` a `float('nan')`, `interpretation="undefined"` y una clave `note`:
|
||||
varianza cero en ambos grupos (`pooled_sd == 0`), N<2 en algun grupo, o listas
|
||||
vacias. Comprueba con `math.isnan(out["cohens_d"])` o la presencia de `note`
|
||||
antes de usar el resultado.
|
||||
- El **signo** de `cohens_d` depende del orden de los argumentos: positivo si
|
||||
`mean_a > mean_b`, negativo en caso contrario. La `interpretation` usa `|d|`,
|
||||
asi que no depende del orden.
|
||||
- `pooled_sd` asume varianzas comparables entre grupos (homogeneidad). Si las
|
||||
dispersiones son muy distintas, Cohen's d clasico pierde precision; considera
|
||||
variantes (Glass's delta) fuera del alcance de esta funcion.
|
||||
- Los umbrales de Cohen (0.2 / 0.5 / 0.8) son convencion, no ley: interpretalos
|
||||
segun el dominio.
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Effect size de dos grupos: Cohen's d, Hedges' g e interpretacion cualitativa.
|
||||
|
||||
Funcion pura del grupo papers. El p-valor responde a "¿hay diferencia?" pero no
|
||||
a "¿como de grande es?". El tamano del efecto (effect size) cuantifica la
|
||||
magnitud de la diferencia entre dos grupos de forma adimensional, independiente
|
||||
del N, y es lo que hace comparables resultados entre estudios (meta-analisis).
|
||||
|
||||
- Cohen's d: diferencia de medias estandarizada por la desviacion tipica
|
||||
combinada (pooled SD), con varianzas muestrales (ddof=1).
|
||||
- Hedges' g: Cohen's d corregido por el sesgo al alza que sufre d con muestras
|
||||
pequenas, multiplicando por el factor de correccion J.
|
||||
- interpretation: etiqueta cualitativa de |d| segun los umbrales clasicos de
|
||||
Cohen (negligible / small / medium / large).
|
||||
|
||||
No usa dependencias externas: aritmetica de la libreria estandar (``math``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def _mean(xs: list) -> float:
|
||||
"""Media aritmetica de una lista no vacia de numeros."""
|
||||
return sum(float(x) for x in xs) / len(xs)
|
||||
|
||||
|
||||
def _sample_variance(xs: list, mean: float) -> float:
|
||||
"""Varianza muestral (ddof=1) de una lista con al menos 2 elementos."""
|
||||
n = len(xs)
|
||||
return sum((float(x) - mean) ** 2 for x in xs) / (n - 1)
|
||||
|
||||
|
||||
def _interpret(abs_d: float) -> str:
|
||||
"""Etiqueta cualitativa del tamano del efecto segun |d| (umbrales de Cohen)."""
|
||||
if abs_d < 0.2:
|
||||
return "negligible"
|
||||
if abs_d < 0.5:
|
||||
return "small"
|
||||
if abs_d < 0.8:
|
||||
return "medium"
|
||||
return "large"
|
||||
|
||||
|
||||
def effect_size_cohens_d(group_a: list, group_b: list) -> dict:
|
||||
"""Calcula el tamano del efecto entre dos grupos numericos.
|
||||
|
||||
Devuelve Cohen's d (diferencia de medias estandarizada por la pooled SD),
|
||||
Hedges' g (d corregido por sesgo de muestra pequena) y una etiqueta
|
||||
cualitativa de la magnitud segun los umbrales de Cohen.
|
||||
|
||||
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
|
||||
excepcion ante datos degenerados; en su lugar devuelve un dict con
|
||||
``cohens_d`` / ``hedges_g`` a ``float('nan')``, ``interpretation`` a
|
||||
``"undefined"`` y una clave ``note`` explicando el caso.
|
||||
|
||||
Definiciones:
|
||||
s_pooled = sqrt(((n1-1)*s1^2 + (n2-1)*s2^2) / (n1+n2-2)), con s1^2, s2^2
|
||||
varianzas muestrales (ddof=1).
|
||||
cohens_d = (mean_a - mean_b) / s_pooled.
|
||||
J = 1 - 3 / (4*(n1+n2) - 9) (factor de correccion de Hedges).
|
||||
hedges_g = cohens_d * J.
|
||||
|
||||
Args:
|
||||
group_a: primera muestra (lista de numeros). Necesita >=2 elementos para
|
||||
que exista la varianza muestral.
|
||||
group_b: segunda muestra (lista de numeros). Necesita >=2 elementos.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
cohens_d: float, diferencia de medias estandarizada (puede ser NaN).
|
||||
hedges_g: float, Cohen's d corregido por sesgo (puede ser NaN).
|
||||
interpretation: str, "negligible" | "small" | "medium" | "large", o
|
||||
"undefined" en casos degenerados.
|
||||
n_a: int, tamano de group_a.
|
||||
n_b: int, tamano de group_b.
|
||||
mean_a: float, media de group_a (NaN si vacio).
|
||||
mean_b: float, media de group_b (NaN si vacio).
|
||||
pooled_sd: float, desviacion tipica combinada (NaN si indefinida).
|
||||
|
||||
Casos degenerados (lista vacia, N<2 en algun grupo, o varianza cero en
|
||||
ambos grupos -> pooled_sd == 0) anaden ademas una clave ``note``.
|
||||
"""
|
||||
nan = float("nan")
|
||||
n_a = len(group_a)
|
||||
n_b = len(group_b)
|
||||
|
||||
# Listas vacias: ni media ni varianza definidas.
|
||||
if n_a == 0 or n_b == 0:
|
||||
return {
|
||||
"cohens_d": nan,
|
||||
"hedges_g": nan,
|
||||
"interpretation": "undefined",
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": _mean(group_a) if n_a else nan,
|
||||
"mean_b": _mean(group_b) if n_b else nan,
|
||||
"pooled_sd": nan,
|
||||
"note": "grupo vacio: media y varianza indefinidas, effect size indefinido",
|
||||
}
|
||||
|
||||
mean_a = _mean(group_a)
|
||||
mean_b = _mean(group_b)
|
||||
|
||||
# N insuficiente: la varianza muestral (ddof=1) no existe con un solo dato,
|
||||
# y la correccion de Hedges no es fiable.
|
||||
if n_a < 2 or n_b < 2:
|
||||
return {
|
||||
"cohens_d": nan,
|
||||
"hedges_g": nan,
|
||||
"interpretation": "undefined",
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": mean_a,
|
||||
"mean_b": mean_b,
|
||||
"pooled_sd": nan,
|
||||
"note": (
|
||||
"N insuficiente: cada grupo necesita >=2 observaciones para la "
|
||||
"varianza muestral; effect size indefinido"
|
||||
),
|
||||
}
|
||||
|
||||
var_a = _sample_variance(group_a, mean_a)
|
||||
var_b = _sample_variance(group_b, mean_b)
|
||||
pooled_sd = math.sqrt(
|
||||
((n_a - 1) * var_a + (n_b - 1) * var_b) / (n_a + n_b - 2)
|
||||
)
|
||||
|
||||
# Varianza cero en ambos grupos: no se puede estandarizar (division por 0).
|
||||
if pooled_sd == 0.0:
|
||||
return {
|
||||
"cohens_d": nan,
|
||||
"hedges_g": nan,
|
||||
"interpretation": "undefined",
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": mean_a,
|
||||
"mean_b": mean_b,
|
||||
"pooled_sd": 0.0,
|
||||
"note": "varianza cero, effect size indefinido",
|
||||
}
|
||||
|
||||
cohens_d = (mean_a - mean_b) / pooled_sd
|
||||
j = 1.0 - 3.0 / (4.0 * (n_a + n_b) - 9.0)
|
||||
hedges_g = cohens_d * j
|
||||
|
||||
return {
|
||||
"cohens_d": cohens_d,
|
||||
"hedges_g": hedges_g,
|
||||
"interpretation": _interpret(abs(cohens_d)),
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": mean_a,
|
||||
"mean_b": mean_b,
|
||||
"pooled_sd": pooled_sd,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests para effect_size_cohens_d (tamano del efecto de dos grupos).
|
||||
|
||||
Importa el modulo hoja directamente (`effect_size_cohens_d`) para no depender de
|
||||
que el paquete reexporte la funcion en su __init__ (lo integra el orquestador al
|
||||
cerrar el grupo papers). El pytest del repo tiene pythonpath=["functions", ...],
|
||||
asi que el modulo hoja se resuelve por su nombre directo.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from effect_size_cohens_d import effect_size_cohens_d
|
||||
|
||||
|
||||
def test_golden_large_effect():
|
||||
# group_a: mean 3, var muestral 2.5; group_b: mean 5, var 2.5.
|
||||
# pooled_sd = sqrt(2.5) ~= 1.5811388.
|
||||
# cohens_d = (3-5)/1.5811388 ~= -1.264911.
|
||||
# J = 1 - 3/(4*10-9) = 1 - 3/31 = 0.9032258.
|
||||
# hedges_g = d * J = -1.2649111 * 0.9032258 ~= -1.142500.
|
||||
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
|
||||
assert abs(out["cohens_d"] - (-1.26491)) < 1e-4
|
||||
assert abs(out["hedges_g"] - (-1.14250)) < 1e-4
|
||||
assert out["interpretation"] == "large"
|
||||
assert out["n_a"] == 5
|
||||
assert out["n_b"] == 5
|
||||
assert abs(out["mean_a"] - 3.0) < 1e-12
|
||||
assert abs(out["mean_b"] - 5.0) < 1e-12
|
||||
assert abs(out["pooled_sd"] - math.sqrt(2.5)) < 1e-9
|
||||
assert "note" not in out
|
||||
|
||||
|
||||
def test_hedges_g_menor_en_magnitud_que_cohens_d():
|
||||
# La correccion J esta en (0, 1), asi que |g| < |d| siempre.
|
||||
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
|
||||
assert abs(out["hedges_g"]) < abs(out["cohens_d"])
|
||||
|
||||
|
||||
def test_interpretation_thresholds():
|
||||
# negligible: |d| < 0.2. Medias casi iguales con varianza grande.
|
||||
neg = effect_size_cohens_d([0, 10, 20, 30], [1, 11, 21, 31])
|
||||
assert neg["interpretation"] == "negligible"
|
||||
assert abs(neg["cohens_d"]) < 0.2
|
||||
|
||||
# small: 0.2 <= |d| < 0.5.
|
||||
small = effect_size_cohens_d([0, 10, 20, 30], [4, 14, 24, 34])
|
||||
assert small["interpretation"] == "small"
|
||||
assert 0.2 <= abs(small["cohens_d"]) < 0.5
|
||||
|
||||
# medium: 0.5 <= |d| < 0.8.
|
||||
medium = effect_size_cohens_d([0, 10, 20, 30], [9, 19, 29, 39])
|
||||
assert medium["interpretation"] == "medium"
|
||||
assert 0.5 <= abs(medium["cohens_d"]) < 0.8
|
||||
|
||||
|
||||
def test_signo_positivo_cuando_a_mayor_que_b():
|
||||
out = effect_size_cohens_d([10, 12, 14, 16], [1, 2, 3, 4])
|
||||
assert out["cohens_d"] > 0
|
||||
assert out["interpretation"] == "large"
|
||||
|
||||
|
||||
def test_varianza_cero_no_lanza():
|
||||
out = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert math.isnan(out["hedges_g"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["pooled_sd"] == 0.0
|
||||
assert "note" in out
|
||||
assert "varianza cero" in out["note"]
|
||||
|
||||
|
||||
def test_n_insuficiente_no_lanza():
|
||||
out = effect_size_cohens_d([3], [1, 2, 3])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert math.isnan(out["hedges_g"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["n_a"] == 1
|
||||
assert out["n_b"] == 3
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_listas_vacias_no_lanza():
|
||||
out = effect_size_cohens_d([], [])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert math.isnan(out["hedges_g"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["n_a"] == 0
|
||||
assert out["n_b"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_un_grupo_vacio_no_lanza():
|
||||
out = effect_size_cohens_d([1, 2, 3], [])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["n_b"] == 0
|
||||
assert "note" in out
|
||||
@@ -3,19 +3,19 @@ name: fdr_correction
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.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]
|
||||
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh'), Bonferroni (FWER, 'bonferroni') o Holm-Bonferroni (FWER step-down, 'holm', mas potente que Bonferroni simple). 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, holm, holm-bonferroni, fwer, 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."
|
||||
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador); 'holm' = Holm-Bonferroni (controla FWER, step-down, uniformemente mas potente que Bonferroni simple). 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 ('bh' | 'bonferroni' | 'holm')}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -23,7 +23,7 @@ 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"]
|
||||
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_holm_golden_rechaza_dos_de_cuatro", "test_holm_entre_bonferroni_y_bh", "test_none_se_propaga_alineado_holm", "test_lista_vacia_holm_devuelve_note"]
|
||||
test_file_path: "python/functions/datascience/fdr_correction_test.py"
|
||||
file_path: "python/functions/datascience/fdr_correction.py"
|
||||
---
|
||||
@@ -45,6 +45,13 @@ 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]
|
||||
|
||||
# Holm-Bonferroni (step-down): controla el FWER como Bonferroni pero es mas
|
||||
# potente; rechaza al menos tanto como Bonferroni simple, nunca menos.
|
||||
holm = fdr_correction([0.01, 0.04, 0.03, 0.005], alpha=0.05, method="holm")
|
||||
print(holm["reject"]) # -> [True, False, False, True]
|
||||
print(holm["p_values_adjusted"]) # -> [0.03, 0.06, 0.06, 0.02]
|
||||
print(holm["n_rejected"]) # -> 2
|
||||
|
||||
# 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])
|
||||
@@ -61,8 +68,11 @@ 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.
|
||||
por defecto (mejor potencia); `"holm"` (Holm-Bonferroni, FWER step-down) cuando
|
||||
quieras controlar el FWER pero sin la perdida de potencia de Bonferroni simple
|
||||
(rechaza al menos tanto como `"bonferroni"`, nunca menos); `"bonferroni"` cuando
|
||||
un falso positivo sea muy costoso y prefieras la maxima cautela del metodo mas
|
||||
simple.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -76,8 +86,16 @@ costoso y prefieras maxima cautela.
|
||||
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
|
||||
- BH controla cosa distinta que Bonferroni/Holm: BH la tasa de falsos
|
||||
descubrimientos (FDR); Bonferroni y Holm la probabilidad de *cualquier* falso
|
||||
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
|
||||
- `"holm"` y `"bonferroni"` controlan ambos el FWER, pero Holm es step-down y
|
||||
uniformemente mas potente: rechaza al menos tantas hipotesis como Bonferroni
|
||||
simple sobre el mismo set, nunca menos. Si controlas FWER, `"holm"` domina a
|
||||
`"bonferroni"` salvo que necesites el ajuste mas simple por interpretabilidad.
|
||||
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
|
||||
con `note`.
|
||||
con `note`. Los metodos validos son `"bh"`, `"bonferroni"` y `"holm"`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-30) — añade method="holm" (Holm-Bonferroni step-down, FWER, más potente que Bonferroni simple).
|
||||
|
||||
@@ -5,12 +5,15 @@ todos los pares de una matriz de asociacion), la probabilidad de obtener al meno
|
||||
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:
|
||||
mediante tres 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.
|
||||
- Holm-Bonferroni (``"holm"``): controla el FWER como Bonferroni pero es un
|
||||
procedimiento step-down uniformemente mas potente; rechaza al menos tantas
|
||||
hipotesis como Bonferroni simple, nunca menos.
|
||||
|
||||
No usa dependencias externas: aritmetica de la libreria estandar.
|
||||
"""
|
||||
@@ -35,8 +38,9 @@ def _is_valid_p(v) -> bool:
|
||||
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
|
||||
Aplica Benjamini-Hochberg (FDR), Bonferroni (FWER) o Holm-Bonferroni
|
||||
(FWER, step-down) 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`` /
|
||||
@@ -53,8 +57,10 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
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).
|
||||
Para BH es el umbral del FDR; para Bonferroni y Holm, del FWER.
|
||||
method: ``"bh"`` (Benjamini-Hochberg, FDR), ``"bonferroni"`` (FWER) o
|
||||
``"holm"`` (Holm-Bonferroni, FWER step-down, mas potente que
|
||||
Bonferroni simple).
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
@@ -68,7 +74,7 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
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"``).
|
||||
method: metodo aplicado (``"bh"``, ``"bonferroni"`` o ``"holm"``).
|
||||
|
||||
Casos degenerados (lista vacia, sin p-valores validos o metodo
|
||||
desconocido) anaden ademas una clave ``note`` y devuelven listas
|
||||
@@ -76,7 +82,7 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
en las posiciones invalidas).
|
||||
"""
|
||||
method_norm = (method or "").strip().lower()
|
||||
if method_norm not in {"bh", "bonferroni"}:
|
||||
if method_norm not in {"bh", "bonferroni", "holm"}:
|
||||
n = len(pvalues)
|
||||
return {
|
||||
"p_values_adjusted": [None] * n,
|
||||
@@ -86,8 +92,8 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
"alpha": float(alpha),
|
||||
"method": method,
|
||||
"note": (
|
||||
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) "
|
||||
"o 'bonferroni'"
|
||||
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg), "
|
||||
"'bonferroni' o 'holm' (Holm-Bonferroni)"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -129,6 +135,20 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
padj = min(1.0, p * m)
|
||||
adjusted[orig_idx] = padj
|
||||
reject[orig_idx] = padj <= a
|
||||
elif method_norm == "holm":
|
||||
# Holm-Bonferroni (step-down). Ordena p ascendente; para el rank k
|
||||
# (1-indexed) el p ajustado crudo es (m - k + 1) * p_(k). Impon
|
||||
# monotonicidad acumulada (no decreciente) recorriendo de menor a mayor:
|
||||
# padj_(k) = max(padj_(k-1), min(1, (m-k+1)*p_(k))), con padj_(0)=0.
|
||||
order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc
|
||||
prev = 0.0
|
||||
for k in range(1, m + 1):
|
||||
orig_idx, p = order[k - 1]
|
||||
raw = min(1.0, (m - k + 1) * p)
|
||||
padj = max(prev, raw)
|
||||
prev = padj
|
||||
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.
|
||||
|
||||
@@ -82,7 +82,8 @@ def test_solo_none_devuelve_note():
|
||||
|
||||
|
||||
def test_metodo_desconocido_devuelve_note():
|
||||
out = fdr_correction([0.01, 0.02], method="holm")
|
||||
# 'holm' ya es un metodo valido (v1.1.0); usamos uno realmente desconocido.
|
||||
out = fdr_correction([0.01, 0.02], method="sidak")
|
||||
assert "note" in out
|
||||
assert out["n_rejected"] == 0
|
||||
assert out["reject"] == [False, False]
|
||||
@@ -97,3 +98,66 @@ def test_todos_significativos():
|
||||
assert bon["n_rejected"] == 3
|
||||
assert all(bh["reject"])
|
||||
assert all(bon["reject"])
|
||||
|
||||
|
||||
def test_holm_golden_rechaza_dos_de_cuatro():
|
||||
# Holm-Bonferroni (step-down) sobre [0.01, 0.04, 0.03, 0.005], m=4, alpha=0.05.
|
||||
# Ordenado ascendente: 0.005, 0.01, 0.03, 0.04.
|
||||
# padj_(1) = 4*0.005 = 0.02
|
||||
# padj_(2) = max(0.02, 3*0.01=0.03) = 0.03
|
||||
# padj_(3) = max(0.03, 2*0.03=0.06) = 0.06
|
||||
# padj_(4) = max(0.06, 1*0.04=0.04) = 0.06
|
||||
# Mapeado al orden de entrada [0.01, 0.04, 0.03, 0.005]:
|
||||
# 0.01 -> 0.03, 0.04 -> 0.06, 0.03 -> 0.06, 0.005 -> 0.02
|
||||
out = fdr_correction([0.01, 0.04, 0.03, 0.005], alpha=0.05, method="holm")
|
||||
assert out["method"] == "holm"
|
||||
assert out["n_tests"] == 4
|
||||
adj = out["p_values_adjusted"]
|
||||
assert abs(adj[0] - 0.03) < 1e-9
|
||||
assert abs(adj[1] - 0.06) < 1e-9
|
||||
assert abs(adj[2] - 0.06) < 1e-9
|
||||
assert abs(adj[3] - 0.02) < 1e-9
|
||||
assert out["reject"] == [True, False, False, True]
|
||||
assert out["n_rejected"] == 2
|
||||
|
||||
|
||||
def test_holm_entre_bonferroni_y_bh():
|
||||
# Holm controla FWER como Bonferroni pero es step-down: rechaza AL MENOS
|
||||
# tanto como Bonferroni simple, y a lo sumo tanto como BH (FDR, menos
|
||||
# conservador). Cadena de potencia: bonferroni <= holm <= bh.
|
||||
pvalues = [0.01, 0.02, 0.04, 0.005]
|
||||
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||
holm = fdr_correction(pvalues, alpha=0.05, method="holm")
|
||||
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||
assert holm["n_rejected"] >= bon["n_rejected"]
|
||||
assert holm["n_rejected"] <= bh["n_rejected"]
|
||||
# En este set Holm gana potencia frente a Bonferroni simple (estricto).
|
||||
assert holm["n_rejected"] > bon["n_rejected"]
|
||||
|
||||
# Un set donde Holm es estrictamente mas conservador que BH.
|
||||
pvals2 = [0.01, 0.02, 0.03, 0.04]
|
||||
bon2 = fdr_correction(pvals2, alpha=0.05, method="bonferroni")
|
||||
holm2 = fdr_correction(pvals2, alpha=0.05, method="holm")
|
||||
bh2 = fdr_correction(pvals2, alpha=0.05, method="bh")
|
||||
assert holm2["n_rejected"] >= bon2["n_rejected"]
|
||||
assert holm2["n_rejected"] < bh2["n_rejected"]
|
||||
|
||||
|
||||
def test_none_se_propaga_alineado_holm():
|
||||
# None se propaga alineado tambien con holm: la posicion central no cuenta
|
||||
# como prueba (m=2) y se devuelve como None / False.
|
||||
out = fdr_correction([0.001, None, 0.9], method="holm")
|
||||
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_holm_devuelve_note():
|
||||
out = fdr_correction([], method="holm")
|
||||
assert out["p_values_adjusted"] == []
|
||||
assert out["reject"] == []
|
||||
assert out["n_tests"] == 0
|
||||
assert out["n_rejected"] == 0
|
||||
assert "note" in out
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: forecast_seasonal_median
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def forecast_seasonal_median(history: list[dict], horizon_dates: list[str], as_of: str, dow_weeks: int = 8, trend_recent_weeks: int = 4, trend_clip: tuple = (0.5, 2.0)) -> list[dict]"
|
||||
description: "Forecast diario por mediana estacional (mismo dia de semana) mas factor de tendencia acotado, para una o varias series temporales. Base estacional = mediana del valor en las ultimas dow_weeks fechas con el mismo dia de semana que la fecha objetivo (dias ausentes = 0, para series intermitentes). Factor de tendencia por serie = razon de la suma de las ultimas trend_recent_weeks semanas frente a las trend_recent_weeks anteriores, clipped a trend_clip. y_pred = max(0, base * factor). Funcion pura y determinista (solo stdlib, sin I/O ni datetime.now). Nucleo del forecast de ventas diarias Aurgi (dia x centro x subcategoria CGQ)."
|
||||
tags: [forecast, bigquery, timeseries, seasonal, median, baseline, sales, aurgi, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: history
|
||||
desc: "lista de observaciones {series_id: str, date: 'YYYY-MM-DD', value: float}. Filas duplicadas (misma serie+fecha) se suman. Los dias sin fila dentro de las ventanas cuentan como valor 0 (series intermitentes: sin fila = sin venta)"
|
||||
- name: horizon_dates
|
||||
desc: "fechas futuras a predecir, strings ISO 'YYYY-MM-DD'. Tipicamente as_of+1..as_of+horizon"
|
||||
- name: as_of
|
||||
desc: "fecha de corte 'YYYY-MM-DD': ultimo dia de historia utilizable, inclusive. Todas las ventanas se calculan hacia atras desde aqui"
|
||||
- name: dow_weeks
|
||||
desc: "numero de fechas del mismo dia de semana que la objetivo a promediar (mediana) para la base estacional. Default 8 (8 semanas)"
|
||||
- name: trend_recent_weeks
|
||||
desc: "tamano en semanas de cada una de las dos ventanas de tendencia (reciente y anterior). Default 4: compara 4 semanas recientes vs las 4 previas"
|
||||
- name: trend_clip
|
||||
desc: "tupla (min, max) al que se acota el factor de tendencia. Default (0.5, 2.0): la prediccion no puede caer a menos de la mitad ni superar el doble por tendencia"
|
||||
output: "list[dict]: una fila {series_id: str, date: str, y_pred: float} por cada serie presente en history y cada fecha de horizon_dates. Ordenada por series_id (asc) y luego por el orden de horizon_dates. y_pred siempre >= 0.0"
|
||||
tested: true
|
||||
tests:
|
||||
- "serie regular con patron semanal claro da la mediana correcta"
|
||||
- "serie intermitente: los dias ausentes cuentan como 0 en la mediana"
|
||||
- "serie con tendencia creciente aplica factor >1 acotado a trend_clip"
|
||||
- "sin datos en la ventana anterior, el factor de tendencia es 1.0"
|
||||
- "horizon de 7 dias produce una fila por serie y fecha, ordenadas"
|
||||
test_file_path: "python/functions/datascience/forecast_seasonal_median_test.py"
|
||||
file_path: "python/functions/datascience/forecast_seasonal_median.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import forecast_seasonal_median
|
||||
|
||||
# Historia diaria por serie (centro|subcategoria). Sin fila = sin venta = 0.
|
||||
history = [
|
||||
{"series_id": "12|NEUMATICOS", "date": "2026-06-23", "value": 1450.0},
|
||||
{"series_id": "12|NEUMATICOS", "date": "2026-06-16", "value": 1380.0},
|
||||
{"series_id": "12|NEUMATICOS", "date": "2026-06-09", "value": 1500.0},
|
||||
# ... mas historia (idealmente >= 8 semanas para la base estacional) ...
|
||||
]
|
||||
|
||||
# as_of = ultimo dia cerrado; predice los 7 dias siguientes.
|
||||
horizon = ["2026-06-30", "2026-07-01", "2026-07-02", "2026-07-03",
|
||||
"2026-07-04", "2026-07-05", "2026-07-06"]
|
||||
|
||||
preds = forecast_seasonal_median(history, horizon, as_of="2026-06-29")
|
||||
for p in preds:
|
||||
print(p["series_id"], p["date"], round(p["y_pred"], 2))
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un baseline de forecast diario robusto y explicable para series
|
||||
con estacionalidad semanal fuerte (ventas por dia de la semana) y posibles huecos
|
||||
(dias sin venta). Es el nucleo puro del pipeline `run_sales_forecast`: se llama una
|
||||
vez con toda la historia agregada y devuelve todas las predicciones de golpe.
|
||||
Usala como punto de partida antes de modelos mas pesados (Prophet, ARIMA, gradient
|
||||
boosting): captura el patron dia-de-semana + una correccion de tendencia acotada
|
||||
sin dependencias externas ni entrenamiento. Ideal para muchas series a la vez
|
||||
(miles de pares centro x subcategoria) donde entrenar un modelo por serie no
|
||||
compensa.
|
||||
|
||||
## Notas
|
||||
|
||||
- Funcion pura y determinista: no hace I/O, no llama `datetime.now()`; el corte
|
||||
temporal siempre es el argumento `as_of` explicito. Solo stdlib (datetime,
|
||||
statistics), sin numpy ni pandas.
|
||||
- La base estacional toma las fechas EXACTAS del calendario: la mas reciente
|
||||
<= as_of con el mismo dia de semana que la objetivo, y de ahi 7 dias hacia atras
|
||||
por punto (hasta `dow_weeks` puntos). Una fecha ausente en `history` cuenta como
|
||||
0, por lo que la mediana refleja bien las series intermitentes.
|
||||
- El factor de tendencia se calcula UNA vez por serie (no depende de la fecha
|
||||
objetivo) como razon de sumas de dos ventanas contiguas de `trend_recent_weeks`
|
||||
semanas. Denominador 0 => factor 1.0 (evita division por cero y no infla series
|
||||
que arrancan). El clip a `trend_clip` evita que un pico reciente dispare la
|
||||
prediccion.
|
||||
- `y_pred = max(0.0, base * factor)`: nunca negativo. No modela festivos ni eventos
|
||||
puntuales; para eso se necesitaria una capa de calendario adicional.
|
||||
- Para que la base estacional sea fiable conviene aportar >= `dow_weeks` semanas de
|
||||
historia. Con menos historia, los puntos ausentes (=0) empujan la mediana hacia
|
||||
abajo.
|
||||
@@ -0,0 +1,126 @@
|
||||
"""forecast_seasonal_median — forecast diario por mediana estacional + tendencia.
|
||||
|
||||
Funcion PURA (sin I/O, sin datetime.now(), determinista). Predice el valor futuro
|
||||
de una o varias series temporales diarias combinando dos senales:
|
||||
|
||||
1. Base estacional: la mediana del valor en las ultimas `dow_weeks` fechas con el
|
||||
MISMO dia de semana que la fecha objetivo (dias ausentes = 0, para series
|
||||
intermitentes donde "sin fila" significa "sin venta").
|
||||
2. Factor de tendencia por serie: cuanto ha crecido/caido la actividad reciente
|
||||
respecto al periodo inmediatamente anterior (razon de sumas), acotado a un
|
||||
rango para no amplificar ruido.
|
||||
|
||||
Disenada para el forecast de ventas diarias de Aurgi (dia x centro x subcategoria
|
||||
CGQ): cada serie es un par centro|subcategoria y el patron semanal domina la
|
||||
demanda (los sabados venden distinto que los martes). Solo usa stdlib
|
||||
(datetime, statistics).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from statistics import median
|
||||
|
||||
|
||||
def _to_date(value: str) -> date:
|
||||
"""Convierte una fecha ISO 'YYYY-MM-DD' (o datetime.date) a datetime.date."""
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
return datetime.strptime(value[:10], "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def forecast_seasonal_median(
|
||||
history: list[dict],
|
||||
horizon_dates: list[str],
|
||||
as_of: str,
|
||||
dow_weeks: int = 8,
|
||||
trend_recent_weeks: int = 4,
|
||||
trend_clip: tuple = (0.5, 2.0),
|
||||
) -> list[dict]:
|
||||
"""Predice el valor de cada serie para cada fecha del horizonte.
|
||||
|
||||
Para cada serie presente en `history` y cada fecha objetivo del horizonte:
|
||||
|
||||
1. Base estacional = mediana del valor en las ultimas `dow_weeks` fechas con el
|
||||
MISMO dia de semana que la fecha objetivo, todas <= `as_of`. Se toman las
|
||||
fechas EXACTAS del calendario (la mas reciente <= as_of con ese dia de
|
||||
semana, y de ahi 7 dias hacia atras por punto); una fecha ausente en la
|
||||
historia cuenta como 0 (series intermitentes).
|
||||
2. Factor de tendencia por serie = suma de los valores de las ultimas
|
||||
`trend_recent_weeks` semanas (desde `as_of` hacia atras) dividida entre la
|
||||
suma de las `trend_recent_weeks` semanas anteriores a esas. Si el
|
||||
denominador es 0 el factor es 1.0. Se acota a `trend_clip`.
|
||||
3. y_pred = max(0.0, base * factor).
|
||||
|
||||
Args:
|
||||
history: observaciones {"series_id": str, "date": "YYYY-MM-DD",
|
||||
"value": float}. Filas duplicadas (misma serie y fecha) se suman. Los
|
||||
dias sin fila dentro de las ventanas se tratan como valor 0.
|
||||
horizon_dates: fechas futuras a predecir (strings ISO 'YYYY-MM-DD').
|
||||
as_of: fecha de corte (ultimo dia de historia utilizable, inclusive).
|
||||
dow_weeks: numero de fechas del mismo dia de semana a promediar para la
|
||||
base estacional. Default 8.
|
||||
trend_recent_weeks: tamano (en semanas) de cada una de las dos ventanas de
|
||||
tendencia (reciente y anterior). Default 4.
|
||||
trend_clip: (min, max) al que se acota el factor de tendencia. Default
|
||||
(0.5, 2.0): la prediccion no puede menos que caer a la mitad ni mas
|
||||
que duplicarse por tendencia.
|
||||
|
||||
Returns:
|
||||
Lista de {"series_id": str, "date": str, "y_pred": float}, una fila por
|
||||
cada serie presente en `history` y cada fecha del horizonte. Ordenada por
|
||||
series_id (asc) y luego por el orden de `horizon_dates`.
|
||||
"""
|
||||
as_of_d = _to_date(as_of)
|
||||
lo_clip, hi_clip = trend_clip
|
||||
|
||||
# Mapa (series_id, date) -> valor acumulado + conjunto de series presentes.
|
||||
values: dict[tuple[str, date], float] = {}
|
||||
series_ids: set[str] = set()
|
||||
for obs in history:
|
||||
sid = obs["series_id"]
|
||||
d = _to_date(obs["date"])
|
||||
v = float(obs.get("value", 0.0) or 0.0)
|
||||
series_ids.add(sid)
|
||||
values[(sid, d)] = values.get((sid, d), 0.0) + v
|
||||
|
||||
# Ventanas de tendencia (en dias) relativas a as_of.
|
||||
span = 7 * trend_recent_weeks
|
||||
recent_lo = as_of_d - timedelta(days=span) # reciente: recent_lo < d <= as_of
|
||||
prior_lo = as_of_d - timedelta(days=2 * span) # anterior: prior_lo < d <= recent_lo
|
||||
|
||||
# Factor de tendencia por serie (una sola vez por serie, no depende del horizonte).
|
||||
trend_factor: dict[str, float] = {}
|
||||
for sid in series_ids:
|
||||
recent_sum = 0.0
|
||||
prior_sum = 0.0
|
||||
for (s, d), v in values.items():
|
||||
if s != sid:
|
||||
continue
|
||||
if recent_lo < d <= as_of_d:
|
||||
recent_sum += v
|
||||
elif prior_lo < d <= recent_lo:
|
||||
prior_sum += v
|
||||
if prior_sum == 0.0:
|
||||
factor = 1.0
|
||||
else:
|
||||
factor = recent_sum / prior_sum
|
||||
trend_factor[sid] = min(hi_clip, max(lo_clip, factor))
|
||||
|
||||
horizon = [_to_date(h) for h in horizon_dates]
|
||||
out: list[dict] = []
|
||||
for sid in sorted(series_ids):
|
||||
factor = trend_factor[sid]
|
||||
for h_str, h_d in zip(horizon_dates, horizon):
|
||||
# Fecha mas reciente <= as_of con el mismo dia de semana que la objetivo.
|
||||
back = (as_of_d.weekday() - h_d.weekday()) % 7
|
||||
anchor = as_of_d - timedelta(days=back)
|
||||
dow_values = [
|
||||
values.get((sid, anchor - timedelta(days=7 * i)), 0.0)
|
||||
for i in range(dow_weeks)
|
||||
]
|
||||
base = median(dow_values)
|
||||
y_pred = max(0.0, base * factor)
|
||||
out.append({"series_id": sid, "date": h_str, "y_pred": y_pred})
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests para forecast_seasonal_median."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from forecast_seasonal_median import forecast_seasonal_median
|
||||
|
||||
|
||||
def _iso(d: date) -> str:
|
||||
return d.isoformat()
|
||||
|
||||
|
||||
def test_serie_regular_patron_semanal_mediana_correcta():
|
||||
"""serie regular con patron semanal claro da la mediana correcta."""
|
||||
# as_of martes; historia diaria de 12 semanas con valor fijo por dia de semana
|
||||
# y patron constante (tendencia neutra -> factor 1).
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
by_weekday = {0: 100.0, 1: 10.0, 2: 20.0, 3: 30.0, 4: 40.0, 5: 5.0, 6: 0.0}
|
||||
history = []
|
||||
for i in range(84): # 12 semanas de dias
|
||||
d = as_of - timedelta(days=i)
|
||||
history.append({"series_id": "c1|sub", "date": _iso(d), "value": by_weekday[d.weekday()]})
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=k)) for k in range(1, 8)] # 7 dias
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# 1 serie x 7 fechas de horizonte
|
||||
assert len(result) == 7
|
||||
for row in result:
|
||||
wd = date.fromisoformat(row["date"]).weekday()
|
||||
# base = mediana de 8 semanas del mismo valor constante; factor = 1.
|
||||
assert row["y_pred"] == by_weekday[wd]
|
||||
assert row["series_id"] == "c1|sub"
|
||||
|
||||
|
||||
def test_serie_intermitente_con_ceros():
|
||||
"""serie intermitente: los dias ausentes cuentan como 0 en la mediana."""
|
||||
# as_of martes. La serie solo vende martes alternos (w=0,2,4,6), el resto 0.
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
history = []
|
||||
for w in (0, 2, 4, 6):
|
||||
history.append({"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": 40.0})
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=7))] # proximo martes
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# dow_weeks=8 martes: [40,0,40,0,40,0,40,0] -> mediana (0+40)/2 = 20.
|
||||
# tendencia: reciente (w0..3)=40+40=80, anterior (w4..7)=40+40=80 -> factor 1.
|
||||
assert len(result) == 1
|
||||
assert result[0]["y_pred"] == 20.0
|
||||
|
||||
|
||||
def test_serie_con_tendencia_creciente_factor_clipped():
|
||||
"""serie con tendencia creciente aplica factor >1 acotado a trend_clip."""
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
# Reciente (4 martes) = 30 c/u, anterior (4 martes) = 10 c/u.
|
||||
vals = {0: 30.0, 1: 30.0, 2: 30.0, 3: 30.0, 4: 10.0, 5: 10.0, 6: 10.0, 7: 10.0}
|
||||
history = [
|
||||
{"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": v}
|
||||
for w, v in vals.items()
|
||||
]
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=7))]
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# base = mediana de [30,30,30,30,10,10,10,10] = 20.
|
||||
# factor = 120/40 = 3.0 -> clipped a 2.0 (trend_clip=(0.5,2.0)).
|
||||
# y_pred = 20 * 2.0 = 40.
|
||||
assert result[0]["y_pred"] == 40.0
|
||||
|
||||
|
||||
def test_serie_sin_datos_en_denominador_tendencia_factor_1():
|
||||
"""sin datos en la ventana anterior, el factor de tendencia es 1.0."""
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
# Solo hay datos en las 4 semanas recientes (w=0..3); nada mas antiguo.
|
||||
history = [
|
||||
{"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": 50.0}
|
||||
for w in range(4)
|
||||
]
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=7))]
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# denominador (semanas anteriores) = 0 -> factor 1.0 (no crashea).
|
||||
# base = mediana [50,50,50,50,0,0,0,0] = (0+50)/2 = 25 -> y_pred = 25.
|
||||
assert result[0]["y_pred"] == 25.0
|
||||
|
||||
|
||||
def test_horizon_de_7_dias_una_fila_por_serie_y_fecha():
|
||||
"""horizon de 7 dias produce una fila por serie y fecha, ordenadas."""
|
||||
as_of = date(2026, 6, 30)
|
||||
history = [
|
||||
{"series_id": "b|x", "date": _iso(as_of - timedelta(days=7 * w)), "value": 12.0}
|
||||
for w in range(8)
|
||||
] + [
|
||||
{"series_id": "a|x", "date": _iso(as_of - timedelta(days=7 * w)), "value": 8.0}
|
||||
for w in range(8)
|
||||
]
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=k)) for k in range(1, 8)]
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# 2 series x 7 fechas = 14 filas, ordenadas por series_id asc.
|
||||
assert len(result) == 14
|
||||
assert [r["series_id"] for r in result[:7]] == ["a|x"] * 7
|
||||
assert [r["series_id"] for r in result[7:]] == ["b|x"] * 7
|
||||
# el orden de fechas dentro de cada serie respeta horizon_dates
|
||||
assert [r["date"] for r in result[:7]] == horizon
|
||||
# y_pred >= 0 siempre
|
||||
assert all(r["y_pred"] >= 0.0 for r in result)
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: generate_synthetic_eda_folder
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def generate_synthetic_eda_folder(out_dir: str, n_rows: int = 2000, seed: int = 42) -> dict"
|
||||
description: "Genera una carpeta con 3 CSV RELACIONADOS (customers, orders, reviews) deterministas por seed (Faker + numpy) para ejercitar el motor AutomaticEDA multi-tabla / profile_database. orders.customer_id y reviews.customer_id estan contenidos al 100% en customers.customer_id (PK uuid), de modo que la deteccion FK por containment (min_inclusion=0.9) descubre ambas relaciones. customers es la tabla padre; reutiliza helpers de generate_synthetic_eda_table (texto multi-idioma, lat/lon validas, amount con outliers). Estilo dict-no-throw: nunca lanza."
|
||||
tags: [eda, synthetic, faker, testing, fixture, datascience]
|
||||
params:
|
||||
- name: out_dir
|
||||
desc: "Carpeta de salida. Se crea con mkdir -p si no existe. Recibe customers.csv, orders.csv y reviews.csv."
|
||||
- name: n_rows
|
||||
desc: "Numero de clientes (filas de customers). orders ~= 2*n_rows filas, reviews ~= n_rows filas. Default 2000."
|
||||
- name: seed
|
||||
desc: "Semilla para Faker (Faker.seed) y numpy (np.random.default_rng). Mismo seed -> CSVs identicos byte a byte. Default 42."
|
||||
output: "dict dict-no-throw. En exito {status:'ok', out_dir, files:{customers,orders,reviews}, n_customers, n_orders, n_reviews, expected_relations:[{from_table,from_col,to_table,to_col}, ...], seed}. En error (sin lanzar, p.ej. n_rows<=0) {status:'error', error:str}. expected_relations declara las 2 FK orders->customers y reviews->customers (ambas por customer_id)."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_genera_ok_y_archivos", "test_determinismo_mismo_seed", "test_seeds_distintos_difieren", "test_fk_containment", "test_review_text_mediana_palabras", "test_n_rows_invalido"]
|
||||
test_file_path: "python/functions/datascience/generate_synthetic_eda_folder_test.py"
|
||||
file_path: "python/functions/datascience/generate_synthetic_eda_folder.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Genera /tmp/eda_folder/{customers,orders,reviews}.csv (300 customers, seed 42)
|
||||
fn run generate_synthetic_eda_folder /tmp/eda_folder 300 42
|
||||
```
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience import generate_synthetic_eda_folder
|
||||
|
||||
res = generate_synthetic_eda_folder("/tmp/eda_folder", n_rows=300, seed=42)
|
||||
# res["files"] -> {"customers": ".../customers.csv", "orders": ..., "reviews": ...}
|
||||
# res["expected_relations"] -> orders.customer_id y reviews.customer_id -> customers.customer_id
|
||||
# Luego perfila la carpeta/base con el grupo eda:
|
||||
# fn run profile_database /tmp/eda_folder
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites un fixture REPRODUCIBLE multi-tabla para evaluar el EDA de carpeta/base (`profile_database`, join graph, capitulo de relaciones inter-tabla) con relaciones FK reales y detectables.
|
||||
- Cuando escribas tests de la deteccion de claves foraneas por containment: orders y reviews referencian customer_id contenido al 100% en customers (inclusion 1.0 >= min_inclusion 0.9).
|
||||
- Como contraparte multi-tabla de `generate_synthetic_eda_table` (que cubre el EDA de UNA tabla).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe 3 CSV a disco (`mkdir -p` de la carpeta). Sobrescribe los CSV existentes con el mismo nombre.
|
||||
- **Requiere `faker`, `numpy` y `pandas`** en el venv. Sin `faker` devuelve `{status:'error'}` (no lanza).
|
||||
- **El containment depende del orden**: customers se genera PRIMERO y orders/reviews muestrean sus `customer_id`. Si se invierte el orden, la FK deja de estar contenida y el detector no la encuentra.
|
||||
- **`signup_date`/`ts` se escriben como texto ISO en el CSV** (`YYYY-MM-DD` / `YYYY-MM-DD HH:MM:SS`): es CSV, todo es texto; el profiler los promociona a datetime al leerlos.
|
||||
- **Determinismo dependiente del orden de llamadas**: se siembra `Faker.seed(seed)` + `np.random.default_rng(seed)` al inicio; mismo seed -> CSVs identicos byte a byte.
|
||||
- **Reutiliza helpers privados** de `generate_synthetic_eda_table` (`_make_fakers`, `_make_latlon`, `_make_reviews`, `_amount_with_outliers`): no romper esas firmas sin actualizar esta funcion.
|
||||
|
||||
## Notas
|
||||
|
||||
Estructura generada:
|
||||
|
||||
| Archivo | PK | FK | Columnas clave |
|
||||
|---|---|---|---|
|
||||
| customers.csv | customer_id (uuid) | — | name, country, signup_date, latitude, longitude, email |
|
||||
| orders.csv | order_id (uuid) | customer_id -> customers | amount (lognormal + outliers), category, ts |
|
||||
| reviews.csv | review_id (uuid) | customer_id -> customers | review_text (multi-idioma, mediana palabras>=20), rating (1..5) |
|
||||
|
||||
orders tiene ~2x filas que customers y reviews ~1x. Todos los `customer_id` de orders
|
||||
y reviews estan contenidos en customers (containment ⊆), por lo que la deteccion FK por
|
||||
inclusion descubre las dos relaciones declaradas en `expected_relations`.
|
||||
@@ -0,0 +1,177 @@
|
||||
"""generate_synthetic_eda_folder — fixture multi-tabla relacionado para el EDA de base/carpeta.
|
||||
|
||||
Funcion impura (escribe CSVs a disco) y determinista por ``seed``: crea una
|
||||
carpeta con 3 CSV RELACIONADOS (customers, orders, reviews) cuyo contenido esta
|
||||
disenado para que el motor AutomaticEDA multi-tabla / `profile_database` detecte
|
||||
las relaciones FK por containment de valores (orders.customer_id y
|
||||
reviews.customer_id contenidos al 100% en customers.customer_id, por encima del
|
||||
``min_inclusion=0.9`` que usa la deteccion).
|
||||
|
||||
Reutiliza los helpers de ``generate_synthetic_eda_table`` (texto multi-idioma,
|
||||
lat/lon validas, amount con outliers, listas fijas de paises/categorias) para no
|
||||
reimplementar logica.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; devuelve
|
||||
``{"status": "error", "error": str}`` ante cualquier fallo.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from .generate_synthetic_eda_table import (
|
||||
_CATEGORIES,
|
||||
_COUNTRIES,
|
||||
_amount_with_outliers,
|
||||
_make_fakers,
|
||||
_make_latlon,
|
||||
_make_reviews,
|
||||
)
|
||||
|
||||
|
||||
def generate_synthetic_eda_folder(out_dir, n_rows=2000, seed=42):
|
||||
"""Genera una carpeta con 3 CSV relacionados (customers/orders/reviews).
|
||||
|
||||
customers es la tabla padre (PK ``customer_id`` uuid unica). orders y reviews
|
||||
referencian ``customer_id`` muestreandolo de customers, de modo que TODOS sus
|
||||
valores estan contenidos en customers (inclusion 1.0 -> FK detectable).
|
||||
|
||||
Funcion impura (escribe a disco) y determinista por ``seed``. NUNCA lanza.
|
||||
|
||||
Args:
|
||||
out_dir: carpeta de salida. Se crea con ``mkdir -p`` si no existe.
|
||||
n_rows: numero de clientes (customers). orders ~= 2*n_rows, reviews ~= n_rows.
|
||||
Default 2000.
|
||||
seed: semilla para Faker y numpy. Default 42.
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw. En exito::
|
||||
|
||||
{"status": "ok", "out_dir": ..., "files": {customers, orders, reviews},
|
||||
"n_customers": ..., "n_orders": ..., "n_reviews": ...,
|
||||
"expected_relations": [{from_table, from_col, to_table, to_col}, ...],
|
||||
"seed": seed}
|
||||
|
||||
En error (sin lanzar)::
|
||||
|
||||
{"status": "error", "error": str}
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
n = int(n_rows)
|
||||
if n <= 0:
|
||||
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
fakers = _make_fakers(seed)
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
# ---------------- customers (tabla padre) ----------------
|
||||
n_cust = n
|
||||
customer_ids = [fakers["en_US"].uuid4() for _ in range(n_cust)]
|
||||
names = [fakers["en_US"].name() for _ in range(n_cust)]
|
||||
cust_country = rng.choice(_COUNTRIES, n_cust)
|
||||
base = np.datetime64("2022-01-01")
|
||||
signup_offsets = rng.integers(0, 730, n_cust)
|
||||
signup_date = pd.to_datetime(base) + pd.to_timedelta(signup_offsets, unit="D")
|
||||
signup_iso = [d.strftime("%Y-%m-%d") for d in signup_date]
|
||||
lat, lon = _make_latlon(cust_country, rng)
|
||||
cust_email = [fakers["en_US"].email() for _ in range(n_cust)]
|
||||
|
||||
customers = pd.DataFrame(
|
||||
{
|
||||
"customer_id": customer_ids,
|
||||
"name": names,
|
||||
"country": cust_country,
|
||||
"signup_date": signup_iso,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"email": cust_email,
|
||||
}
|
||||
)
|
||||
|
||||
# ---------------- orders (FK -> customers) ----------------
|
||||
n_orders = n_cust * 2
|
||||
order_ids = [fakers["en_US"].uuid4() for _ in range(n_orders)]
|
||||
order_cust = rng.choice(customer_ids, n_orders) # subset/multiset de customers
|
||||
amount = _amount_with_outliers(n_orders, rng, n_extreme=10)
|
||||
order_cat = rng.choice(_CATEGORIES, n_orders)
|
||||
ts_offsets = rng.integers(0, 730 * 24 * 3600, n_orders)
|
||||
ts = pd.to_datetime(np.datetime64("2022-01-01T00:00:00")) + pd.to_timedelta(
|
||||
ts_offsets, unit="s"
|
||||
)
|
||||
ts_iso = [t.strftime("%Y-%m-%d %H:%M:%S") for t in ts]
|
||||
|
||||
orders = pd.DataFrame(
|
||||
{
|
||||
"order_id": order_ids,
|
||||
"customer_id": order_cust,
|
||||
"amount": amount,
|
||||
"category": order_cat,
|
||||
"ts": ts_iso,
|
||||
}
|
||||
)
|
||||
|
||||
# ---------------- reviews (FK -> customers) ----------------
|
||||
n_reviews = n_cust
|
||||
review_ids = [fakers["en_US"].uuid4() for _ in range(n_reviews)]
|
||||
# Subconjunto de customers (no todos) -> containment estricto ⊆ customers.
|
||||
rev_cust = rng.choice(customer_ids, n_reviews)
|
||||
review_text = _make_reviews(n_reviews, rng, fakers, null_frac=0.0)
|
||||
rating = rng.integers(1, 6, n_reviews)
|
||||
|
||||
reviews = pd.DataFrame(
|
||||
{
|
||||
"review_id": review_ids,
|
||||
"customer_id": rev_cust,
|
||||
"review_text": review_text,
|
||||
"rating": rating,
|
||||
}
|
||||
)
|
||||
|
||||
files = {
|
||||
"customers": os.path.join(out_dir, "customers.csv"),
|
||||
"orders": os.path.join(out_dir, "orders.csv"),
|
||||
"reviews": os.path.join(out_dir, "reviews.csv"),
|
||||
}
|
||||
customers.to_csv(files["customers"], index=False)
|
||||
orders.to_csv(files["orders"], index=False)
|
||||
reviews.to_csv(files["reviews"], index=False)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"out_dir": out_dir,
|
||||
"files": files,
|
||||
"n_customers": n_cust,
|
||||
"n_orders": n_orders,
|
||||
"n_reviews": n_reviews,
|
||||
"expected_relations": [
|
||||
{
|
||||
"from_table": "orders",
|
||||
"from_col": "customer_id",
|
||||
"to_table": "customers",
|
||||
"to_col": "customer_id",
|
||||
},
|
||||
{
|
||||
"from_table": "reviews",
|
||||
"from_col": "customer_id",
|
||||
"to_table": "customers",
|
||||
"to_col": "customer_id",
|
||||
},
|
||||
],
|
||||
"seed": seed,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
|
||||
return {"status": "error", "error": str(exc)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
out = args[0] if len(args) > 0 else "/tmp/synthetic_eda_folder"
|
||||
rows = int(args[1]) if len(args) > 1 else 2000
|
||||
sd = int(args[2]) if len(args) > 2 else 42
|
||||
print(json.dumps(generate_synthetic_eda_folder(out, rows, sd), indent=2))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests para generate_synthetic_eda_folder."""
|
||||
|
||||
import os
|
||||
import statistics
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from datascience.generate_synthetic_eda_folder import generate_synthetic_eda_folder
|
||||
|
||||
|
||||
def test_genera_ok_y_archivos(tmp_path):
|
||||
out = str(tmp_path / "folder")
|
||||
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
|
||||
assert res["status"] == "ok"
|
||||
assert res["n_customers"] == 300
|
||||
assert res["n_orders"] == 600
|
||||
assert res["n_reviews"] == 300
|
||||
for key in ("customers", "orders", "reviews"):
|
||||
assert os.path.exists(res["files"][key])
|
||||
# Relaciones esperadas declaradas.
|
||||
rels = {(r["from_table"], r["to_table"]) for r in res["expected_relations"]}
|
||||
assert ("orders", "customers") in rels
|
||||
assert ("reviews", "customers") in rels
|
||||
|
||||
|
||||
def test_determinismo_mismo_seed(tmp_path):
|
||||
out1 = str(tmp_path / "f1")
|
||||
out2 = str(tmp_path / "f2")
|
||||
generate_synthetic_eda_folder(out1, n_rows=250, seed=11)
|
||||
generate_synthetic_eda_folder(out2, n_rows=250, seed=11)
|
||||
for name in ("customers.csv", "orders.csv", "reviews.csv"):
|
||||
a = open(os.path.join(out1, name), "rb").read()
|
||||
b = open(os.path.join(out2, name), "rb").read()
|
||||
assert a == b, f"{name} difiere entre dos generaciones con el mismo seed"
|
||||
|
||||
|
||||
def test_seeds_distintos_difieren(tmp_path):
|
||||
out1 = str(tmp_path / "f1")
|
||||
out2 = str(tmp_path / "f2")
|
||||
generate_synthetic_eda_folder(out1, n_rows=250, seed=11)
|
||||
generate_synthetic_eda_folder(out2, n_rows=250, seed=12)
|
||||
a = open(os.path.join(out1, "customers.csv"), "rb").read()
|
||||
b = open(os.path.join(out2, "customers.csv"), "rb").read()
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_fk_containment(tmp_path):
|
||||
out = str(tmp_path / "folder")
|
||||
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
|
||||
customers = pd.read_csv(res["files"]["customers"])
|
||||
orders = pd.read_csv(res["files"]["orders"])
|
||||
reviews = pd.read_csv(res["files"]["reviews"])
|
||||
cust_ids = set(customers["customer_id"])
|
||||
# Todos los customer_id de orders y reviews ⊆ customers.
|
||||
assert set(orders["customer_id"]) <= cust_ids
|
||||
assert set(reviews["customer_id"]) <= cust_ids
|
||||
# customer_id es PK unica en customers.
|
||||
assert customers["customer_id"].is_unique
|
||||
assert orders["order_id"].is_unique
|
||||
assert reviews["review_id"].is_unique
|
||||
|
||||
|
||||
def test_review_text_mediana_palabras(tmp_path):
|
||||
out = str(tmp_path / "folder")
|
||||
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
|
||||
reviews = pd.read_csv(res["files"]["reviews"])
|
||||
words = [len(str(t).split()) for t in reviews["review_text"].dropna()]
|
||||
assert statistics.median(words) >= 20
|
||||
|
||||
|
||||
def test_n_rows_invalido(tmp_path):
|
||||
out = str(tmp_path / "folder")
|
||||
res = generate_synthetic_eda_folder(out, n_rows=0, seed=42)
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: generate_synthetic_eda_table
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def generate_synthetic_eda_table(out_db_path: str, table: str = 'synthetic', n_rows: int = 2000, seed: int = 42) -> dict"
|
||||
description: "Genera una tabla DuckDB sintetica (Faker + numpy, determinista por seed) cuyo contenido esta disenado para ACTIVAR el maximo de capitulos del motor AutomaticEDA del grupo eda: numericas continuas con correlacion lineal/no-lineal, numericas con outliers, categoricas desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie temporal, lat/lon validas, semanticos/PII (uuid/email/iban/phone) y nulos con patron MCAR/MAR. Fixture para evaluar el EDA de punta a punta. Estilo dict-no-throw: nunca lanza."
|
||||
tags: [eda, synthetic, faker, testing, fixture, datascience]
|
||||
params:
|
||||
- name: out_db_path
|
||||
desc: "Ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la tabla se reemplaza con CREATE OR REPLACE TABLE si ya existe."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla a crear. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el DDL. Default 'synthetic'."
|
||||
- name: n_rows
|
||||
desc: "Numero de filas (clientes unicos). Cada fila es un cliente con id/email/iban/phone propios. Default 2000."
|
||||
- name: seed
|
||||
desc: "Semilla para Faker (Faker.seed) y numpy (np.random.default_rng). Mismo seed -> tabla identica byte a byte. Default 42."
|
||||
output: "dict dict-no-throw. En exito {status:'ok', db_path, table, n_rows, columns:[19 nombres de columna], seed}. En error (sin lanzar, p.ej. nombre de tabla invalido o n_rows<=0) {status:'error', error:str}. Columnas: customer_id,email,iban,phone,income,spending,age,risk_score,tenure_months,engagement_quad,amount,n_purchases,country,category,plan,review,signup_date,latitude,longitude."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_genera_ok_y_columnas", "test_determinismo_mismo_seed", "test_seeds_distintos_difieren", "test_latlon_en_rango", "test_plan_solo_niveles_validos", "test_income_spending_co_nulos", "test_review_mediana_palabras_y_signup_datetime", "test_phone_matchea_regex_internacional", "test_outliers_y_correlaciones", "test_tabla_invalida_devuelve_error"]
|
||||
test_file_path: "python/functions/datascience/generate_synthetic_eda_table_test.py"
|
||||
file_path: "python/functions/datascience/generate_synthetic_eda_table.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Genera /tmp/x.duckdb con la tabla `synthetic` (2000 filas, seed 42)
|
||||
fn run generate_synthetic_eda_table /tmp/x.duckdb synthetic 2000 42
|
||||
```
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience import generate_synthetic_eda_table
|
||||
|
||||
res = generate_synthetic_eda_table("/tmp/x.duckdb", "synthetic", n_rows=2000, seed=42)
|
||||
# res == {"status":"ok", "db_path":"/tmp/x.duckdb", "table":"synthetic",
|
||||
# "n_rows":2000, "columns":[...19...], "seed":42}
|
||||
# Luego perfilala con el grupo eda:
|
||||
# fn run profile_table /tmp/x.duckdb synthetic
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites un dataset de prueba REPRODUCIBLE para evaluar el motor AutomaticEDA de punta a punta: su contenido dispara, a proposito, num_distr, cat_distr, text_distr, correlacion, missingness (MCAR/MAR), modelos (PCA/KMeans/outliers), timeseries, geospatial, calidad, agregacion y los detectores semanticos / PII (`infer_semantic_type`).
|
||||
- Cuando escribas tests de capitulos del EDA y quieras una tabla con una columna que active CADA detector sin montar datos a mano.
|
||||
- Cuando quieras un fixture determinista (mismo seed -> misma tabla) para comparar el render del EDA entre versiones.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe a disco (crea/reutiliza el archivo DuckDB). Reemplaza la tabla destino con `CREATE OR REPLACE`.
|
||||
- **Requiere `faker`, `duckdb`, `numpy` y `pandas`** instalados en el venv. Sin `faker` la generacion devuelve `{status:'error'}` (no lanza).
|
||||
- **`signup_date` queda como TIMESTAMP/DATE en DuckDB** (se construye con `datetime64[ns]`), NO VARCHAR — condicion para que `detect_time_column` la elija y se active el capitulo timeseries. Si fuese VARCHAR, el detector de fecha fallaria.
|
||||
- **El texto de `review` debe superar el gate de text_distr**: media de caracteres >= 50 y mediana de palabras >= 20. Por eso cada review concatena dos parrafos Faker (~50 palabras de mediana); no reducir el numero de frases o el capitulo text_distr no activa.
|
||||
- **Determinismo dependiente del orden de llamadas**: se siembra `Faker.seed(seed)` + `np.random.default_rng(seed)` al inicio; cambiar el orden de las extracciones cambia la salida aunque el seed sea el mismo.
|
||||
- **PII real-istica**: `email`/`iban`/`phone`/`customer_id` matchean los regex de `infer_semantic_type` (email/iban/phone_intl/uuid) al 100%; son datos sinteticos de Faker, no personas reales.
|
||||
|
||||
## Notas
|
||||
|
||||
Mapa columna -> detector que activa:
|
||||
|
||||
| Columna(s) | Tipo | Detector / capitulo |
|
||||
|---|---|---|
|
||||
| income, spending | num continua | correlacion POSITIVA fuerte (Pearson > 0.8) |
|
||||
| age, risk_score | num continua | correlacion NEGATIVA |
|
||||
| tenure_months, engagement_quad | num continua | relacion NO LINEAL (cuadratica) |
|
||||
| amount, n_purchases | num + outliers | num_distr / outliers (cola pesada + extremos inyectados) |
|
||||
| country (12), category (6), plan (3 desbalanceado) | categorica | cat_distr / agregacion (entropia baja en plan) |
|
||||
| review | texto libre multi-idioma | text_distr (len_mean>=50, mediana palabras>=20) + duplicados exactos |
|
||||
| signup_date | DATE/TIMESTAMP | timeseries |
|
||||
| latitude, longitude | num [-90,90]/[-180,180] | geospatial (detect_latlon_columns) |
|
||||
| customer_id, email, iban, phone | texto | semantic_type uuid/email/iban/phone_intl (PII) |
|
||||
| income+spending (co-nulos 12%), risk_score (nulo si plan=alta), review (8%) | nulos con patron | missingness MCAR/MAR |
|
||||
@@ -0,0 +1,314 @@
|
||||
"""generate_synthetic_eda_table — fixture sintetico para ejercitar el motor AutomaticEDA.
|
||||
|
||||
Funcion impura (escribe un archivo DuckDB a disco) y determinista por ``seed``:
|
||||
construye una unica tabla cuyo CONTENIDO esta disenado para ACTIVAR el maximo
|
||||
numero de capitulos del motor AutomaticEDA del grupo `eda` (num_distr, cat_distr,
|
||||
text_distr, correlacion, missingness, modelos, timeseries, geospatial, relaciones,
|
||||
calidad, agregacion) y los detectores semanticos / PII (`infer_semantic_type`).
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; captura cualquier error y
|
||||
devuelve ``{"status": "error", "error": str}``.
|
||||
|
||||
Determinismo: con el mismo ``seed`` el DataFrame y, por tanto, la tabla DuckDB
|
||||
resultante son identicos byte a byte. Se siembra Faker (``Faker.seed``) y numpy
|
||||
(``np.random.default_rng(seed)``) al inicio de cada generacion.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Lista fija de paises (12 -> cardinalidad media para cat_distr / agregacion).
|
||||
_COUNTRIES = [
|
||||
"ES", "FR", "DE", "IT", "PT", "NL",
|
||||
"BE", "US", "GB", "IE", "SE", "PL",
|
||||
]
|
||||
|
||||
# Lista fija de categorias de producto (6 -> cardinalidad media).
|
||||
_CATEGORIES = [
|
||||
"electronics", "clothing", "home", "sports", "books", "toys",
|
||||
]
|
||||
|
||||
# Niveles de plan con probabilidades DESBALANCEADAS (entropia baja para cat_distr).
|
||||
_PLANS = ["baja", "media", "alta"]
|
||||
_PLAN_PROBS = [0.70, 0.25, 0.05]
|
||||
|
||||
# Centroides (lat, lon) aproximados por pais: muestrean coordenadas validas
|
||||
# dentro de [-90, 90] x [-180, 180] para que detect_latlon_columns las acepte.
|
||||
_CENTROIDS = {
|
||||
"ES": (40.4, -3.7), "FR": (46.6, 2.2), "DE": (51.1, 10.4), "IT": (41.9, 12.5),
|
||||
"PT": (39.4, -8.2), "NL": (52.1, 5.3), "BE": (50.5, 4.5), "US": (39.0, -98.0),
|
||||
"GB": (54.0, -2.0), "IE": (53.4, -8.0), "SE": (60.1, 18.6), "PL": (52.0, 19.1),
|
||||
}
|
||||
|
||||
# Locales rotados para generar texto multi-idioma (es/en/fr).
|
||||
_TEXT_LOCALES = ["es_ES", "en_US", "fr_FR"]
|
||||
|
||||
# Identificador SQL valido (DuckDB no parametriza el nombre de tabla en DDL).
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _make_fakers(seed):
|
||||
"""Crea los Faker por locale tras sembrar el generador compartido.
|
||||
|
||||
``Faker.seed(seed)`` siembra el ``random.Random`` compartido por todas las
|
||||
instancias Faker que usan el generador por defecto, asi que el orden de
|
||||
llamadas determina por completo la salida (determinismo).
|
||||
"""
|
||||
from faker import Faker
|
||||
|
||||
Faker.seed(seed)
|
||||
es_es, en_us, fr_fr = (Faker(loc) for loc in _TEXT_LOCALES)
|
||||
return {"es_ES": es_es, "en_US": en_us, "fr_FR": fr_fr}
|
||||
|
||||
|
||||
# Texto duplicado canonico (multi-idioma, > 20 palabras) que se inyecta en una
|
||||
# fraccion de las filas para que el analisis de duplicados exactos lo detecte.
|
||||
_DUP_REVIEW = (
|
||||
"Servicio excelente y entrega muy rapida, el producto llego en perfecto "
|
||||
"estado y coincide con la descripcion publicada en la tienda. The customer "
|
||||
"support team answered every question quickly and the packaging was solid "
|
||||
"and well protected during shipping. Je recommande vivement ce vendeur a "
|
||||
"tous mes amis, la qualite est vraiment au rendez-vous cette fois."
|
||||
)
|
||||
|
||||
|
||||
def _make_reviews(n, rng, fakers, dup_frac=0.04, null_frac=0.08):
|
||||
"""Genera ``n`` reviews de texto libre largo multi-idioma (es/en/fr).
|
||||
|
||||
Cada review concatena dos parrafos de Faker en el idioma rotado por fila, de
|
||||
modo que la MEDIANA de palabras por documento queda muy por encima de 20 y la
|
||||
media de caracteres por encima de 50 (gates del capitulo text_distr). Se
|
||||
inyectan duplicados exactos (``dup_frac``) y nulos (``null_frac``).
|
||||
|
||||
Devuelve una ``list`` de ``str`` o ``None`` (nulos) de longitud ``n``.
|
||||
"""
|
||||
# Numero de frases por parrafo precomputado con numpy (determinista) para no
|
||||
# interleavar draws de rng dentro del bucle de faker.
|
||||
nb1 = rng.integers(4, 8, n)
|
||||
nb2 = rng.integers(3, 7, n)
|
||||
|
||||
reviews = []
|
||||
for i in range(n):
|
||||
fk = fakers[_TEXT_LOCALES[i % 3]]
|
||||
p1 = fk.paragraph(nb_sentences=int(nb1[i]))
|
||||
p2 = fk.paragraph(nb_sentences=int(nb2[i]))
|
||||
reviews.append(f"{p1} {p2}")
|
||||
|
||||
# Duplicados exactos: una fraccion de filas comparte un review identico.
|
||||
if n > 0 and dup_frac > 0:
|
||||
k_dup = max(1, int(n * dup_frac))
|
||||
dup_idx = rng.choice(n, size=min(k_dup, n), replace=False)
|
||||
for j in dup_idx:
|
||||
reviews[int(j)] = _DUP_REVIEW
|
||||
|
||||
# Nulos MCAR-ish: una fraccion de filas al azar queda en None.
|
||||
if n > 0 and null_frac > 0:
|
||||
k_null = max(1, int(n * null_frac))
|
||||
null_idx = rng.choice(n, size=min(k_null, n), replace=False)
|
||||
for j in null_idx:
|
||||
reviews[int(j)] = None
|
||||
|
||||
return reviews
|
||||
|
||||
|
||||
def _make_phone_intl(rng):
|
||||
"""Construye un telefono en formato internacional que casa phone_intl.
|
||||
|
||||
Regex objetivo (fullmatch): ``\\+\\d[\\d\\s()-]{6,}\\d``. Empieza por '+',
|
||||
digito, bloques de digitos separados por espacios y termina en digito.
|
||||
"""
|
||||
cc = int(rng.integers(1, 99))
|
||||
a = int(rng.integers(100, 999))
|
||||
b = int(rng.integers(100, 999))
|
||||
c = int(rng.integers(100, 999))
|
||||
return f"+{cc} {a} {b} {c}"
|
||||
|
||||
|
||||
def _make_latlon(countries, rng):
|
||||
"""Devuelve (latitudes, longitudes) muestreando centroides de pais + jitter.
|
||||
|
||||
Mantiene los valores dentro de [-90, 90] y [-180, 180] (validez exigida por
|
||||
detect_latlon_columns). El jitter es pequeno para no salirse del rango.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
lats = np.empty(len(countries), dtype=float)
|
||||
lons = np.empty(len(countries), dtype=float)
|
||||
jitter_lat = rng.normal(0.0, 0.5, len(countries))
|
||||
jitter_lon = rng.normal(0.0, 0.5, len(countries))
|
||||
for i, code in enumerate(countries):
|
||||
base_lat, base_lon = _CENTROIDS[code]
|
||||
lats[i] = float(np.clip(base_lat + jitter_lat[i], -90.0, 90.0))
|
||||
lons[i] = float(np.clip(base_lon + jitter_lon[i], -180.0, 180.0))
|
||||
return lats, lons
|
||||
|
||||
|
||||
def _amount_with_outliers(n, rng, n_extreme=6, factor=50.0):
|
||||
"""Serie lognormal de cola pesada con ~``n_extreme`` outliers altos (x``factor``)."""
|
||||
import numpy as np
|
||||
|
||||
amount = rng.lognormal(mean=4.0, sigma=1.0, size=n)
|
||||
if n > 0 and n_extreme > 0:
|
||||
idx = rng.choice(n, size=min(n_extreme, n), replace=False)
|
||||
amount[idx] = amount[idx] * factor
|
||||
return amount
|
||||
|
||||
|
||||
def generate_synthetic_eda_table(
|
||||
out_db_path, table="synthetic", n_rows=2000, seed=42
|
||||
):
|
||||
"""Genera una tabla DuckDB sintetica que activa el maximo de capitulos del EDA.
|
||||
|
||||
Construye un DataFrame de ``n_rows`` clientes unicos con columnas elegidas para
|
||||
disparar detectores concretos del motor AutomaticEDA (numericas continuas con
|
||||
correlaciones lineal/no-lineal, numericas con outliers, categoricas
|
||||
desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie
|
||||
temporal, lat/lon validas, semanticos/PII y nulos con patron MCAR/MAR), y la
|
||||
materializa en ``out_db_path`` con ``CREATE OR REPLACE TABLE``.
|
||||
|
||||
Funcion impura (escribe a disco) y determinista por ``seed``: con el mismo
|
||||
seed la tabla resultante es identica byte a byte. NUNCA lanza.
|
||||
|
||||
Args:
|
||||
out_db_path: ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la
|
||||
tabla se reemplaza si ya existe.
|
||||
table: nombre de la tabla a crear. Se valida contra
|
||||
``^[A-Za-z_][A-Za-z0-9_]*$`` y se cita en el DDL.
|
||||
n_rows: numero de filas (clientes unicos). Default 2000.
|
||||
seed: semilla para Faker y numpy. Default 42.
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw. En exito::
|
||||
|
||||
{"status": "ok", "db_path": out_db_path, "table": table,
|
||||
"n_rows": n_rows, "columns": [<nombres de columna>], "seed": seed}
|
||||
|
||||
En error (sin lanzar)::
|
||||
|
||||
{"status": "error", "error": str}
|
||||
"""
|
||||
try:
|
||||
import duckdb
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
if not _IDENT_RE.match(table or ""):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"nombre de tabla invalido: {table!r} "
|
||||
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
|
||||
),
|
||||
}
|
||||
n = int(n_rows)
|
||||
if n <= 0:
|
||||
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
|
||||
|
||||
fakers = _make_fakers(seed)
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
# --- Numericas continuas (distinct alto, correlaciones) ---
|
||||
income = np.clip(rng.normal(40000.0, 12000.0, n), 1000.0, None)
|
||||
spending = income * 0.35 + rng.normal(0.0, 2000.0, n) # corr POSITIVA fuerte
|
||||
age = rng.integers(18, 91, n)
|
||||
risk_score = 90.0 - age * 0.7 + rng.normal(0.0, 5.0, n) # corr NEGATIVA con age
|
||||
tenure_months = rng.uniform(0.0, 60.0, n)
|
||||
engagement_quad = ((tenure_months - 30.0) ** 2) / 30.0 + rng.normal(0.0, 1.0, n)
|
||||
|
||||
# --- Numericas con outliers claros ---
|
||||
amount = _amount_with_outliers(n, rng)
|
||||
n_purchases = rng.poisson(3.0, n).astype(float)
|
||||
if n > 0:
|
||||
k_hi = min(max(1, int(n * 0.002)) + 2, n) # ~3-5 valores altisimos
|
||||
hi_idx = rng.choice(n, size=k_hi, replace=False)
|
||||
n_purchases[hi_idx] = rng.integers(200, 400, len(hi_idx)).astype(float)
|
||||
|
||||
# --- Categoricas ---
|
||||
country = rng.choice(_COUNTRIES, n)
|
||||
category = rng.choice(_CATEGORIES, n)
|
||||
plan = rng.choice(_PLANS, n, p=_PLAN_PROBS)
|
||||
|
||||
# --- Texto libre multi-idioma con duplicados ---
|
||||
review = _make_reviews(n, rng, fakers)
|
||||
|
||||
# --- Fecha / serie temporal (rango ~2 anios, cadencia ~diaria) ---
|
||||
base = np.datetime64("2022-01-01")
|
||||
offsets = rng.integers(0, 730, n)
|
||||
signup_date = pd.to_datetime(base) + pd.to_timedelta(offsets, unit="D")
|
||||
|
||||
# --- Geo lat/lon validas ---
|
||||
latitude, longitude = _make_latlon(country, rng)
|
||||
|
||||
# --- Semanticos / PII (>=80% match para infer_semantic_type) ---
|
||||
customer_id = [fakers["en_US"].uuid4() for _ in range(n)]
|
||||
email = [fakers["en_US"].email() for _ in range(n)]
|
||||
iban = [fakers["en_US"].iban() for _ in range(n)]
|
||||
phone = [_make_phone_intl(rng) for _ in range(n)]
|
||||
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"customer_id": customer_id,
|
||||
"email": email,
|
||||
"iban": iban,
|
||||
"phone": phone,
|
||||
"income": income,
|
||||
"spending": spending,
|
||||
"age": age,
|
||||
"risk_score": risk_score,
|
||||
"tenure_months": tenure_months,
|
||||
"engagement_quad": engagement_quad,
|
||||
"amount": amount,
|
||||
"n_purchases": n_purchases,
|
||||
"country": country,
|
||||
"category": category,
|
||||
"plan": plan,
|
||||
"review": review,
|
||||
"signup_date": signup_date,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Nulos con patron ---
|
||||
# income + spending faltan JUNTAS en las MISMAS filas (co-ocurrencia -> MAR).
|
||||
k_co = max(1, int(n * 0.12))
|
||||
co_idx = rng.choice(n, size=min(k_co, n), replace=False)
|
||||
df.loc[co_idx, "income"] = np.nan
|
||||
df.loc[co_idx, "spending"] = np.nan
|
||||
# risk_score falta cuando plan == "alta" (mas una pizca de azar) -> MAR.
|
||||
risk_mask = (df["plan"] == "alta").to_numpy() | (rng.random(n) < 0.02)
|
||||
df.loc[risk_mask, "risk_score"] = np.nan
|
||||
|
||||
columns = list(df.columns)
|
||||
|
||||
con = duckdb.connect(out_db_path)
|
||||
try:
|
||||
con.register("df_synth_eda", df)
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{table}" AS SELECT * FROM df_synth_eda'
|
||||
)
|
||||
con.unregister("df_synth_eda")
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": out_db_path,
|
||||
"table": table,
|
||||
"n_rows": n,
|
||||
"columns": columns,
|
||||
"seed": seed,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
|
||||
return {"status": "error", "error": str(exc)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
db_path = args[0] if len(args) > 0 else "/tmp/synthetic_eda.duckdb"
|
||||
tbl = args[1] if len(args) > 1 else "synthetic"
|
||||
rows = int(args[2]) if len(args) > 2 else 2000
|
||||
sd = int(args[3]) if len(args) > 3 else 42
|
||||
print(json.dumps(generate_synthetic_eda_table(db_path, tbl, rows, sd), indent=2))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests para generate_synthetic_eda_table."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
|
||||
import duckdb
|
||||
|
||||
from datascience.generate_synthetic_eda_table import generate_synthetic_eda_table
|
||||
|
||||
_EXPECTED_COLS = [
|
||||
"customer_id", "email", "iban", "phone", "income", "spending", "age",
|
||||
"risk_score", "tenure_months", "engagement_quad", "amount", "n_purchases",
|
||||
"country", "category", "plan", "review", "signup_date", "latitude", "longitude",
|
||||
]
|
||||
_PHONE_RE = re.compile(r"\+\d[\d\s()-]{6,}\d")
|
||||
|
||||
|
||||
def _load(db_path, table="synthetic"):
|
||||
con = duckdb.connect(db_path, read_only=True)
|
||||
try:
|
||||
return con.execute(f'SELECT * FROM "{table}"').fetch_df()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_genera_ok_y_columnas(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
res = generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||
assert res["status"] == "ok"
|
||||
assert res["table"] == "synthetic"
|
||||
assert res["n_rows"] == 500
|
||||
assert res["columns"] == _EXPECTED_COLS
|
||||
assert os.path.exists(db)
|
||||
df = _load(db)
|
||||
assert list(df.columns) == _EXPECTED_COLS
|
||||
assert len(df) == 500
|
||||
|
||||
|
||||
def test_determinismo_mismo_seed(tmp_path):
|
||||
db1 = str(tmp_path / "a.duckdb")
|
||||
db2 = str(tmp_path / "b.duckdb")
|
||||
generate_synthetic_eda_table(db1, "synthetic", n_rows=400, seed=7)
|
||||
generate_synthetic_eda_table(db2, "synthetic", n_rows=400, seed=7)
|
||||
df1 = _load(db1).astype(str)
|
||||
df2 = _load(db2).astype(str)
|
||||
# Misma semilla -> tabla identica fila a fila.
|
||||
assert df1.equals(df2)
|
||||
|
||||
|
||||
def test_seeds_distintos_difieren(tmp_path):
|
||||
db1 = str(tmp_path / "a.duckdb")
|
||||
db2 = str(tmp_path / "b.duckdb")
|
||||
generate_synthetic_eda_table(db1, "synthetic", n_rows=400, seed=7)
|
||||
generate_synthetic_eda_table(db2, "synthetic", n_rows=400, seed=8)
|
||||
df1 = _load(db1).astype(str)
|
||||
df2 = _load(db2).astype(str)
|
||||
assert not df1.equals(df2)
|
||||
|
||||
|
||||
def test_latlon_en_rango(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||
df = _load(db)
|
||||
assert df["latitude"].between(-90, 90).all()
|
||||
assert df["longitude"].between(-180, 180).all()
|
||||
|
||||
|
||||
def test_plan_solo_niveles_validos(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||
df = _load(db)
|
||||
assert set(df["plan"].unique()) <= {"baja", "media", "alta"}
|
||||
|
||||
|
||||
def test_income_spending_co_nulos(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
generate_synthetic_eda_table(db, "synthetic", n_rows=600, seed=42)
|
||||
df = _load(db)
|
||||
inc_null = df["income"].isna()
|
||||
sp_null = df["spending"].isna()
|
||||
# income y spending faltan exactamente en las MISMAS filas.
|
||||
assert (inc_null == sp_null).all()
|
||||
assert inc_null.sum() > 0
|
||||
|
||||
|
||||
def test_review_mediana_palabras_y_signup_datetime(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||
df = _load(db)
|
||||
words = [len(str(r).split()) for r in df["review"].dropna()]
|
||||
assert statistics.median(words) >= 20
|
||||
# signup_date debe ser datetime/date en DuckDB (no VARCHAR).
|
||||
con = duckdb.connect(db, read_only=True)
|
||||
try:
|
||||
dtype = con.execute(
|
||||
"SELECT column_type FROM (DESCRIBE synthetic) WHERE column_name='signup_date'"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
con.close()
|
||||
assert dtype.upper().startswith(("DATE", "TIMESTAMP"))
|
||||
|
||||
|
||||
def test_phone_matchea_regex_internacional(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||
df = _load(db)
|
||||
phones = [p for p in df["phone"].tolist() if p is not None]
|
||||
assert all(_PHONE_RE.fullmatch(str(p)) for p in phones)
|
||||
|
||||
|
||||
def test_outliers_y_correlaciones(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
generate_synthetic_eda_table(db, "synthetic", n_rows=800, seed=42)
|
||||
df = _load(db)
|
||||
# amount tiene cola con outliers altos evidentes.
|
||||
assert df["amount"].max() > df["amount"].median() * 20
|
||||
# correlacion positiva fuerte income~spending y negativa age~risk_score.
|
||||
sub = df[["income", "spending"]].dropna()
|
||||
assert sub["income"].corr(sub["spending"]) > 0.8
|
||||
sub2 = df[["age", "risk_score"]].dropna()
|
||||
assert sub2["age"].corr(sub2["risk_score"]) < -0.6
|
||||
|
||||
|
||||
def test_tabla_invalida_devuelve_error(tmp_path):
|
||||
db = str(tmp_path / "t.duckdb")
|
||||
res = generate_synthetic_eda_table(db, "bad name;", n_rows=10, seed=42)
|
||||
assert res["status"] == "error"
|
||||
assert "invalido" in res["error"]
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: list_bq_dataset_tables
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def list_bq_dataset_tables(project_id: str, dataset: str, include_views: bool = True, location: str = None) -> dict"
|
||||
description: "Lista todas las tablas y vistas de un dataset BigQuery y enriquece las BASE TABLE con conteo de filas y tamaño en disco. Capa de descubrimiento del grupo eda: qué hay en el dataset, cuánto pesa cada tabla, qué es tabla vs vista, antes de perfilar una concreta. Query 1 sobre INFORMATION_SCHEMA.TABLES (catálogo completo) + query 2 sobre __TABLES__ (row_count, size_bytes). Las vistas dejan n_rows/size_mb en None (contarlas exigiría full scan). Auth ADC con fix de quota project (403 USER_PROJECT_DENIED)."
|
||||
tags: [eda, bigquery]
|
||||
params:
|
||||
- name: project_id
|
||||
desc: "Proyecto GCP que contiene el dataset (ej. `autingo-159109`). Se usa como proyecto de facturación de las dos queries."
|
||||
- name: dataset
|
||||
desc: "Nombre del dataset BigQuery a listar (ej. `customer_marts`). Solo el dataset, sin proyecto ni tabla."
|
||||
- name: include_views
|
||||
desc: "True (DEFAULT) incluye tablas y vistas. False filtra y devuelve solo las BASE TABLE."
|
||||
- name: location
|
||||
desc: "Región del dataset para las queries (ej. `europe-west1`, `EU`). None (DEFAULT) deja que el cliente resuelva la ubicación. Necesario si el dataset vive en una región no-US."
|
||||
output: "dict dict-no-throw. En éxito {status:'ok', project_id, dataset, n_tables:int, tables:[{table, fqn:'project.dataset.table', table_type:'BASE TABLE'|'VIEW'|..., n_rows:int|None, size_mb:float|None, created:str|None}]}. En error {status:'error', error:str}."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/list_bq_dataset_tables.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import list_bq_dataset_tables
|
||||
|
||||
# Catálogo completo del dataset (tablas + vistas) con filas y tamaño.
|
||||
r = list_bq_dataset_tables("autingo-159109", "customer_marts")
|
||||
print(r["status"], r["n_tables"])
|
||||
for t in r["tables"]:
|
||||
print(t["table"], t["table_type"], t["n_rows"], t["size_mb"], "MB")
|
||||
|
||||
# Solo tablas base, dataset en europe-west1 (necesita location).
|
||||
r = list_bq_dataset_tables(
|
||||
"autingo-159109", "customer_marts",
|
||||
include_views=False, location="europe-west1",
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Antes de perfilar una tabla concreta con el grupo `eda` (`profile_bq_table`, `load_bq_table_to_duckdb`): descubre qué tablas y vistas hay en el dataset y cuánto pesa cada una para decidir cuál analizar.
|
||||
- Cuando necesites un inventario rápido de un dataset BigQuery (nombre, tipo, filas, tamaño, fecha de creación) sin abrir la consola de GCP.
|
||||
- Cuando quieras distinguir tablas base de vistas antes de una carga o un cruce (las vistas no traen conteo de filas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: hace I/O de red contra la API de BigQuery (dos queries). Requiere ADC configurado (`gcloud auth application-default login`).
|
||||
- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC del usuario arrastra un quota project ajeno (memoria `bq_direct_quota_project`). Mismo patrón que `load_bq_table_to_duckdb`.
|
||||
- **Región del dataset**: si el dataset vive en `europe-west1` (o cualquier región distinta de la que asume el cliente por defecto) y no pasas `location`, las queries fallan con "Not found: Dataset ... was not found in location US". Pasa `location="europe-west1"` o `location="EU"` según corresponda. Muchos datasets de Aurgi están en `europe-west1`; otros en `EU` multi-region.
|
||||
- **Las vistas no traen n_rows ni size_mb**: `__TABLES__` no da conteo fiable para vistas y contarlas exigiría un full scan por vista (coste + latencia). Por eso `n_rows`/`size_mb` van a None para todo lo que no sea `BASE TABLE`.
|
||||
- **size_mb es tamaño lógico en disco** (bytes de `__TABLES__` / 1024²), no el coste de una query sobre la tabla.
|
||||
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (project/dataset inválido, auth, región, permisos) devuelve `{status:'error', error:str}`.
|
||||
|
||||
## Notas
|
||||
|
||||
Capa de descubrimiento del grupo de capacidad `eda`. Complementa a
|
||||
`load_bq_table_to_duckdb` (que trae UNA tabla a DuckDB) y a `profile_bq_table`
|
||||
(que perfila UNA tabla end-to-end): esta función responde "¿qué tablas hay en
|
||||
este dataset y cuáles merece la pena perfilar?". `project_id` y `dataset` se
|
||||
validan con regex (`^[A-Za-z0-9\-]+$` y `^[A-Za-z0-9_]+$`) antes de
|
||||
interpolarlos en los identificadores con backticks de las dos queries, para
|
||||
cerrar la superficie de inyección.
|
||||
|
||||
A diferencia de `bq_list_tables_py_infra` (dominio infra, usa el wrapper
|
||||
`BQClient` del SDK y no enriquece con filas ni tamaño), esta función es
|
||||
standalone (auth ADC propia con el fix de quota project) y devuelve el conteo de
|
||||
filas y el tamaño por tabla en el estilo dict-no-throw del grupo `eda`.
|
||||
@@ -0,0 +1,134 @@
|
||||
"""list_bq_dataset_tables — catálogo de tablas y vistas de un dataset BigQuery.
|
||||
|
||||
Lista todas las tablas y vistas de un dataset de Google BigQuery y enriquece las
|
||||
BASE TABLE con su conteo de filas y su tamaño en disco. Es la capa de
|
||||
descubrimiento del grupo `eda`: antes de perfilar una tabla concreta (con
|
||||
`profile_bq_table` / `load_bq_table_to_duckdb`) necesitas saber qué hay en el
|
||||
dataset, cuántas filas pesa cada tabla y qué es tabla vs vista.
|
||||
|
||||
Estrategia de dos queries:
|
||||
1. `INFORMATION_SCHEMA.TABLES` del dataset -> table_name, table_type,
|
||||
creation_time de TODOS los objetos (tablas y vistas).
|
||||
2. `__TABLES__` del dataset (una sola query adicional) -> row_count y
|
||||
size_bytes por tabla. Solo las BASE TABLE se enriquecen; las vistas
|
||||
dejan n_rows y size_mb en None (contarlas exigiría un full scan por vista,
|
||||
con coste y latencia que no compensan para un catálogo).
|
||||
|
||||
Autenticación: ADC (gcloud auth). Aplica `creds.with_quota_project(None)` para
|
||||
evitar el 403 USER_PROJECT_DENIED cuando el ADC del usuario lleva un quota
|
||||
project ajeno — mismo patrón que `load_bq_table_to_duckdb`.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve
|
||||
{status:'error', ...} en cualquier fallo.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_PROJECT_RE = re.compile(r"^[A-Za-z0-9\-]+$")
|
||||
_DATASET_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
def list_bq_dataset_tables(
|
||||
project_id: str,
|
||||
dataset: str,
|
||||
include_views: bool = True,
|
||||
location: str = None,
|
||||
) -> dict:
|
||||
try:
|
||||
import google.auth
|
||||
from google.cloud import bigquery
|
||||
|
||||
if not project_id or not _PROJECT_RE.match(project_id):
|
||||
return {"status": "error", "error": f"project_id inválido: {project_id!r}"}
|
||||
if not dataset or not _DATASET_RE.match(dataset):
|
||||
return {"status": "error", "error": f"dataset inválido: {dataset!r}"}
|
||||
|
||||
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
|
||||
creds, adc_project = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"]
|
||||
)
|
||||
if hasattr(creds, "with_quota_project"):
|
||||
creds = creds.with_quota_project(None)
|
||||
proj = project_id or adc_project
|
||||
client = bigquery.Client(project=proj, credentials=creds)
|
||||
|
||||
# Query 1: catálogo de objetos (tablas + vistas) del dataset.
|
||||
info_sql = (
|
||||
"SELECT table_name, table_type, creation_time "
|
||||
f"FROM `{proj}.{dataset}`.INFORMATION_SCHEMA.TABLES "
|
||||
"ORDER BY table_name"
|
||||
)
|
||||
info_rows = list(client.query(info_sql, location=location).result())
|
||||
|
||||
# Query 2: enriquecimiento (row_count, size_bytes) desde __TABLES__.
|
||||
stats_sql = (
|
||||
"SELECT table_id, row_count, size_bytes "
|
||||
f"FROM `{proj}.{dataset}`.__TABLES__"
|
||||
)
|
||||
stats = {}
|
||||
for row in client.query(stats_sql, location=location).result():
|
||||
stats[row["table_id"]] = (row["row_count"], row["size_bytes"])
|
||||
|
||||
tables = []
|
||||
for row in info_rows:
|
||||
table_name = row["table_name"]
|
||||
table_type = row["table_type"]
|
||||
is_base_table = table_type == "BASE TABLE"
|
||||
|
||||
if not include_views and not is_base_table:
|
||||
continue
|
||||
|
||||
created = row["creation_time"]
|
||||
created_iso = created.isoformat() if created is not None else None
|
||||
|
||||
n_rows = None
|
||||
size_mb = None
|
||||
if is_base_table and table_name in stats:
|
||||
raw_rows, raw_bytes = stats[table_name]
|
||||
if raw_rows is not None:
|
||||
n_rows = int(raw_rows)
|
||||
if raw_bytes is not None:
|
||||
size_mb = round(int(raw_bytes) / (1024 * 1024), 3)
|
||||
|
||||
tables.append(
|
||||
{
|
||||
"table": table_name,
|
||||
"fqn": f"{proj}.{dataset}.{table_name}",
|
||||
"table_type": table_type,
|
||||
"n_rows": n_rows,
|
||||
"size_mb": size_mb,
|
||||
"created": created_iso,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"project_id": proj,
|
||||
"dataset": dataset,
|
||||
"n_tables": len(tables),
|
||||
"tables": tables,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 2:
|
||||
print(
|
||||
"uso: list_bq_dataset_tables.py <project_id> <dataset> [--no-views] [--location LOC]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
proj_arg, dataset_arg = args[0], args[1]
|
||||
include_views_arg = "--no-views" not in args
|
||||
loc_arg = None
|
||||
if "--location" in args:
|
||||
loc_arg = args[args.index("--location") + 1]
|
||||
result = list_bq_dataset_tables(
|
||||
proj_arg, dataset_arg, include_views=include_views_arg, location=loc_arg
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: load_bq_table_to_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.3.0"
|
||||
purity: impure
|
||||
signature: "def load_bq_table_to_duckdb(table_fqn: str, duckdb_path: str, dest_table: str = '', sample_frac: float = None, max_rows: int = 0, project_id: str = '', pseudonymize_cols: list = None, where_sql: str = '', select_sql: str = '') -> dict"
|
||||
description: "Adaptador BigQuery -> DuckDB local para el grupo eda. Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto COMPLETA, todas las filas; muestreo opt-in con sample_frac), de modo que las funciones del grupo de capacidad eda (que solo hablan DuckDB/PostgreSQL) puedan perfilarla. Ingesta streaming Arrow -> DuckDB por batches (pyarrow.RecordBatch) para RAM acotada en tablas de decenas de millones de filas; fallback al camino DataFrame completo si pyarrow no esta. Filtra el origen con where_sql y proyecta/castea con select_sql. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)."
|
||||
tags: [eda, bigquery, duckdb, datascience]
|
||||
params:
|
||||
- name: table_fqn
|
||||
desc: "FQN completo de la tabla/vista BigQuery: `project.dataset.table`."
|
||||
- name: duckdb_path
|
||||
desc: "Ruta del archivo DuckDB local donde materializar la tabla (se crea/sobrescribe la tabla dest)."
|
||||
- name: dest_table
|
||||
desc: "Nombre de la tabla DuckDB destino. Vacío = último segmento del FQN, saneado."
|
||||
- name: sample_frac
|
||||
desc: "None (DEFAULT) = FULL, trae todas las filas. Un float en (0,1) activa el muestreo opt-in con `WHERE rand() < frac` (~frac del total). Vistas no admiten TABLESAMPLE, por eso rand()."
|
||||
- name: max_rows
|
||||
desc: "Tope duro opcional de filas (LIMIT). 0 (DEFAULT) = sin tope. Se combina con sample_frac si ambos se pasan."
|
||||
- name: project_id
|
||||
desc: "Proyecto GCP de facturación. Vacío = primer segmento del FQN o el del ADC."
|
||||
- name: pseudonymize_cols
|
||||
desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad. En el camino streaming se aplica POR BATCH antes de insertar."
|
||||
- name: where_sql
|
||||
desc: "Clausula WHERE SQL (SIN la palabra WHERE) aplicada al SELECT sobre el origen y tambien al COUNT de n_rows_source (cuenta el origen filtrado). Se combina con el muestreo (sample_frac) via AND. Ej: `fecha <= CURRENT_DATE() AND venta_n IS NOT NULL`. Se interpola tal cual: NO usar con input no confiable."
|
||||
- name: select_sql
|
||||
desc: "Lista de expresiones del SELECT (SIN la palabra SELECT). Vacio (DEFAULT) = `*`. Permite proyectar/castear tipos problematicos, ej. `fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n` (util para castear BIGNUMERIC a FLOAT64 antes de ingerir). Se interpola tal cual: NO usar con input no confiable."
|
||||
output: "dict dict-no-throw. En éxito {status:'ok', duckdb_path, table, n_rows_source, n_rows_fetched, sampled, sample_frac, columns, pseudonymized, streamed, auto_casts}. En error {status:'error', error, stage?}. streamed=True si la ingesta fue por batches Arrow; n_rows_fetched = suma de filas de los batches insertados."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "test_default_selects_star_no_where_no_limit"
|
||||
- "test_select_sql_replaces_star"
|
||||
- "test_select_sql_blank_and_whitespace_fall_back_to_star"
|
||||
- "test_where_sql_only"
|
||||
- "test_sample_frac_only"
|
||||
- "test_where_sql_and_sample_frac_combined_with_and_parenthesized"
|
||||
- "test_single_condition_not_parenthesized"
|
||||
- "test_max_rows_appends_limit"
|
||||
- "test_max_rows_zero_or_negative_no_limit"
|
||||
- "test_all_combined_order_where_then_limit"
|
||||
- "test_sample_frac_out_of_range_ignored"
|
||||
- "test_dest_empty_uses_last_fqn_segment"
|
||||
- "test_dest_explicit_valid_kept"
|
||||
- "test_dest_invalid_chars_replaced_with_underscore"
|
||||
- "test_dest_from_fqn_segment_with_hyphen_sanitized"
|
||||
test_file_path: "python/functions/datascience/load_bq_table_to_duckdb_test.py"
|
||||
file_path: "python/functions/datascience/load_bq_table_to_duckdb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import load_bq_table_to_duckdb
|
||||
|
||||
# FULL por defecto: trae TODAS las filas de la vista (3,8M) a DuckDB.
|
||||
r = load_bq_table_to_duckdb(
|
||||
"autingo-159109.customer_marts.customer_profile",
|
||||
"/tmp/eda_bq.duckdb",
|
||||
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
|
||||
)
|
||||
print(r["table"], r["n_rows_fetched"], "de", r["n_rows_source"], "sampled=", r["sampled"])
|
||||
|
||||
# Muestreo opt-in: ~5 % de las filas.
|
||||
r = load_bq_table_to_duckdb(
|
||||
"autingo-159109.customer_marts.customer_profile",
|
||||
"/tmp/eda_bq_sample.duckdb",
|
||||
sample_frac=0.05,
|
||||
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
|
||||
)
|
||||
|
||||
# Filtrar el origen + castear columnas problematicas antes de ingerir. El COUNT de
|
||||
# n_rows_source respeta el mismo where_sql (cuenta el origen filtrado). Streaming
|
||||
# Arrow por batches: RAM acotada aunque la tabla tenga decenas de millones de filas.
|
||||
r = load_bq_table_to_duckdb(
|
||||
"autingo-159109.data.ventas_39M",
|
||||
"/tmp/eda_ventas.duckdb",
|
||||
where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL",
|
||||
select_sql="fecha, idCentro, CAST(importe_bignumeric AS FLOAT64) AS importe",
|
||||
)
|
||||
print(r["n_rows_fetched"], "de", r["n_rows_source"], "streamed=", r["streamed"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Antes de perfilar una tabla/vista de BigQuery con el grupo `eda` (que solo habla DuckDB/PostgreSQL): trae el origen COMPLETO a DuckDB local (o una muestra con `sample_frac`) con seudonimización PII.
|
||||
- Cuando necesites un puente único BigQuery -> DuckDB local -> grupo `eda` sin escribir el bridge inline cada vez.
|
||||
- Cuando quieras que un EDA sobre datos de negocio conserve valor analítico (cardinalidad, nulos, distribución) sin incrustar datos personales reales.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: hace I/O de red (BigQuery) + escritura a disco (DuckDB). Requiere ADC configurado (`gcloud auth application-default login`).
|
||||
- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC arrastra un quota project ajeno (memoria `bq_direct_quota_project`).
|
||||
- **TABLESAMPLE no funciona en vistas**: el muestreo (opt-in, `sample_frac`) usa `WHERE rand() < frac` (aplicable a tablas y vistas). `max_rows` es un `LIMIT` como tope duro opcional. `where_sql` y el muestreo se combinan con AND (cada condición entre paréntesis cuando hay varias, para respetar precedencia).
|
||||
- **Ingesta streaming Arrow (RAM acotada)**: cuando `pyarrow` + `to_arrow_iterable` están disponibles, el resultado se materializa por `pyarrow.RecordBatch` (primer batch `CREATE OR REPLACE TABLE ... AS SELECT`, siguientes `INSERT INTO`), con `streamed=True` en el retorno. Así una tabla de decenas de millones de filas no se carga entera en RAM. El cliente BigQuery Storage se crea con las mismas credenciales corregidas (`with_quota_project(None)`).
|
||||
- **Fallback DataFrame completo (carga TODO en RAM)**: si `pyarrow` o `to_arrow_iterable` no están disponibles, se cae al camino antiguo — `to_dataframe()` completo antes de materializar (`streamed=False`), que puede consumir varios GB en tablas grandes. Para acotar, pasa `sample_frac`, `max_rows` o `where_sql`.
|
||||
- **Auto-cast de tipos problemáticos (v1.3.0)**: si NO se pasa `select_sql`, la función inspecciona el schema del origen (`client.get_table`) y castea automáticamente en el SELECT: BIGNUMERIC -> `CAST(col AS FLOAT64)` (Arrow decimal256, DuckDB no lo ingiere), REPEATED/RECORD/JSON -> `TO_JSON_STRING(col)` (los LIST/STRUCT rompen el perfilado aguas abajo con "unhashable type: 'list'"), GEOGRAPHY -> `ST_ASTEXT(col)`. Las transformaciones aplicadas se reportan en `auto_casts` del retorno. Si se pasa `select_sql` explícito, se respeta tal cual (sin auto-cast). Si el schema no se puede leer, degrada a `SELECT *`. El guard decimal256 en la ingesta se conserva como backstop (`{status:'error', stage:'stream_schema'|'stream_insert'}`).
|
||||
- **Inyección SQL**: `where_sql` y `select_sql` (igual que `table_fqn`) se interpolan TAL CUAL en la query, sin escapar. NO los construyas a partir de input no confiable.
|
||||
- **db-dtypes solo en el camino DataFrame**: la normalización de `dbdate`/`dbtime` a tipos que DuckDB reconoce solo aplica al fallback pandas. En el camino Arrow los DATE/TIME llegan como tipos Arrow nativos que DuckDB ingiere directamente.
|
||||
- **La seudonimización es un hash unidireccional** (SHA-1 truncado a 12 hex): no es reversible, correcto para EDA. Preserva nulos, cardinalidad y patrón de faltantes, pero NO permite recuperar el valor original. En streaming se aplica por batch (columnas no PII conservan su tipo Arrow; las PII se reescriben a string).
|
||||
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query, ingesta) devuelve `{status:'error', error:str}` (con `stage` en fallos de ingesta streaming).
|
||||
|
||||
## Notas
|
||||
|
||||
Adaptador del grupo de capacidad `eda`: el resto de funciones del grupo perfilan
|
||||
DuckDB/PostgreSQL, pero no hablan BigQuery de forma nativa. Esta función cubre ese
|
||||
hueco materializando una sola tabla DuckDB desde el resultado de la query BigQuery,
|
||||
por batches Arrow cuando es posible. El SELECT sobre el origen lo compone el helper
|
||||
puro `_build_source_sql` (testeable sin red) y el nombre de tabla destino se sanea
|
||||
con `_sanitize_dest_table` (`^[A-Za-z_][A-Za-z0-9_]*$`) antes de citarlo en el
|
||||
`CREATE OR REPLACE TABLE`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.3.0 (2026-07-02) — Auto-cast de tipos problemáticos cuando no se pasa `select_sql`: inspecciona el schema del origen y castea BIGNUMERIC->FLOAT64, REPEATED/RECORD/JSON->TO_JSON_STRING y GEOGRAPHY->ST_ASTEXT (elimina el gotcha decimal256 y el "unhashable type: 'list'" de profile_table sobre columnas array). Nueva clave `auto_casts` en el retorno. Descubierto en el piloto AEDA del dataset external_datasets (product_info_mat con BIGNUMERIC, product_object con arrays).
|
||||
- v1.2.0 (2026-07-02) — Añade `where_sql` (cláusula WHERE en origen, combinada con el muestreo vía AND y aplicada también al COUNT de `n_rows_source`) y `select_sql` (proyección/casteo de columnas, útil para castear BIGNUMERIC->FLOAT64). Ingesta streaming Arrow -> DuckDB por batches (`pyarrow.RecordBatch`, RAM acotada al tamaño del batch) para tablas de decenas de millones de filas que no caben como DataFrame; fallback al camino DataFrame completo si pyarrow/`to_arrow_iterable` no están. Gotcha decimal256 (BIGNUMERIC) devuelto como error con recomendación de castear vía `select_sql`. Nueva clave `streamed` en el retorno. Tests unitarios sin red del builder de SQL y del saneado del destino.
|
||||
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explícito. Fetch acelerado via BigQuery Storage Read API (Arrow) con fallback REST. Preferencia estándar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario.
|
||||
@@ -0,0 +1,419 @@
|
||||
"""load_bq_table_to_duckdb — adaptador BigQuery -> DuckDB local para el grupo `eda`.
|
||||
|
||||
Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto
|
||||
COMPLETA — todas las filas — o una muestra si se pasa `sample_frac`), de modo que
|
||||
las funciones del grupo de capacidad `eda` (que perfilan DuckDB/PostgreSQL)
|
||||
puedan analizarla sin un adaptador BigQuery nativo. Materializa una sola tabla
|
||||
DuckDB desde el resultado de la query.
|
||||
|
||||
Modo por defecto = FULL: `sample_frac=None` trae la vista/tabla entera (preferencia
|
||||
estándar del usuario: los EDA se corren sobre el total salvo que se pida lo
|
||||
contrario). El muestreo es opt-in explícito: `sample_frac=0.05` trae ~5 %; `max_rows`
|
||||
es un tope duro opcional (0 = sin tope).
|
||||
|
||||
Ingesta streaming Arrow -> DuckDB por batches: cuando `pyarrow` y el iterador
|
||||
`to_arrow_iterable` están disponibles, el resultado se trae y materializa por
|
||||
`pyarrow.RecordBatch`, insertando batch a batch en DuckDB. Así la RAM queda
|
||||
acotada al tamaño de un batch y una tabla de decenas de millones de filas cabe sin
|
||||
cargarse entera como DataFrame de pandas. Si `pyarrow`/`to_arrow_iterable` no están
|
||||
disponibles, cae al camino DataFrame completo (que sí carga todo en RAM).
|
||||
|
||||
Filtrado en origen: `where_sql` aplica una cláusula WHERE SQL sobre la tabla origen
|
||||
(y también al COUNT del origen, para contar las filas filtradas). `select_sql`
|
||||
permite proyectar/castear expresiones concretas en el SELECT (vacío = `*`), útil
|
||||
para castear tipos problemáticos (p. ej. BIGNUMERIC -> FLOAT64) antes de ingerir.
|
||||
|
||||
Seudonimización LOPDGDD/RGPD: las columnas listadas en `pseudonymize_cols` se
|
||||
transforman con un hash SHA-1 truncado ANTES de escribir a disco, preservando
|
||||
nulos, cardinalidad y patrón de faltantes pero sin volcar el valor real (DNI,
|
||||
nombre, email, teléfono, etc.). En el camino streaming se aplica POR BATCH antes de
|
||||
insertar. El EDA conserva su valor analítico sin incrustar datos personales reales.
|
||||
|
||||
Autenticación: ADC (gcloud auth). Aplica creds.with_quota_project(None) para
|
||||
evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno. El
|
||||
cliente BigQuery Storage (usado por el streaming Arrow) se crea con esas MISMAS
|
||||
credenciales corregidas.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
_FQN_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
|
||||
|
||||
|
||||
def _pseudonymize_series(values):
|
||||
"""Hash SHA-1 truncado (12 hex) de cada valor no nulo; conserva None/NaN."""
|
||||
import pandas as pd
|
||||
out = []
|
||||
for v in values:
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or (
|
||||
not isinstance(v, (list, dict)) and pd.isna(v) if _safe_isna(v) else False
|
||||
):
|
||||
out.append(None)
|
||||
else:
|
||||
h = hashlib.sha1(str(v).encode("utf-8")).hexdigest()[:12]
|
||||
out.append(h)
|
||||
return out
|
||||
|
||||
|
||||
def _safe_isna(v):
|
||||
import pandas as pd
|
||||
try:
|
||||
return bool(pd.isna(v))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_dest_table(dest_table: str, table_fqn: str) -> str:
|
||||
"""Nombre de tabla DuckDB destino saneado (helper puro, testeable sin red).
|
||||
|
||||
Reglas:
|
||||
- `dest_table` vacío -> último segmento del FQN.
|
||||
- Si el resultado no casa `^[A-Za-z_][A-Za-z0-9_]*$`, cada carácter inválido
|
||||
se sustituye por `_`; si quedara vacío se usa `bq_table`.
|
||||
"""
|
||||
dest = dest_table or table_fqn.split(".")[-1]
|
||||
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", dest):
|
||||
dest = re.sub(r"[^A-Za-z0-9_]", "_", dest) or "bq_table"
|
||||
return dest
|
||||
|
||||
|
||||
def _build_source_sql(
|
||||
table_fqn: str,
|
||||
select_sql: str = "",
|
||||
where_sql: str = "",
|
||||
sample_frac: float = None,
|
||||
max_rows: int = 0,
|
||||
) -> str:
|
||||
"""Compone el SELECT sobre la tabla/vista origen de BigQuery (helper puro).
|
||||
|
||||
Sin efectos: solo construye la cadena SQL, testeable sin red.
|
||||
|
||||
SEGURIDAD: `select_sql` y `where_sql` se interpolan TAL CUAL (no se escapan),
|
||||
igual que `table_fqn`, por lo que NO deben construirse a partir de input no
|
||||
confiable (riesgo de inyección SQL).
|
||||
|
||||
Reglas:
|
||||
- `select_sql` vacío -> `SELECT *`; en otro caso `SELECT <select_sql>`.
|
||||
- `where_sql` y el muestreo (`rand() < sample_frac`, para `sample_frac` en
|
||||
(0,1)) se combinan con AND. Si hay más de una condición cada una se
|
||||
envuelve en paréntesis para respetar la precedencia de operadores.
|
||||
- `max_rows` > 0 añade un `LIMIT` como tope duro.
|
||||
"""
|
||||
select_expr = select_sql.strip() if (select_sql and select_sql.strip()) else "*"
|
||||
|
||||
conditions = []
|
||||
ws = (where_sql or "").strip()
|
||||
if ws:
|
||||
conditions.append(ws)
|
||||
if sample_frac is not None and 0 < float(sample_frac) < 1:
|
||||
conditions.append(f"rand() < {float(sample_frac)}")
|
||||
|
||||
if len(conditions) > 1:
|
||||
where = " WHERE " + " AND ".join(f"({c})" for c in conditions)
|
||||
elif conditions:
|
||||
where = " WHERE " + conditions[0]
|
||||
else:
|
||||
where = ""
|
||||
|
||||
limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else ""
|
||||
return f"SELECT {select_expr} FROM `{table_fqn}`{where}{limit}"
|
||||
|
||||
|
||||
def _decimal256_columns(schema) -> list:
|
||||
"""Nombres de columnas Arrow de tipo decimal256 (BigQuery BIGNUMERIC).
|
||||
|
||||
DuckDB no ingiere decimal256 directamente; se usa para dar un error claro que
|
||||
recomiende castear esas columnas a FLOAT64 vía `select_sql`.
|
||||
"""
|
||||
import pyarrow as pa
|
||||
return [f.name for f in schema if pa.types.is_decimal256(f.type)]
|
||||
|
||||
|
||||
def _auto_select_exprs(schema_fields) -> tuple:
|
||||
"""Construye el SELECT auto-casteado desde el schema BigQuery (helper puro).
|
||||
|
||||
Recibe la lista de campos top-level del schema de BigQuery
|
||||
(`google.cloud.bigquery.SchemaField` o cualquier objeto con `.name`,
|
||||
`.field_type` y `.mode`) y devuelve `(select_sql, auto_casts)`:
|
||||
|
||||
- BIGNUMERIC -> CAST(col AS FLOAT64) (Arrow decimal256, DuckDB no lo ingiere)
|
||||
- REPEATED / RECORD / JSON -> TO_JSON_STRING(col) (arrays/structs rompen profile_table:
|
||||
"unhashable type: 'list'")
|
||||
- GEOGRAPHY -> ST_ASTEXT(col) (WKT string)
|
||||
- resto -> col sin tocar
|
||||
|
||||
Si ninguna columna necesita transformación devuelve ("", {}) para que el
|
||||
caller use `SELECT *` (comportamiento previo intacto).
|
||||
"""
|
||||
exprs = []
|
||||
auto_casts = {}
|
||||
for f in schema_fields:
|
||||
name = f.name
|
||||
ftype = (f.field_type or "").upper()
|
||||
mode = (getattr(f, "mode", "") or "").upper()
|
||||
if mode == "REPEATED" or ftype in ("RECORD", "STRUCT", "JSON"):
|
||||
exprs.append(f"TO_JSON_STRING(`{name}`) AS `{name}`")
|
||||
auto_casts[name] = "TO_JSON_STRING"
|
||||
elif ftype == "BIGNUMERIC":
|
||||
exprs.append(f"CAST(`{name}` AS FLOAT64) AS `{name}`")
|
||||
auto_casts[name] = "CAST_FLOAT64"
|
||||
elif ftype == "GEOGRAPHY":
|
||||
exprs.append(f"ST_ASTEXT(`{name}`) AS `{name}`")
|
||||
auto_casts[name] = "ST_ASTEXT"
|
||||
else:
|
||||
exprs.append(f"`{name}`")
|
||||
if not auto_casts:
|
||||
return "", {}
|
||||
return ", ".join(exprs), auto_casts
|
||||
|
||||
|
||||
def _pseudonymize_arrow_table(batch, pseudo_set: set, pseudo_applied: list):
|
||||
"""Envuelve un `pyarrow.RecordBatch` en una `pyarrow.Table`, hasheando las PII.
|
||||
|
||||
Las columnas no listadas en `pseudo_set` conservan su tipo Arrow NATIVO (DATE,
|
||||
TIME, TIMESTAMP incluidos), que DuckDB ingiere directamente sin normalización.
|
||||
Solo las columnas PII se reescriben a string con el hash SHA-1 truncado.
|
||||
|
||||
Muta `pseudo_applied` in situ (añade el nombre de cada columna seudonimizada la
|
||||
primera vez que aparece).
|
||||
"""
|
||||
import pyarrow as pa
|
||||
if not pseudo_set:
|
||||
return pa.Table.from_batches([batch])
|
||||
names = list(batch.schema.names)
|
||||
arrays = []
|
||||
for i, name in enumerate(names):
|
||||
col = batch.column(i)
|
||||
if name in pseudo_set:
|
||||
hashed = _pseudonymize_series(col.to_pylist())
|
||||
arrays.append(pa.array(hashed, type=pa.string()))
|
||||
if name not in pseudo_applied:
|
||||
pseudo_applied.append(name)
|
||||
else:
|
||||
arrays.append(col)
|
||||
new_batch = pa.RecordBatch.from_arrays(arrays, names=names)
|
||||
return pa.Table.from_batches([new_batch])
|
||||
|
||||
|
||||
def load_bq_table_to_duckdb(
|
||||
table_fqn: str,
|
||||
duckdb_path: str,
|
||||
dest_table: str = "",
|
||||
sample_frac: float = None,
|
||||
max_rows: int = 0,
|
||||
project_id: str = "",
|
||||
pseudonymize_cols: list = None,
|
||||
where_sql: str = "",
|
||||
select_sql: str = "",
|
||||
) -> dict:
|
||||
try:
|
||||
import duckdb
|
||||
import google.auth
|
||||
from google.cloud import bigquery
|
||||
|
||||
if not table_fqn or not _FQN_RE.match(table_fqn):
|
||||
return {"status": "error", "error": f"table_fqn inválido: {table_fqn!r}"}
|
||||
|
||||
# dest_table: derivar del último segmento del FQN si no se pasa, saneado.
|
||||
dest = _sanitize_dest_table(dest_table, table_fqn)
|
||||
|
||||
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
|
||||
creds, adc_project = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"]
|
||||
)
|
||||
if hasattr(creds, "with_quota_project"):
|
||||
creds = creds.with_quota_project(None)
|
||||
proj = project_id or table_fqn.split(".")[0] or adc_project
|
||||
client = bigquery.Client(project=proj, credentials=creds)
|
||||
|
||||
# Auto-cast de tipos problemáticos: si el caller no proyecta un
|
||||
# select_sql propio, se inspecciona el schema del origen y se castean
|
||||
# automáticamente BIGNUMERIC -> FLOAT64 (Arrow decimal256 que DuckDB no
|
||||
# ingiere), REPEATED/RECORD/JSON -> TO_JSON_STRING (los LIST/STRUCT
|
||||
# rompen el perfilado aguas abajo) y GEOGRAPHY -> ST_ASTEXT. Best-effort:
|
||||
# si el schema no se puede leer, se sigue con SELECT * como antes.
|
||||
auto_casts = {}
|
||||
if not (select_sql and select_sql.strip()):
|
||||
try:
|
||||
src = client.get_table(table_fqn)
|
||||
auto_sel, auto_casts = _auto_select_exprs(src.schema)
|
||||
if auto_sel:
|
||||
select_sql = auto_sel
|
||||
except Exception: # noqa: BLE001
|
||||
auto_casts = {}
|
||||
|
||||
# Conteo de filas del origen FILTRADO: aplica el mismo `where_sql` (cuenta
|
||||
# las filas que se van a traer, no la tabla entera). El muestreo NO entra
|
||||
# en el conteo (es un submuestreo aparte del origen filtrado).
|
||||
count_where = ""
|
||||
_ws = (where_sql or "").strip()
|
||||
if _ws:
|
||||
count_where = f" WHERE {_ws}"
|
||||
cnt = client.query(
|
||||
f"SELECT COUNT(*) AS n FROM `{table_fqn}`{count_where}"
|
||||
).result()
|
||||
n_source = 0
|
||||
for row in cnt:
|
||||
n_source = int(row["n"])
|
||||
|
||||
# Modo por defecto = FULL (sample_frac=None -> todas las filas). El
|
||||
# muestreo es opt-in: sample_frac in (0,1) muestrea esa fracción con
|
||||
# `rand() < frac`, combinado con `where_sql` vía AND. max_rows>0 es un tope
|
||||
# duro opcional (LIMIT). `select_sql` proyecta expresiones (vacío = `*`).
|
||||
sampled = sample_frac is not None and 0 < float(sample_frac) < 1
|
||||
sql = _build_source_sql(table_fqn, select_sql, where_sql, sample_frac, max_rows)
|
||||
|
||||
# ¿Está pyarrow disponible? Decide el camino de ingesta ANTES de consumir
|
||||
# el resultado (streaming Arrow por batches vs DataFrame completo).
|
||||
try:
|
||||
import pyarrow # noqa: F401
|
||||
has_pyarrow = True
|
||||
except Exception: # noqa: BLE001
|
||||
has_pyarrow = False
|
||||
|
||||
job = client.query(sql)
|
||||
result = job.result()
|
||||
use_stream = has_pyarrow and hasattr(result, "to_arrow_iterable")
|
||||
|
||||
pseudo_set = set(pseudonymize_cols or [])
|
||||
pseudo_applied = []
|
||||
n_fetched = 0
|
||||
columns = []
|
||||
streamed = False
|
||||
|
||||
con = duckdb.connect(duckdb_path)
|
||||
try:
|
||||
if use_stream:
|
||||
# Cliente BigQuery Storage con las MISMAS creds corregidas
|
||||
# (quota None). Si la lib no está, to_arrow_iterable cae al
|
||||
# transporte REST-Arrow con bqstorage_client=None.
|
||||
try:
|
||||
from google.cloud import bigquery_storage
|
||||
bqstorage_client = bigquery_storage.BigQueryReadClient(
|
||||
credentials=creds
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
bqstorage_client = None
|
||||
|
||||
first = True
|
||||
for batch in result.to_arrow_iterable(
|
||||
bqstorage_client=bqstorage_client
|
||||
):
|
||||
# Seudonimización PII POR BATCH; no PII conserva tipo Arrow.
|
||||
tbl = _pseudonymize_arrow_table(batch, pseudo_set, pseudo_applied)
|
||||
|
||||
# Gotcha BIGNUMERIC: decimal256 no lo ingiere DuckDB. Detectar
|
||||
# en el primer batch y devolver un error claro que recomiende
|
||||
# castear a FLOAT64 vía select_sql (no intentar magia de tipos).
|
||||
if first:
|
||||
dcols = _decimal256_columns(tbl.schema)
|
||||
if dcols:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"Ingesta Arrow bloqueada: columnas BIGNUMERIC "
|
||||
f"(Arrow decimal256) que DuckDB no ingiere: {dcols}. "
|
||||
"Castéalas a FLOAT64 con select_sql, p. ej. "
|
||||
"select_sql='..., CAST(col AS FLOAT64) AS col, ...'."
|
||||
),
|
||||
"stage": "stream_schema",
|
||||
}
|
||||
|
||||
con.register("_batch_arrow", tbl)
|
||||
try:
|
||||
if first:
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{dest}" '
|
||||
f"AS SELECT * FROM _batch_arrow"
|
||||
)
|
||||
columns = list(tbl.schema.names)
|
||||
first = False
|
||||
else:
|
||||
con.execute(
|
||||
f'INSERT INTO "{dest}" SELECT * FROM _batch_arrow'
|
||||
)
|
||||
except Exception as ie: # noqa: BLE001
|
||||
msg = str(ie).lower()
|
||||
if "decimal256" in msg or ("decimal" in msg and "256" in msg):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"Ingesta Arrow falló por columna BIGNUMERIC "
|
||||
"(Arrow decimal256) que DuckDB no ingiere. Castea "
|
||||
"esas columnas a FLOAT64 con select_sql. Detalle: "
|
||||
+ str(ie)
|
||||
),
|
||||
"stage": "stream_insert",
|
||||
}
|
||||
raise
|
||||
finally:
|
||||
con.unregister("_batch_arrow")
|
||||
n_fetched += tbl.num_rows
|
||||
|
||||
# Origen vacío: si el iterable no emitió ningún batch, materializa
|
||||
# una tabla vacía con el esquema del origen (evita que aguas abajo
|
||||
# falle por "tabla inexistente"). job.result() da un iterador fresco.
|
||||
if first:
|
||||
empty_df = job.result().to_dataframe(create_bqstorage_client=False)
|
||||
con.register("_empty_df", empty_df)
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _empty_df'
|
||||
)
|
||||
con.unregister("_empty_df")
|
||||
columns = list(empty_df.columns)
|
||||
streamed = True
|
||||
else:
|
||||
# Fallback: camino DataFrame completo (carga TODO el resultado en
|
||||
# RAM). Mismo comportamiento que antes del streaming Arrow.
|
||||
try:
|
||||
df = result.to_dataframe(create_bqstorage_client=True)
|
||||
except Exception: # noqa: BLE001
|
||||
df = job.result().to_dataframe(create_bqstorage_client=False)
|
||||
n_fetched = len(df)
|
||||
|
||||
# Normalizar dtypes de db-dtypes (solo camino pandas): el conversor
|
||||
# REST mapea DATE/TIME a las extension dtypes `dbdate`/`dbtime` de
|
||||
# db-dtypes, que DuckDB NO reconoce al registrar el DataFrame. Se
|
||||
# convierten a tipos estándar: DATE -> datetime64[ns], TIME ->
|
||||
# string. En el camino Arrow esto no aplica (tipos Arrow nativos).
|
||||
import pandas as pd
|
||||
for col in df.columns:
|
||||
dt = str(df[col].dtype)
|
||||
if dt == "dbdate":
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
elif dt == "dbtime":
|
||||
df[col] = df[col].astype("string").astype(object)
|
||||
|
||||
# Seudonimización de columnas PII antes de escribir a disco.
|
||||
for col in (pseudonymize_cols or []):
|
||||
if col in df.columns:
|
||||
df[col] = _pseudonymize_series(df[col].tolist())
|
||||
pseudo_applied.append(col)
|
||||
|
||||
con.register("_src_df", df)
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df'
|
||||
)
|
||||
con.unregister("_src_df")
|
||||
columns = list(df.columns)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"duckdb_path": duckdb_path,
|
||||
"table": dest,
|
||||
"n_rows_source": n_source,
|
||||
"n_rows_fetched": n_fetched,
|
||||
"sampled": sampled,
|
||||
"sample_frac": float(sample_frac) if sampled else None,
|
||||
"columns": columns,
|
||||
"pseudonymized": pseudo_applied,
|
||||
"streamed": streamed,
|
||||
"auto_casts": auto_casts,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests para load_bq_table_to_duckdb.
|
||||
|
||||
Cubre la lógica PURA extraíble sin red ni BigQuery: la construcción del SELECT
|
||||
sobre el origen (`_build_source_sql` — combinación de where_sql + sample_frac con
|
||||
AND, select_sql sustituyendo a `*`, límite duro) y el saneado del nombre de tabla
|
||||
destino (`_sanitize_dest_table`). No se toca la red: importar el módulo solo carga
|
||||
`hashlib`/`re` a nivel superior (BigQuery/DuckDB/pyarrow se importan dentro de la
|
||||
función impura, que aquí no se invoca).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from load_bq_table_to_duckdb import _build_source_sql, _sanitize_dest_table
|
||||
|
||||
_FQN = "autingo-159109.data.ventas"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _build_source_sql — golden / defaults
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_default_selects_star_no_where_no_limit():
|
||||
sql = _build_source_sql(_FQN)
|
||||
assert sql == "SELECT * FROM `autingo-159109.data.ventas`"
|
||||
|
||||
|
||||
def test_select_sql_replaces_star():
|
||||
sql = _build_source_sql(
|
||||
_FQN,
|
||||
select_sql="fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n",
|
||||
)
|
||||
assert sql == (
|
||||
"SELECT fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n "
|
||||
"FROM `autingo-159109.data.ventas`"
|
||||
)
|
||||
|
||||
|
||||
def test_select_sql_blank_and_whitespace_fall_back_to_star():
|
||||
assert _build_source_sql(_FQN, select_sql="").startswith("SELECT * FROM")
|
||||
assert _build_source_sql(_FQN, select_sql=" ").startswith("SELECT * FROM")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# where_sql y sample_frac — solos y combinados con AND
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_where_sql_only():
|
||||
sql = _build_source_sql(_FQN, where_sql="fecha <= CURRENT_DATE()")
|
||||
assert sql == (
|
||||
"SELECT * FROM `autingo-159109.data.ventas` "
|
||||
"WHERE fecha <= CURRENT_DATE()"
|
||||
)
|
||||
|
||||
|
||||
def test_sample_frac_only():
|
||||
sql = _build_source_sql(_FQN, sample_frac=0.05)
|
||||
assert sql == "SELECT * FROM `autingo-159109.data.ventas` WHERE rand() < 0.05"
|
||||
|
||||
|
||||
def test_where_sql_and_sample_frac_combined_with_and_parenthesized():
|
||||
sql = _build_source_sql(
|
||||
_FQN,
|
||||
where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL",
|
||||
sample_frac=0.1,
|
||||
)
|
||||
# Dos condiciones -> cada una entre paréntesis, unidas con AND.
|
||||
assert sql == (
|
||||
"SELECT * FROM `autingo-159109.data.ventas` "
|
||||
"WHERE (fecha <= CURRENT_DATE() AND venta_n IS NOT NULL) "
|
||||
"AND (rand() < 0.1)"
|
||||
)
|
||||
|
||||
|
||||
def test_single_condition_not_parenthesized():
|
||||
# Con una sola condición no se envuelve en paréntesis (más limpio).
|
||||
assert " WHERE fecha = 1" in _build_source_sql(_FQN, where_sql="fecha = 1")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# max_rows (LIMIT) — solo y combinado
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_max_rows_appends_limit():
|
||||
sql = _build_source_sql(_FQN, max_rows=1000)
|
||||
assert sql == "SELECT * FROM `autingo-159109.data.ventas` LIMIT 1000"
|
||||
|
||||
|
||||
def test_max_rows_zero_or_negative_no_limit():
|
||||
assert "LIMIT" not in _build_source_sql(_FQN, max_rows=0)
|
||||
assert "LIMIT" not in _build_source_sql(_FQN, max_rows=-5)
|
||||
|
||||
|
||||
def test_all_combined_order_where_then_limit():
|
||||
sql = _build_source_sql(
|
||||
_FQN,
|
||||
select_sql="a, b",
|
||||
where_sql="a > 0",
|
||||
sample_frac=0.2,
|
||||
max_rows=500,
|
||||
)
|
||||
assert sql == (
|
||||
"SELECT a, b FROM `autingo-159109.data.ventas` "
|
||||
"WHERE (a > 0) AND (rand() < 0.2) LIMIT 500"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sample_frac fuera de rango -> no muestrea
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_sample_frac_out_of_range_ignored():
|
||||
# >=1, <=0 y None no añaden la cláusula rand().
|
||||
assert "rand()" not in _build_source_sql(_FQN, sample_frac=1.0)
|
||||
assert "rand()" not in _build_source_sql(_FQN, sample_frac=0.0)
|
||||
assert "rand()" not in _build_source_sql(_FQN, sample_frac=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _sanitize_dest_table
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_dest_empty_uses_last_fqn_segment():
|
||||
assert _sanitize_dest_table("", "proj.dataset.customer_profile") == "customer_profile"
|
||||
|
||||
|
||||
def test_dest_explicit_valid_kept():
|
||||
assert _sanitize_dest_table("mi_tabla", _FQN) == "mi_tabla"
|
||||
|
||||
|
||||
def test_dest_invalid_chars_replaced_with_underscore():
|
||||
assert _sanitize_dest_table("my-table", _FQN) == "my_table"
|
||||
assert _sanitize_dest_table("weird!!name", _FQN) == "weird__name"
|
||||
|
||||
|
||||
def test_dest_from_fqn_segment_with_hyphen_sanitized():
|
||||
# El último segmento con guiones se sanea (guion no es válido en identificador).
|
||||
assert _sanitize_dest_table("", "proj.dataset.tabla-con-guiones") == "tabla_con_guiones"
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: preregister_hypothesis
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict"
|
||||
description: "Pre-registra (congela) la hipotesis y el plan de analisis de un paper ANTES de mirar los datos: antidoto al HARKing (Hypothesizing After the Results are Known). Escribe/actualiza <paper_dir>/preregistration.md con un frontmatter (paper_slug, frozen_at, content_hash, status) y un cuerpo markdown DETERMINISTA derivado de (hypotheses, analysis_plan) (mismo input -> mismo cuerpo byte a byte, claves ordenadas alfabeticamente). El content_hash es sha256 del cuerpo NORMALIZADO (strip por linea + colapso de blancos), nunca del frontmatter. Una vez status=frozen es INMUTABLE: re-congelar con el mismo contenido es idempotente (no reescribe, devuelve unchanged) y re-congelar con contenido distinto se RECHAZA (no sobrescribe, devuelve error) para que no se pueda ajustar la hipotesis a los resultados. Estilo dict-no-throw: nunca lanza."
|
||||
tags: [papers, preregistration, reproducibility, anti-harking, python]
|
||||
params:
|
||||
- name: paper_dir
|
||||
desc: "ruta del directorio del paper, p.ej. 'papers/0001-mi-paper'. Debe existir (no se crea aqui). El paper_slug del frontmatter es el basename del dir. Si no existe o no es str -> {status:error, path, note} sin crash ni creacion."
|
||||
- name: hypotheses
|
||||
desc: "dict de hipotesis, p.ej. {'h0': 'no hay diferencia ...', 'h1': 'el grupo A > grupo B ...'}. Se renderiza en la seccion '## Hypotheses' con una linea por clave, ordenadas alfabeticamente para determinismo."
|
||||
- name: analysis_plan
|
||||
desc: "dict con el plan de analisis, p.ej. {'test': 'welch_t_test', 'effect_size_metric': 'cohens_d', 'decision_rule': 'rechazar H0 si p<0.05 tras Holm y |d|>=0.5', 'planned_n': 100, 'multiple_correction': 'holm'}. Se renderiza en '## Analysis plan' con una linea por clave (ordenadas alfabeticamente). Acepta valores no-str (int, etc.)."
|
||||
output: "dict dict-no-throw (NUNCA lanza). status='frozen' cuando escribe el archivo por primera vez o congela un draft previo ({status, path, content_hash, frozen_at}). status='unchanged' cuando ya estaba frozen con el mismo content_hash: no reescribe y preserva el archivo byte-identico incl. el frozen_at original ({status, path, content_hash, frozen_at}). status='error' cuando paper_dir no existe, ya esta frozen con un hash distinto (rechazo anti-HARKing, no sobrescribe), inputs invalidos o error de I/O ({status, path, note, [content_hash]})."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [hashlib]
|
||||
tested: true
|
||||
tests: ["test_golden_congela_y_escribe_archivo", "test_idempotente_mismo_input_no_reescribe", "test_inmutabilidad_anti_harking_rechaza_contenido_distinto", "test_error_paper_dir_inexistente_no_crash_no_crea"]
|
||||
test_file_path: "python/functions/datascience/preregister_hypothesis_test.py"
|
||||
file_path: "python/functions/datascience/preregister_hypothesis.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import os, tempfile
|
||||
from datascience import preregister_hypothesis
|
||||
|
||||
# Un directorio de paper que ya existe.
|
||||
paper_dir = tempfile.mkdtemp(prefix="0001-")
|
||||
|
||||
hypotheses = {
|
||||
"h0": "no hay diferencia entre el grupo A y el grupo B",
|
||||
"h1": "el grupo A tiene mayor conversion que el grupo B",
|
||||
}
|
||||
analysis_plan = {
|
||||
"test": "welch_t_test",
|
||||
"effect_size_metric": "cohens_d",
|
||||
"decision_rule": "rechazar H0 si p<0.05 tras Holm y |d|>=0.5",
|
||||
"planned_n": 100,
|
||||
"multiple_correction": "holm",
|
||||
}
|
||||
|
||||
# 1) Primera vez: congela y escribe <paper_dir>/preregistration.md
|
||||
r1 = preregister_hypothesis(paper_dir, hypotheses, analysis_plan)
|
||||
print(r1["status"]) # -> "frozen"
|
||||
print(r1["content_hash"]) # sha256 del cuerpo
|
||||
|
||||
# 2) Mismo input: idempotente, no reescribe.
|
||||
r2 = preregister_hypothesis(paper_dir, hypotheses, analysis_plan)
|
||||
print(r2["status"]) # -> "unchanged"
|
||||
|
||||
# 3) Cambiar la hipotesis tras congelar (HARKing): rechazado, archivo intacto.
|
||||
r3 = preregister_hypothesis(paper_dir, {"h0": "...", "h1": "otra cosa"}, analysis_plan)
|
||||
print(r3["status"]) # -> "error"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Llamala al ARRANCAR el analisis de un paper, antes de tocar los datos, para
|
||||
dejar por escrito (y firmado por hash) que vas a probar y como vas a decidir.
|
||||
Es el primer paso de un flujo reproducible: pre-registras la hipotesis y el plan
|
||||
(`test`, `effect_size_metric`, `decision_rule`, `planned_n`,
|
||||
`multiple_correction`), y solo despues corres el analisis y comparas con lo
|
||||
pre-registrado. Si mas tarde el analisis "descubre" otra hipotesis que encaja
|
||||
mejor con los datos, el pre-registro congelado deja en evidencia el cambio: no se
|
||||
puede reescribir. Combinala con `effect_size_cohens_d` y `fdr_correction` para
|
||||
cerrar el plan declarado (effect size + correccion de multiples comparaciones).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Inmutabilidad (el corazon)**: una vez `status: frozen`, el pre-registro NO se
|
||||
puede editar. Re-congelar con el MISMO contenido es idempotente (`unchanged`,
|
||||
no reescribe, preserva incluso el `frozen_at` original). Re-congelar con
|
||||
contenido DISTINTO devuelve `error` y deja el archivo intacto: asi se mata el
|
||||
HARKing. Para cambiar de verdad la hipotesis hay que borrar el archivo a mano y
|
||||
asumir explicitamente que ya no es un pre-registro valido.
|
||||
- **dict-no-throw**: la funcion NUNCA lanza. Cualquier error previsible
|
||||
(directorio inexistente, inputs no-dict, fallo de I/O, excepcion inesperada) se
|
||||
captura y se devuelve como `{"status": "error", "note": ...}`. Siempre incluye
|
||||
`path` (la ruta esperada del `preregistration.md`).
|
||||
- **El hash es SOLO del cuerpo, nunca del frontmatter**: el frontmatter contiene
|
||||
el propio `content_hash` y el `frozen_at` (timestamp), asi que incluirlos en el
|
||||
hash seria circular y romperia la idempotencia. El cuerpo se normaliza antes de
|
||||
hashear (strip por linea + colapso de lineas en blanco + strip final): cambios
|
||||
irrelevantes de whitespace no alteran el hash, pero cambios de contenido SI.
|
||||
- **Determinismo**: el cuerpo se genera con las claves de `hypotheses` y
|
||||
`analysis_plan` ordenadas alfabeticamente, de modo que el orden de insercion del
|
||||
dict no afecta al hash. Mismo `(hypotheses, analysis_plan)` -> mismo cuerpo y
|
||||
mismo hash, byte a byte.
|
||||
- **No crea el directorio del paper**: si `paper_dir` no existe, devuelve `error`
|
||||
sin crear nada (ni el dir ni el archivo).
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Congela (pre-registra) la hipotesis y el plan de analisis de un paper.
|
||||
|
||||
Anti-HARKing (Hypothesizing After the Results are Known): el pre-registro fija
|
||||
la hipotesis y el plan de analisis ANTES de mirar los datos. Una vez congelado
|
||||
(``status: frozen``) es INMUTABLE: cualquier intento posterior de re-congelar con
|
||||
un contenido distinto se RECHAZA en vez de sobrescribir, de modo que no se puede
|
||||
"ajustar" la hipotesis a los resultados despues de verlos.
|
||||
|
||||
Escribe/actualiza ``<paper_dir>/preregistration.md`` con un frontmatter
|
||||
(``paper_slug``, ``frozen_at``, ``content_hash``, ``status``) y un cuerpo
|
||||
markdown DETERMINISTA derivado de ``(hypotheses, analysis_plan)``.
|
||||
|
||||
Estilo dict-no-throw: NUNCA lanza; cualquier error previsible se captura y se
|
||||
devuelve como ``{"status": "error", "note": ...}``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _build_body(hypotheses: dict, analysis_plan: dict) -> str:
|
||||
"""Construye el cuerpo markdown del pre-registro de forma DETERMINISTA.
|
||||
|
||||
Mismo ``(hypotheses, analysis_plan)`` -> mismo cuerpo byte a byte. Las claves
|
||||
se ordenan alfabeticamente para no depender del orden de insercion del dict.
|
||||
"""
|
||||
lines = ["## Hypotheses", ""]
|
||||
for k in sorted(hypotheses.keys()):
|
||||
lines.append(f"- **{k}**: {hypotheses[k]}")
|
||||
lines.append("")
|
||||
lines.append("## Analysis plan")
|
||||
lines.append("")
|
||||
for k in sorted(analysis_plan.keys()):
|
||||
lines.append(f"- **{k}**: {analysis_plan[k]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize(body: str) -> str:
|
||||
"""Normaliza el cuerpo para el hash: strip por linea + colapsa blancos.
|
||||
|
||||
Cambios irrelevantes de whitespace (espacios al final, dobles lineas en
|
||||
blanco) no alteran el hash; cambios de contenido SI. Esto hace el hash
|
||||
robusto sin perder la capacidad de detectar ediciones reales.
|
||||
"""
|
||||
out = []
|
||||
prev_blank = False
|
||||
for raw in body.splitlines():
|
||||
line = raw.strip()
|
||||
if line == "":
|
||||
if prev_blank:
|
||||
continue
|
||||
prev_blank = True
|
||||
else:
|
||||
prev_blank = False
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def _content_hash(body: str) -> str:
|
||||
"""sha256 hex del cuerpo NORMALIZADO (nunca del frontmatter)."""
|
||||
return hashlib.sha256(_normalize(body).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
"""Parsea el frontmatter ``--- ... ---`` simple (key: value) de un .md."""
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return {}
|
||||
fm = {}
|
||||
for line in parts[1].splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
fm[key.strip()] = value.strip()
|
||||
return fm
|
||||
|
||||
|
||||
def _render_file(slug: str, frozen_at: str, content_hash: str, body: str) -> str:
|
||||
"""Compone el archivo completo: frontmatter frozen + cuerpo."""
|
||||
return (
|
||||
"---\n"
|
||||
f"paper_slug: {slug}\n"
|
||||
f"frozen_at: {frozen_at}\n"
|
||||
f"content_hash: {content_hash}\n"
|
||||
"status: frozen\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
|
||||
def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict:
|
||||
"""Congela la hipotesis y el plan de analisis de un paper (anti-HARKing).
|
||||
|
||||
Escribe ``<paper_dir>/preregistration.md`` con frontmatter ``status: frozen``
|
||||
y un cuerpo markdown determinista. Una vez congelado es inmutable.
|
||||
|
||||
Args:
|
||||
paper_dir: ruta del directorio del paper (p.ej. ``"papers/0001-mi-paper"``).
|
||||
El ``paper_slug`` es el basename del directorio. Debe existir.
|
||||
hypotheses: dict de hipotesis, p.ej.
|
||||
``{"h0": "no hay diferencia ...", "h1": "grupo A > grupo B ..."}``.
|
||||
analysis_plan: dict con el plan, p.ej.
|
||||
``{"test": "welch_t_test", "effect_size_metric": "cohens_d",
|
||||
"decision_rule": "...", "planned_n": 100, "multiple_correction": "holm"}``.
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw (NUNCA lanza). Claves segun el caso:
|
||||
- frozen: {"status": "frozen", "path", "content_hash", "frozen_at"}
|
||||
- unchanged: {"status": "unchanged", "path", "content_hash", "frozen_at"}
|
||||
- error: {"status": "error", "path", "note", ...}
|
||||
"""
|
||||
expected_path = os.path.join(paper_dir, "preregistration.md")
|
||||
try:
|
||||
# 1) El directorio del paper debe existir; no se crea aqui.
|
||||
if not isinstance(paper_dir, str) or not os.path.isdir(paper_dir):
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"paper_dir no existe: {paper_dir}",
|
||||
}
|
||||
|
||||
if not isinstance(hypotheses, dict) or not isinstance(analysis_plan, dict):
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": "hypotheses y analysis_plan deben ser dict",
|
||||
}
|
||||
|
||||
slug = os.path.basename(os.path.normpath(paper_dir))
|
||||
|
||||
# 2) + 3) Cuerpo determinista y su hash (solo del cuerpo, no del frontmatter).
|
||||
body = _build_body(hypotheses, analysis_plan)
|
||||
new_hash = _content_hash(body)
|
||||
|
||||
# 5) Logica de escritura.
|
||||
if os.path.exists(expected_path):
|
||||
existing = ""
|
||||
try:
|
||||
with open(expected_path, "r", encoding="utf-8") as fh:
|
||||
existing = fh.read()
|
||||
except OSError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"no se pudo leer el pre-registro existente: {exc}",
|
||||
}
|
||||
fm = _parse_frontmatter(existing)
|
||||
old_status = fm.get("status", "")
|
||||
old_hash = fm.get("content_hash", "")
|
||||
old_frozen_at = fm.get("frozen_at", "")
|
||||
|
||||
if old_status == "frozen":
|
||||
if old_hash == new_hash:
|
||||
# Idempotente: mismo contenido ya congelado. No se reescribe.
|
||||
return {
|
||||
"status": "unchanged",
|
||||
"path": expected_path,
|
||||
"content_hash": new_hash,
|
||||
"frozen_at": old_frozen_at,
|
||||
}
|
||||
# Inmutabilidad: ya congelado con OTRO hash -> se rechaza (anti-HARKing).
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"content_hash": new_hash,
|
||||
"note": (
|
||||
"pre-registro inmutable: ya esta congelado (frozen) con un "
|
||||
"hash distinto; un pre-registro no se puede editar tras "
|
||||
"congelarse"
|
||||
),
|
||||
}
|
||||
# status != "frozen" (p.ej. draft) -> se congela ahora.
|
||||
|
||||
# Archivo nuevo o draft existente: congelar con timestamp actual.
|
||||
frozen_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
file_text = _render_file(slug, frozen_at, new_hash, body)
|
||||
try:
|
||||
with open(expected_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(file_text)
|
||||
except OSError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"no se pudo escribir el pre-registro: {exc}",
|
||||
}
|
||||
return {
|
||||
"status": "frozen",
|
||||
"path": expected_path,
|
||||
"content_hash": new_hash,
|
||||
"frozen_at": frozen_at,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 - dict-no-throw: nunca propagar.
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"error inesperado: {exc}",
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests para preregister_hypothesis (pre-registro inmutable, anti-HARKing).
|
||||
|
||||
Importa el modulo hoja directamente (`preregister_hypothesis`) para no depender
|
||||
de que el paquete reexporte la funcion en su __init__ (lo integra el orquestador
|
||||
al cerrar el grupo papers). El pytest del repo resuelve el modulo hoja por su
|
||||
nombre directo.
|
||||
|
||||
Todos los tests son hermeticos y deterministas: usan el fixture `tmp_path` de
|
||||
pytest; NUNCA escriben en `papers/`.
|
||||
"""
|
||||
|
||||
from preregister_hypothesis import preregister_hypothesis
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
parts = text.split("---", 2)
|
||||
fm = {}
|
||||
for line in parts[1].splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
fm[key.strip()] = value.strip()
|
||||
return fm
|
||||
|
||||
|
||||
HYP = {"h0": "no hay diferencia entre A y B", "h1": "el grupo A > grupo B"}
|
||||
PLAN = {
|
||||
"test": "welch_t_test",
|
||||
"effect_size_metric": "cohens_d",
|
||||
"decision_rule": "rechazar H0 si p<0.05 tras Holm y |d|>=0.5",
|
||||
"planned_n": 100,
|
||||
"multiple_correction": "holm",
|
||||
}
|
||||
|
||||
|
||||
def test_golden_congela_y_escribe_archivo(tmp_path):
|
||||
paper = tmp_path / "0001-x"
|
||||
paper.mkdir()
|
||||
|
||||
res = preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
|
||||
assert res["status"] == "frozen"
|
||||
pre = paper / "preregistration.md"
|
||||
assert pre.exists()
|
||||
|
||||
text = pre.read_text(encoding="utf-8")
|
||||
fm = _parse_frontmatter(text)
|
||||
assert fm["status"] == "frozen"
|
||||
assert fm["paper_slug"] == "0001-x"
|
||||
assert fm["content_hash"] # no vacio
|
||||
assert fm["frozen_at"] # no vacio
|
||||
assert res["content_hash"] == fm["content_hash"]
|
||||
assert res["frozen_at"] == fm["frozen_at"]
|
||||
|
||||
|
||||
def test_idempotente_mismo_input_no_reescribe(tmp_path):
|
||||
paper = tmp_path / "0001-x"
|
||||
paper.mkdir()
|
||||
pre = paper / "preregistration.md"
|
||||
|
||||
first = preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
assert first["status"] == "frozen"
|
||||
bytes_before = pre.read_bytes()
|
||||
|
||||
second = preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
assert second["status"] == "unchanged"
|
||||
# Mismo hash y frozen_at original preservado.
|
||||
assert second["content_hash"] == first["content_hash"]
|
||||
assert second["frozen_at"] == first["frozen_at"]
|
||||
# El archivo NO cambio byte a byte (incl. frozen_at).
|
||||
assert pre.read_bytes() == bytes_before
|
||||
|
||||
|
||||
def test_inmutabilidad_anti_harking_rechaza_contenido_distinto(tmp_path):
|
||||
paper = tmp_path / "0001-x"
|
||||
paper.mkdir()
|
||||
pre = paper / "preregistration.md"
|
||||
|
||||
preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
bytes_frozen = pre.read_bytes()
|
||||
|
||||
# Intento de re-congelar con una hipotesis DISTINTA (HARKing) -> rechazado.
|
||||
hyp_tramposo = {"h0": "no hay diferencia", "h1": "el grupo B > grupo A (cambiado tras ver datos)"}
|
||||
res = preregister_hypothesis(str(paper), hyp_tramposo, PLAN)
|
||||
|
||||
assert res["status"] == "error"
|
||||
# Asercion mas importante: el archivo en disco SIGUE siendo el original.
|
||||
assert pre.read_bytes() == bytes_frozen
|
||||
|
||||
|
||||
def test_error_paper_dir_inexistente_no_crash_no_crea(tmp_path):
|
||||
missing = tmp_path / "no-existe"
|
||||
res = preregister_hypothesis(str(missing), HYP, PLAN)
|
||||
|
||||
assert res["status"] == "error"
|
||||
# No se creo el directorio ni el archivo.
|
||||
assert not missing.exists()
|
||||
assert not (missing / "preregistration.md").exists()
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: render_paper_pdf
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_paper_pdf(paper_dir: str) -> dict"
|
||||
description: "Convierte un paper académico IMRaD escrito en Markdown (papers/<slug>/paper.md, con frontmatter YAML opcional title/authors/date/abstract + cuerpo) en un PDF papers/<slug>/out/paper.pdf. REUTILIZA el paginador de flujo del paquete automatic_eda (el mismo motor del PDF móvil A5 de los informes EDA): no reimplementa paginación ni toca matplotlib. Cada sección IMRaD (encabezado de nivel 1, p.ej. # Introduction, # Methods) se mapea a un Chapter que empieza en página nueva; el motor parsea por sí mismo headings, listas, tablas pipe, párrafos y **negrita** dentro del texto. Como el motor NO entiende la sintaxis de imagen Markdown , esta función detecta esas líneas y las parte en bloques Image separados, resolviendo el src relativo a base_dir y base_dir/figures/. La portada (si hay título) lista autores y fecha (DD/MM/AAAA si parseable) más el abstract. dict-no-throw: nunca lanza, devuelve {status, pdf_path, n_pages, note}."
|
||||
tags: [papers, pdf, academic, render, report, imrad, mobile, automatic-eda, markdown, no-cut, matplotlib, datascience, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, re, datetime, yaml, "datascience.automatic_eda"]
|
||||
params:
|
||||
- name: paper_dir
|
||||
desc: "ruta al directorio del paper (papers/<slug>/, del que se lee paper.md) O directamente la ruta a un archivo paper.md (cualquier ruta terminada en .md). El directorio base para resolver figuras y escribir el PDF es el dirname del paper.md. Si el paper.md no existe (incluida una ruta totalmente inexistente) devuelve status='error' sin crash."
|
||||
output: "dict (nunca lanza): {status: 'ok'|'error', pdf_path: str|None, n_pages: int, note: str}. En éxito status='ok', pdf_path es la ruta del PDF escrito (<base_dir>/out/paper.pdf) y n_pages el total de páginas. En error status='error', pdf_path=None, n_pages=0 y note explica la causa (paper.md no encontrado, fallo del motor, o excepción inesperada)."
|
||||
tested: true
|
||||
tests: ["test_golden_genera_pdf_con_portada_y_secciones", "test_edge_sin_frontmatter_ni_figuras", "test_edge_path_inexistente_no_revienta", "test_edge_figura_inexistente_degrada", "test_acepta_ruta_directa_al_md"]
|
||||
test_file_path: "python/functions/datascience/render_paper_pdf_test.py"
|
||||
file_path: "python/functions/datascience/render_paper_pdf.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import render_paper_pdf
|
||||
|
||||
# Estructura del paper:
|
||||
# papers/zz-demo/paper.md (frontmatter YAML + cuerpo IMRaD)
|
||||
# papers/zz-demo/figures/fig1.png (figuras referenciadas con )
|
||||
#
|
||||
# paper.md:
|
||||
# ---
|
||||
# title: A Minimal IMRaD Paper
|
||||
# authors: [Ada Lovelace, Alan Turing]
|
||||
# date: 2026-06-30
|
||||
# abstract: Demostramos que el motor pagina un paper sin cortar nada.
|
||||
# ---
|
||||
# # Introduction
|
||||
# Texto con **negrita** y una lista:
|
||||
# - Punto uno.
|
||||
# 
|
||||
# # Methods
|
||||
# | Métrica | Valor |
|
||||
# | --- | --- |
|
||||
# | Precisión | 0.91 |
|
||||
|
||||
res = render_paper_pdf("papers/zz-demo")
|
||||
print(res["status"], res["n_pages"], res["pdf_path"])
|
||||
# -> ok 3 papers/zz-demo/out/paper.pdf
|
||||
|
||||
# También acepta la ruta directa al .md:
|
||||
render_paper_pdf("papers/zz-demo/paper.md")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas un paper académico (o cualquier documento IMRaD) escrito en
|
||||
Markdown y quieras un **PDF móvil A5 listo para leer**, sin montar LaTeX ni
|
||||
configurar un pipeline de pandoc. Úsala después de redactar `paper.md` con su
|
||||
frontmatter (título, autores, fecha, abstract) y secciones de nivel 1; obtienes
|
||||
`out/paper.pdf` con portada, una página nueva por sección IMRaD, tablas que se
|
||||
parten repitiendo la cabecera y figuras escaladas para caber enteras —
|
||||
garantía de no-corte heredada del motor `automatic_eda`. Es la capa de
|
||||
presentación PDF del grupo `papers`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe `out/paper.pdf` (y crea el directorio `out/`) junto al
|
||||
`paper.md`. Necesita **matplotlib** instalado en el venv (lo usa el motor
|
||||
`automatic_eda.render_pdf` con backend headless `Agg`; corre en agentes/CI sin
|
||||
display). `pyyaml` es opcional: si falta, el frontmatter se parsea con un
|
||||
parser line-based `clave: valor` degradado.
|
||||
- **Reutiliza el motor `automatic_eda.render_pdf`**: NO reimplementa paginación
|
||||
ni toca matplotlib. `render_pdf` no tiene ID propio en el registry (es parte
|
||||
del paquete de soporte `automatic_eda`), por eso `uses_functions` queda vacío;
|
||||
la dependencia real es ese motor del paquete.
|
||||
- **Nunca lanza** (dict-no-throw): `paper.md` inexistente → `{status:"error",
|
||||
pdf_path:None, note:"paper.md no encontrado: ..."}`; cualquier excepción
|
||||
inesperada → `{status:"error", note:"fallo: ..."}`. Frontmatter ausente o
|
||||
incompleto degrada limpio (sin portada, el cuerpo entero se pagina).
|
||||
- **Figuras relativas a `figures/`**: el `src` de `` se resuelve
|
||||
probando `<base_dir>/<src>` y `<base_dir>/figures/<basename>`; usa el primero
|
||||
que exista. Si ninguno existe, el motor **degrada** dibujando
|
||||
"(imagen no encontrada: ...)" — el PDF se genera igual, no crashea. Las URLs
|
||||
`http(s)` se dejan como texto Markdown, no se descargan.
|
||||
- **Solo imágenes en línea propia**: el motor `_place_markdown` NO entiende
|
||||
``; esta función solo convierte a `Image` las líneas cuyo único
|
||||
contenido es la imagen. Una imagen embebida a mitad de un párrafo se quedaría
|
||||
como texto crudo.
|
||||
- **A5 portrait mobile-first**: el formato (tamaño de página, tipografía, pie
|
||||
`Capítulo · vX.Y.Z`) lo fija el motor EDA y no es configurable desde aquí.
|
||||
@@ -0,0 +1,297 @@
|
||||
"""render_paper_pdf — convierte un paper académico IMRaD en Markdown a un PDF.
|
||||
|
||||
Toma un paper escrito en Markdown con frontmatter YAML opcional (título,
|
||||
autores, fecha, abstract) más un cuerpo dividido en secciones IMRaD por
|
||||
encabezados de nivel 1 (``# Introduction``, ``# Methods``, ...) y produce un PDF
|
||||
``out/paper.pdf`` junto al paper.
|
||||
|
||||
REUTILIZA el paginador de flujo del paquete ``automatic_eda`` (el mismo motor
|
||||
que rinde los informes EDA en PDF móvil A5): no reimplementa paginación ni toca
|
||||
matplotlib directamente. Cada sección IMRaD se mapea a un ``Chapter`` (empieza
|
||||
en página nueva). El motor ``_place_markdown`` parsea por sí mismo headings,
|
||||
listas, tablas pipe, párrafos y ``**negrita**`` dentro del texto, pero NO
|
||||
entiende la sintaxis de imagen Markdown ````; por eso esta función
|
||||
detecta esas líneas y las convierte en bloques ``Image`` separados, partiendo el
|
||||
texto Markdown alrededor de cada imagen.
|
||||
|
||||
dict-no-throw (estilo del grupo eda): NUNCA lanza. Devuelve
|
||||
``{status, pdf_path, n_pages, note}``; ante cualquier fallo devuelve
|
||||
``status="error"`` con ``pdf_path=None`` y la causa en ``note``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import os
|
||||
import re
|
||||
|
||||
from datascience.automatic_eda import Chapter, Heading, Image, Markdown, render_pdf
|
||||
|
||||
# Una línea cuyo único contenido es una imagen Markdown: 
|
||||
_IMG_LINE = re.compile(r"^\s*!\[([^\]]*)\]\(\s*([^)\s]+)\s*\)\s*$")
|
||||
# Un encabezado de nivel 1 al inicio de línea (un solo '#' seguido de espacio).
|
||||
_H1_LINE = re.compile(r"^#[ \t]+(.+?)\s*$")
|
||||
|
||||
|
||||
def render_paper_pdf(paper_dir: str) -> dict:
|
||||
"""Renderiza un paper académico Markdown IMRaD en un PDF.
|
||||
|
||||
Args:
|
||||
paper_dir: ruta al directorio del paper (``papers/<slug>/``, del que se
|
||||
lee ``paper.md``) o directamente la ruta a un archivo ``paper.md``.
|
||||
|
||||
Returns:
|
||||
dict (nunca lanza): ``{status: "ok"|"error", pdf_path: str|None,
|
||||
n_pages: int, note: str}``. En éxito ``pdf_path`` es la ruta escrita y
|
||||
``n_pages`` el total de páginas; en error ``pdf_path`` es None y
|
||||
``note`` explica la causa.
|
||||
"""
|
||||
try:
|
||||
# 1) Resolver el path del paper.md y el directorio base.
|
||||
arg = str(paper_dir)
|
||||
md_path = arg if arg.endswith(".md") else os.path.join(arg, "paper.md")
|
||||
|
||||
# 2) Si el paper.md no existe, degradar sin crash.
|
||||
if not os.path.isfile(md_path):
|
||||
return {"status": "error", "pdf_path": None, "n_pages": 0,
|
||||
"note": f"paper.md no encontrado: {md_path}"}
|
||||
|
||||
base_dir = os.path.dirname(os.path.abspath(md_path))
|
||||
|
||||
# 3) Leer el archivo y separar frontmatter del cuerpo.
|
||||
with open(md_path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
fm_text, body = _split_frontmatter(text)
|
||||
fm = _parse_frontmatter(fm_text)
|
||||
|
||||
title = _safe_str(fm.get("title")).strip()
|
||||
authors = fm.get("authors")
|
||||
date_raw = fm.get("date")
|
||||
abstract = _safe_str(fm.get("abstract")).strip()
|
||||
|
||||
# 4) Construir los capítulos: portada (si hay título) + cuerpo IMRaD.
|
||||
chapters: list = []
|
||||
if title:
|
||||
cover_md = _portada_markdown(authors, date_raw, abstract)
|
||||
cover_blocks: list = [Heading(text=title, level=1)]
|
||||
if cover_md.strip():
|
||||
cover_blocks.append(Markdown(text=cover_md))
|
||||
chapters.append(Chapter(id="portada", title=title, version="1.0.0",
|
||||
blocks=cover_blocks))
|
||||
|
||||
preamble, sections = _split_body_sections(body)
|
||||
|
||||
if not sections:
|
||||
# Sin encabezados H1: todo el cuerpo en un único capítulo.
|
||||
chapters.append(Chapter(
|
||||
id="cuerpo", title="Cuerpo", version="1.0.0",
|
||||
blocks=_markdown_to_blocks(body, base_dir)))
|
||||
else:
|
||||
# Texto antes del primer H1 (si lo hay) como capítulo previo.
|
||||
if preamble.strip():
|
||||
chapters.append(Chapter(
|
||||
id="cuerpo", title="Cuerpo", version="1.0.0",
|
||||
blocks=_markdown_to_blocks(preamble, base_dir)))
|
||||
for idx, (sec_title, sec_body) in enumerate(sections):
|
||||
blocks: list = [Heading(text=sec_title, level=1)]
|
||||
blocks.extend(_markdown_to_blocks(sec_body, base_dir))
|
||||
chapters.append(Chapter(
|
||||
id=_slugify(sec_title) or f"sec{idx}",
|
||||
title=sec_title, version="1.0.0", blocks=blocks))
|
||||
|
||||
# 5) Renderizar con el motor de automatic_eda.
|
||||
out_path = os.path.join(base_dir, "out", "paper.pdf")
|
||||
res = render_pdf(chapters, out_path, meta={"title": title or "paper"})
|
||||
|
||||
# 6) Mapear el retorno del motor a la forma de esta función.
|
||||
path = res.get("path")
|
||||
return {
|
||||
"status": "ok" if path else "error",
|
||||
"pdf_path": path,
|
||||
"n_pages": int(res.get("n_pages") or 0),
|
||||
"note": res.get("note"),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — dict-no-throw estricto.
|
||||
return {"status": "error", "pdf_path": None, "n_pages": 0,
|
||||
"note": f"fallo: {e}"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Frontmatter
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _split_frontmatter(text: str):
|
||||
"""Separa el bloque frontmatter YAML inicial del cuerpo.
|
||||
|
||||
Devuelve ``(fm_text|None, body)``. Si el archivo no empieza con una valla
|
||||
``---`` o no se cierra, no hay frontmatter y el cuerpo es el texto entero.
|
||||
"""
|
||||
if text.startswith(""):
|
||||
text = text.lstrip("")
|
||||
lines = text.split("\n")
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return None, text
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:])
|
||||
# Valla de apertura sin cierre: tratar todo como cuerpo.
|
||||
return None, text
|
||||
|
||||
|
||||
def _parse_frontmatter(fm_text) -> dict:
|
||||
"""Parsea el frontmatter. Intenta YAML; si no, parser line-based simple."""
|
||||
if not fm_text:
|
||||
return {}
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
data = yaml.safe_load(fm_text)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception: # noqa: BLE001 — yaml ausente o frontmatter inválido.
|
||||
pass
|
||||
# Fallback degradado: 'clave: valor' por línea.
|
||||
out: dict = {}
|
||||
for line in fm_text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
||||
continue
|
||||
k, _, v = stripped.partition(":")
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Portada
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _portada_markdown(authors, date_raw, abstract) -> str:
|
||||
"""Markdown de la portada: autores, fecha y, si hay, el abstract."""
|
||||
parts: list = []
|
||||
authors_str = _fmt_authors(authors)
|
||||
if authors_str:
|
||||
parts.append(f"**Autores:** {authors_str}")
|
||||
if date_raw not in (None, ""):
|
||||
parts.append(f"**Fecha:** {_fmt_date(date_raw)}")
|
||||
md = "\n\n".join(parts)
|
||||
abstract = _safe_str(abstract).strip()
|
||||
if abstract:
|
||||
md = (md + "\n\n" if md else "") + "## Abstract\n\n" + abstract
|
||||
return md
|
||||
|
||||
|
||||
def _fmt_authors(authors) -> str:
|
||||
"""Lista o string de autores → string separado por comas."""
|
||||
if authors in (None, ""):
|
||||
return ""
|
||||
if isinstance(authors, (list, tuple)):
|
||||
return ", ".join(_safe_str(a).strip() for a in authors
|
||||
if _safe_str(a).strip())
|
||||
return _safe_str(authors).strip()
|
||||
|
||||
|
||||
def _fmt_date(raw) -> str:
|
||||
"""Fecha → ``DD/MM/AAAA`` si es parseable; si no, el valor crudo."""
|
||||
if isinstance(raw, _dt.datetime):
|
||||
return raw.strftime("%d/%m/%Y")
|
||||
if isinstance(raw, _dt.date):
|
||||
return raw.strftime("%d/%m/%Y")
|
||||
s = _safe_str(raw).strip()
|
||||
if not s:
|
||||
return s
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"):
|
||||
try:
|
||||
return _dt.datetime.strptime(s, fmt).strftime("%d/%m/%Y")
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return _dt.datetime.fromisoformat(s).strftime("%d/%m/%Y")
|
||||
except Exception: # noqa: BLE001
|
||||
return s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cuerpo y figuras
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _split_body_sections(body: str):
|
||||
"""Divide el cuerpo en (preámbulo, [(título_H1, contenido)...]) por H1."""
|
||||
preamble_lines: list = []
|
||||
sections: list = []
|
||||
current = None # (titulo, [lineas])
|
||||
for line in body.split("\n"):
|
||||
m = _H1_LINE.match(line)
|
||||
if m and not line.startswith("##"):
|
||||
if current is not None:
|
||||
sections.append((current[0], "\n".join(current[1])))
|
||||
current = (m.group(1).strip(), [])
|
||||
elif current is None:
|
||||
preamble_lines.append(line)
|
||||
else:
|
||||
current[1].append(line)
|
||||
if current is not None:
|
||||
sections.append((current[0], "\n".join(current[1])))
|
||||
return "\n".join(preamble_lines), sections
|
||||
|
||||
|
||||
def _markdown_to_blocks(text: str, base_dir: str) -> list:
|
||||
"""Parte un Markdown en bloques Markdown/Image alrededor de cada figura.
|
||||
|
||||
Las líneas ```` con ``src`` local se convierten en ``Image``; las
|
||||
que apuntan a URLs http(s) se dejan como texto Markdown.
|
||||
"""
|
||||
blocks: list = []
|
||||
buf: list = []
|
||||
|
||||
def _flush():
|
||||
chunk = "\n".join(buf).strip("\n")
|
||||
if chunk.strip():
|
||||
blocks.append(Markdown(text=chunk))
|
||||
buf.clear()
|
||||
|
||||
for line in text.split("\n"):
|
||||
m = _IMG_LINE.match(line)
|
||||
if m:
|
||||
alt, src = m.group(1), m.group(2)
|
||||
if src.lower().startswith(("http://", "https://")):
|
||||
buf.append(line) # URL remota: se mantiene como texto.
|
||||
continue
|
||||
_flush()
|
||||
blocks.append(Image(path=_resolve_src(src, base_dir),
|
||||
caption=(alt or None)))
|
||||
else:
|
||||
buf.append(line)
|
||||
_flush()
|
||||
return blocks
|
||||
|
||||
|
||||
def _resolve_src(src: str, base_dir: str) -> str:
|
||||
"""Resuelve la ruta de una figura relativa al paper.
|
||||
|
||||
Absoluta → tal cual. Relativa → prueba ``base_dir/src`` y
|
||||
``base_dir/figures/<basename>``; usa la primera que exista, o el join con
|
||||
``base_dir`` si ninguna (el motor degrada dibujando el aviso de no-encontrada).
|
||||
"""
|
||||
if os.path.isabs(src):
|
||||
return src
|
||||
cand1 = os.path.join(base_dir, src)
|
||||
cand2 = os.path.join(base_dir, "figures", os.path.basename(src))
|
||||
for c in (cand1, cand2):
|
||||
if os.path.exists(c):
|
||||
return c
|
||||
return cand1
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Slug ASCII corto para el id del capítulo."""
|
||||
s = re.sub(r"[^a-z0-9]+", "_", _safe_str(text).lower()).strip("_")
|
||||
return s[:40]
|
||||
|
||||
|
||||
def _safe_str(v) -> str:
|
||||
"""str() que nunca lanza y mapea None a ''."""
|
||||
if v is None:
|
||||
return ""
|
||||
try:
|
||||
return str(v)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests para render_paper_pdf — DoD: golden + edges + error path.
|
||||
|
||||
Autocontenido y sin red: escribe papers Markdown sintéticos en directorios
|
||||
temporales y verifica que el PDF se genera (estado, nº de páginas, archivo
|
||||
no vacío) reutilizando el motor de paginación de ``automatic_eda``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from datascience.render_paper_pdf import render_paper_pdf
|
||||
|
||||
|
||||
_GOLDEN_PAPER = """---
|
||||
title: A Minimal IMRaD Paper
|
||||
authors:
|
||||
- Ada Lovelace
|
||||
- Alan Turing
|
||||
date: 2026-06-30
|
||||
abstract: >
|
||||
Demostramos que el motor de paginación rinde un paper IMRaD completo en PDF
|
||||
móvil sin cortar texto ni tablas.
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Este es el cuerpo de la introducción con **texto en negrita** y una lista:
|
||||
|
||||
- Primer punto.
|
||||
- Segundo punto.
|
||||
|
||||
# Methods
|
||||
|
||||
Resultados resumidos en una tabla pipe:
|
||||
|
||||
| Métrica | Valor |
|
||||
| --- | --- |
|
||||
| Precisión | 0.91 |
|
||||
| Recall | 0.88 |
|
||||
|
||||
Texto final de la sección de métodos.
|
||||
"""
|
||||
|
||||
|
||||
def test_golden_genera_pdf_con_portada_y_secciones(tmp_path):
|
||||
"""Golden: paper IMRaD con frontmatter + 2 secciones + tabla → PDF válido."""
|
||||
paper_dir = tmp_path / "zz-demo"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(_GOLDEN_PAPER, encoding="utf-8")
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
pdf_path = res["pdf_path"]
|
||||
assert pdf_path is not None
|
||||
assert os.path.exists(pdf_path)
|
||||
assert os.path.getsize(pdf_path) > 0
|
||||
|
||||
|
||||
def test_edge_sin_frontmatter_ni_figuras(tmp_path):
|
||||
"""Edge 1: cuerpo plano sin frontmatter ni figuras → genera PDF igual."""
|
||||
paper_dir = tmp_path / "plano"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(
|
||||
"Solo un cuerpo plano, sin frontmatter ni encabezados de nivel 1.\n"
|
||||
"Un par de líneas de texto corrido para que el motor lo pagine.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
|
||||
|
||||
def test_edge_path_inexistente_no_revienta():
|
||||
"""Edge 2: directorio inexistente → status error, sin crash, pdf_path None."""
|
||||
res = render_paper_pdf("/tmp/no_existe_xyz_123")
|
||||
|
||||
assert res["status"] == "error"
|
||||
assert res["pdf_path"] is None
|
||||
assert res["n_pages"] == 0
|
||||
assert "no encontrado" in (res["note"] or "")
|
||||
|
||||
|
||||
def test_edge_figura_inexistente_degrada(tmp_path):
|
||||
"""Edge 3: referencia a figura inexistente → el PDF se genera igual."""
|
||||
paper_dir = tmp_path / "con-figura"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(
|
||||
"---\n"
|
||||
"title: Paper Con Figura Rota\n"
|
||||
"---\n\n"
|
||||
"# Results\n\n"
|
||||
"Texto antes de la figura.\n\n"
|
||||
"\n\n"
|
||||
"Texto después de la figura.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
|
||||
|
||||
def test_acepta_ruta_directa_al_md(tmp_path):
|
||||
"""Acepta también la ruta directa a un paper.md (no solo el directorio)."""
|
||||
md = tmp_path / "paper.md"
|
||||
md.write_text("# Discussion\n\nCuerpo de la discusión.\n", encoding="utf-8")
|
||||
|
||||
res = render_paper_pdf(str(md))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
id: render_table_as_figure_py_datascience
|
||||
name: render_table_as_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_table_as_figure(header, rows, title=None, note=None, fontsize=9.0, max_cell_chars=40) -> \"matplotlib.figure.Figure\""
|
||||
description: "Dibuja un bloque tabular (cabecera + filas) como una matplotlib.figure.Figure nítida, lista para rasterizar a DPI alto. Pensada para tablas que NO caben como texto en una página/slide del informe EDA: se rasteriza a alta resolución (el caller usa dpi=220, bbox_inches='tight') y el usuario hace zoom en el móvil para leerla entera sin perder datos. Cabecera sombreada (#eef3f6) y en negrita, filas pares (1-based) con zebra suave (#f6f8fa), tinta oscura (#1b1b1b) sobre blanco, rejilla gris muy fina (#cccccc). Trunca cada celda a max_cell_chars con elipsis y str()-ea cada valor (None -> \"\"). figsize proporcional al contenido (ancho por nº y longitud de columnas, alto por nº de filas) para que sea legible con zoom. Backend Agg sin pyplot global. Defensiva: header/rows vacíos o None, filas irregulares o cualquier error interno devuelven una Figure placeholder con texto centrado \"(tabla no disponible)\". NUNCA lanza."
|
||||
tags: [eda, table, figure, matplotlib, visualization, rasterize, zoom, render, datascience, impure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib]
|
||||
example: |
|
||||
from datascience.render_table_as_figure import render_table_as_figure
|
||||
header = ["columna", "n_nulos", "%_nulos", "distintos", "tipo", "ejemplo"]
|
||||
rows = [
|
||||
["ingresos", 12, "1.2%", 980, "float64", "2345.67"],
|
||||
["edad", 0, "0.0%", 88, "int64", "37"],
|
||||
["ciudad", 5, "0.5%", 412, "object", "Madrid"],
|
||||
]
|
||||
fig = render_table_as_figure(header, rows, title="Resumen de columnas",
|
||||
note="rasteriza a dpi=220 y haz zoom")
|
||||
fig.savefig("/tmp/tabla.png", dpi=220, bbox_inches="tight")
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure_with_table"
|
||||
- "test_rows_none_does_not_raise"
|
||||
- "test_header_none_does_not_raise"
|
||||
- "test_empty_lists_return_placeholder_figure"
|
||||
- "test_both_none_return_placeholder_figure"
|
||||
- "test_long_cell_is_truncated"
|
||||
- "test_none_cells_become_empty_strings"
|
||||
- "test_can_rasterize_to_png_high_dpi"
|
||||
- "test_placeholder_can_rasterize"
|
||||
- "test_ragged_rows_are_padded"
|
||||
test_file_path: "python/functions/datascience/render_table_as_figure_test.py"
|
||||
file_path: "python/functions/datascience/render_table_as_figure.py"
|
||||
params:
|
||||
- name: header
|
||||
desc: "Lista de nombres de columna (puede ser [] o None). Cada nombre se str()-ea, se trunca a max_cell_chars y se pinta en la fila cabecera sombreada en negrita. Si está vacío/None no se dibuja fila de cabecera (solo cuerpo)."
|
||||
- name: rows
|
||||
desc: "Lista de filas; cada fila es una lista de celdas con valores cualesquiera (se str()-ean; None -> \"\"). Admite None (se trata como []), filas escalares (se envuelven en una celda) y filas de distinta longitud (la rejilla se rectangulariza al ancho máximo, rellenando con celdas vacías). Saltos de línea/tabs en una celda se colapsan a espacios para que no desborde a otras filas."
|
||||
- name: title
|
||||
desc: "Título opcional dibujado encima de la tabla, en negrita tinta #1b1b1b, alineado a la izquierda. None o \"\" => sin título. Default None."
|
||||
- name: note
|
||||
desc: "Nota opcional al pie de la figura, en gris #8a8a8a e itálica. None o \"\" => sin nota. Default None."
|
||||
- name: fontsize
|
||||
desc: "Tamaño de fuente base (pt) de las celdas del cuerpo. La cabecera usa fontsize+3 y la nota max(7, fontsize-1). Un valor no numérico o <= 0 cae a 9.0. Default 9.0."
|
||||
- name: max_cell_chars
|
||||
desc: "Trunca el texto de cada celda a este nº de chars (con … final cuando se recorta) para que el ancho no explote. Un valor no entero cae a 40; <= 0 deja las celdas vacías. Default 40."
|
||||
output: "Un matplotlib.figure.Figure (figsize proporcional al contenido: ancho ≈ 0.9-1.6\" por columna según su texto, total acotado a 3-26\"; alto ≈ 0.32\" por fila + cabecera + espacio para título/nota, acotado) con un Axes sin ejes que contiene un ax.table(...) NO cerrado. Cabecera fondo #eef3f6 texto #1b1b1b bold; filas pares (1-based) zebra #f6f8fa, impares blanco; tinta #1b1b1b; bordes/rejilla #cccccc lw 0.4; texto alineado a la izquierda. Título encima (bold) y nota debajo (gris itálica) si se pasan. Si header/rows son vacíos o None, o ante cualquier error interno, devuelve una Figure placeholder pequeña con el texto centrado \"(tabla no disponible)\". NUNCA lanza. El caller la rasteriza (dpi=220, bbox_inches='tight') y la cierra; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.render_table_as_figure import render_table_as_figure
|
||||
|
||||
# Tabla que no cabe como texto en la slide -> se rasteriza y se lee con zoom.
|
||||
header = ["columna", "n_nulos", "%_nulos", "distintos", "tipo", "ejemplo"]
|
||||
rows = [
|
||||
["ingresos", 12, "1.2%", 980, "float64", "2345.67"],
|
||||
["edad", 0, "0.0%", 88, "int64", "37"],
|
||||
["ciudad", 5, "0.5%", 412, "object", "Madrid"],
|
||||
["categoria_producto", 0, "0.0%", 1840, "object",
|
||||
"un_valor_categorico_muy_largo_que_se_trunca"],
|
||||
]
|
||||
|
||||
fig = render_table_as_figure(
|
||||
header,
|
||||
rows,
|
||||
title="Resumen de columnas",
|
||||
note="rasteriza a dpi=220 y haz zoom en el móvil",
|
||||
fontsize=9.0,
|
||||
max_cell_chars=40,
|
||||
)
|
||||
|
||||
# El renderer del informe lo rasteriza a alta resolución; aquí lo persistimos.
|
||||
fig.savefig("/tmp/tabla.png", dpi=220, bbox_inches="tight")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala en un informe EDA cuando una tabla **no cabe como texto** en una página o
|
||||
slide y prefieres una imagen nítida que el lector pueda ampliar en el móvil para
|
||||
leerla entera (perfiles de columnas, matrices de conteo, tablas de frecuencias
|
||||
con muchas filas o columnas anchas). Pásale la cabecera y las filas tal cual (los
|
||||
valores se `str()`-ean por ti) más un `title`/`note` opcionales; el llamante la
|
||||
rasteriza a `dpi=220` con `bbox_inches='tight'`. Es la pareja "tabla-como-imagen"
|
||||
de los gráficos `build_boxplots_figure` / `categorical_top_pie_figure`: misma
|
||||
paleta y mismo contrato (Agg, sin `pyplot`, el caller cierra la figura).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||
es thread-safe; esta función construye el `Figure` directamente, así que es
|
||||
segura de llamar en bucle desde el renderer.
|
||||
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
|
||||
guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
|
||||
- **Pensada para rasterizar a DPI alto.** El `figsize` es proporcional al
|
||||
contenido pero la legibilidad real viene del DPI: rasteriza con `dpi=220` y
|
||||
`bbox_inches='tight'`. Una tabla con muchísimas filas crece en alto (capado a
|
||||
~60") — para miles de filas, parte la tabla o resume antes de pasarla.
|
||||
- **Truncación de celda visible.** Cada celda se recorta a `max_cell_chars`
|
||||
(default 40) con `…` final y los saltos de línea/tabs se colapsan a espacios,
|
||||
para que ninguna celda desborde a otras filas. Sube `max_cell_chars` si
|
||||
necesitas ver el valor completo (a costa de ancho).
|
||||
- **Defensiva, nunca lanza.** `header`/`rows` vacíos o `None`, filas escalares,
|
||||
filas de distinta longitud o cualquier error interno se manejan sin propagar:
|
||||
en el peor caso devuelve una `Figure` placeholder con "(tabla no disponible)".
|
||||
No envuelvas la llamada en try/except por miedo a un raise — no lo hay.
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Impure EDA helper: a crisp table rendered as a matplotlib Figure (`eda` group).
|
||||
|
||||
Draws a tabular block (header + rows) as a sharp ``matplotlib.figure.Figure``
|
||||
ready to be rasterized at high DPI, so a table that does NOT fit as text on a
|
||||
page/slide can still be read in full by zooming into the rasterized image on a
|
||||
phone. The header is shaded and bold, even rows carry a soft zebra stripe, the
|
||||
ink is dark on white and the grid is very thin.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||
global state and is safe to call repeatedly from a report renderer. It is fully
|
||||
defensive and NEVER raises: empty/invalid input or any internal error returns a
|
||||
small placeholder figure carrying a centered "(tabla no disponible)".
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
# Palette shared with the EDA report renderer so the document stays coherent.
|
||||
_HEADER_BG = "#eef3f6" # header cell background.
|
||||
_HEADER_TEXT = "#1b1b1b" # header cell text (bold).
|
||||
_ZEBRA_BG = "#f6f8fa" # even (1-based) row background stripe.
|
||||
_BODY_BG = "#ffffff" # odd row background.
|
||||
_INK = "#1b1b1b" # body text + title ink.
|
||||
_GRID = "#cccccc" # cell borders / grid (thin).
|
||||
_NOTE_TEXT = "#8a8a8a" # muted gray for the note (italic).
|
||||
|
||||
|
||||
def _placeholder_figure(message: str = "(tabla no disponible)") -> "Figure":
|
||||
"""Return a small fallback ``Figure`` carrying a single centered message."""
|
||||
fig = Figure(figsize=(6.0, 1.6), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=11,
|
||||
color=_NOTE_TEXT,
|
||||
style="italic",
|
||||
wrap=True,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def _cell_text(value, max_cell_chars: int) -> str:
|
||||
"""``str()`` a cell value defensively, None -> "", truncate with an ellipsis."""
|
||||
s = "" if value is None else str(value)
|
||||
# Collapse newlines/tabs so a single cell never spills across table rows.
|
||||
s = s.replace("\n", " ").replace("\r", " ").replace("\t", " ")
|
||||
try:
|
||||
limit = int(max_cell_chars)
|
||||
except (TypeError, ValueError):
|
||||
limit = 40
|
||||
if limit <= 0:
|
||||
return ""
|
||||
if len(s) <= limit:
|
||||
return s
|
||||
if limit == 1:
|
||||
return "…"
|
||||
return s[: limit - 1] + "…"
|
||||
|
||||
|
||||
def render_table_as_figure(
|
||||
header,
|
||||
rows,
|
||||
title=None,
|
||||
note=None,
|
||||
fontsize=9.0,
|
||||
max_cell_chars=40,
|
||||
):
|
||||
"""Dibuja una tabla nítida como matplotlib.figure.Figure, lista para rasterizar a DPI alto.
|
||||
|
||||
Pensada para tablas que NO caben como texto en una página/slide: se rasteriza
|
||||
a alta resolución y el usuario hace zoom en el móvil para leerla entera sin
|
||||
perder datos. Cabecera sombreada + negrita, filas pares con zebra suave,
|
||||
tinta oscura sobre blanco, rejilla muy fina.
|
||||
|
||||
Args:
|
||||
header: lista de nombres de columna (puede ser []).
|
||||
rows: lista de filas; cada fila es una lista de celdas (valores cualquiera, se str()-ean).
|
||||
title: título opcional dibujado encima de la tabla (o None).
|
||||
note: nota opcional en gris/itálica bajo la tabla (o None).
|
||||
fontsize: tamaño de fuente base (pt) de las celdas.
|
||||
max_cell_chars: trunca el texto de celda a este nº de chars (con … final) para que no explote el ancho.
|
||||
|
||||
Returns:
|
||||
matplotlib.figure.Figure — NO cerrada (el llamante la rasteriza y la cierra).
|
||||
Nunca lanza: ante cualquier error devuelve una Figure con el texto "(tabla no disponible)".
|
||||
"""
|
||||
try:
|
||||
# --- Defensive normalization of header/rows into a rectangular grid.
|
||||
header_list = list(header) if isinstance(header, (list, tuple)) else []
|
||||
raw_rows = list(rows) if isinstance(rows, (list, tuple)) else []
|
||||
|
||||
clean_rows = []
|
||||
for row in raw_rows:
|
||||
if isinstance(row, (list, tuple)):
|
||||
clean_rows.append(list(row))
|
||||
elif row is None:
|
||||
clean_rows.append([])
|
||||
else:
|
||||
# A scalar row becomes a single-cell row instead of being dropped.
|
||||
clean_rows.append([row])
|
||||
|
||||
# Nothing to draw at all -> placeholder.
|
||||
if not header_list and not clean_rows:
|
||||
return _placeholder_figure()
|
||||
|
||||
# Number of columns = widest of header / any row.
|
||||
n_cols = len(header_list)
|
||||
for row in clean_rows:
|
||||
if len(row) > n_cols:
|
||||
n_cols = len(row)
|
||||
if n_cols <= 0:
|
||||
return _placeholder_figure()
|
||||
|
||||
# Base font size, tolerate a bad value.
|
||||
try:
|
||||
base_fs = float(fontsize)
|
||||
except (TypeError, ValueError):
|
||||
base_fs = 9.0
|
||||
if base_fs <= 0:
|
||||
base_fs = 9.0
|
||||
|
||||
# --- Build the truncated, padded text matrix.
|
||||
header_cells = [
|
||||
_cell_text(header_list[c] if c < len(header_list) else "", max_cell_chars)
|
||||
for c in range(n_cols)
|
||||
]
|
||||
body_cells = []
|
||||
for row in clean_rows:
|
||||
body_cells.append(
|
||||
[
|
||||
_cell_text(row[c] if c < len(row) else "", max_cell_chars)
|
||||
for c in range(n_cols)
|
||||
]
|
||||
)
|
||||
|
||||
has_header = any(t for t in header_cells)
|
||||
n_body = len(body_cells)
|
||||
# Total drawn table rows (header counts as one when present).
|
||||
n_table_rows = n_body + (1 if has_header else 0)
|
||||
if n_table_rows <= 0:
|
||||
return _placeholder_figure()
|
||||
|
||||
# --- figsize proportional to content so it reads under zoom.
|
||||
# Width: per-column width scales with the longest text in that column,
|
||||
# clamped to a sensible per-column range, total capped.
|
||||
per_col_widths = []
|
||||
for c in range(n_cols):
|
||||
col_texts = [header_cells[c]] if has_header else []
|
||||
col_texts += [body_cells[r][c] for r in range(n_body)]
|
||||
longest = max((len(t) for t in col_texts), default=0)
|
||||
# ~0.085" per char at the base font, clamped to [0.9, 1.6] inches.
|
||||
w = 0.9 + 0.085 * max(longest - 6, 0)
|
||||
w = max(0.9, min(1.6, w))
|
||||
per_col_widths.append(w)
|
||||
fig_w = sum(per_col_widths)
|
||||
fig_w = max(3.0, min(26.0, fig_w))
|
||||
|
||||
# Height: ~0.32" per row + room for title / note.
|
||||
fig_h = 0.32 * n_table_rows + 0.30
|
||||
if title is not None and str(title) != "":
|
||||
fig_h += 0.45
|
||||
if note is not None and str(note) != "":
|
||||
fig_h += 0.30
|
||||
fig_h = max(1.0, min(60.0, fig_h))
|
||||
|
||||
fig = Figure(figsize=(fig_w, fig_h), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
ax.axis("off")
|
||||
|
||||
# Reserve vertical bands for the optional title (top) and note (bottom)
|
||||
# so the table itself never overlaps them.
|
||||
title_band = 0.10 if (title is not None and str(title) != "") else 0.0
|
||||
note_band = 0.07 if (note is not None and str(note) != "") else 0.0
|
||||
table_bbox = [0.0, note_band, 1.0, max(0.05, 1.0 - title_band - note_band)]
|
||||
|
||||
cell_text = ([header_cells] if has_header else []) + body_cells
|
||||
|
||||
col_widths = [w / fig_w for w in per_col_widths]
|
||||
|
||||
table = ax.table(
|
||||
cellText=cell_text,
|
||||
colWidths=col_widths,
|
||||
cellLoc="left",
|
||||
loc="center",
|
||||
bbox=table_bbox,
|
||||
)
|
||||
table.auto_set_font_size(False)
|
||||
table.set_fontsize(base_fs)
|
||||
|
||||
# --- Style every cell: zebra body, shaded bold header, thin gray grid.
|
||||
for (r, _c), cell in table.get_celld().items():
|
||||
cell.set_edgecolor(_GRID)
|
||||
cell.set_linewidth(0.4)
|
||||
# Small horizontal padding so text does not touch the border.
|
||||
cell.PAD = 0.04
|
||||
if has_header and r == 0:
|
||||
cell.set_facecolor(_HEADER_BG)
|
||||
cell.set_text_props(color=_HEADER_TEXT, fontweight="bold", ha="left")
|
||||
else:
|
||||
body_index = r - 1 if has_header else r # 0-based body row.
|
||||
# 1-based even rows get the zebra stripe.
|
||||
is_even = ((body_index + 1) % 2) == 0
|
||||
cell.set_facecolor(_ZEBRA_BG if is_even else _BODY_BG)
|
||||
cell.set_text_props(color=_INK, ha="left")
|
||||
|
||||
if title is not None and str(title) != "":
|
||||
ax.set_title(
|
||||
str(title),
|
||||
fontsize=base_fs + 3.0,
|
||||
fontweight="bold",
|
||||
color=_INK,
|
||||
loc="left",
|
||||
pad=8,
|
||||
)
|
||||
|
||||
if note is not None and str(note) != "":
|
||||
fig.text(
|
||||
0.01,
|
||||
0.01,
|
||||
str(note),
|
||||
ha="left",
|
||||
va="bottom",
|
||||
fontsize=max(7.0, base_fs - 1.0),
|
||||
color=_NOTE_TEXT,
|
||||
style="italic",
|
||||
)
|
||||
|
||||
return fig
|
||||
except Exception: # noqa: BLE001 — never raise from a figure builder.
|
||||
return _placeholder_figure()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests para render_table_as_figure (tabla nítida como Figure, grupo eda).
|
||||
|
||||
Usa el backend Agg sin display; no muestra ni guarda figuras a disco salvo a un
|
||||
BytesIO en memoria. Cada test cierra explícitamente la Figure construida
|
||||
(matplotlib.pyplot.close) para no acumular estado entre tests.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from render_table_as_figure import render_table_as_figure
|
||||
|
||||
|
||||
def _grid(n_cols, n_rows):
|
||||
"""Cabecera de n_cols columnas + n_rows filas de celdas."""
|
||||
header = [f"col_{c}" for c in range(n_cols)]
|
||||
rows = [[f"r{r}c{c}" for c in range(n_cols)] for r in range(n_rows)]
|
||||
return header, rows
|
||||
|
||||
|
||||
def test_returns_figure_with_table():
|
||||
header, rows = _grid(6, 5)
|
||||
fig = render_table_as_figure(header, rows, title="Tabla", note="nota al pie")
|
||||
assert isinstance(fig, Figure)
|
||||
# Hay al menos un Axes y ese Axes contiene una tabla con celdas.
|
||||
assert len(fig.axes) >= 1
|
||||
ax = fig.axes[0]
|
||||
assert len(ax.tables) >= 1
|
||||
# 6 columnas x (1 cabecera + 5 filas) = 36 celdas.
|
||||
assert len(ax.tables[0].get_celld()) == 6 * (5 + 1)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_rows_none_does_not_raise():
|
||||
fig = render_table_as_figure(["a", "b"], None)
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_header_none_does_not_raise():
|
||||
fig = render_table_as_figure(None, [["x", "y"], ["z", "w"]])
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_lists_return_placeholder_figure():
|
||||
fig = render_table_as_figure([], [])
|
||||
assert isinstance(fig, Figure)
|
||||
# Placeholder: un Axes con texto, sin tabla.
|
||||
assert len(fig.axes) >= 1
|
||||
assert len(fig.axes[0].tables) == 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_both_none_return_placeholder_figure():
|
||||
fig = render_table_as_figure(None, None)
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes[0].tables) == 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_long_cell_is_truncated():
|
||||
long_value = "x" * 200
|
||||
header, _ = _grid(2, 0)
|
||||
fig = render_table_as_figure(header, [[long_value, "ok"]], max_cell_chars=20)
|
||||
assert isinstance(fig, Figure)
|
||||
ax = fig.axes[0]
|
||||
texts = [c.get_text().get_text() for c in ax.tables[0].get_celld().values()]
|
||||
# La celda larga aparece truncada con elipsis y nunca en su forma completa.
|
||||
assert any(t.endswith("…") and len(t) <= 20 for t in texts)
|
||||
assert long_value not in texts
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_none_cells_become_empty_strings():
|
||||
fig = render_table_as_figure(["a", "b"], [[None, "v"], ["w", None]])
|
||||
assert isinstance(fig, Figure)
|
||||
ax = fig.axes[0]
|
||||
texts = [c.get_text().get_text() for c in ax.tables[0].get_celld().values()]
|
||||
# Hay celdas vacías (los None) y celdas con valor.
|
||||
assert "" in texts
|
||||
assert "v" in texts
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_can_rasterize_to_png_high_dpi():
|
||||
header, rows = _grid(6, 8)
|
||||
fig = render_table_as_figure(header, rows, title="Render", note="zoom me")
|
||||
buf = BytesIO()
|
||||
# No debe lanzar al rasterizar a DPI alto con bbox tight.
|
||||
fig.savefig(buf, format="png", dpi=220, bbox_inches="tight")
|
||||
assert buf.getbuffer().nbytes > 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_placeholder_can_rasterize():
|
||||
fig = render_table_as_figure([], [])
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=220, bbox_inches="tight")
|
||||
assert buf.getbuffer().nbytes > 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_ragged_rows_are_padded():
|
||||
# Filas de distinta longitud: la rejilla se rectangulariza al ancho máximo.
|
||||
fig = render_table_as_figure(["a", "b", "c"], [["1"], ["1", "2", "3", "4"]])
|
||||
assert isinstance(fig, Figure)
|
||||
ax = fig.axes[0]
|
||||
# 4 columnas (la fila más ancha) x (1 cabecera + 2 filas) = 12 celdas.
|
||||
assert len(ax.tables[0].get_celld()) == 4 * (2 + 1)
|
||||
plt.close(fig)
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: scrape_gumroad_discover
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def scrape_gumroad_discover(taxonomy: str, sort: str = 'best_selling', max_products: int = 300, page_size: int = 100) -> list[dict]"
|
||||
description: "Scrapea el marketplace publico de Gumroad Discover usando el endpoint JSON verificado gumroad.com/products/search (taxonomy+sort+from+size). Recolecta los productos de una taxonomy (nicho) ordenados por el criterio elegido y estampa en cada producto el total de la taxonomy (saturacion del nicho). Normaliza cada producto a un dict plano con id, seller_name, ratings, precio (cents/usd), pay-what-you-want/free, native_type, url y metadatos de scrape (taxonomy, total_in_taxonomy, sort_used, rank 0-based). Solo stdlib (urllib+json+time)."
|
||||
tags: [gumroad, scraping, market-intel, trends, datascience]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_normaliza_producto_a_dict_plano", "test_paginacion_para_al_agotar_ventana", "test_sort_invalido_lanza_valueerror", "test_body_no_json_lanza_runtimeerror"]
|
||||
test_file_path: "python/functions/datascience/scrape_gumroad_discover_test.py"
|
||||
file_path: "python/functions/datascience/scrape_gumroad_discover.py"
|
||||
params:
|
||||
- name: taxonomy
|
||||
desc: "Slug de taxonomy / nicho de Gumroad (ej. 'design', 'business-and-money', '3d'). Determina el segmento de mercado scrapeado y el valor total_in_taxonomy (numero total de productos = saturacion del nicho) que se estampa en cada producto."
|
||||
- name: sort
|
||||
desc: "Criterio de orden. Uno de: best_selling, most_reviewed, hot_and_new, highest_rated, newest, price_asc, price_desc. Cualquier otro valor lanza ValueError. Default 'best_selling'."
|
||||
- name: max_products
|
||||
desc: "Cota superior de productos a recolectar entre paginas. Default 300. La ventana de paginacion de Gumroad es finita (from~960 aun devuelve datos), asi que valores muy altos pueden recibir menos productos de los pedidos."
|
||||
- name: page_size
|
||||
desc: "Numero de productos pedidos por pagina via 'size'. Gumroad admite al menos 300. Una pagina que devuelve menos de page_size items señala el fin de la ventana y detiene la paginacion. Default 100."
|
||||
output: "Lista de dicts planos, uno por producto, con exactamente estas claves: id, permalink, name, seller_name, ratings_count, ratings_avg, price_cents, currency_code, price_usd (float = price_cents/100), is_pay_what_you_want (bool), is_free (bool = price_cents==0), native_type, url, taxonomy (el arg), total_in_taxonomy (el 'total' del JSON = saturacion del nicho), sort_used (el arg sort), rank (posicion 0-based en el orden devuelto)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.scrape_gumroad_discover import scrape_gumroad_discover
|
||||
|
||||
# Top best-sellers del nicho "design" en Gumroad Discover
|
||||
rows = scrape_gumroad_discover(taxonomy="design", sort="best_selling", max_products=300, page_size=100)
|
||||
print(len(rows), "productos")
|
||||
print("saturacion del nicho:", rows[0]["total_in_taxonomy"])
|
||||
print(rows[0])
|
||||
# {'id': '...', 'permalink': '...', 'name': '...', 'seller_name': '...',
|
||||
# 'ratings_count': 128, 'ratings_avg': 4.9, 'price_cents': 2900,
|
||||
# 'currency_code': 'usd', 'price_usd': 29.0, 'is_pay_what_you_want': False,
|
||||
# 'is_free': False, 'native_type': 'digital', 'url': 'https://...',
|
||||
# 'taxonomy': 'design', 'total_in_taxonomy': 4213, 'sort_used': 'best_selling', 'rank': 0}
|
||||
|
||||
# Productos mas nuevos de un nicho concreto
|
||||
nuevos = scrape_gumroad_discover(taxonomy="3d", sort="newest", max_products=50)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando quieras hacer market intelligence sobre productos digitales: descubrir que se vende mas en un nicho de Gumroad, medir la saturacion del nicho (`total_in_taxonomy`) y capturar precios, valoraciones y vendedores para decidir si un nicho merece la pena o esta saturado. Es la fuente de un pipeline de deteccion de oportunidades de producto digital (grupo `market-intel`): scrapea varias taxonomies/sorts, cruza los snapshots y prioriza nichos con demanda alta y competencia manejable. Llamala antes de cualquier analisis de catalogo digital; el dict devuelto es plano y esta listo para insertar en una tabla tras añadir `snapshot_date`/`scraped_at`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **ratings.count son REVIEWS, no ventas**: `ratings_count` cuenta valoraciones dejadas, NO unidades vendidas. Como proxy de ventas hay que multiplicar por un factor incierto (solo una fraccion de compradores valora, y esa fraccion varia por nicho/precio). Trata `ratings_count` como un limite inferior ruidoso de la demanda, no como ventas reales.
|
||||
- **price=0 no siempre significa gratis util**: `price_cents==0` marca `is_free=True`, pero puede tratarse de un producto pay-what-you-want (`is_pay_what_you_want=True`) con minimo 0, no de un regalo. Cruza siempre `is_free` con `is_pay_what_you_want` antes de sacar conclusiones de precio.
|
||||
- **Ventana de paginacion finita**: `page`/`per_page` se IGNORAN (siempre devuelven desde 0); solo `from`+`size` paginan. La ventana es amplia pero finita (from~960 aun devuelve, mas alla se agota). Pedir `max_products` muy alto puede recibir menos productos de los pedidos: la funcion para cuando una pagina devuelve menos de `page_size` items.
|
||||
- **Cloudflare bloquea sin UA de navegador**: el endpoint exige `Accept: application/json` y un `User-Agent` de navegador. Sin ello Gumroad/Cloudflare puede devolver una pagina de challenge en HTML (no JSON) o redirigir. La funcion ya envia un UA de Chrome; si aun asi recibe un body no-JSON lanza `RuntimeError` claro — en ese caso cae al navegador del ecosistema (browser MCP / CDP).
|
||||
- **Moneda no siempre USD**: `price_usd` es solo `price_cents/100` por conveniencia; si `currency_code != 'usd'` el valor NO esta convertido a dolares. Conserva y usa `currency_code` para convertir tu mismo.
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Scrape the public Gumroad Discover marketplace for niche/market intelligence.
|
||||
|
||||
Uses Gumroad's verified public JSON search endpoint
|
||||
|
||||
GET https://gumroad.com/products/search?taxonomy=<taxonomy>&sort=<sort>&from=<offset>&size=<n>
|
||||
|
||||
to collect the products of a taxonomy (niche) sorted by a chosen criterion. The
|
||||
endpoint exposes, besides the product list, the ``total`` count of products in
|
||||
that taxonomy (a proxy for niche saturation) and ``tags_data`` (sub-niches with
|
||||
their own product counts). This scraper focuses on the product list and stamps
|
||||
each product with the taxonomy-level ``total`` so a downstream consumer can
|
||||
reason about saturation without a second request.
|
||||
|
||||
Only stdlib (``urllib``, ``json``, ``time``) is used — no heavy dependencies.
|
||||
The function is impure (it performs network I/O) and raises ``RuntimeError`` on
|
||||
HTTP / JSON failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
|
||||
_BASE_URL = "https://gumroad.com/products/search"
|
||||
|
||||
# A browser User-Agent is required: without it Gumroad / Cloudflare may reject
|
||||
# the request or redirect away from the JSON payload.
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
_VALID_SORTS = (
|
||||
"best_selling",
|
||||
"most_reviewed",
|
||||
"hot_and_new",
|
||||
"highest_rated",
|
||||
"newest",
|
||||
"price_asc",
|
||||
"price_desc",
|
||||
)
|
||||
|
||||
|
||||
def _build_headers() -> dict:
|
||||
"""Headers Gumroad needs to serve the JSON search payload."""
|
||||
return {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
# Request an uncompressed body: urllib does not transparently inflate
|
||||
# gzip/deflate, and Cloudflare serves gzip when a browser UA is present.
|
||||
# Asking for identity keeps the payload as plain JSON. A defensive
|
||||
# inflate in _fetch_json covers the case where Cloudflare ignores this.
|
||||
"Accept-Encoding": "identity",
|
||||
"Connection": "keep-alive",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
|
||||
def _build_url(taxonomy: str, sort: str, offset: int, size: int) -> str:
|
||||
"""Compose the Discover search URL for a page window.
|
||||
|
||||
Note: Gumroad ignores ``page``/``per_page`` (they always return from 0).
|
||||
Only ``from`` (offset) + ``size`` paginate.
|
||||
"""
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"taxonomy": taxonomy,
|
||||
"sort": sort,
|
||||
"from": offset,
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
return f"{_BASE_URL}?{query}"
|
||||
|
||||
|
||||
def _fetch_json(url: str, headers: dict, timeout: int) -> dict:
|
||||
"""GET the URL and decode the JSON body. Raises RuntimeError on failure."""
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
# Defensive inflate: Cloudflare may still return a gzip/deflate body
|
||||
# (magic bytes 1f 8b for gzip) even when we ask for identity.
|
||||
encoding = (resp.headers.get("Content-Encoding") or "").lower()
|
||||
if "gzip" in encoding or raw[:2] == b"\x1f\x8b":
|
||||
raw = gzip.decompress(raw)
|
||||
elif "deflate" in encoding:
|
||||
raw = zlib.decompress(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(
|
||||
f"Gumroad search HTTP {exc.code} for {url}: {exc.reason}. "
|
||||
"Cloudflare may be blocking the request; ensure a browser "
|
||||
"User-Agent is sent, or fall back to the browser MCP/CDP path."
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Gumroad search request to {url} failed: {exc.reason}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Gumroad search returned non-JSON body for {url}: {exc}. "
|
||||
"A browser User-Agent is required; a Cloudflare challenge page "
|
||||
"is returned as HTML, not JSON."
|
||||
) from exc
|
||||
|
||||
|
||||
def _normalize_product(
|
||||
product: dict,
|
||||
taxonomy: str,
|
||||
total_in_taxonomy: int,
|
||||
sort: str,
|
||||
rank: int,
|
||||
) -> dict:
|
||||
"""Flatten a raw Gumroad product into the flat dict contract."""
|
||||
seller = product.get("seller") or {}
|
||||
ratings = product.get("ratings") or {}
|
||||
price_cents = product.get("price_cents")
|
||||
if not isinstance(price_cents, int):
|
||||
price_cents = 0
|
||||
currency_code = product.get("currency_code")
|
||||
|
||||
return {
|
||||
"id": product.get("id"),
|
||||
"permalink": product.get("permalink"),
|
||||
"name": product.get("name"),
|
||||
"seller_name": seller.get("name"),
|
||||
"ratings_count": ratings.get("count"),
|
||||
"ratings_avg": ratings.get("average"),
|
||||
"price_cents": price_cents,
|
||||
"currency_code": currency_code,
|
||||
# price_usd is a convenience float (cents/100). If the currency is not
|
||||
# USD we keep the numeric value but preserve currency_code so the
|
||||
# consumer can convert/decide.
|
||||
"price_usd": price_cents / 100.0,
|
||||
"is_pay_what_you_want": bool(product.get("is_pay_what_you_want")),
|
||||
"is_free": price_cents == 0,
|
||||
"native_type": product.get("native_type"),
|
||||
"url": product.get("url"),
|
||||
"taxonomy": taxonomy,
|
||||
"total_in_taxonomy": total_in_taxonomy,
|
||||
"sort_used": sort,
|
||||
"rank": rank,
|
||||
}
|
||||
|
||||
|
||||
def scrape_gumroad_discover(
|
||||
taxonomy: str,
|
||||
sort: str = "best_selling",
|
||||
max_products: int = 300,
|
||||
page_size: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Scrape the public Gumroad Discover marketplace for a taxonomy (niche).
|
||||
|
||||
Paginates the verified Gumroad search endpoint with ``from``+``size`` until
|
||||
``max_products`` are collected or a page returns fewer than ``page_size``
|
||||
items (end of window). Each product is normalized to a flat dict carrying
|
||||
the taxonomy-level ``total`` (niche saturation), the sort used and the
|
||||
0-based rank in the returned order.
|
||||
|
||||
Args:
|
||||
taxonomy: Gumroad taxonomy slug / niche, e.g. ``"design"``,
|
||||
``"business-and-money"``, ``"3d"``. Determines the market segment
|
||||
scraped and the ``total_in_taxonomy`` reported on every product.
|
||||
sort: One of ``best_selling, most_reviewed, hot_and_new,
|
||||
highest_rated, newest, price_asc, price_desc``. Any other value
|
||||
raises ``ValueError``.
|
||||
max_products: Upper bound on how many products to collect across pages.
|
||||
Gumroad's pagination window is finite (from~960 still returns), so
|
||||
very high values may hit fewer results than requested.
|
||||
page_size: Items requested per page via ``size``. Gumroad accepts at
|
||||
least 300; a page returning fewer than this signals the end.
|
||||
|
||||
Returns:
|
||||
A list of flat dicts, one per product, with exactly these keys:
|
||||
``id, permalink, name, seller_name, ratings_count, ratings_avg,
|
||||
price_cents, currency_code, price_usd, is_pay_what_you_want, is_free,
|
||||
native_type, url, taxonomy, total_in_taxonomy, sort_used, rank``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``sort`` is not one of the allowed values, or if
|
||||
``max_products``/``page_size`` are not positive.
|
||||
RuntimeError: On network failure, non-2xx HTTP, or a non-JSON body
|
||||
(typically a Cloudflare challenge served without a browser UA).
|
||||
"""
|
||||
if sort not in _VALID_SORTS:
|
||||
raise ValueError(
|
||||
f"sort must be one of {_VALID_SORTS}, got {sort!r}"
|
||||
)
|
||||
if max_products <= 0:
|
||||
raise ValueError(f"max_products must be positive, got {max_products}")
|
||||
if page_size <= 0:
|
||||
raise ValueError(f"page_size must be positive, got {page_size}")
|
||||
|
||||
headers = _build_headers()
|
||||
results: list[dict] = []
|
||||
total_in_taxonomy = 0
|
||||
offset = 0
|
||||
|
||||
while len(results) < max_products:
|
||||
# Never ask for more than we still need on the last page.
|
||||
size = min(page_size, max_products - len(results))
|
||||
url = _build_url(taxonomy, sort, offset, page_size)
|
||||
payload = _fetch_json(url, headers, timeout=20)
|
||||
|
||||
# The taxonomy-level total is stamped on every product; capture it once.
|
||||
total_val = payload.get("total")
|
||||
if isinstance(total_val, int):
|
||||
total_in_taxonomy = total_val
|
||||
|
||||
products = payload.get("products") or []
|
||||
if not products:
|
||||
break
|
||||
|
||||
for product in products:
|
||||
if len(results) >= max_products:
|
||||
break
|
||||
rank = len(results) # 0-based position across the whole scrape
|
||||
results.append(
|
||||
_normalize_product(
|
||||
product,
|
||||
taxonomy=taxonomy,
|
||||
total_in_taxonomy=total_in_taxonomy,
|
||||
sort=sort,
|
||||
rank=rank,
|
||||
)
|
||||
)
|
||||
|
||||
# A short page means we exhausted the window: stop.
|
||||
if len(products) < page_size:
|
||||
break
|
||||
|
||||
offset += page_size
|
||||
# Be polite between requests so we don't hammer Gumroad.
|
||||
time.sleep(0.4)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Tests para scrape_gumroad_discover.
|
||||
|
||||
Mockean urllib.request.urlopen para NO hacer red: se inyecta un cuerpo JSON de
|
||||
Gumroad con productos de ejemplo y se verifica la normalizacion a dict plano, el
|
||||
corte de la paginacion, la validacion de sort y el manejo de un body no-JSON
|
||||
(escenario tipico de challenge de Cloudflare). El scrape real no se testea aqui.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from scrape_gumroad_discover import scrape_gumroad_discover
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Context manager que imita la respuesta de urllib.request.urlopen."""
|
||||
|
||||
def __init__(self, raw: bytes, headers: dict | None = None):
|
||||
self._raw = raw
|
||||
# urllib response exposes .headers; the scraper reads Content-Encoding
|
||||
# from it to decide whether to inflate the body.
|
||||
self.headers = headers or {}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._raw
|
||||
|
||||
|
||||
def _json_response(payload: dict) -> _FakeResponse:
|
||||
return _FakeResponse(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
|
||||
# Dos productos de ejemplo con la estructura real verificada de Gumroad.
|
||||
_SAMPLE_PRODUCTS = [
|
||||
{
|
||||
"id": "prod_1",
|
||||
"permalink": "coolkit",
|
||||
"name": "Cool Design Kit",
|
||||
"seller": {"id": "s1", "name": "Alice Design", "avatar_url": "http://a"},
|
||||
"ratings": {"count": 128, "average": 4.9},
|
||||
"thumbnail_url": "http://thumb1",
|
||||
"native_type": "digital",
|
||||
"price_cents": 2900,
|
||||
"currency_code": "usd",
|
||||
"is_pay_what_you_want": False,
|
||||
"url": "https://alice.gumroad.com/l/coolkit",
|
||||
"description": "A kit",
|
||||
},
|
||||
{
|
||||
"id": "prod_2",
|
||||
"permalink": "freebie",
|
||||
"name": "Free Font Pack",
|
||||
"seller": {"id": "s2", "name": "Bob Type"},
|
||||
"ratings": {"count": 0, "average": 0.0},
|
||||
"native_type": "digital",
|
||||
"price_cents": 0,
|
||||
"currency_code": "eur",
|
||||
"is_pay_what_you_want": True,
|
||||
"url": "https://bob.gumroad.com/l/freebie",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_normaliza_producto_a_dict_plano(monkeypatch):
|
||||
payload = {"total": 4213, "tags_data": [], "products": _SAMPLE_PRODUCTS}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _json_response(payload)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
rows = scrape_gumroad_discover(taxonomy="design", sort="best_selling", max_products=10, page_size=100)
|
||||
|
||||
assert len(rows) == 2
|
||||
|
||||
first = rows[0]
|
||||
# Estructura plana exacta.
|
||||
assert set(first.keys()) == {
|
||||
"id", "permalink", "name", "seller_name", "ratings_count", "ratings_avg",
|
||||
"price_cents", "currency_code", "price_usd", "is_pay_what_you_want",
|
||||
"is_free", "native_type", "url", "taxonomy", "total_in_taxonomy",
|
||||
"sort_used", "rank",
|
||||
}
|
||||
assert first["id"] == "prod_1"
|
||||
assert first["name"] == "Cool Design Kit"
|
||||
assert first["seller_name"] == "Alice Design" # anidado -> plano
|
||||
assert first["ratings_count"] == 128
|
||||
assert first["ratings_avg"] == 4.9
|
||||
assert first["price_cents"] == 2900
|
||||
assert first["price_usd"] == 29.0 # cents/100
|
||||
assert first["currency_code"] == "usd"
|
||||
assert first["is_pay_what_you_want"] is False
|
||||
assert first["is_free"] is False
|
||||
assert first["native_type"] == "digital"
|
||||
assert first["taxonomy"] == "design" # el arg
|
||||
assert first["total_in_taxonomy"] == 4213 # el total del JSON
|
||||
assert first["sort_used"] == "best_selling"
|
||||
assert first["rank"] == 0
|
||||
|
||||
# Segundo producto: gratis / pay-what-you-want, moneda no-usd conservada.
|
||||
second = rows[1]
|
||||
assert second["price_cents"] == 0
|
||||
assert second["price_usd"] == 0.0
|
||||
assert second["is_free"] is True
|
||||
assert second["is_pay_what_you_want"] is True
|
||||
assert second["currency_code"] == "eur" # se conserva, no se convierte
|
||||
assert second["rank"] == 1
|
||||
|
||||
|
||||
def test_paginacion_para_al_agotar_ventana(monkeypatch):
|
||||
# page_size=2 y una sola pagina con 2 productos: como len(products) == page_size
|
||||
# se intentaria otra pagina; la segunda devuelve products vacios -> corta.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return _json_response({"total": 2, "products": _SAMPLE_PRODUCTS})
|
||||
return _json_response({"total": 2, "products": []})
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
monkeypatch.setattr("time.sleep", lambda *_: None) # no dormir en test
|
||||
|
||||
rows = scrape_gumroad_discover(taxonomy="design", max_products=100, page_size=2)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert call_count["n"] == 2 # pidio segunda pagina, vino vacia, paro
|
||||
assert [r["rank"] for r in rows] == [0, 1]
|
||||
|
||||
|
||||
def test_sort_invalido_lanza_valueerror(monkeypatch):
|
||||
# No debe llegar a hacer red: falla en validacion antes.
|
||||
def fake_urlopen(req, timeout=None):
|
||||
raise AssertionError("no deberia hacer red con sort invalido")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
with pytest.raises(ValueError, match="sort must be one of"):
|
||||
scrape_gumroad_discover(taxonomy="design", sort="trending")
|
||||
|
||||
|
||||
def test_body_gzip_se_descomprime(monkeypatch):
|
||||
# Cloudflare puede servir el JSON gzip-comprimido aunque se pida identity.
|
||||
# El scraper debe inflar el cuerpo (magic bytes 1f 8b) y parsear el JSON.
|
||||
import gzip as _gzip
|
||||
|
||||
payload = {"total": 7, "products": _SAMPLE_PRODUCTS}
|
||||
gz = _gzip.compress(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResponse(gz, headers={"Content-Encoding": "gzip"})
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
monkeypatch.setattr("time.sleep", lambda *_: None)
|
||||
|
||||
rows = scrape_gumroad_discover(taxonomy="design", max_products=10, page_size=100)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["name"] == "Cool Design Kit"
|
||||
assert rows[0]["total_in_taxonomy"] == 7
|
||||
|
||||
|
||||
def test_body_no_json_lanza_runtimeerror(monkeypatch):
|
||||
# Cloudflare challenge: devuelve HTML, no JSON.
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResponse(b"<html><body>Just a moment...</body></html>")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
with pytest.raises(RuntimeError, match="non-JSON"):
|
||||
scrape_gumroad_discover(taxonomy="design", max_products=10, page_size=100)
|
||||
@@ -18,6 +18,7 @@ from .caldav_put_event import caldav_put_event
|
||||
from .dav_list_resources import dav_list_resources
|
||||
from .dav_get_resource import dav_get_resource
|
||||
from .dav_delete_resource import dav_delete_resource
|
||||
from .oo_bridge_send import oo_bridge_send
|
||||
from .pg_insert_rows import pg_insert_rows
|
||||
from .pg_apply_sql import pg_apply_sql
|
||||
from .pg_query import pg_query
|
||||
@@ -34,6 +35,7 @@ from .upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
from .duckdb_query_readonly import duckdb_query_readonly
|
||||
from .duckdb_execute import duckdb_execute
|
||||
from .duckdb_upsert import duckdb_upsert
|
||||
from .load_folder_to_duckdb import load_folder_to_duckdb
|
||||
from .imap_connect import imap_connect
|
||||
from .imap_list_mailboxes import imap_list_mailboxes
|
||||
from .imap_search import imap_search
|
||||
@@ -50,6 +52,7 @@ __all__ = [
|
||||
"upsert_xlsx_sheet",
|
||||
"duckdb_query_readonly",
|
||||
"duckdb_execute",
|
||||
"load_folder_to_duckdb",
|
||||
"duckdb_upsert",
|
||||
"pg_insert_rows",
|
||||
"pg_apply_sql",
|
||||
@@ -83,4 +86,5 @@ __all__ = [
|
||||
"dav_list_resources",
|
||||
"dav_get_resource",
|
||||
"dav_delete_resource",
|
||||
"oo_bridge_send",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: load_folder_to_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def load_folder_to_duckdb(folder: str, db_path: str = None, pattern: str = '*.csv,*.parquet,*.json') -> dict"
|
||||
description: "Escanea el primer nivel de una CARPETA buscando archivos tabulares (CSV/TSV/TXT, Parquet, JSON/NDJSON) y los carga como tablas en una base DuckDB usando los lectores nativos read_csv_auto/read_parquet/read_json_auto. Es la pieza de entrada del EDA a nivel de carpeta (grupo eda). Por cada archivo crea una tabla cuyo nombre se deriva del basename saneado a [0-9a-zA-Z_] en minusculas (prefijo t_ si empieza por digito, sufijos _2/_3 ante colisiones, tabla_<i> si queda vacio). El path se escapa (comilla simple '->'') antes de interpolarlo porque los lectores DuckDB no aceptan el path como parametro posicional. Glob NO recursivo: un glob.glob(os.path.join(folder, g)) por cada patron del CSV, dedup y ordenado. db_path=None genera una DuckDB temporal (mkstemp, se borra el placeholder vacio porque DuckDB rechaza un archivo de 0 bytes) y devuelve su ruta. Un fallo al cargar un archivo concreto no aborta el resto: se registra en errors y se continua. Devuelve siempre un dict sin lanzar (estilo del grupo duckdb): {status:'ok', db_path, tables, errors} en exito (carpeta sin archivos tabulares incluida, tables=[]) y {status:'error', error} cuando la carpeta no existe o falla algo global. Depende del paquete duckdb (1.5.2)."
|
||||
tags: [eda, duckdb, ingest, etl, folder]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [glob, os, re, tempfile, duckdb]
|
||||
params:
|
||||
- name: folder
|
||||
desc: "ruta a un directorio. Se escanea solo su primer nivel (NO recursivo). Si no existe o no es un directorio devuelve {status:'error'} sin lanzar."
|
||||
- name: db_path
|
||||
desc: "ruta del archivo DuckDB destino, abierto en modo read-write (lo crea si no existe). None (default) genera una DuckDB temporal unica con tempfile.mkstemp y devuelve su ruta en el campo db_path del retorno. DuckDB es single-writer: si otro proceso lo tiene abierto en escritura, connect falla con error de lock devuelto en el dict."
|
||||
- name: pattern
|
||||
desc: "CSV de globs separados por coma (default '*.csv,*.parquet,*.json'). Cada glob se aplica con glob.glob(os.path.join(folder, g)) sobre el primer nivel de folder; los resultados de todos los globs se deduplican y ordenan. Los globs con ** NO descienden recursivamente (glob.glob sin recursive=True)."
|
||||
output: "dict. En exito: {status:'ok', db_path:str (ruta DuckDB usada), tables:[{name:str, source_file:str, n_rows:int}], errors:[{name?:str, source_file:str, error:str}]}. La carpeta sin archivos tabulares es un exito con tables=[] y errors=[]. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_carga_dos_csv_como_tablas"
|
||||
- "test_db_path_none_crea_temporal"
|
||||
- "test_carpeta_vacia_es_ok_sin_tablas"
|
||||
- "test_carpeta_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/load_folder_to_duckdb_test.py"
|
||||
file_path: "python/functions/infra/load_folder_to_duckdb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.load_folder_to_duckdb import load_folder_to_duckdb
|
||||
|
||||
# Preparar una carpeta de demo con dos CSV.
|
||||
import os
|
||||
os.makedirs("/tmp/eda_folder_demo", exist_ok=True)
|
||||
with open("/tmp/eda_folder_demo/ventas.csv", "w") as f:
|
||||
f.write("id,total\n1,10.5\n2,20.0\n3,5.25\n")
|
||||
with open("/tmp/eda_folder_demo/clientes.csv", "w") as f:
|
||||
f.write("id,nombre\n1,ana\n2,luis\n")
|
||||
|
||||
# Cargar todos los tabulares de la carpeta a una DuckDB temporal.
|
||||
res = load_folder_to_duckdb("/tmp/eda_folder_demo")
|
||||
print(res["status"]) # ok
|
||||
print(res["db_path"]) # /tmp/tmpXXXXXXXX.duckdb (temporal)
|
||||
for t in res["tables"]:
|
||||
print(t["name"], t["n_rows"]) # ventas 3 / clientes 2
|
||||
|
||||
# Persistir en una DuckDB concreta y limitar a CSV.
|
||||
res2 = load_folder_to_duckdb(
|
||||
"/tmp/eda_folder_demo",
|
||||
db_path="/tmp/eda_folder_demo/folder.duckdb",
|
||||
pattern="*.csv",
|
||||
)
|
||||
print(res2["tables"]) # [{'name': 'clientes', ...}, {'name': 'ventas', ...}]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes una carpeta de datos sueltos (un dump, un export, varios CSV/Parquet
|
||||
descargados) y quieres analizarlos juntos con SQL sin montar la ingesta a mano,
|
||||
archivo por archivo. Es el primer eslabon del EDA a nivel de carpeta (grupo `eda`):
|
||||
deja una DuckDB con una tabla por archivo, lista para perfilar con
|
||||
`duckdb_table_schema_py_infra`, consultar con `duckdb_query_readonly_py_infra`, o
|
||||
correlacionar aguas abajo. Usala antes de cualquier paso de perfilado cuando la
|
||||
unidad de trabajo es "todos los archivos de este directorio".
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Glob NO recursivo**: solo se escanea el primer nivel de `folder`. Archivos en
|
||||
subdirectorios se ignoran (ni siquiera con `**` en el patron, porque
|
||||
`glob.glob` se llama sin `recursive=True`). Si necesitas recursion, aplana la
|
||||
carpeta antes o amplia la funcion.
|
||||
- **Saneo de nombres de tabla**: el basename se reduce a `[0-9a-zA-Z_]` en
|
||||
minusculas. `Ventas 2024.csv` -> tabla `ventas_2024`. Dos archivos distintos
|
||||
pueden sanear al mismo nombre (`a-b.csv` y `a_b.csv`); el segundo se desambigua
|
||||
con sufijo `_2`, `_3`, ... El mapeo real archivo->tabla esta en `tables[].name`
|
||||
/ `tables[].source_file`, no lo asumas.
|
||||
- **`read_json_auto` requiere JSON tabular** (array de objetos u objetos NDJSON
|
||||
homogeneos). Un JSON anidado o irregular puede fallar la carga de ESA tabla; el
|
||||
error se registra en `errors` y el resto de archivos siguen cargandose.
|
||||
- **Extension desconocida = se salta**, no falla: queda anotada en `errors` con
|
||||
`unsupported extension`. Mapeo de lectores: `.csv/.tsv/.txt`->`read_csv_auto`,
|
||||
`.parquet/.pq`->`read_parquet`, `.json/.ndjson`->`read_json_auto`.
|
||||
- **Escritura real en disco (impura)**. DuckDB es single-writer: si otro proceso
|
||||
tiene `db_path` abierto en escritura, `connect` falla con error de lock devuelto
|
||||
en el dict. Un `db_path` con un directorio padre inexistente tambien falla.
|
||||
- **`db_path=None` crea un archivo temporal que NO se borra solo**: la ruta se
|
||||
devuelve en `db_path` para que el llamador la consuma y la limpie cuando termine.
|
||||
- **Tipos inferidos por los lectores `_auto`**: los tipos de columna los infiere
|
||||
DuckDB. Revisa el schema con `duckdb_table_schema_py_infra` si el tipado importa
|
||||
aguas abajo.
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Carga una carpeta de archivos tabulares (CSV/Parquet/JSON) como tablas DuckDB.
|
||||
|
||||
Funcion impura: escanea el primer nivel de un directorio buscando archivos que
|
||||
casen con uno o varios globs, y por cada archivo crea una tabla en una base
|
||||
DuckDB usando los lectores nativos (`read_csv_auto`, `read_parquet`,
|
||||
`read_json_auto`). Es la pieza de entrada del EDA a nivel de carpeta (grupo
|
||||
`eda`): deja una DuckDB con una tabla por archivo, lista para perfilar y
|
||||
correlacionar aguas abajo.
|
||||
|
||||
Devuelve siempre un dict sin lanzar excepciones, siguiendo el estilo del grupo
|
||||
duckdb del registry: {status:'ok', db_path, tables, errors} en exito (incluida
|
||||
la carpeta sin archivos tabulares, que es un exito con tables=[]) y
|
||||
{status:'error', error:str} cuando la carpeta no existe o falla algo global.
|
||||
|
||||
El nombre de cada tabla se deriva del basename del archivo, saneado a
|
||||
`[0-9a-zA-Z_]` en minusculas, prefijado con `t_` si empieza por digito, y
|
||||
desambiguado con sufijos `_2`, `_3`, ... ante colisiones. El path del archivo se
|
||||
escapa (comilla simple, `'`->`''`) antes de interpolarlo en el SQL del lector,
|
||||
ya que los lectores DuckDB no admiten el path como parametro posicional. Un fallo
|
||||
al cargar un archivo concreto NO aborta el resto: se registra en `errors` y se
|
||||
continua con los siguientes.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
|
||||
def _sanitize_table_name(basename_no_ext: str, index: int) -> str:
|
||||
"""Deriva un identificador de tabla valido desde el basename de un archivo.
|
||||
|
||||
Reemplaza todo lo que no sea ``[0-9a-zA-Z_]`` por ``_`` y baja a minusculas.
|
||||
Si tras el saneo queda vacio, usa ``tabla_<index>``. Si empieza por digito,
|
||||
prefija ``t_`` para que sea un identificador SQL valido.
|
||||
"""
|
||||
name = re.sub(r"[^0-9a-zA-Z_]", "_", basename_no_ext).lower()
|
||||
if not name:
|
||||
name = f"tabla_{index}"
|
||||
if name[0].isdigit():
|
||||
name = "t_" + name
|
||||
return name
|
||||
|
||||
|
||||
def _reader_for_extension(ext: str, quoted_path: str):
|
||||
"""Devuelve la expresion de lector DuckDB para una extension, o None.
|
||||
|
||||
El ``quoted_path`` ya viene escapado y entre comillas simples. Extensiones
|
||||
desconocidas devuelven None para que el llamador salte el archivo.
|
||||
"""
|
||||
ext = ext.lower()
|
||||
if ext in (".csv", ".tsv", ".txt"):
|
||||
return f"read_csv_auto('{quoted_path}')"
|
||||
if ext in (".parquet", ".pq"):
|
||||
return f"read_parquet('{quoted_path}')"
|
||||
if ext in (".json", ".ndjson"):
|
||||
return f"read_json_auto('{quoted_path}')"
|
||||
return None
|
||||
|
||||
|
||||
def load_folder_to_duckdb(
|
||||
folder: str,
|
||||
db_path: str = None,
|
||||
pattern: str = "*.csv,*.parquet,*.json",
|
||||
) -> dict:
|
||||
"""Carga los archivos tabulares de una carpeta como tablas en una DuckDB.
|
||||
|
||||
Args:
|
||||
folder: ruta a un directorio. Si no existe o no es un directorio,
|
||||
devuelve {status:'error', ...} sin lanzar.
|
||||
db_path: ruta de la DuckDB destino (read-write, se crea si no existe). Si
|
||||
es None, se genera una base temporal con NamedTemporaryFile y su ruta
|
||||
se devuelve en el retorno (`db_path`).
|
||||
pattern: CSV de globs separados por coma (default
|
||||
"*.csv,*.parquet,*.json"). Cada glob se aplica con
|
||||
glob.glob(os.path.join(folder, g)) en el primer nivel (NO recursivo);
|
||||
los resultados se deduplican y ordenan.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', db_path:str, tables:[{name, source_file,
|
||||
n_rows}], errors:[{name?, source_file, error}]}. La carpeta sin archivos
|
||||
tabulares es un exito con tables=[] y errors=[]. En error (sin lanzar):
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
if not isinstance(folder, str) or not os.path.isdir(folder):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"folder does not exist or is not a directory: {folder!r}",
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
# Resolver la ruta de la DuckDB destino. Si no se da, reservar un nombre
|
||||
# temporal unico y borrar el archivo vacio que crea mkstemp: DuckDB 1.5.2
|
||||
# rechaza abrir un archivo de 0 bytes ("not a valid DuckDB database
|
||||
# file"), por lo que debe crear el archivo el mismo desde cero.
|
||||
if db_path is None:
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".duckdb")
|
||||
os.close(fd)
|
||||
os.remove(tmp_name)
|
||||
db_path = tmp_name
|
||||
|
||||
# Resolver los archivos: un glob por cada patron, dedup + orden estable.
|
||||
globs = [g.strip() for g in pattern.split(",") if g.strip()]
|
||||
found = set()
|
||||
for g in globs:
|
||||
for path in glob.glob(os.path.join(folder, g)):
|
||||
if os.path.isfile(path):
|
||||
found.add(path)
|
||||
files = sorted(found)
|
||||
|
||||
conn = __import__("duckdb").connect(db_path)
|
||||
|
||||
tables = []
|
||||
errors = []
|
||||
used_names = set()
|
||||
|
||||
for i, path in enumerate(files):
|
||||
base = os.path.basename(path)
|
||||
stem, ext = os.path.splitext(base)
|
||||
quoted_path = path.replace("'", "''")
|
||||
reader = _reader_for_extension(ext, quoted_path)
|
||||
if reader is None:
|
||||
errors.append(
|
||||
{
|
||||
"source_file": path,
|
||||
"error": f"unsupported extension: {ext!r}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
name = _sanitize_table_name(stem, i)
|
||||
# Desambiguar colisiones con sufijos _2, _3, ...
|
||||
if name in used_names:
|
||||
suffix = 2
|
||||
while f"{name}_{suffix}" in used_names:
|
||||
suffix += 1
|
||||
name = f"{name}_{suffix}"
|
||||
|
||||
quoted_ident = '"' + name.replace('"', '""') + '"'
|
||||
try:
|
||||
conn.execute(
|
||||
f"CREATE TABLE {quoted_ident} AS SELECT * FROM {reader}"
|
||||
)
|
||||
n_rows = conn.execute(
|
||||
f"SELECT count(*) FROM {quoted_ident}"
|
||||
).fetchone()[0]
|
||||
used_names.add(name)
|
||||
tables.append(
|
||||
{
|
||||
"name": name,
|
||||
"source_file": path,
|
||||
"n_rows": int(n_rows),
|
||||
}
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(
|
||||
{
|
||||
"name": name,
|
||||
"source_file": path,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": db_path,
|
||||
"tables": tables,
|
||||
"errors": errors,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user