Compare commits

...

10 Commits

Author SHA1 Message Date
egutierrez ea6678ec23 feat(eda): generadores de datasets sintéticos Faker que ejercitan el AutomaticEDA
Añade dos funciones impuras dict-no-throw, deterministas por seed, al dominio
datascience (grupo eda):

- generate_synthetic_eda_table: una tabla DuckDB de 19 columnas (numéricas
  correlacionadas + outliers, categóricas desbalanceadas, texto largo
  multi-idioma es/en/fr, fecha DATE, lat/lon válidas, PII email/iban/phone/uuid,
  nulos con patrón MCAR/MAR co-ocurrentes). Activa 14 capítulos del motor
  AutomaticEDA (num_distr, cat_distr, text_distr, calidad, missingness,
  correlacion, relaciones, modelos, timeseries, geospatial, agregacion,
  glosario + portada/overview).
- generate_synthetic_eda_folder: 3 CSV relacionados (customers/orders/reviews)
  con FK customer detectable por containment, para el EDA de carpeta multi-tabla.

Determinismo via Faker.seed_instance + numpy.default_rng. Tests: 16 passed
(incluye determinismo por hash, rangos lat/lon, co-nulos income/spending,
mediana palabras review >=20, phone formato internacional, FK containment).

Añade faker (40.27.0) a python/pyproject.toml + uv.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:25:31 +02:00
egutierrez 792b890195 merge(eda): pipeline EDA de carpeta/base multi-tabla + join graph rasterizado a Figure
# Conflicts:
#	python/functions/datascience/__init__.py
2026-06-30 21:00:35 +02:00
egutierrez 9886e2905d feat(eda): rasterizar join graph a Figure matplotlib real en el capitulo de relaciones
draw_join_graph_figure (datascience, grupo eda): dibuja el join graph de la base
como una matplotlib Figure real (networkx spring_layout seed=42, nodos = tablas,
hubs destacados, flechas dirigidas con etiqueta from_col->to_col + cardinalidad).
Nunca lanza: devuelve una Figure de error si algo falla; entrada vacia -> Figure
'Sin relaciones FK detectadas'.

render_automatic_eda_folder ahora inserta esa Figure (bloque Figure lazy via make)
en el capitulo de relaciones cuando hay edges, ademas del texto Mermaid (util para
el MD/LLM). Antes solo se volcaba el texto del grafo; ahora el PDF/PPTX muestran el
diagrama dibujado. Tests nuevos: la Figure real se construye con edges y se omite
sin edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:57:52 +02:00
egutierrez bebbd05de5 merge(papers): render_paper_pdf (Markdown IMRaD -> PDF) + agente paper-reviewer adversarial (verificado met) 2026-06-30 20:56:11 +02:00
egutierrez 6fb6ef6cfe merge(papers): rigor experimental — effect size (Cohen/Hedges), IC media+diferencia, Holm/FDR + preregistro inmutable (verificado met) 2026-06-30 20:56:11 +02:00
egutierrez 857c3d8637 merge(papers): estructura, scaffolding (init_paper) y capability page del artefacto papers/ (verificado met) 2026-06-30 20:56:11 +02:00
egutierrez 4f1530797e feat(datascience): rigor experimental para papers — effect size, IC, Holm + preregistro inmutable
Subsistema de papers reproducibles (grupo de capacidad `papers`). Añade las
funciones estadísticas que un paper honesto necesita y la función que congela la
hipótesis antes de mirar los datos (anti-HARKing).

Nuevas funciones (puras salvo la última):
- effect_size_cohens_d: Cohen's d + Hedges' g (corrección de sesgo para N
  pequeño) + interpretación cualitativa (negligible/small/medium/large por los
  umbrales de Cohen). Dict-no-throw ante varianza cero / N insuficiente.
- confidence_interval_mean: intervalo de confianza de una media (t de Student) o
  de la diferencia de medias con Welch (df de Welch–Satterthwaite, sin asumir
  varianzas iguales). Dict-no-throw; el IC colapsa al punto cuando la varianza es
  cero.
- preregister_hypothesis (impura): congela hipótesis + plan de análisis en
  papers/<slug>/preregistration.md con frozen_at (UTC) y content_hash (sha256 del
  cuerpo normalizado, no del frontmatter). Inmutabilidad: una vez frozen, un
  contenido distinto se RECHAZA sin sobrescribir (mata el HARKing); idempotente si
  el contenido es idéntico. Siempre dict-no-throw.

Extensión:
- fdr_correction 1.0.0 -> 1.1.0: añade method="holm" (Holm-Bonferroni step-down,
  controla FWER, más potente que Bonferroni simple). Reúsa la maquinaria de
  alineación 1:1 con None/inválidos; no rompe los métodos bh/bonferroni.

Reutiliza del registry: fdr_correction (BH + Bonferroni ya existían) como base
para Holm. pearson y spearman_corr ya cubrían correlación.

Tests: 36 pytest verdes (cohen/hedges 8, confidence/welch 8, fdr/holm/bonferroni
12, preregister 4 + extras), golden contra valores conocidos y validados con
scipy. Golden manual del preregistro: congela, idempotente, rechaza edición
(bytes en disco idénticos al congelado).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:42:12 +02:00
egutierrez 9c1b7dd0f3 feat(papers): render_paper_pdf (Markdown IMRaD → PDF) + agente paper-reviewer
Subsistema papers/: pieza de entrega + control de calidad.

- render_paper_pdf_py_datascience (Python, impure, dominio datascience, grupo
  `papers`): convierte papers/<slug>/paper.md (frontmatter YAML + cuerpo IMRaD)
  en papers/<slug>/out/paper.pdf. Reutiliza el motor de paginación de flujo del
  paquete automatic_eda (matplotlib PdfPages, el mismo PDF móvil A5 de los
  informes EDA) — no reimplementa paginación ni toca matplotlib, y no añade
  dependencias. Cada sección IMRaD (# H1) → un Chapter en página nueva; portada
  desde el frontmatter (title/authors/date europea/abstract); detecta las
  imágenes Markdown ![alt](src) que el motor no entiende y las parte en bloques
  Image resueltos contra base_dir y base_dir/figures/. dict-no-throw estricto.
  5 tests verdes (golden + edges: sin frontmatter, path inexistente, figura
  inexistente, ruta directa al .md).

- .claude/agents/paper-reviewer: revisor académico adversarial read-only (gate
  anti paper-mill). Puntúa novedad/rigor/reproducibilidad/validez (0-5), intenta
  refutar cada claim contra la evidencia citada, detecta HARKing contra el
  preregistration.md, exige limitaciones declaradas y claims ≤ evidencia, y
  emite veredicto estructurado JSON (accept|major_revision|reject) con default
  conservador. Tools: Read, Grep, Glob, Bash (sin Edit/Write: solo juzga).

Diseño completo: reports/0001-2026-06-30-papers-system-design.md (agente C).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:39:59 +02:00
egutierrez 6e3c3cf2a2 feat(papers): estructura, scaffolding y capability page del artefacto papers/
Nuevo tipo de artefacto para papers académicos reproducibles (papers/<NNNN-slug>/):

- Plantillas docs/templates/paper.md (IMRaD completo con guías por sección:
  Abstract, Introduction, Related work, Methods, Results, Discussion con
  Limitaciones + Amenazas a la validez, Conclusion + Future work) y
  docs/templates/preregistration.md (H0/H1 falsable, variables, diseño, plan
  de análisis con test exacto + effect size + corrección múltiple, predicción
  cuantitativa; nota anti-HARKing de congelado).
- Pipeline init_paper (bash/functions/pipelines/init_paper.sh + .md): calcula el
  siguiente NNNN, crea las subcarpetas (experiments data figures reviews out),
  copia las plantillas rellenando el frontmatter (title, slug, date, phase=question,
  status=draft) y crea references.md. No hace git init (fase interna local).
- Función atómica reutilizable next_numbered_dir (bash/functions/io): siguiente
  prefijo NNNN- escaneando un directorio numerado (reutilizable por papers/reports/issues).
- papers/ como artefacto local gitignored (bloque en .gitignore + papers/.gitkeep):
  un paper en fase interna no contamina el repo padre; al promocionar a publishable
  se vuelve sub-repo Gitea propio.
- Página de capacidad docs/capabilities/papers.md + fila en el INDEX: tabla de
  funciones del grupo papers (disponibles + en construcción por la flota), ejemplo
  canónico end-to-end y fronteras.

Reutiliza slugify_ascii del registry. Diseño: reports/0001-2026-06-30-papers-system-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:38 +02:00
egutierrez 6a1520f458 feat(eda): EDA de carpeta/base multi-tabla -> AutomaticEDA por capitulos (PDF+PPTX+MD)
Pipeline render_automatic_eda_folder: apunta el AutomaticEDA a una CARPETA de
archivos tabulares (CSV/Parquet/JSON) o a una DuckDB existente y emite el informe
de la BASE por capitulos en PDF (A5 movil) + PPTX (16:9) + Markdown. Documento-base
con portada-base, resumen de todas las tablas y relaciones inter-tabla (FK
candidatas por containment + diagrama Mermaid del join graph). Flag per_table_eda
anexa el mini-EDA de cada tabla. Aditivo: render_automatic_eda (tabla unica) intacto.

Funcion nueva load_folder_to_duckdb (infra, grupo eda+duckdb): carga una carpeta a
una DuckDB (temp si no se da path), CREATE TABLE por archivo con read_csv_auto/
read_parquet/read_json_auto. dict-no-throw.

Compone profile_database + los 3 renderers del motor AutomaticEDA + build_document
(per-tabla), sin reimplementar su logica. Tests: golden 3 CSV relacionados (FK
orders.customer_id->customers.id detectada) + edges (carpeta vacia, 1 tabla,
DuckDB existente, path inexistente). fn index sin error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:34:10 +02:00
45 changed files with 4806 additions and 21 deletions
+141
View File
@@ -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.
+7
View File
@@ -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/
+58
View File
@@ -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`.
+46
View File
@@ -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
+69
View File
@@ -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`.
+177
View File
@@ -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 ""
+1
View File
@@ -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 |
+82
View File
@@ -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.
+94
View File
@@ -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. -->
+59
View File
@@ -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.
-->
View File
+14
View File
@@ -59,6 +59,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 +75,16 @@ 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
__all__ = [
"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 +101,9 @@ __all__ = [
"stl_decompose",
"to_returns",
"fdr_correction",
"effect_size_cohens_d",
"confidence_interval_mean",
"preregister_hypothesis",
"suggest_reexpression",
"exploratory_caveats",
"render_eda_pdf",
@@ -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
@@ -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
+29 -11
View File
@@ -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).
+29 -9
View File
@@ -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,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,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 ![alt](src), 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 ![alt](figures/fig1.png))
#
# 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.
# ![Figura 1](figures/fig1.png)
# # 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 `![alt](src)` 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
`![alt](src)`; 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 ``![alt](src)``; 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: ![alt](src)
_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 ``![alt](src)`` 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"
"![Una figura que no existe](figures/no.png)\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"])
+2
View File
@@ -34,6 +34,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 +51,7 @@ __all__ = [
"upsert_xlsx_sheet",
"duckdb_query_readonly",
"duckdb_execute",
"load_folder_to_duckdb",
"duckdb_upsert",
"pg_insert_rows",
"pg_apply_sql",
@@ -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()
@@ -0,0 +1,73 @@
"""Tests para load_folder_to_duckdb."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import duckdb # noqa: E402
from load_folder_to_duckdb import load_folder_to_duckdb # noqa: E402
def _write_csv(path: str, header: str, rows: list[str]) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(header + "\n")
for r in rows:
f.write(r + "\n")
def test_carga_dos_csv_como_tablas(tmp_path):
_write_csv(
str(tmp_path / "ventas.csv"),
"id,total",
["1,10.5", "2,20.0", "3,5.25"],
)
_write_csv(
str(tmp_path / "clientes.csv"),
"id,nombre",
["1,ana", "2,luis"],
)
db = tmp_path / "out.duckdb"
res = load_folder_to_duckdb(str(tmp_path), str(db))
assert res["status"] == "ok", res
assert res["errors"] == []
assert len(res["tables"]) == 2
assert res["db_path"] == str(db)
assert os.path.exists(str(db))
by_name = {t["name"]: t for t in res["tables"]}
assert by_name["ventas"]["n_rows"] == 3
assert by_name["clientes"]["n_rows"] == 2
# Verificar que las tablas existen realmente en la base.
con = duckdb.connect(str(db), read_only=True)
assert con.execute("SELECT count(*) FROM ventas").fetchone()[0] == 3
assert con.execute("SELECT count(*) FROM clientes").fetchone()[0] == 2
con.close()
def test_db_path_none_crea_temporal(tmp_path):
_write_csv(str(tmp_path / "datos.csv"), "x", ["1", "2"])
res = load_folder_to_duckdb(str(tmp_path))
assert res["status"] == "ok", res
assert res["db_path"]
assert os.path.exists(res["db_path"])
assert len(res["tables"]) == 1
assert res["tables"][0]["n_rows"] == 2
os.remove(res["db_path"])
def test_carpeta_vacia_es_ok_sin_tablas(tmp_path):
db = tmp_path / "out.duckdb"
res = load_folder_to_duckdb(str(tmp_path), str(db))
assert res["status"] == "ok", res
assert res["tables"] == []
assert res["errors"] == []
def test_carpeta_inexistente_devuelve_status_error(tmp_path):
res = load_folder_to_duckdb(str(tmp_path / "no_existe"))
assert res["status"] == "error"
assert "folder" in res["error"]
@@ -0,0 +1,115 @@
---
name: render_automatic_eda_folder
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def render_automatic_eda_folder(path: str, out_dir: str = \"reports\", basename: str = None, profile_level: str = \"standard\", emit_pdf: bool = True, emit_pptx: bool = True, emit_md: bool = True, per_table_eda: bool = False, min_inclusion: float = 0.9, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA a nivel de BASE one-shot de una CARPETA de archivos tabulares (CSV/Parquet/JSON) o de una DuckDB existente. Carga la carpeta a una DuckDB temporal con load_folder_to_duckdb (o usa la DuckDB dada directa), perfila TODA la base con profile_database (resumen de cada tabla + FK candidatas por containment + join graph con diagrama Mermaid), ENSAMBLA un documento-base por capitulos (portada-base con nombre/n tablas/totales/fecha/fuente, resumen de tablas con una fila por tabla, y relaciones inter-tabla con la tabla de FK candidatas + una Figure matplotlib REAL del join graph dibujada con draw_join_graph_figure mas el texto Mermaid) y lo renderiza con el motor AutomaticEDA a PDF (A5 movil), PPTX (16:9) y Markdown autocontenido a la vez. Con per_table_eda=True anexa los capitulos de mini-EDA de cada tabla (build_document por tabla). Es el hermano a nivel de base de render_automatic_eda (que perfila UNA tabla): aqui el informe es de la base y de sus relaciones. Devuelve las rutas de PDF/PPTX/MD, el manifiesto y el DatabaseProfile."
tags: [eda, duckdb, database, profiling, relations, pipeline, dataops, report, pdf, pptx, launcher]
uses_functions:
- load_folder_to_duckdb_py_infra
- profile_database_py_pipelines
- render_automatic_eda_pdf_py_datascience
- render_automatic_eda_pptx_py_datascience
- render_automatic_eda_markdown_py_datascience
- draw_join_graph_figure_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: true
tests:
- "golden: carpeta con 3 CSV relacionados (customers/orders/products) emite PDF+PPTX+MD del documento-base con 3 tablas y la FK orders.customer_id->customers.id"
- "edge: carpeta vacia -> status ok con documento minimo, sin lanzar"
- "edge: 1 sola tabla -> funciona sin relaciones (capitulo relaciones dice 'sin FK')"
test_file_path: "python/functions/pipelines/render_automatic_eda_folder_test.py"
file_path: "python/functions/pipelines/render_automatic_eda_folder.py"
params:
- name: path
desc: "DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que se cargan a una DuckDB temporal, o una DuckDB ya existente (.duckdb/.ddb/.db) que se perfila directa."
- name: out_dir
desc: "Directorio de salida de los informes (se crea si no existe). Default 'reports'."
- name: basename
desc: "Nombre base de los archivos sin extension. Default 'aeda_base_<nombre>_<timestamp>'."
- name: profile_level
desc: "Preset de coste del perfil por tabla ('lite'/'standard'/'full'); ajusta el sample que profile_database pasa a cada tabla (lite=2000, standard/full=5000)."
- name: emit_pdf
desc: "Emite el PDF A5 movil del documento-base. Default True."
- name: emit_pptx
desc: "Emite el PPTX 16:9 del documento-base. Default True."
- name: emit_md
desc: "Emite el Markdown autocontenido del documento-base. Default True."
- name: per_table_eda
desc: "Si True, anexa al documento-base los capitulos de mini-EDA de cada tabla (Heading 'Tabla: <n>' + build_document por tabla). Default False (solo documento-base: portada + resumen + relaciones)."
- name: min_inclusion
desc: "Umbral de inclusion (0-1) para emitir una FK candidata (se pasa a profile_database). Default 0.9."
- name: ctx_extra
desc: "Dict opcional de claves de presentacion (p.ej. dataset_name, description) que se mezclan en el contexto de la portada-base."
output: "Dict dict-no-throw. En exito: {status:'ok', pdf_path, pptx_path, md_path, manifest_path, n_tables, n_pages, n_slides, md_chars, db_path, db_profile}. En error: {status:'error', error:str}."
---
# render_automatic_eda_folder
EDA de una **carpeta / base multi-tabla** → informe AutomaticEDA por capítulos
en PDF (móvil A5) + PPTX (16:9) + Markdown, en una sola llamada. Es el hermano a
nivel de **base** de `render_automatic_eda` (que perfila una sola tabla): aquí el
documento resume **todas** las tablas y, sobre todo, sus **relaciones**
inter-tabla (FK candidatas por containment + join graph con diagrama Mermaid).
Compone, sin reimplementar su lógica: `load_folder_to_duckdb` (carga la carpeta),
`profile_database` (perfila la base + infiere FK + join graph) y los tres
renderers del motor AutomaticEDA (`render_automatic_eda_pdf`/`_pptx`/`_markdown`),
que aceptan directamente la lista de capítulos del documento-base que este
pipeline ensambla. El pipeline de tabla única (`render_automatic_eda`) queda
intacto: esto es aditivo.
## Ejemplo
```bash
# Carpeta con varios CSV/Parquet/JSON relacionados:
./fn run render_automatic_eda_folder /tmp/eda_folder_demo
# Una DuckDB ya existente (rama directa):
./fn run render_automatic_eda_folder temp/bigdata/taxi.duckdb
```
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.render_automatic_eda_folder import render_automatic_eda_folder
r = render_automatic_eda_folder("/tmp/eda_folder_demo", out_dir="reports")
# r["status"] == "ok"; r["pdf_path"], r["pptx_path"], r["md_path"]
# r["n_tables"] == 3; r["db_profile"]["fk_candidates"] incluye
# orders.customer_id -> customers.id
```
## Cuando usarla
Cuando quieras un EDA de una **base entera** (una carpeta de exports o una
DuckDB con varias tablas), no de una sola tabla: para ver de un vistazo qué
tablas hay, su tamaño y calidad, y cómo se relacionan (FK candidatas + diagrama),
en el mismo formato rico por capítulos (PDF móvil + PPTX + MD) que el EDA de
tabla. Usa `per_table_eda=True` cuando además quieras el mini-EDA de cada tabla
anexado.
## Gotchas
- Impuro: lee archivos del disco y escribe PDF/PPTX/MD en `out_dir`. En la rama
"carpeta" crea una **DuckDB temporal** (su ruta sale en `db_path`); no se borra
automáticamente (queda para reinspección).
- `path` se interpreta así: directorio → se carga la carpeta; archivo con
extensión `.duckdb`/`.ddb`/`.db` → se usa directo; cualquier otro archivo o un
path inexistente → `{status:'error'}` (no lanza).
- El escaneo de la carpeta es **no recursivo** (solo el primer nivel) y por
defecto cubre `*.csv,*.parquet,*.json` (ver `load_folder_to_duckdb`).
- El join graph se rasteriza a una **Figure matplotlib real** (vía
`draw_join_graph_figure`) que aparece dibujada en PDF/PPTX (nodos = tablas,
flechas = FK). Además, el **texto Mermaid** del grafo se incluye como bloque de
código (en el Markdown queda como diagrama renderizable y es útil para pegar a
un LLM).
- Carpeta vacía o con 1 sola tabla: funciona igual; el capítulo de relaciones
dice "sin FK". dict-no-throw en todos los caminos.
@@ -0,0 +1,366 @@
"""render_automatic_eda_folder — EDA de una CARPETA / base multi-tabla one-shot.
Pipeline impuro del grupo de capacidad `eda`, a nivel de BASE. Dada una CARPETA
de archivos tabulares (CSV/Parquet/JSON) o una DuckDB ya existente, produce el
informe AutomaticEDA de la BASE en sus tres formatos a la vez (PDF móvil A5 +
PPTX 16:9 + Markdown autocontenido), con los capítulos POBLADOS, en una sola
llamada. Es el hermano a nivel de base de ``render_automatic_eda`` (que perfila
UNA tabla): aquí el documento por capítulos resume TODAS las tablas y, sobre
todo, sus RELACIONES inter-tabla (FK candidatas + join graph).
Compone funciones del registry SIN reimplementar su lógica:
- load_folder_to_duckdb : carga una carpeta de archivos a una DuckDB temporal
(rama "carpeta"). En la rama "ya es duckdb" se omite.
- profile_database : perfila TODA la base (resumen de cada tabla,
TableProfiles completos, FK candidatas por
containment y join graph con diagrama Mermaid).
- render_automatic_eda_pdf : renderiza el documento-base por capítulos a PDF.
- render_automatic_eda_pptx : renderiza el mismo documento-base a PPTX.
- render_automatic_eda_markdown : serializa el mismo documento-base a Markdown
autocontenido (texto + tablas markdown).
- build_document : (solo con per_table_eda=True) ensambla los capítulos
canónicos de CADA tabla para anexarlos al documento.
La capa propia de este pipeline es ENSAMBLAR EL DOCUMENTO-BASE de capítulos a
partir del ``DatabaseProfile`` que devuelve ``profile_database`` y cablear los
tres renderers del motor AutomaticEDA. El documento-base mínimo tiene tres
capítulos: portada-base (nombre/nº tablas/totales/fecha/fuente), resumen de
tablas (una fila por tabla) y relaciones inter-tabla (FK candidatas + diagrama
Mermaid). Con ``per_table_eda=True`` anexa, por cada tabla, sus capítulos de
mini-EDA.
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
degrada a ``{"status": "error", "error": str}``.
"""
import os
from datetime import datetime, timezone
from datascience import (
draw_join_graph_figure,
render_automatic_eda_markdown,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
)
from datascience.automatic_eda import build_document
from infra import load_folder_to_duckdb
from pipelines.profile_database import profile_database
# Mapa profile_level -> tamaño de muestra por columna del perfil de cada tabla.
# A nivel de base el coste lo domina el nº de tablas; el preset solo ajusta el
# sample que profile_database pasa a profile_table.
_SAMPLE_BY_LEVEL = {"lite": 2000, "standard": 5000, "full": 5000}
# Extensiones que se consideran "una DuckDB ya hecha" en la rama directa.
_DUCKDB_EXTS = (".duckdb", ".ddb", ".db")
def _fmt_num(v) -> str:
"""Formatea un entero con separador de millar; '' si no es número."""
if isinstance(v, bool) or not isinstance(v, (int, float)):
return ""
try:
return f"{int(v):,}".replace(",", ".")
except Exception: # noqa: BLE001
return str(v)
def _portada_chapter(db_profile: dict, source_path: str, db_path: str,
meta_ctx: dict) -> dict:
"""Capítulo de portada a nivel de base (NO reusa chapters/portada.py, que es
de tabla única): nombre de la base, nº de tablas, totales y procedencia."""
tables = db_profile.get("tables", []) or []
total_rows = sum(
(t.get("n_rows") or 0) for t in tables if isinstance(t.get("n_rows"), (int, float))
)
total_cols = sum(
(t.get("n_cols") or 0) for t in tables if isinstance(t.get("n_cols"), (int, float))
)
base_name = (meta_ctx or {}).get("dataset_name") or os.path.basename(
os.path.normpath(source_path)
) or source_path
rows = [
("Base", base_name),
("Tablas", _fmt_num(db_profile.get("n_tables"))),
("Filas totales", _fmt_num(total_rows)),
("Columnas totales", _fmt_num(total_cols)),
("Relaciones FK", _fmt_num(len(db_profile.get("fk_candidates", []) or []))),
("Fuente", source_path),
("DuckDB", db_path),
("Generado", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")),
]
blocks = [
{"kind": "heading", "text": f"EDA de la base — {base_name}", "level": 1},
{"kind": "kv_table", "rows": rows, "title": "Resumen de la base"},
]
errs = db_profile.get("errors", []) or []
if errs:
blocks.append({
"kind": "note",
"text": f"{len(errs)} aviso(s) durante el perfilado (ver detalle).",
})
return {"id": "portada_base", "title": "Portada", "version": "1.0.0",
"blocks": blocks}
def _resumen_chapter(db_profile: dict) -> dict:
"""Capítulo con una fila por tabla: filas, columnas, calidad, key_candidates."""
header = ["Tabla", "Filas", "Columnas", "Calidad", "key_candidates"]
rows = []
for t in db_profile.get("tables", []) or []:
keys = ", ".join(t.get("key_candidates") or []) or ""
rows.append([
t.get("table"),
_fmt_num(t.get("n_rows")),
_fmt_num(t.get("n_cols")),
t.get("quality_score"),
keys,
])
if rows:
blocks = [{
"kind": "data_table", "header": header, "rows": rows,
"title": "Tablas de la base",
"note": "Una fila por tabla. Calidad = score agregado del TableProfile.",
}]
else:
blocks = [{"kind": "note",
"text": "La base no contiene tablas perfilables."}]
return {"id": "resumen_tablas", "title": "Resumen de tablas",
"version": "1.0.0", "blocks": blocks}
def _relaciones_chapter(db_profile: dict) -> dict:
"""Capítulo de relaciones inter-tabla: tabla de FK candidatas + diagrama
Mermaid del join graph (vuelca el Mermaid como bloque de código)."""
fks = db_profile.get("fk_candidates", []) or []
blocks = [{
"kind": "heading", "text": "Relaciones inter-tabla", "level": 2,
}]
if fks:
header = ["From", "To", "Inclusión", "Cardinalidad"]
rows = []
for fk in fks:
frm = f"{fk.get('from_table')}.{fk.get('from_col')}"
to = f"{fk.get('to_table')}.{fk.get('to_col')}"
inc = fk.get("inclusion")
inc_s = f"{inc:.3f}" if isinstance(inc, (int, float)) else str(inc)
rows.append([frm, to, inc_s, fk.get("cardinality")])
blocks.append({
"kind": "data_table", "header": header, "rows": rows,
"title": "FK candidatas (por containment de valores)",
"note": "Inclusión = fracción de valores de From contenidos en To.",
})
else:
blocks.append({
"kind": "note",
"text": "Sin relaciones FK candidatas detectadas entre las tablas.",
})
join_graph = db_profile.get("join_graph") or {}
has_edges = bool(join_graph.get("edges"))
if has_edges:
blocks.append({"kind": "heading", "text": "Diagrama (join graph)",
"level": 3})
# Figure matplotlib REAL del grafo de relaciones (nodos = tablas,
# aristas = FK). Lazy via `make`: el renderer la construye solo al
# paginar, y se rasteriza en PDF/PPTX. draw_join_graph_figure nunca
# lanza (devuelve una Figure de error si algo falla).
blocks.append({
"kind": "figure",
"make": (lambda jg=join_graph: draw_join_graph_figure(
jg, title="Join graph (relaciones inter-tabla)")),
"caption": "Grafo de relaciones: nodos = tablas, flechas = FK "
"candidatas (etiqueta from_col→to_col).",
"height_in": 4.5,
})
# Además, el Mermaid en texto: en el Markdown queda como diagrama
# renderizable y es útil para pegar a un LLM.
mermaid = (join_graph.get("mermaid", "") or "").strip()
if mermaid:
blocks.append({"kind": "markdown",
"text": "```mermaid\n" + mermaid + "\n```"})
return {"id": "relaciones", "title": "Relaciones inter-tabla",
"version": "1.0.0", "blocks": blocks}
def _build_db_document(db_profile: dict, source_path: str, db_path: str,
meta_ctx: dict, per_table_eda: bool) -> list:
"""Ensambla el documento-base por capítulos a partir del DatabaseProfile.
Mínimo: portada-base + resumen de tablas + relaciones. Con per_table_eda
True anexa, por cada tabla, un capítulo separador + los capítulos canónicos
de su mini-EDA (reusando build_document sobre cada TableProfile)."""
chapters = [
_portada_chapter(db_profile, source_path, db_path, meta_ctx),
_resumen_chapter(db_profile),
_relaciones_chapter(db_profile),
]
if per_table_eda:
for prof in db_profile.get("table_profiles", []) or []:
tname = prof.get("table") or "tabla"
chapters.append({
"id": f"tabla_{tname}", "title": f"Tabla: {tname}",
"version": "1.0.0",
"blocks": [{"kind": "heading", "text": f"Tabla: {tname}",
"level": 1}],
})
try:
# build_document devuelve los capítulos canónicos de la tabla.
# ctx None -> los capítulos que necesitan datos crudos degradan,
# pero salen completos los de portada/overview/distrib/calidad.
chapters.extend(build_document(prof, None) or [])
except Exception: # noqa: BLE001 — una tabla mala no rompe el doc.
chapters.append({
"id": f"tabla_{tname}_err", "title": f"Tabla: {tname}",
"version": "1.0.0",
"blocks": [{"kind": "note",
"text": "No se pudo ensamblar el mini-EDA de "
"esta tabla."}],
})
return chapters
def _resolve_db_path(path: str) -> dict:
"""Resuelve el DuckDB a perfilar desde ``path``.
- Directorio -> carga la carpeta con load_folder_to_duckdb (DuckDB temp).
- Archivo .duckdb/.ddb/.db -> se usa directo (rama "ya es duckdb").
- Otro archivo / inexistente -> error.
Devuelve {status, db_path, loaded, n_tables, load_errors}.
"""
if os.path.isdir(path):
lr = load_folder_to_duckdb(path)
if lr.get("status") != "ok":
return {"status": "error",
"error": f"load_folder_to_duckdb falló: {lr.get('error')}"}
return {
"status": "ok",
"db_path": lr.get("db_path"),
"loaded": True,
"n_tables": len(lr.get("tables", []) or []),
"load_errors": lr.get("errors", []) or [],
}
if os.path.isfile(path):
if path.lower().endswith(_DUCKDB_EXTS):
return {"status": "ok", "db_path": path, "loaded": False,
"n_tables": None, "load_errors": []}
return {"status": "error",
"error": f"'{path}' no es un directorio ni una DuckDB "
f"(extensiones {_DUCKDB_EXTS})."}
return {"status": "error", "error": f"path no existe: {path}"}
def render_automatic_eda_folder(
path: str,
out_dir: str = "reports",
basename: str = None,
profile_level: str = "standard",
emit_pdf: bool = True,
emit_pptx: bool = True,
emit_md: bool = True,
per_table_eda: bool = False,
min_inclusion: float = 0.9,
ctx_extra: dict = None,
) -> dict:
"""Perfila una CARPETA (o una DuckDB) y emite el informe AutomaticEDA de la base.
Args:
path: o bien un DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que
se cargan a una DuckDB temporal, o bien una DuckDB ya existente
(``.duckdb``/``.ddb``/``.db``) que se perfila directa.
out_dir: directorio de salida (se crea si no existe). Default "reports".
basename: nombre base de los archivos sin extensión. Default
"aeda_base_<nombre>_<timestamp>".
profile_level: preset de coste del perfil por tabla ("lite"/"standard"/
"full"); ajusta el ``sample`` que profile_database pasa a cada tabla.
emit_pdf / emit_pptx / emit_md: qué formatos emitir. Default los tres.
per_table_eda: si True, anexa al documento-base los capítulos de mini-EDA
de cada tabla (un Heading "Tabla: <n>" + build_document por tabla).
Default False (solo el documento-base: portada + resumen + relaciones).
min_inclusion: umbral de inclusión para emitir una FK candidata (0-1).
ctx_extra: dict opcional de claves de presentación (p.ej. dataset_name,
description) que se mezclan en el contexto de la portada.
Returns:
dict (nunca lanza). En éxito::
{"status": "ok", "pdf_path": str|None, "pptx_path": str|None,
"md_path": str|None, "manifest_path": str|None,
"n_tables": int, "n_pages": int|None, "n_slides": int|None,
"md_chars": int|None, "db_path": str, "db_profile": <DatabaseProfile>}
En error: {"status": "error", "error": str}.
"""
try:
# 1) Resolver la DuckDB a perfilar (cargar carpeta o usar la dada).
rdb = _resolve_db_path(path)
if rdb.get("status") != "ok":
return {"status": "error", "error": rdb.get("error")}
db_path = rdb.get("db_path")
# 2) Perfilar la base entera (resumen + FK + join graph). Sin report
# propio (write_report/emit_pdf False): este pipeline emite el suyo.
sample = _SAMPLE_BY_LEVEL.get(profile_level, 5000)
pres = profile_database(
db_path, sample=sample, write_report=False,
min_inclusion=min_inclusion, emit_pdf=False,
)
if pres.get("status") != "ok":
return {"status": "error",
"error": f"profile_database falló: {pres.get('error')}"}
db_profile = pres.get("db_profile") or {}
# 3) Ensamblar el documento-base por capítulos.
meta_ctx = dict(ctx_extra or {})
chapters = _build_db_document(
db_profile, path, db_path, meta_ctx, per_table_eda
)
# 4) Render a los tres formatos desde el MISMO documento por capítulos.
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
nm = (meta_ctx.get("dataset_name")
or os.path.basename(os.path.normpath(path)) or "base")
nm = "".join(c if c.isalnum() else "_" for c in str(nm)).strip("_") or "base"
base = basename or f"aeda_base_{nm}_{ts}"
title = f"EDA base — {meta_ctx.get('dataset_name') or nm}"
meta = {"title": title}
pdf_path = pptx_path = md_path = manifest_path = None
n_pages = n_slides = md_chars = None
if emit_pdf:
target = os.path.join(out_dir, base + ".pdf")
rpdf = render_automatic_eda_pdf(chapters, target, meta) or {}
pdf_path = rpdf.get("path")
n_pages = rpdf.get("n_pages")
manifest_path = rpdf.get("manifest_path")
if emit_pptx:
target = os.path.join(out_dir, base + ".pptx")
rpptx = render_automatic_eda_pptx(chapters, target, meta) or {}
pptx_path = rpptx.get("path")
n_slides = rpptx.get("n_slides")
if emit_md:
target = os.path.join(out_dir, base + ".md")
rmd = render_automatic_eda_markdown(chapters, target, meta) or {}
md_path = rmd.get("path")
md_chars = rmd.get("n_chars")
return {
"status": "ok",
"pdf_path": pdf_path,
"pptx_path": pptx_path,
"md_path": md_path,
"manifest_path": manifest_path,
"n_tables": db_profile.get("n_tables"),
"n_pages": n_pages,
"n_slides": n_slides,
"md_chars": md_chars,
"db_path": db_path,
"db_profile": db_profile,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
return {"status": "error", "error": str(e)}
@@ -0,0 +1,188 @@
"""Tests para render_automatic_eda_folder — EDA de una carpeta / base multi-tabla.
Golden: una carpeta con 3 CSV relacionados (customers/orders/products) produce el
documento-base en PDF + PPTX + MD, con las 3 tablas en el resumen y la FK
orders.customer_id -> customers.id en el capítulo de relaciones. Edges: carpeta
vacía (documento mínimo, sin lanzar), 1 sola tabla (sin relaciones) y la rama
"ya es una DuckDB" sobre un archivo .duckdb existente.
"""
import os
import sys
import duckdb
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from pipelines.render_automatic_eda_folder import (
_relaciones_chapter,
render_automatic_eda_folder,
)
def _write_demo_folder(folder: str) -> None:
"""3 CSV relacionados: orders.customer_id -> customers.id (FK detectable)."""
with open(os.path.join(folder, "customers.csv"), "w", encoding="utf-8") as fh:
fh.write("id,name,city\n")
fh.write("1,Alice,Madrid\n2,Bob,Barcelona\n3,Carol,Valencia\n"
"4,Dave,Sevilla\n5,Eve,Madrid\n")
with open(os.path.join(folder, "orders.csv"), "w", encoding="utf-8") as fh:
fh.write("order_id,customer_id,product_id,total\n")
fh.write("100,1,10,49.90\n101,1,11,12.50\n102,2,10,49.90\n"
"103,3,12,8.00\n104,3,11,12.50\n105,5,10,49.90\n"
"106,2,12,8.00\n")
with open(os.path.join(folder, "products.csv"), "w", encoding="utf-8") as fh:
fh.write("product_id,product_name,price\n")
fh.write("10,Widget,49.90\n11,Gadget,12.50\n12,Gizmo,8.00\n")
def _has_fk(db_profile: dict, from_t: str, from_c: str, to_t: str) -> bool:
for fk in db_profile.get("fk_candidates", []) or []:
if (fk.get("from_table") == from_t and fk.get("from_col") == from_c
and fk.get("to_table") == to_t):
return True
return False
def test_golden_folder_three_csv(tmp_path):
"""Carpeta con 3 CSV relacionados -> PDF+PPTX+MD, 3 tablas, FK detectada."""
folder = tmp_path / "demo"
folder.mkdir()
_write_demo_folder(str(folder))
out = tmp_path / "out"
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 3
# Los tres formatos se emitieron y existen en disco.
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
assert r["md_path"] and os.path.exists(r["md_path"])
assert (r["n_pages"] or 0) >= 1
assert (r["n_slides"] or 0) >= 1
# La FK orders.customer_id -> customers.id se detecta por containment.
assert _has_fk(r["db_profile"], "orders", "customer_id", "customers"), \
r["db_profile"].get("fk_candidates")
# El Markdown menciona las 3 tablas y la relación.
md = open(r["md_path"], encoding="utf-8").read()
for t in ("customers", "orders", "products"):
assert t in md
assert "customer_id" in md
def test_edge_empty_folder(tmp_path):
"""Carpeta vacía -> status ok con documento mínimo, sin lanzar."""
folder = tmp_path / "empty"
folder.mkdir()
out = tmp_path / "out"
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 0
# Aun sin tablas, emite el documento-base mínimo (portada + resumen vacío +
# relaciones "sin FK").
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["md_path"] and os.path.exists(r["md_path"])
def test_edge_single_table_no_relations(tmp_path):
"""Carpeta con 1 sola tabla -> funciona sin relaciones (capítulo 'sin FK')."""
folder = tmp_path / "single"
folder.mkdir()
with open(folder / "lonely.csv", "w", encoding="utf-8") as fh:
fh.write("a,b\n1,x\n2,y\n3,z\n")
out = tmp_path / "out"
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 1
assert not (r["db_profile"].get("fk_candidates") or [])
md = open(r["md_path"], encoding="utf-8").read()
assert "Sin relaciones FK" in md or "sin FK" in md.lower()
def test_accepts_existing_duckdb(tmp_path):
"""Rama 'ya es una DuckDB': un archivo .duckdb existente se perfila directo."""
db = tmp_path / "base.duckdb"
conn = duckdb.connect(str(db))
try:
conn.execute("CREATE TABLE customers (id INTEGER, name VARCHAR)")
conn.execute("INSERT INTO customers VALUES (1,'Ana'),(2,'Luis'),(3,'Eva')")
conn.execute("CREATE TABLE orders (oid INTEGER, customer_id INTEGER)")
conn.execute("INSERT INTO orders VALUES (10,1),(11,2),(12,1),(13,3)")
finally:
conn.close()
out = tmp_path / "out"
r = render_automatic_eda_folder(str(db), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 2
assert r["db_path"] == str(db)
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
def test_emit_flags_select_formats(tmp_path):
"""emit_pdf/pptx/md controlan qué formatos se emiten."""
folder = tmp_path / "demo"
folder.mkdir()
_write_demo_folder(str(folder))
out = tmp_path / "out"
r = render_automatic_eda_folder(
str(folder), out_dir=str(out),
emit_pdf=True, emit_pptx=False, emit_md=False,
)
assert r["status"] == "ok", r
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] is None
assert r["md_path"] is None
def test_path_does_not_exist(tmp_path):
"""Path inexistente -> status error, sin lanzar."""
r = render_automatic_eda_folder(str(tmp_path / "nope"))
assert r["status"] == "error"
assert "no existe" in r["error"].lower()
def test_relaciones_chapter_has_real_figure_when_edges():
"""Con edges, el capítulo de relaciones incluye un bloque Figure matplotlib
REAL (no solo el texto Mermaid): su make() devuelve una Figure."""
db_profile = {
"join_graph": {
"nodes": [
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dim"},
],
"edges": [{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id",
"cardinality": "N:1"}],
"mermaid": "graph LR orders --> customers",
"hubs": ["orders"],
},
"fk_candidates": [{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id",
"inclusion": 1.0, "cardinality": "N:1"}],
}
ch = _relaciones_chapter(db_profile)
figs = [b for b in ch["blocks"] if b.get("kind") == "figure"]
assert len(figs) == 1, ch["blocks"]
# El make() perezoso produce una matplotlib Figure real.
import matplotlib
matplotlib.use("Agg")
fig = figs[0]["make"]()
from matplotlib.figure import Figure
assert isinstance(fig, Figure)
assert fig.get_axes(), "la Figure del join graph debe tener al menos un eje"
def test_relaciones_chapter_no_figure_when_no_edges():
"""Sin edges, no se añade bloque Figure (capítulo dice 'sin FK')."""
db_profile = {"join_graph": {"nodes": [], "edges": [], "mermaid": "",
"hubs": []}, "fk_candidates": []}
ch = _relaciones_chapter(db_profile)
assert not [b for b in ch["blocks"] if b.get("kind") == "figure"]
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"contextily>=1.7.0",
"cryptography>=46.0.6",
"duckdb>=1.5.2",
"faker>=40.27.0",
"fpdf2>=2.8.7",
"geopandas>=1.1.3",
"google-api-python-client>=2.197.0",
+14
View File
@@ -839,6 +839,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
[[package]]
name = "faker"
version = "40.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/7b/c62c98764137c949be240ad83f763b6f96cf76055952a3e2835359acc3af/faker-40.27.0.tar.gz", hash = "sha256:f697cf07f461474ad7d511164c21f45317e69f1d531d25f3e0f872b639e346a1", size = 2018361, upload-time = "2026-06-30T18:05:17.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/b2/788aae329da3d7e4f08f8e1a82e82243c3376c0f3f49b75ae29eea40b371/faker-40.27.0-py3-none-any.whl", hash = "sha256:6099bd6d7bc79041b46c28e100815e2558952bcf384b76ce6c71c8bdca744256", size = 2057897, upload-time = "2026-06-30T18:05:15.555Z" },
]
[[package]]
name = "fastapi"
version = "0.136.3"
@@ -890,6 +902,7 @@ dependencies = [
{ name = "contextily" },
{ name = "cryptography" },
{ name = "duckdb" },
{ name = "faker" },
{ name = "fpdf2" },
{ name = "geopandas" },
{ name = "google-api-python-client" },
@@ -949,6 +962,7 @@ requires-dist = [
{ name = "contextily", specifier = ">=1.7.0" },
{ name = "cryptography", specifier = ">=46.0.6" },
{ name = "duckdb", specifier = ">=1.5.2" },
{ name = "faker", specifier = ">=40.27.0" },
{ name = "fpdf2", specifier = ">=2.8.7" },
{ name = "geopandas", specifier = ">=1.1.3" },
{ name = "gliner", marker = "extra == 'nlp'", specifier = ">=0.2.13" },