Compare commits

...

18 Commits

Author SHA1 Message Date
egutierrez 54a9ab70c7 feat(eda): render AutomaticEDA por capítulos sueltos con resolución de dependencias
Permite renderizar un SUBCONJUNTO de capítulos del informe AutomaticEDA
(only_chapters=[...]) para iterar/testear un capítulo concreto sin generar el
documento entero, garantizando que el capítulo pedido SIEMPRE llegue poblado.

- Nuevo módulo automatic_eda/chapter_deps.py: mapa central CHAPTER_DEPS (fuente
  de verdad) que declara, por capítulo de CHAPTER_ORDER, qué flags de cómputo
  (run_models/run_series/run_llm) y qué piezas de ctx (raw_numeric, timeseries_raw,
  geo_points, head_rows, db_path/table) necesita para no salir degradado. Helpers
  puros: resolve_requirements, resolve_profile_flags, needs_render_ctx,
  resolve_ctx_data_keys, validate_chapter_ids.
- build_document(profile, ctx, only=None): parámetro only opcional que restringe
  el cuerpo a esos capítulos (portada primera + glosario última siempre). Lee la
  clave reservada ctx['_only_chapters'] cuando only es None, para propagar la
  selección a través de los renderers sin modificarlos. Retrocompatible.
- render_automatic_eda(..., only_chapters=None): valida los ids (error claro
  dict-no-throw), resuelve las dependencias activando el cómputo necesario aunque
  el caller no lo pidiera (un flag explícito siempre prima) y construyendo solo
  las piezas de ctx que los capítulos pedidos leen (salta build_eda_render_ctx
  entero si ninguno necesita datos crudos). only_chapters=None produce el
  documento completo idéntico al de hoy.
- Tests: chapter_deps_test.py (resolución pura), build_document_only_test.py
  (filtro), render_automatic_eda_only_test.py (golden con DuckDB: outliers suelto
  con IsolationForest poblado por resolución; timeseries activa run_series;
  eficiencia geospatial sin modelos; edge cases).
- .md del pipeline: documenta only_chapters + emit_md; version 1.1.0 -> 1.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:35:46 +02:00
egutierrez 50c05d126c merge(eda): capitulo OUTLIERS — univariante (Tukey/z) + multivariante (IsolationForest) 2026-06-30 21:15:05 +02:00
egutierrez 6f88f184f1 feat(eda): capítulo OUTLIERS — valores atípicos univariantes + multivariantes
Nuevo capítulo dedicado `outliers` para el motor AutomaticEDA que reúne y
profundiza en un solo sitio el análisis de valores atípicos, hoy disperso entre
`num_distr` (conteo por columna) y `modelos` (IsolationForest). Se registra en
`chapters_registry.py` entre `missingness` y `correlacion` (bloque de calidad de
datos: calidad → missingness → outliers).

Contenido del capítulo:
- Resumen univariante por columna: nº y % de atípicos por Tukey (1.5·IQR) y por
  z-score (|z| > 3), con vallas inferior/superior y valores extremos. Ordenado
  por contaminación y marcando las columnas más afectadas. Reusa las funciones
  del registry `build_boxplot_stats` (vallas desde los percentiles del profile)
  y `detect_outliers` (regla z-score sobre la muestra cruda de `ctx`).
- Boxplots de Tukey de las columnas más contaminadas (caja, bigotes y puntos
  atípicos), delegados a la función nueva `build_boxplots_figure`.
- Multivariante: filas anómalas considerando todas las columnas a la vez con
  `isolation_forest_outliers` — nº y % de filas, las más anómalas con su score y
  las dimensiones que las hacen raras (top columnas por |z|, vía la función nueva
  `summarize_outlier_dims`). El detector se corre en vivo sobre `raw_numeric`
  para que el indexado de filas coincida exactamente con el de las dimensiones;
  cae al bloque precomputado del perfil cuando no hay muestra cruda (preset lite).
- Interpretación exploratoria: un atípico no es necesariamente un error
  (distingue error de dato vs dato real extremo) y recomendaciones (revisar,
  winsorizar o re-expresar, enlazando con la re-expresión de Tukey del perfil).

Términos clicables registrados en el glosario compartido: `outlier`,
`tukey_fence`, `zscore`, `isolation_forest`.

Funciones nuevas del registry (dominio datascience, grupo eda):
- `build_boxplots_figure_py_datascience` (figure helper, impura)
- `summarize_outlier_dims_py_datascience` (pura)

El capítulo se activa con ≥1 columna numérica y devuelve None en su ausencia;
lee todo defensivo y nunca lanza. Tests: capítulo (golden + edges + error path +
render PDF/PPTX) y ambas funciones nuevas. Suite de no-regresión de AutomaticEDA
verde. Verificado end-to-end con el dataset Titanic (Fare/Parch/SibSp como las
columnas más contaminadas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:12:40 +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 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 e5abc18211 merge(eda): capitulo MISSINGNESS — patrones de nulos (co-ocurrencia + MCAR/MAR) 2026-06-30 20:42:46 +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 9da1ee6533 merge(eda): capitulo text_distr (TEXTO/NLP) — primer capitulo no tabular 2026-06-30 20:41:29 +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 5d4a48ec5e merge(eda): scatters de pares correlacionados + tipo de relacion en cap CORRELACION 2026-06-30 20:39:16 +02:00
egutierrez 7fa19d65db feat(eda): capítulo MISSINGNESS — patrones de datos faltantes (co-ocurrencia + MCAR/MAR)
Añade el capítulo `missingness` al motor AutomaticEDA, complemento natural de
`calidad`: donde calidad reporta cuánto falta por columna, este capítulo analiza
el PATRÓN de los nulos — dónde faltan y si las columnas faltan juntas
(co-ocurrencia de ausencias), la señal que distingue MCAR de MAR antes de imputar.

Capítulo (`chapters/missingness.py`), registrado en `chapters_registry.py` justo
tras `calidad`:
- Resumen global: % de celdas faltantes, columnas con nulos, filas completas vs
  incompletas.
- Ranking por columna (tabla + barras horizontales).
- Co-ocurrencia: correlación de las máscaras is-null entre columnas (heatmap +
  tabla de los pares que co-faltan, con co-faltantes y Jaccard).
- Patrones de fila más frecuentes (estilo matriz de missingno).
- Lectura MCAR/MAR exploratoria (heurística por correlación/solape de ausencias,
  no confirmatoria), que cita la evidencia concreta.
- Términos de glosario clicables: missingness, MCAR, MAR.

La máscara is-null por fila de TODAS las columnas (numéricas y categóricas) se
construye con un push-down DuckDB sobre ctx['db_path']/table (mismo patrón que el
capítulo agregación), con fallback a ctx['raw_numeric'] cuando no hay BD. Activa
solo si la tabla tiene nulos; si no, devuelve None.

Funciones nuevas del grupo `eda` (dominio datascience):
- extract_null_mask (impura): máscara is-null por fila vía query_fn.
- missingness_overview (pura): resumen global + filas completas/incompletas.
- missingness_correlation (pura): correlación de ausencias + pares + Jaccard,
  reutiliza pearson.
- missingness_row_patterns (pura): patrones de fila más comunes.
- missingness_corr_heatmap_figure / missingness_rank_bar_figure (impuras): figuras.

Verificado: EDA de titanic genera el capítulo en PDF + PPTX + MD con Cabin 77.1%,
Age 19.9% y la co-ocurrencia Age↔Cabin (158 filas). Suite completa de AutomaticEDA
+ render_automatic_eda en verde (125 passed); tests por función y por capítulo;
fn index sin error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:39 +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 105e56cf05 feat(eda): capítulo text_distr (TEXTO/NLP) — primer capítulo de datos no tabulares
Añade el capítulo `text_distr` al motor AutomaticEDA: perfila columnas de texto
libre largo (reseñas, descripciones, comentarios) que la distribución categórica
no resume bien. Sigue el patrón de cat_distr/num_distr (build_text_distr(profile,
ctx) -> Chapter | None) y se registra en CHAPTER_ORDER tras cat_distr.

Activación en dos fases: gate barato desde el perfil (columna no numérica con
len_mean >= 50 chars) + confirmación con muestra cruda (mediana de palabras >= 20).
Un dataset sin texto largo (p.ej. titanic) devuelve None sin tocar el informe.

Bloques por columna (Group con page_break): resumen (longitudes, vocabulario con
TTR y % hapax, idioma dominante, % duplicados, legibilidad), histograma de
longitudes, top términos (tabla + barras), bigramas/trigramas, idiomas detectados
y nube de palabras opcional. Términos ttr/hapax enganchados al glosario clicable.

Lógica delegada a 7 funciones nuevas del registry (datascience, tag eda),
estilo dict-no-throw:
- extract_text_sample (impura, push-down SQL DuckDB/Postgres)
- compute_text_length_stats, compute_vocabulary_stats, compute_top_ngrams (puras, stdlib)
- detect_corpus_language (langdetect opcional), compute_text_readability (textstat
  opcional), compute_text_duplicates (hash + datasketch opcional)

Versión barata sin modelos pesados: las piezas que dependen de una librería
opcional (langdetect, textstat, wordcloud, datasketch) degradan a omitidas sin
lanzar. Añade langdetect y textstat (ligeras) al pyproject + uv.lock.

Verificado: golden sobre dataset de reviews multi-idioma (capítulo presente en
PDF+PPTX+MD con métricas reales), titanic sin capítulo (None), degradación sin
libs, suite automatic_eda + pipeline verde (128 passed), fn index OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:17 +02:00
egutierrez eaca41a532 feat(eda): scatters de pares más correlacionados + tipo de relación en capítulo CORRELACION
Añade al capítulo `correlacion` del AutomaticEDA la visualización con scatters de
los pares numérico-numérico más correlacionados (positiva y negativamente) y,
para cada uno, la clasificación del tipo de relación: lineal, polinómica
(grado 2/3), monótona no-lineal o débil/sin forma.

Funciones nuevas del registry (dominio datascience, grupo eda):
- classify_relationship_type_py_datascience (pura): dadas dos listas numéricas
  pareadas, cruza Pearson r (lineal), Spearman ρ (monótona) y ajustes
  polinómicos de grado 2 y 3 (numpy.polyfit + R² manual) para etiquetar la
  forma. Reusa pearson y spearman_corr del registry. Umbrales calibrados para
  datos reales discretos/ruidosos (orden: débil → monótona → polinómica →
  lineal). Devuelve los coeficientes del mejor modelo para pintar la curva.
  No-throw.
- relationship_scatter_figure_py_datascience (impure): construye la Figure
  matplotlib del scatter de un par con su recta/curva de ajuste y una anotación
  del tipo + métricas (r, ρ, R²lin, R²poly). Backend Agg sin pyplot global,
  downsample determinista de los puntos dibujados, tendencia ordenada (binned /
  por valor) para el caso monótona sin polinomio. Defensiva ante vacío.

Capítulo correlacion.py (1.0.0 → 1.1.0): nueva sección "Relaciones más fuertes
(scatter)" tras la matriz + tablas top. Toma los top-K pares num↔num por |valor|
de profile['correlations']['pairs'], obtiene los datos crudos de cada par desde
ctx['raw_numeric'] y emite, por par, un Figure dentro de un Group keep-together
junto a una nota de texto con el tipo de relación (extraíble por pdftotext).
Solo num↔num: los pares cat↔cat (Cramér's V) y num↔cat (razón de correlación)
no llevan scatter. Cuando no hay raw_numeric (perfil lite/agregado o ctx None)
los scatters se omiten sin lanzar; la matriz + tablas siguen.

Verificado: golden EDA de titanic (run_models) — el capítulo Correlación del PDF
y PPTX incluye los scatters (pclass↔fare → monótona no-lineal, sibsp↔parch →
lineal, …) con su ajuste y etiqueta de tipo en texto. Tests de clasificación
sintética (lineal, y=x² → polinómica, y=exp(x) → monótona, ruido → débil) +
tests del capítulo (golden con raw_numeric, edge sin raw, par sin columna). Suite
automatic_eda + pipeline render_automatic_eda verde (141 passed). fn index sin
error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:37:01 +02:00
egutierrez e815f5b3b9 merge(eda): MD del AutomaticEDA vuelca TODOS los datos del profile (28 pares, skew/kurtosis/percentiles, scores_by_k) 2026-06-30 20:31:50 +02:00
egutierrez 7ec2bb1b45 feat(eda): el Markdown del AutomaticEDA vuelca TODOS los datos del profile
El .md del grupo `eda` es la salida pensada para pegar a un LLM, así que debe
contener todo lo que el motor computó, aunque el PDF/PPTX (vista humana) resuman.
La evaluación 2053 detectó 6 datos que el .md perdía respecto al profile. Se
cierran de forma aditiva (el .md tiene MÁS que el PDF/PPTX, sin tocar esos
renderers ni los capítulos).

render_automatic_eda.py pasa el profile al serializador Markdown vía
meta['profile'] (un meta propio del MD; el de PDF/PPTX queda intacto).
render_md_impl.py añade un "Apéndice — Datos completos del perfil" al final del
documento, emitido solo cuando hay profile y degradando limpio cuando falta una
sección (lite sin modelos, profile sin correlaciones). El apéndice no se acopla
a los ids de capítulo (que editan otros agentes en paralelo).

Pérdidas cerradas:
1. Matriz de asociación COMPLETA: los N pares de correlations.pairs (no solo el
   top-17), incluidos correlation_ratio (num↔cat) y cramers_v (cat↔cat).
2. Numéricas: describe completo por columna — mean/median/mode/std/variance/cv,
   skew y kurtosis para TODAS (no solo las asimétricas), p1/p5/p25/p50/p75/p95/
   p99, iqr, min/max, outliers, distribution_type.
3. Re-expresión: nombra la transformación concreta (log1p/sqrt/yeo-johnson) con
   potencia, razón y alternativas, no un vago "considerar re-expresión".
4. KMeans: tabla scores_by_k (silhouette + inercia por k) marcando el k elegido.
5. Normalidad: el estadístico (stat) de cada test junto al p-value.
6. Encabezados de figuras de barras/scree dejan de heredar
   "Desde/Hasta/Frecuencia" del histograma; usan "Inicio/Fin/Valor" cuando el
   caption no es un histograma.

Test nuevo md_completeness_test.py: profile sintético, asserta los N pares de
correlación, skew/kurtosis de cada numérica, percentiles extendidos, log1p,
scores_by_k, stat de normalidad, headers de barras y los edges (sin modelos /
sin correlaciones / sin profile, defensivo).

Verificado con titanic (profile_level=full): 28 pares en la tabla (incl.
Sex↔Embarked cramers_v), 7 numéricas con skew+kurtosis, p5/p95/p99, scores_by_k
y JB/D'Agostino/Shapiro stat presentes. PDF/PPTX/manifest siguen saliendo.
Suite automatic_eda + render_automatic_eda_test: 134 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:27:30 +02:00
97 changed files with 12388 additions and 39 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
+8
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,9 +75,11 @@ 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
__all__ = [
"render_paper_pdf",
"draw_join_graph_figure",
"suggest_intratable_fk_candidates",
"detect_time_column",
@@ -92,6 +97,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,109 @@
"""Tests del filtro `only` de build_document (selección de capítulos).
Verifican que:
- only=None mantiene el comportamiento histórico (todos los capítulos).
- only=[ids] restringe el CUERPO a esos ids, pero portada (primera) y glosario
(última) están SIEMPRE presentes.
- only=[] produce el documento mínimo (solo portada + glosario).
- la selección también viaja por la clave reservada ctx['_only_chapters']
(el canal que usan los renderers, que llaman build_document sin `only`), y
esa clave nunca se filtra a los capítulos.
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
from datascience.automatic_eda import build_document # noqa: E402
def _profile_with_cat_and_num():
"""Perfil mínimo que hace construir cat_distr y num_distr (cuerpo no vacío)."""
return {
"table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91,
"duplicate_pct": 1.5, "null_cell_pct": 0.8,
"columns": [
{"name": "region", "inferred_type": "categorical",
"categorical": {
"top": [{"value": "norte", "count": 50, "pct": 0.42},
{"value": "sur", "count": 40, "pct": 0.33},
{"value": "este", "count": 30, "pct": 0.25}],
"mode": "norte", "n_distinct": 3, "entropy": 1.55,
"imbalance": 0.1}},
{"name": "importe", "inferred_type": "numeric",
"numeric": {"mean": 50.0, "median": 48.0, "std": 10.0,
"min": 10, "max": 99, "iqr": 15,
"histogram": [{"lo": 0, "hi": 50, "count": 40},
{"lo": 50, "hi": 100, "count": 80}]}},
],
}
def test_only_none_is_full_document():
"""Retro-compat: sin `only`, salen todos los capítulos aplicables."""
chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
ids = [c.id for c in chs]
assert ids[0] == "portada"
assert ids[-1] == "glosario"
# El cuerpo trae las distribuciones (cat/num), no solo portada+glosario.
assert "num_distr" in ids
assert "cat_distr" in ids
def test_only_restricts_body_but_keeps_cover_and_glossary():
# cat_distr registra el término "entropía" en el glosario, así que el
# glosario (destino del término clicable) aparece — demuestra el contrato
# "portada primera + capítulo + glosario última".
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v"}, only=["cat_distr"])
ids = [c.id for c in chs]
assert ids[0] == "portada", f"portada no es la primera: {ids}"
assert ids[-1] == "glosario", f"glosario no es la última: {ids}"
assert "cat_distr" in ids
# num_distr quedó fuera de la selección.
assert "num_distr" not in ids
def test_only_empty_yields_minimal_document():
# only=[] -> cuerpo vacío. La portada está siempre; el glosario solo aparece
# si algún capítulo registró términos (patrón preexistente: glosario vacío se
# omite). Sin cuerpo no hay términos → documento mínimo = solo portada.
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v"}, only=[])
ids = [c.id for c in chs]
assert ids == ["portada"], \
f"only=[] debe dar el documento mínimo (solo portada), no {ids}"
def test_selection_via_reserved_ctx_key():
"""La selección viaja por ctx['_only_chapters'] cuando no se pasa `only`."""
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v",
"_only_chapters": ["cat_distr"]})
ids = [c.id for c in chs]
assert "cat_distr" in ids
assert "num_distr" not in ids
assert ids[0] == "portada" and ids[-1] == "glosario"
def test_explicit_only_arg_wins_over_ctx_key():
"""Si se pasan ambos, el argumento `only` manda sobre la clave del ctx."""
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v",
"_only_chapters": ["cat_distr"]},
only=["num_distr"])
ids = [c.id for c in chs]
assert "num_distr" in ids
assert "cat_distr" not in ids
def test_reserved_key_not_leaked_to_caller_ctx():
"""build_document no muta el ctx del caller (copia interna)."""
ctx = {"dataset_name": "v", "_only_chapters": ["num_distr"]}
build_document(_profile_with_cat_and_num(), ctx=ctx)
# La clave reservada sigue en el dict del caller (no se mutó su copia).
assert ctx["_only_chapters"] == ["num_distr"]
@@ -0,0 +1,205 @@
"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA.
Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para
computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo
consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un
SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve
los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos
capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la
vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa.
Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se
evita tocar los ``chapters/<id>.py`` (cada agente es dueño de su capítulo) y se
elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del
``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira.
Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo:
- ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR
para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros:
* ``run_models`` -> ``profile['models']`` (KMeans/IsolationForest/PCA).
Lo leen ``outliers`` (fallback del multivariante) y ``modelos``.
* ``run_series`` -> ``profile['series']`` (análisis de serie temporal).
Lo lee ``timeseries``.
* ``run_llm`` -> ``profile['llm']`` (interpretación del modelo).
Lo lee ``analisis_llm``.
- ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye
``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está
vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse
``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide.
Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``):
* ``head_rows`` -> ``ctx['head_rows']`` (overview: df.head real).
* ``raw_numeric`` -> ``ctx['raw_numeric']`` (outliers/modelos/
correlacion/missingness/geospatial: muestra numérica alineada por fila).
* ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda).
* ``geo_points`` -> ``ctx['geo_points']`` (+ ``raw_numeric``)
(geospatial: lat/lon).
* ``db_path_table`` -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/
text_distr/missingness/relaciones: push-down de queries propias).
``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE
(la portada resume el documento y el glosario es el destino de los términos
clicables), así que aquí se declaran sin requisitos de cómputo.
Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan
a test unitario directo.
"""
from __future__ import annotations
# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los
# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las
# claves vacías significan "no necesita ese tipo de dependencia".
CHAPTER_DEPS = {
# Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee
# el document_summary que arma build_document; el glosario lee los términos
# que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER
# entero y la validación los reconozca.
"portada": {"profile_flags": [], "ctx": []},
"overview": {"profile_flags": [], "ctx": ["head_rows"]},
"analisis_llm": {"profile_flags": ["run_llm"], "ctx": []},
"num_distr": {"profile_flags": [], "ctx": []},
"cat_distr": {"profile_flags": [], "ctx": []},
# text_distr empuja su propia query de texto (no usa raw_numeric); necesita
# db_path/table en el ctx para hacerlo.
"text_distr": {"profile_flags": [], "ctx": ["db_path_table"]},
"calidad": {"profile_flags": [], "ctx": []},
# missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y
# puede empujar una query de patrón de nulos con db_path/table.
"missingness": {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]},
# outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models
# asegura además el fallback profile['models']['outliers'] si el ctx faltara.
"outliers": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
"correlacion": {"profile_flags": [], "ctx": ["raw_numeric"]},
"relaciones": {"profile_flags": [], "ctx": ["db_path_table"]},
"modelos": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
"timeseries": {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]},
"geospatial": {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]},
"agregacion": {"profile_flags": [], "ctx": ["db_path_table"]},
"glosario": {"profile_flags": [], "ctx": []},
}
# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters.
ALWAYS_PRESENT = ("portada", "glosario")
# Flags de coste reconocidos (el orden no importa; se devuelven como set).
KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm")
# Mapeo de cada etiqueta de ctx a las claves REALES que produce
# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se
# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no
# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/
# geo_points) son las piezas de datos podables.
CTX_LABEL_TO_KEYS = {
"head_rows": {"head_rows"},
"raw_numeric": {"raw_numeric"},
"timeseries_raw": {"timeseries_raw"},
"geo_points": {"geo_points", "raw_numeric"},
"db_path_table": set(), # db_path/table siempre presentes; nunca se podan.
}
# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo
# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí.
DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points")
def _as_id_list(chapter_ids):
"""Normaliza la entrada a una lista de ids string, defensiva. None -> []."""
if chapter_ids is None:
return []
if isinstance(chapter_ids, str):
return [chapter_ids]
return [c for c in chapter_ids if isinstance(c, str)]
def validate_chapter_ids(chapter_ids, order):
"""Separa los ids pedidos en válidos y desconocidos respecto a ``order``.
Args:
chapter_ids: lista (o str) de ids de capítulo pedidos.
order: lista canónica de ids válidos (CHAPTER_ORDER).
Returns:
dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de
aparición de la entrada. Función pura.
"""
valid_set = set(order or [])
valid, unknown = [], []
for cid in _as_id_list(chapter_ids):
(valid if cid in valid_set else unknown).append(cid)
return {"valid": valid, "unknown": unknown}
def resolve_requirements(chapter_ids):
"""Une los requisitos de cómputo de los capítulos pedidos.
Es el corazón de la resolución de dependencias: dado el subconjunto de
capítulos a renderizar, devuelve TODO lo que hay que activar/construir para
que esos capítulos lleguen COMPLETOS, y solo eso.
Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente
porque el pipeline siempre los incluye; como no tienen requisitos, no alteran
el resultado, pero se contemplan para que el conjunto sea coherente.
Args:
chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se
ignoran silenciosamente (la validación estricta es de quien llama).
None o lista vacía -> requisitos vacíos.
Returns:
dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde
``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función
pura.
"""
ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT)
profile_flags = set()
ctx_keys = set()
for cid in ids:
dep = CHAPTER_DEPS.get(cid)
if not isinstance(dep, dict):
continue
for f in dep.get("profile_flags", []) or []:
if f in KNOWN_PROFILE_FLAGS:
profile_flags.add(f)
for k in dep.get("ctx", []) or []:
ctx_keys.add(k)
return {"profile_flags": profile_flags, "ctx_keys": ctx_keys}
def resolve_profile_flags(chapter_ids):
"""Atajo: solo el set de profile_flags a activar para los capítulos pedidos.
Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS.
"""
return resolve_requirements(chapter_ids)["profile_flags"]
def needs_render_ctx(chapter_ids):
"""True si algún capítulo pedido necesita datos crudos del ctx.
Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero
(ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de
datos crudos. Función pura.
"""
return bool(resolve_requirements(chapter_ids)["ctx_keys"])
def resolve_ctx_data_keys(chapter_ids):
"""Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos.
Traduce las etiquetas de ctx a las claves concretas que produce
``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points).
El pipeline poda del ctx las claves de datos que NO estén en este set, para
que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table
nunca se podan (no aparecen aquí). Función pura.
Returns:
set[str] subconjunto de DATA_CTX_KEYS.
"""
req = resolve_requirements(chapter_ids)
keep = set()
for label in req["ctx_keys"]:
keep |= CTX_LABEL_TO_KEYS.get(label, set())
# Solo claves de datos podables (db_path/table se gestionan aparte).
return {k for k in keep if k in DATA_CTX_KEYS}
@@ -0,0 +1,160 @@
"""Tests del mapa central de dependencias por capítulo (chapter_deps).
Todas las funciones bajo prueba son PURAS (sin I/O): se ejercitan directamente
sin DuckDB ni renderizado. Cubren la resolución de requisitos (golden + edges),
la validación de ids y los helpers de eficiencia (qué cómputo se salta).
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
from datascience.automatic_eda.chapter_deps import ( # noqa: E402
ALWAYS_PRESENT,
CHAPTER_DEPS,
DATA_CTX_KEYS,
needs_render_ctx,
resolve_ctx_data_keys,
resolve_profile_flags,
resolve_requirements,
validate_chapter_ids,
)
from datascience.automatic_eda.chapters_registry import CHAPTER_ORDER # noqa: E402
# --------------------------------------------------------------------------- #
# El mapa cubre CHAPTER_ORDER entero (sin huecos ni claves de más).
# --------------------------------------------------------------------------- #
def test_chapter_deps_covers_every_chapter_in_order():
assert set(CHAPTER_DEPS) == set(CHAPTER_ORDER), (
"CHAPTER_DEPS debe declarar exactamente los ids de CHAPTER_ORDER")
# Cada entrada tiene la forma esperada.
for cid, dep in CHAPTER_DEPS.items():
assert isinstance(dep.get("profile_flags"), list), cid
assert isinstance(dep.get("ctx"), list), cid
# --------------------------------------------------------------------------- #
# resolve_requirements — golden: outliers exige run_models + raw_numeric.
# --------------------------------------------------------------------------- #
def test_resolve_outliers_requires_run_models_and_raw_numeric():
req = resolve_requirements(["outliers"])
assert "run_models" in req["profile_flags"]
assert "raw_numeric" in req["ctx_keys"]
assert "run_series" not in req["profile_flags"]
assert "run_llm" not in req["profile_flags"]
def test_resolve_timeseries_requires_run_series():
req = resolve_requirements(["timeseries"])
assert req["profile_flags"] == {"run_series"}
assert "timeseries_raw" in req["ctx_keys"]
def test_resolve_analisis_llm_requires_run_llm():
assert resolve_requirements(["analisis_llm"])["profile_flags"] == {"run_llm"}
def test_resolve_union_of_several_chapters():
req = resolve_requirements(["outliers", "timeseries", "analisis_llm"])
assert req["profile_flags"] == {"run_models", "run_series", "run_llm"}
# --------------------------------------------------------------------------- #
# Eficiencia: capítulos que NO necesitan flags caros no los activan.
# --------------------------------------------------------------------------- #
def test_resolve_geospatial_needs_no_cost_flags():
"""geospatial sale de geo_points/raw_numeric del ctx, NO de los modelos."""
req = resolve_requirements(["geospatial"])
assert req["profile_flags"] == set(), \
"geospatial no debe activar run_models/run_series/run_llm"
assert "geo_points" in req["ctx_keys"]
def test_resolve_correlacion_needs_raw_numeric_but_no_models():
req = resolve_requirements(["correlacion"])
assert req["profile_flags"] == set()
assert "raw_numeric" in req["ctx_keys"]
def test_always_present_chapters_add_no_requirements():
"""portada y glosario están siempre, pero no arrastran cómputo."""
for cid in ALWAYS_PRESENT:
req = resolve_requirements([cid])
assert req["profile_flags"] == set()
assert req["ctx_keys"] == set()
def test_resolve_profile_flags_shortcut():
assert resolve_profile_flags(["modelos"]) == {"run_models"}
assert resolve_profile_flags(["num_distr"]) == set()
# --------------------------------------------------------------------------- #
# needs_render_ctx — cuándo se puede saltar build_eda_render_ctx por completo.
# --------------------------------------------------------------------------- #
def test_needs_render_ctx_true_when_chapter_reads_raw_data():
assert needs_render_ctx(["outliers"]) is True
assert needs_render_ctx(["agregacion"]) is True # db_path/table push-down
assert needs_render_ctx(["timeseries"]) is True
def test_needs_render_ctx_false_for_purely_aggregated_chapters():
"""num_distr / cat_distr / calidad solo leen el profile agregado."""
assert needs_render_ctx(["num_distr"]) is False
assert needs_render_ctx(["cat_distr", "calidad"]) is False
# --------------------------------------------------------------------------- #
# resolve_ctx_data_keys — poda: qué claves de DATOS conservar (db_path/table no).
# --------------------------------------------------------------------------- #
def test_resolve_ctx_data_keys_outliers_keeps_only_raw_numeric():
assert resolve_ctx_data_keys(["outliers"]) == {"raw_numeric"}
def test_resolve_ctx_data_keys_geospatial_keeps_geo_and_numeric():
assert resolve_ctx_data_keys(["geospatial"]) == {"geo_points", "raw_numeric"}
def test_resolve_ctx_data_keys_aggregation_keeps_nothing_prunable():
"""agregacion usa db_path/table (siempre presentes), 0 claves podables."""
assert resolve_ctx_data_keys(["agregacion"]) == set()
def test_resolve_ctx_data_keys_subset_of_data_keys():
keep = resolve_ctx_data_keys(["overview", "timeseries", "geospatial"])
assert keep <= set(DATA_CTX_KEYS)
assert {"head_rows", "timeseries_raw", "geo_points", "raw_numeric"} == keep
# --------------------------------------------------------------------------- #
# validate_chapter_ids — separa válidos de desconocidos preservando orden.
# --------------------------------------------------------------------------- #
def test_validate_separates_known_and_unknown():
out = validate_chapter_ids(["outliers", "nope", "timeseries", "ghost"],
CHAPTER_ORDER)
assert out["valid"] == ["outliers", "timeseries"]
assert out["unknown"] == ["nope", "ghost"]
def test_validate_all_known():
out = validate_chapter_ids(["portada", "glosario"], CHAPTER_ORDER)
assert out["unknown"] == []
# --------------------------------------------------------------------------- #
# Robustez: entradas raras nunca lanzan.
# --------------------------------------------------------------------------- #
def test_resolve_handles_none_and_empty():
assert resolve_requirements(None)["profile_flags"] == set()
assert resolve_requirements([])["profile_flags"] == set()
# ids desconocidos se ignoran silenciosamente en la resolución.
assert resolve_requirements(["no_existe"])["ctx_keys"] == set()
def test_resolve_accepts_single_string():
assert resolve_requirements("outliers")["profile_flags"] == {"run_models"}
@@ -31,7 +31,7 @@ import math
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "correlacion"
CHAPTER_TITLE = "Correlación"
@@ -47,6 +47,13 @@ _MAX_MATRIX_LABELS = 16
# How many pairs to show in each of the top-positive / top-negative tables.
_TOP_N = 10
# How many of the strongest numeric-numeric pairs to draw as scatter plots on
# each sign (positive / negative). A scatter per pair carries a fitted line/curve
# and a relationship-type label; keeping the count small keeps the chapter
# readable on a phone / a slide. Only signed (Pearson/Spearman) pairs qualify —
# Cramér's V / correlation ratio pairs are not numeric-numeric, so no scatter.
_SCATTER_TOP_N = 3
# Glossary terms this chapter explains. Each is registered in the shared
# collector (ctx['glossary']) and marked clickable on its first appearance in the
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
@@ -314,6 +321,139 @@ def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
return " ".join(parts)
def _is_seq(values) -> bool:
"""True for a non-empty list/tuple of values (a raw numeric column)."""
return isinstance(values, (list, tuple)) and len(values) > 0
def _select_scatter_pairs(pairs: list, top_n: int = _SCATTER_TOP_N):
"""Pick the strongest numeric-numeric pairs to draw as scatters.
Only signed (Pearson/Spearman) pairs are numeric-numeric and thus eligible
for a scatter with a fitted curve. Returns up to ``top_n`` of the strongest
positive pairs followed by up to ``top_n`` of the strongest negative ones,
each ranked by magnitude. Mixed-type metrics (Cramér's V, correlation ratio,
mutual information) are excluded — they have no x/y scatter interpretation.
"""
positive = []
negative = []
for pair in pairs:
if not isinstance(pair, dict) or not _is_signed(pair):
continue
value = pair.get("value")
if not _is_num(value):
continue
if value > 0:
positive.append(pair)
elif value < 0:
negative.append(pair)
positive.sort(key=lambda p: abs(float(p.get("value", 0.0))), reverse=True)
negative.sort(key=lambda p: abs(float(p.get("value", 0.0))), reverse=True)
return positive[:top_n] + negative[:top_n]
def _classification_note(a: str, b: str, cls: dict) -> str:
"""Human-readable sentence describing the relationship of a pair.
Plain text (not baked into the figure image) so the type label is selectable
in the PDF / extractable by pdftotext, and sits right next to its scatter
inside the keep-together Group.
"""
tipo = model._safe_str(cls.get("tipo")) or "sin forma clara"
bits = []
pearson = cls.get("pearson")
spearman = cls.get("spearman")
r2_lin = cls.get("r2_linear")
r2_poly = None
for key in ("r2_poly2", "r2_poly3"):
v = cls.get(key)
if _is_num(v) and (r2_poly is None or float(v) > r2_poly):
r2_poly = float(v)
if _is_num(pearson):
bits.append(f"Pearson r={float(pearson):+.2f}")
if _is_num(spearman):
bits.append(f"Spearman ρ={float(spearman):+.2f}")
if _is_num(r2_lin):
bits.append(f"R² lineal={float(r2_lin):.2f}")
if r2_poly is not None:
bits.append(f"R² polinómico={r2_poly:.2f}")
metrics = "; ".join(bits)
text = (f"Relación **{tipo}** entre «{a}» y «{b}»."
+ (f" {metrics}." if metrics else ""))
return text
def _scatter_blocks(pairs: list, raw_numeric):
"""Build keep-together scatter Groups for the strongest num-num pairs.
Returns a list of blocks (a Heading plus one Group per pair), or an empty
list when there is no raw numeric data (e.g. the lite profile drops
``ctx['raw_numeric']`` to skip live recomputation) or the relationship
helpers are unavailable. Never raises: any failure degrades to no scatters,
leaving the matrix + tables intact.
"""
if not isinstance(raw_numeric, dict) or not raw_numeric:
return []
selected = _select_scatter_pairs(pairs)
if not selected:
return []
# The relationship helpers live in the datascience package. Import lazily so
# the chapter still builds (matrix + tables) when they are absent.
try:
from datascience.classify_relationship_type import (
classify_relationship_type,
)
from datascience.relationship_scatter_figure import (
relationship_scatter_figure,
)
except Exception: # noqa: BLE001 — degrade, never break the chapter.
return []
groups = []
for pair in selected:
a = pair.get("a")
b = pair.get("b")
xs = raw_numeric.get(a)
ys = raw_numeric.get(b)
# Edge: a selected pair has no raw column (aggregated profile, renamed
# column, …) — skip just that pair, keep the rest.
if not _is_seq(xs) or not _is_seq(ys):
continue
try:
cls = classify_relationship_type(list(xs), list(ys)) or {}
except Exception: # noqa: BLE001
continue
a_lbl = model._safe_str(a)
b_lbl = model._safe_str(b)
def _make(xs=xs, ys=ys, a_lbl=a_lbl, b_lbl=b_lbl, cls=cls):
return relationship_scatter_figure(
list(xs), list(ys), x_label=a_lbl, y_label=b_lbl,
classification=cls)
groups.append(model.Group(blocks=[
model.Heading(text=f"{a_lbl}{b_lbl}", level=2),
model.Figure(
make=_make,
caption=(f"Dispersión de «{a_lbl}» frente a «{b_lbl}» con la "
"curva de ajuste del mejor modelo.")),
model.Markdown(text=_classification_note(a_lbl, b_lbl, cls)),
]))
if not groups:
return []
intro = model.Markdown(text=(
"Para los pares numéricos más fuertes (positivos y negativos) se dibuja "
"la nube de puntos con su ajuste y se clasifica el **tipo de relación**: "
"**lineal** (una recta basta), **polinómica** (curva de grado 2/3 que "
"mejora claramente el ajuste lineal), **monótona no-lineal** (crece o "
"decrece siempre pero no en línea recta; Spearman ≫ Pearson) o "
"**débil/sin forma**."))
return [model.Heading(text="Relaciones más fuertes (scatter)", level=2),
intro] + groups
def build_correlacion(profile: dict, ctx: dict):
"""Build the Correlation Chapter, or None if there are no pairs to show.
@@ -392,6 +532,18 @@ def build_correlacion(profile: dict, ctx: dict):
"No se han hallado correlaciones negativas significativas entre "
"columnas numéricas.")))
# 2.5) Scatter plots of the strongest numeric-numeric pairs, each with its
# fitted curve and a relationship-type label (lineal / polinómica / monótona
# / débil). Needs the raw numeric sample (ctx['raw_numeric'], row-aligned);
# when it is absent (aggregated/lite profile) the scatters are simply omitted
# and the matrix + tables above stand on their own.
raw_numeric = None
if isinstance(ctx, dict):
raw_numeric = ctx.get("raw_numeric") or profile.get("raw_numeric")
else:
raw_numeric = profile.get("raw_numeric")
blocks.extend(_scatter_blocks(pairs, raw_numeric))
# 3) Spuriousness caveat for level-based correlations (GrangerNewbold).
caveat = corr.get("levels_caveat")
if isinstance(caveat, str) and caveat.strip():
@@ -175,6 +175,105 @@ def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
assert "azufre" in _pdf_text(pdf)
def _raw_numeric_for_profile(n: int = 80) -> dict:
"""Row-aligned raw numeric sample matching the signed pairs of _profile().
Builds columns with a clear, deterministic shape so the relationship-type
classifier has something unambiguous to label:
- density vs alcohol: strong negative linear (the top-negative pair).
- alcohol vs quality: positive linear.
- ph, fixed_acidity, sulphates: filler columns for the remaining pairs.
"""
import math as _m
alcohol = [8.0 + 0.05 * i for i in range(n)]
density = [1.0 - 0.002 * a for a in alcohol] # neg linear vs alcohol
quality = [3.0 + 0.4 * a + (0.1 if i % 2 else -0.1) # pos linear vs alcohol
for i, a in enumerate(alcohol)]
ph = [3.0 + 0.3 * _m.sin(i / 5.0) for i in range(n)]
fixed_acidity = [7.0 - 0.5 * p for p in ph] # neg linear vs ph
sulphates = [0.5 + 0.01 * (i % 7) for i in range(n)]
return {
"alcohol": alcohol, "density": density, "quality": quality,
"ph": ph, "fixed_acidity": fixed_acidity, "sulphates": sulphates,
}
def test_golden_scatters_de_pares_num_num_con_tipo_de_relacion():
"""Con ctx['raw_numeric'], el capítulo añade scatters (Figure dentro de Group)
de los pares num-num más fuertes, cada uno con su etiqueta de tipo en texto."""
from datascience.automatic_eda.model import Group
ctx = {"raw_numeric": _raw_numeric_for_profile()}
ch = build_correlacion(_profile(), ctx)
assert ch is not None
groups = [b for b in ch.blocks if isinstance(b, Group)]
assert groups, "debe emitir al menos un Group con scatter"
# Cada Group lleva su figura (lazy) y una nota de texto con el tipo.
for g in groups:
gkinds = [b.kind for b in g.blocks]
assert "figure" in gkinds and "markdown" in gkinds
# La sección y la etiqueta de tipo aparecen como texto plano (extraíble).
headings = " ".join(b.text for b in ch.blocks if b.kind == "heading")
assert "Relaciones más fuertes" in headings
body = " ".join(b.text for g in groups for b in g.blocks
if b.kind == "markdown")
assert any(t in body for t in
("lineal", "polinómica", "monótona", "sin forma"))
# El par num-num más fuerte (density ↔ alcohol) tiene scatter; el par cat-cat
# (region ↔ type) NO — no es numérico.
assert "density" in body or "alcohol" in body
assert "region" not in body and "type" not in body
def test_golden_pdf_muestra_scatters_con_etiqueta_de_tipo():
"""En el PDF, el capítulo Correlación incluye los scatters y su etiqueta de
tipo en texto seleccionable (pdftotext la encuentra)."""
prof = _profile()
ctx = {"raw_numeric": _raw_numeric_for_profile()}
with tempfile.TemporaryDirectory() as d:
pdf = os.path.join(d, "corr_scatter.pdf")
rp = render_automatic_eda_pdf(prof, pdf, {"title": "EDA — wine",
"ctx": ctx})
assert rp["path"] == pdf and rp["n_pages"] >= 1
txt = _pdf_text(pdf)
assert "Relaciones" in txt and "scatter" in txt.lower()
# Alguna etiqueta de tipo de relación, en texto.
assert any(t in txt for t in
("lineal", "polin", "monóton", "monoton", "sin forma"))
def test_edge_sin_raw_numeric_omite_scatters_sin_lanzar():
"""profile lite / ctx None: sin raw_numeric el capítulo omite los scatters
pero sigue emitiendo matriz + tablas (no lanza)."""
from datascience.automatic_eda.model import Group
for ctx in (None, {}, {"raw_numeric": None}, {"raw_numeric": {}}):
ch = build_correlacion(_profile(), ctx)
assert ch is not None
assert not [b for b in ch.blocks if isinstance(b, Group)]
# La matriz y al menos una tabla top siguen presentes.
assert any(b.kind == "figure" for b in ch.blocks)
assert any(b.kind == "data_table" for b in ch.blocks)
def test_edge_par_sin_columna_cruda_se_omite_sin_lanzar():
"""Si un par seleccionado no tiene su columna en raw_numeric, se omite ese
par (no lanza); los demás scatters se construyen igual."""
from datascience.automatic_eda.model import Group
raw = _raw_numeric_for_profile()
raw.pop("density", None) # rompe el par density ↔ alcohol
ch = build_correlacion(_profile(), {"raw_numeric": raw})
assert ch is not None
groups = [b for b in ch.blocks if isinstance(b, Group)]
body = " ".join(b.text for g in groups for b in g.blocks
if b.kind == "markdown")
# density desaparece de los scatters; otros pares (p.ej. ph↔fixed_acidity,
# alcohol↔quality) pueden seguir presentes sin error.
assert "density" not in body
def test_glosario_engancha_metodos_y_fdr():
"""Mejora 4b: los métodos de correlación (Pearson, Spearman, Cramér's V,
razón de correlación) y la corrección por comparaciones múltiples (FDR) se
@@ -0,0 +1,594 @@
"""Missingness chapter (MISSINGNESS) — patterns of missing data.
Complements the CALIDAD chapter: where CALIDAD reports *how much* is missing per
column (the null percentage that lowers the completeness score), this chapter
reports the **pattern** of the missing data — whether columns tend to be missing
*together* (co-occurrence of absences) or independently. That distinction is what
separates data that is missing completely at random ([[term:mcar]]MCAR[[/term]])
from data missing as a function of another variable ([[term:mar]]MAR[[/term]]),
which is the key question to settle before imputing or modelling.
The chapter activates only when the table actually has missing data (at least one
column with a null in the aggregated profile); otherwise it returns ``None`` and
disappears from the document.
Sections, in order:
1. **Resumen global** — % of missing cells in the dataset, number of columns with
nulls, and complete rows (no missing) vs incomplete rows (≥1 missing).
2. **Ranking por columna** — columns sorted by their null percentage, with a
horizontal bar figure.
3. **Co-ocurrencia de ausencias** — the correlation of the binary is-null masks
between columns (which columns tend to be missing together): a heatmap plus a
table of the top column pairs that co-miss.
4. **Patrones de fila** — the most frequent "which columns are missing together"
row patterns, in the style of missingno's pattern matrix.
5. **Lectura MCAR/MAR** — an interpretive, *exploratory* note (not a confirmatory
test such as Little's) reading the absence correlations as a hint of MCAR
(independent absences) vs MAR (co-occurring absences).
The aggregate per-column null counts come from the ``eda`` group ``TableProfile``
(``columns[i]['null_count'] / 'null_pct'`` and the table-level ``null_cell_pct``).
The per-row is-null mask needed for co-occurrence is built from raw data: a single
DuckDB push-down over ``ctx['db_path'] / ctx['table']`` (same pattern as the
AGREGACION chapter) covering ALL columns, with a fallback to the numeric-only
``ctx['raw_numeric']`` when no database is reachable. All the heavy lifting is
delegated to pure registry functions (``missingness_overview``,
``missingness_correlation``, ``missingness_row_patterns``) and two figure helpers
(``missingness_rank_bar_figure``, ``missingness_corr_heatmap_figure``); every one
is imported lazily and degrades to an honest note so this chapter never raises.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "missingness"
CHAPTER_TITLE = "Datos faltantes"
# Sample cap for the per-row is-null mask push-down. Co-occurrence and row
# patterns are computed on this sample; the global % of missing cells and the
# per-column ranking come from the (exact) aggregated profile instead.
MASK_SAMPLE = 5000
# Thresholds for the MCAR/MAR heuristic note. A pair counts as a *strong*
# co-occurrence when the absence correlation alone is high; as a *partial*
# co-occurrence when the absences overlap materially (high Jaccard) even if the
# Pearson correlation is modest — the usual case when one column is missing far
# more often than the other (e.g. Cabin 77% vs Age 20% in Titanic), which dilutes
# the correlation while the rows still co-miss in absolute terms.
_CORR_STRONG = 0.30
_JACCARD_NOTABLE = 0.20
# Rows shown in the top-pairs and row-patterns tables (bounded, never silently
# truncated: the table note reports the full count).
_TOP_PAIRS = 12
_TOP_PATTERNS = 12
# Truncate long column names in tables (the renderer also wraps).
_LABEL_MAX = 28
# Glossary terms this chapter explains (contract §11.1). Registered in the shared
# collector and marked clickable on their first appearance.
_TERMS = {
"missingness": (
"Patrón de datos faltantes (missingness)",
"El patrón con el que faltan los datos: cuánto falta, en qué columnas y "
"si las ausencias de unas columnas coinciden (co-ocurren) con las de "
"otras. Analizarlo —no solo contar nulos— distingue datos que faltan al "
"azar (MCAR) de los que faltan en función de otra variable (MAR), lo que "
"decide cómo imputar o si descartar filas sin sesgar el análisis.",
),
"mcar": (
"MCAR (Missing Completely At Random)",
"Los valores faltan de forma independiente de cualquier dato, observado o "
"no: las ausencias de unas columnas no se relacionan entre sí ni con los "
"valores. Es el caso más benigno —descartar filas o imputar la media no "
"introduce sesgo—, pero rara vez se cumple del todo en datos reales.",
),
"mar": (
"MAR (Missing At Random)",
"La probabilidad de que un valor falte depende de OTRAS variables "
"observadas (p. ej. una medición que falta más en cierto grupo). Las "
"ausencias co-ocurren entre columnas o se relacionan con los valores de "
"otras; imputar exige condicionar en esas variables para no sesgar. La "
"co-ocurrencia fuerte de ausencias es un indicio (exploratorio) de MAR.",
),
}
# --------------------------------------------------------------------------- #
# Small defensive formatters (own copy: the chapter never imports siblings).
# --------------------------------------------------------------------------- #
def _fmt_int(value) -> str:
if value is None:
return ""
try:
return f"{int(round(float(value))):,}".replace(",", ".")
except (TypeError, ValueError):
return model._safe_str(value)
def _fmt_pct(value, decimals: int = 1) -> str:
"""Format an already-0-100 value as a percentage. None -> placeholder."""
if value is None:
return ""
try:
return f"{float(value):.{decimals}f}%"
except (TypeError, ValueError):
return model._safe_str(value)
def _fmt_num(value, decimals: int = 3) -> str:
if value is None:
return ""
try:
f = float(value)
except (TypeError, ValueError):
return model._safe_str(value)
if f != f: # NaN
return ""
text = f"{f:.{decimals}f}".rstrip("0").rstrip(".")
return text if text else "0"
def _truncate(text, limit: int = _LABEL_MAX) -> str:
s = model._safe_str(text)
if len(s) <= limit:
return s
return s[: max(1, limit - 1)].rstrip() + ""
def _term(key: str, label: str, mark: bool) -> str:
if mark:
return f"[[term:{key}]]**{label}**[[/term]]"
return f"**{label}**"
# --------------------------------------------------------------------------- #
# Profile reads (exact, all rows).
# --------------------------------------------------------------------------- #
def _null_count_of(col: dict):
"""Best-effort null count of a column: ``null_count`` or null_pct*n_rows."""
nc = col.get("null_count")
if isinstance(nc, (int, float)) and not isinstance(nc, bool):
return int(nc)
np_ = col.get("null_pct")
nr = col.get("n_rows")
if isinstance(np_, (int, float)) and isinstance(nr, (int, float)):
return int(round(float(np_) * float(nr)))
return 0
def _columns_with_nulls(profile: dict):
"""Return ``[(name, null_count, null_pct_0_100)]`` for columns with nulls,
sorted by null percentage descending. Reads the aggregated profile (exact)."""
cols = profile.get("columns") or []
out = []
for c in cols:
if not isinstance(c, dict):
continue
nc = _null_count_of(c)
if nc <= 0:
continue
np_ = c.get("null_pct")
nr = c.get("n_rows") or profile.get("n_rows")
if isinstance(np_, (int, float)) and not isinstance(np_, bool):
pct = float(np_) * 100.0 if np_ <= 1.0 else float(np_)
elif nr:
pct = nc / float(nr) * 100.0
else:
pct = None
out.append((c.get("name") or "(col)", nc, pct))
out.sort(key=lambda t: (t[2] if t[2] is not None else -1.0), reverse=True)
return out
def _global_missing_pct(profile: dict):
"""Table-level % of missing cells (0-100), exact, from the profile."""
v = profile.get("null_cell_pct")
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v) * 100.0 if v <= 1.0 else float(v)
return None
# --------------------------------------------------------------------------- #
# Per-row is-null mask (sample): DuckDB push-down, fallback to raw_numeric.
# --------------------------------------------------------------------------- #
def _build_query_fn(ctx: dict):
"""Return ``(query_fn, table)`` for a DuckDB-backed ctx, or ``(None, None)``.
Mirrors build_eda_render_ctx: a read-only closure over the registry wrapper.
Only DuckDB is supported here; any other backend degrades to raw_numeric."""
db_path = ctx.get("db_path")
table = ctx.get("table")
if not db_path or not table:
return None, None
try:
from infra import duckdb_query_readonly
except Exception: # noqa: BLE001 — wrapper unavailable -> degrade.
return None, None
def query_fn(sql):
return duckdb_query_readonly(db_path, sql)
return query_fn, table
def _null_mask(profile: dict, ctx: dict):
"""Build the per-row is-null mask ``{col: [0/1, ...]}``.
Tries a single DuckDB push-down over ALL columns first (so categorical
columns like Cabin are covered, not only numeric ones); falls back to the
numeric-only ``ctx['raw_numeric']`` (None -> missing); returns ``(None, 0,
None)`` when neither is reachable. Never raises.
Returns ``(mask, n_sampled, source)`` with source in {"db","raw_numeric"}.
"""
cols = profile.get("columns") or []
names = [c.get("name") for c in cols
if isinstance(c, dict) and c.get("name")]
# 1) DuckDB push-down over every column (covers categoricals too).
query_fn, table = _build_query_fn(ctx)
if query_fn is not None and names:
try:
from datascience.extract_null_mask import extract_null_mask
res = extract_null_mask(query_fn, table, names, max_rows=MASK_SAMPLE)
if isinstance(res, dict) and res.get("status") == "ok":
mask = res.get("mask") or {}
if mask:
return mask, int(res.get("n") or 0), "db"
except Exception: # noqa: BLE001 — degrade to raw_numeric.
pass
# 2) Fallback: numeric-only mask derived from raw_numeric (None -> missing).
rn = ctx.get("raw_numeric")
if isinstance(rn, dict) and rn:
mask = {}
for col, vals in rn.items():
if isinstance(vals, (list, tuple)):
mask[col] = [1 if v is None else 0 for v in vals]
if mask:
n = max((len(v) for v in mask.values()), default=0)
return mask, n, "raw_numeric"
return None, 0, None
# --------------------------------------------------------------------------- #
# Lazy registry delegations (each degrades to None on any failure).
# --------------------------------------------------------------------------- #
def _overview(mask: dict):
try:
from datascience.missingness_overview import missingness_overview
out = missingness_overview(mask)
return out if isinstance(out, dict) else None
except Exception: # noqa: BLE001
return None
def _correlation(mask: dict, top_k: int):
try:
from datascience.missingness_correlation import missingness_correlation
out = missingness_correlation(mask, top_k=top_k)
return out if isinstance(out, dict) else None
except Exception: # noqa: BLE001
return None
def _row_patterns(mask: dict, top_n: int):
try:
from datascience.missingness_row_patterns import missingness_row_patterns
out = missingness_row_patterns(mask, top_n=top_n)
return out if isinstance(out, dict) else None
except Exception: # noqa: BLE001
return None
def _rank_bar_make(names, pcts, title):
def make():
try:
from datascience.missingness_rank_bar_figure import (
missingness_rank_bar_figure,
)
return missingness_rank_bar_figure(names, pcts, title=title)
except Exception: # noqa: BLE001 — minimal fallback figure.
return _fallback_fig("ranking de nulos no disponible")
return make
def _heatmap_make(matrix, labels, title):
def make():
try:
from datascience.missingness_corr_heatmap_figure import (
missingness_corr_heatmap_figure,
)
return missingness_corr_heatmap_figure(matrix, labels, title=title)
except Exception: # noqa: BLE001 — minimal fallback figure.
return _fallback_fig("heatmap de co-ocurrencia no disponible")
return make
def _fallback_fig(message: str):
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
fig = Figure(figsize=(5.0, 2.2))
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, message, ha="center", va="center")
ax.axis("off")
return fig
# --------------------------------------------------------------------------- #
# Block builders.
# --------------------------------------------------------------------------- #
def _summary_block(profile: dict, with_nulls: list, overview, sampled, n_total):
rows = []
gpct = _global_missing_pct(profile)
rows.append(("Celdas faltantes (global)", _fmt_pct(gpct)))
rows.append(("Columnas con faltantes", str(len(with_nulls))))
all_null = profile.get("all_null_cols")
if isinstance(all_null, (list, tuple)) and all_null:
rows.append(("Columnas 100% faltantes", str(len(all_null))))
if isinstance(overview, dict):
cr = overview.get("complete_rows")
ir = overview.get("incomplete_rows")
suffix = ""
if (isinstance(sampled, int) and isinstance(n_total, (int, float))
and sampled and n_total and sampled < n_total):
suffix = f" (sobre muestra de {_fmt_int(sampled)} filas)"
if cr is not None:
rows.append(("Filas completas (sin faltantes)",
f"{_fmt_int(cr)} ({_fmt_pct(overview.get('complete_pct'))})"
+ suffix))
if ir is not None:
rows.append(("Filas con ≥1 faltante",
f"{_fmt_int(ir)} "
f"({_fmt_pct(overview.get('incomplete_pct'))})" + suffix))
return model.KVTable(rows=rows, title="Resumen de datos faltantes")
def _ranking_block(with_nulls: list):
header = ["Columna", "Faltantes", "% faltante"]
rows = [[_truncate(n), _fmt_int(c), _fmt_pct(p)] for (n, c, p) in with_nulls]
if not rows:
return None
return model.DataTable(
header=header, rows=rows, title="Faltantes por columna",
note="ordenado de más a menos faltante")
def _ranking_figure(with_nulls: list):
names = [n for (n, _, p) in with_nulls if p is not None]
pcts = [p for (_, _, p) in with_nulls if p is not None]
if not names:
return None
return model.Figure(
make=_rank_bar_make(names, pcts, "% de valores faltantes por columna"),
caption="Porcentaje de valores faltantes por columna (barras).")
def _pairs_block(corr: dict):
"""Top column pairs whose absences co-occur, as a table, or None."""
pairs = (corr or {}).get("pairs") or []
header = ["Columna A", "Columna B", "Corr. ausencia", "Co-faltan", "Jaccard"]
rows = []
for p in pairs[:_TOP_PAIRS]:
if not isinstance(p, dict):
continue
rows.append([
_truncate(p.get("a")),
_truncate(p.get("b")),
_fmt_num(p.get("corr")),
_fmt_int(p.get("co_missing")),
_fmt_num(p.get("jaccard")),
])
if not rows:
return None
shown = len(rows)
total = len(pairs)
note = ("correlación de las máscaras is-null entre columnas; "
"«Co-faltan» = nº de filas en que ambas faltan a la vez")
if total > shown:
note += f" — top {shown} de {total} pares"
return model.DataTable(header=header, rows=rows,
title="Pares de columnas que co-faltan", note=note)
def _heatmap_block(corr: dict):
cols = (corr or {}).get("columns") or []
matrix = (corr or {}).get("matrix") or []
if len(cols) < 2 or not matrix:
return None
labels = [_truncate(c, 16) for c in cols]
return model.Figure(
make=_heatmap_make(matrix, labels, "Co-ocurrencia de ausencias"),
caption=("Correlación de las ausencias entre columnas (azul = faltan "
"juntas; rojo = cuando una falta la otra tiende a estar)."))
def _patterns_block(patterns_res: dict):
patterns = (patterns_res or {}).get("patterns") or []
header = ["Columnas que faltan juntas", "Filas", "%"]
rows = []
for p in patterns[:_TOP_PATTERNS]:
if not isinstance(p, dict):
continue
cols = p.get("missing_cols") or []
if cols:
label = ", ".join(_truncate(c, 18) for c in cols)
else:
label = "(fila completa — sin faltantes)"
rows.append([label, _fmt_int(p.get("n_rows")), _fmt_pct(p.get("pct"))])
if not rows:
return None
total = (patterns_res or {}).get("n_patterns")
shown = len(rows)
note = "cada fila es un patrón de «qué columnas faltan juntas»"
if isinstance(total, int) and total > shown:
note += f" — top {shown} de {total} patrones distintos"
return model.DataTable(header=header, rows=rows,
title="Patrones de fila más comunes", note=note)
def _mcar_mar_note(corr: dict, mark: bool):
"""Interpretive, exploratory MCAR/MAR note from the absence correlations.
Reads the absence correlations at two levels so the verdict never contradicts
the visible evidence: a *strong* correlation flags a clear non-random (MAR)
pattern; a *partial* overlap (many rows co-miss — high Jaccard — even if the
correlation is diluted by one column being missing far more often) flags a
localized possible-MAR and cites the concrete co-missing pair; only when
neither holds does it read the absences as compatible with MCAR."""
def _pairs_with(attr_ok):
out = []
for p in (corr or {}).get("pairs") or []:
if isinstance(p, dict) and attr_ok(p):
out.append(p)
return out
def _cf(v):
try:
return float(v)
except (TypeError, ValueError):
return 0.0
strong = _pairs_with(lambda p: abs(_cf(p.get("corr"))) >= _CORR_STRONG)
partial = _pairs_with(
lambda p: _cf(p.get("corr")) > 0 and _cf(p.get("jaccard")) >= _JACCARD_NOTABLE)
mcar = _term("mcar", "MCAR", mark)
mar = _term("mar", "MAR", mark)
head = (
"**Lectura exploratoria MCAR/MAR.** Esta es una heurística basada en la "
"correlación de las ausencias entre columnas, NO un test confirmatorio "
"(como el de Little); orienta, no demuestra. ")
if strong:
top = strong[0]
ev = (f"«{model._safe_str(top.get('a'))}» y "
f"«{model._safe_str(top.get('b'))}» "
f"(corr {_fmt_num(top.get('corr'))})")
body = (
f"Hay ausencias que co-ocurren con fuerza —{ev}—: las columnas no "
f"faltan de forma independiente, lo que es un indicio de un patrón no "
f"aleatorio ({mar}). Antes de imputar o descartar filas conviene "
f"comprobar si la ausencia depende de otra variable observada; en ese "
f"caso la imputación debería condicionar en ella para no sesgar.")
elif partial:
top = max(partial, key=lambda p: _cf(p.get("jaccard")))
ev = (f"«{model._safe_str(top.get('a'))}» y "
f"«{model._safe_str(top.get('b'))}» faltan a la vez en "
f"{_fmt_int(top.get('co_missing'))} filas "
f"(Jaccard {_fmt_num(top.get('jaccard'))})")
body = (
f"Hay co-ocurrencia parcial de ausencias —{ev}—: algunas columnas "
f"tienden a faltar juntas aunque la correlación global sea modesta "
f"(habitual cuando una columna falta mucho más que la otra). Es un "
f"indicio de un posible patrón localizado no aleatorio ({mar}); "
f"conviene revisar si esa ausencia depende de otra variable observada "
f"antes de imputar, en lugar de asumir que faltan al azar.")
else:
body = (
f"Las ausencias entre columnas no muestran correlación ni solape "
f"relevante: parecen independientes, lo que es compatible con que "
f"falten al azar ({mcar}). Aun así, la ausencia podría depender de "
f"variables no observadas (la heurística no lo descarta).")
return model.Markdown(text=head + body)
def _intro_block(mark: bool, source):
missingness = _term("missingness", "missingness", mark)
text = (
f"Este capítulo analiza el {missingness} de la tabla: no solo cuánto "
"falta (eso lo cubre la calidad), sino DÓNDE falta y si las columnas "
"faltan juntas. La co-ocurrencia de ausencias se calcula sobre la matriz "
"binaria «is-null» por fila.")
if source == "raw_numeric":
text += (" Nota: no se pudo leer la tabla cruda completa, así que la "
"co-ocurrencia se limita a las columnas numéricas disponibles.")
return model.Markdown(text=text)
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def build_missingness(profile: dict, ctx: dict):
"""Build the missingness Chapter, or None if the table has no missing data."""
if not isinstance(profile, dict):
profile = {}
ctx = ctx or {}
with_nulls = _columns_with_nulls(profile)
if not with_nulls:
return None # no missing data anywhere -> chapter does not apply.
# Register glossary terms (if a collector is present) and mark them clickable.
glossary = ctx.get("glossary")
mark = False
if isinstance(glossary, model.GlossaryCollector):
for key, (label, definition) in _TERMS.items():
glossary.add(key, label, definition)
mark = True
# Per-row is-null mask (sample) for co-occurrence and row patterns.
mask, sampled, source = _null_mask(profile, ctx)
overview = _overview(mask) if mask else None
n_total = profile.get("n_rows")
blocks = [
model.Heading(text="Cuánto y dónde faltan datos", level=2),
_intro_block(mark, source),
_summary_block(profile, with_nulls, overview, sampled, n_total),
model.Heading(text="Faltantes por columna", level=2),
]
ranking = _ranking_block(with_nulls)
if ranking is not None:
blocks.append(ranking)
rank_fig = _ranking_figure(with_nulls)
if rank_fig is not None:
blocks.append(rank_fig)
# Co-occurrence + row patterns need the per-row mask. Without it, say so.
if not mask:
blocks.append(model.Note(
"No se pudo construir la matriz «is-null» por fila (sin acceso a los "
"datos crudos), así que no se analiza la co-ocurrencia de ausencias "
"ni los patrones de fila en este informe."))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
corr = _correlation(mask, _TOP_PAIRS) or {}
co_blocks = [model.Heading(text="Co-ocurrencia de ausencias", level=2)]
heatmap = _heatmap_block(corr)
if heatmap is not None:
co_blocks.append(heatmap)
pairs = _pairs_block(corr)
if pairs is not None:
co_blocks.append(pairs)
if heatmap is None and pairs is None:
co_blocks.append(model.Note(
"Ninguna pareja de columnas comparte ausencias con variación "
"suficiente para correlacionarlas (p. ej. una sola columna con "
"faltantes), así que no hay co-ocurrencia que mostrar."))
# Keep the co-occurrence heading next to its heatmap and table.
blocks.append(model.Group(blocks=co_blocks))
patterns_res = _row_patterns(mask, _TOP_PATTERNS) or {}
patterns = _patterns_block(patterns_res)
if patterns is not None:
blocks.append(model.Heading(text="Patrones de fila", level=2))
blocks.append(patterns)
blocks.append(model.Heading(text="Lectura MCAR / MAR", level=2))
blocks.append(_mcar_mar_note(corr, mark))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -0,0 +1,162 @@
"""Tests for the MISSINGNESS chapter.
Covers the Definition of Done for this chapter:
* Activates (non-None Chapter with the expected sections) when the profile has
missing data, building the co-occurrence from the per-row is-null mask.
* Returns None when the table has no missing data at all (edge case).
* Registers the MCAR/MAR/missingness glossary terms.
* The DuckDB push-down path covers categorical columns (not only numeric),
so a categorical column that co-misses with a numeric one is detected.
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
from datascience.automatic_eda import model # noqa: E402
from datascience.automatic_eda.chapters.missingness import ( # noqa: E402
build_missingness,
)
def _titles(chapter):
"""Collect heading texts and table/figure titles for assertions."""
out = []
for b in chapter.blocks:
kind = getattr(b, "kind", None)
if kind == "heading":
out.append(("heading", getattr(b, "text", "")))
elif kind in ("data_table", "kv_table"):
out.append((kind, getattr(b, "title", "")))
elif kind == "group":
for inner in getattr(b, "blocks", []):
ik = getattr(inner, "kind", None)
if ik == "heading":
out.append(("heading", getattr(inner, "text", "")))
elif ik in ("data_table", "kv_table"):
out.append((ik, getattr(inner, "title", "")))
elif ik == "figure":
out.append(("figure", getattr(inner, "caption", "")))
elif kind == "figure":
out.append(("figure", getattr(b, "caption", "")))
return out
def _all_text(chapter):
parts = []
def walk(blocks):
for b in blocks:
for attr in ("text", "title", "note", "caption"):
v = getattr(b, attr, None)
if v:
parts.append(str(v))
if getattr(b, "kind", None) == "group":
walk(getattr(b, "blocks", []))
walk(chapter.blocks)
return "\n".join(parts)
def test_returns_none_when_no_missing_data():
profile = {
"n_rows": 4,
"null_cell_pct": 0.0,
"columns": [
{"name": "a", "null_count": 0, "null_pct": 0.0, "n_rows": 4},
{"name": "b", "null_count": 0, "null_pct": 0.0, "n_rows": 4},
],
}
assert build_missingness(profile, {}) is None
def test_activates_with_cooccurrence_via_raw_numeric():
# a and b are missing in EXACTLY the same rows (0,1,2) -> perfect absence
# correlation. c has no nulls. No db_path -> the chapter falls back to the
# numeric raw_numeric mask.
profile = {
"n_rows": 6,
"null_cell_pct": (0.5 + 0.5 + 0.0) / 3.0,
"columns": [
{"name": "a", "null_count": 3, "null_pct": 0.5, "n_rows": 6},
{"name": "b", "null_count": 3, "null_pct": 0.5, "n_rows": 6},
{"name": "c", "null_count": 0, "null_pct": 0.0, "n_rows": 6},
],
}
glossary = model.GlossaryCollector()
ctx = {
"raw_numeric": {
"a": [None, None, None, 1.0, 2.0, 3.0],
"b": [None, None, None, 4.0, 5.0, 6.0],
},
"glossary": glossary,
}
ch = build_missingness(profile, ctx)
assert ch is not None
assert ch.id == "missingness"
assert ch.blocks
titles = _titles(ch)
headings = {t for (k, t) in titles if k == "heading"}
# Core sections present.
assert any("Cuánto y dónde" in h for h in headings)
assert any("Faltantes por columna" in h for h in headings)
assert any("Co-ocurrencia" in h for h in headings)
assert any("MCAR" in h for h in headings)
# A summary KVTable, a ranking DataTable, a co-occurrence figure and the
# pairs table all exist.
kinds = {k for (k, _) in titles}
assert "kv_table" in kinds
assert "data_table" in kinds
assert "figure" in kinds
# Glossary terms registered.
keys = {t["key"] for t in glossary.terms()}
assert {"missingness", "mcar", "mar"} <= keys
# The MCAR/MAR note reads the co-occurrence; with a perfect overlap it must
# flag the non-random (MAR) reading.
text = _all_text(ch)
assert "MAR" in text
def test_db_pushdown_covers_categorical_column(tmp_path):
"""The is-null mask push-down must cover a categorical column, so a
categorical that co-misses with a numeric one shows up in the pairs."""
import duckdb
db = str(tmp_path / "miss.duckdb")
con = duckdb.connect(db)
con.execute("CREATE TABLE t (num1 DOUBLE, num2 DOUBLE, cat VARCHAR)")
# num1 and cat are NULL together in the first 4 of 10 rows; num2 never null.
rows = []
for i in range(10):
if i < 4:
rows.append((None, float(i), None))
else:
rows.append((float(i), float(i), f"c{i}"))
con.executemany("INSERT INTO t VALUES (?,?,?)", rows)
con.close()
profile = {
"n_rows": 10,
"null_cell_pct": (0.4 + 0.0 + 0.4) / 3.0,
"columns": [
{"name": "num1", "null_count": 4, "null_pct": 0.4, "n_rows": 10},
{"name": "num2", "null_count": 0, "null_pct": 0.0, "n_rows": 10},
{"name": "cat", "null_count": 4, "null_pct": 0.4, "n_rows": 10},
],
}
ctx = {"db_path": db, "table": "t", "glossary": model.GlossaryCollector()}
ch = build_missingness(profile, ctx)
assert ch is not None
# The pairs table must mention both num1 and cat (they co-miss perfectly),
# which is only possible if the mask covered the categorical column.
text = _all_text(ch)
assert "num1" in text and "cat" in text
# Co-occurrence section + a pairs data table exist.
titles = _titles(ch)
assert any("co-faltan" in (t or "").lower() for (k, t) in titles)
@@ -0,0 +1,593 @@
"""Outliers chapter (OUTLIERS) — univariate + multivariate atypical values.
Today the analysis of atypical values is scattered across the document: the
NUM DISTR chapter mentions the per-column outlier count inside each distribution
figure, and the MODELOS chapter runs Isolation Forest as one of several cheap
models. This chapter gathers and deepens the whole outlier story in a single
place, with its interpretation: an [[term:outlier]]outlier[[/term]] is **not
necessarily an error** — it can be a legitimate, extreme but real observation —
so the reading is exploratory (what to look at), never confirmatory (what to
delete).
Sections, in order:
1. **Resumen univariante por columna** — for every numeric column, the number
and percentage of atypical values by two complementary criteria: Tukey's
1.5·IQR rule ([[term:tukey_fence]]vallas de Tukey[[/term]]) and the
[[term:zscore]]z-score[[/term]] rule (|z| > 3). The most contaminated columns
are flagged. The fences come from the pure registry function
``build_boxplot_stats`` (derived from the profile percentiles); the per-column
counts use the raw sample in ``ctx['raw_numeric']`` when available (the exact
count), degrading to the profile's own z-score counts otherwise.
2. **Boxplots** — a single figure with the Tukey boxplots of the most
contaminated columns (box, whiskers and atypical points), delegated to the
reusable registry helper ``build_boxplots_figure``.
3. **Multivariante (filas anómalas)** — rows that are atypical considering ALL
columns at once, via the registry function ``isolation_forest_outliers``: the
count and percentage of anomalous rows, the most anomalous rows with their
score, and the dimensions that make each one rare (top columns by |z|, via
``summarize_outlier_dims``). Run live on ``ctx['raw_numeric']`` (the same
numeric columns ``summarize_outlier_dims`` uses, so the row indexing stays
coherent and the dimension breakdown is correct); falls back to the
precomputed ``profile['models']['outliers']`` only when no raw sample is
available (e.g. the lite preset), where no per-row breakdown is shown.
4. **Interpretación** — outlier ≠ error: how to tell a data-entry error from a
genuine extreme value, and what to do (inspect, winsorize, or re-express —
linking to the Tukey re-expression the profile already computes).
The chapter activates whenever the table has at least one numeric column; with
no numeric column it returns ``None`` and disappears from the document.
Reads everything defensively (``.get``) and never raises: every registry
delegation is imported lazily and degraded to an honest note on any failure.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "outliers"
CHAPTER_TITLE = "Valores atípicos"
# z-score threshold for the univariate z rule: |z| > 3 flags a value ~3 standard
# deviations from the mean (≈99.7% of a normal distribution lies within ±3σ).
_Z_THRESH = 3.0
# How many columns to draw in the boxplots figure (most contaminated first) and
# how many anomalous rows to list in the multivariate table.
_TOP_BOX = 12
_TOP_ROWS = 12
# Cap on the raw atypical values passed as boxplot fliers, so a heavy-tailed
# column does not flood the figure with thousands of points.
_MAX_FLIERS = 200
# How many columns flagged as "most contaminated" in the summary note.
_TOP_FLAGGED = 3
# Glossary terms this chapter explains (contract §11.1). Registered in the shared
# collector and marked clickable on first appearance. ``isolation_forest`` and
# ``zscore`` may also be registered by the MODELOS chapter — ``add`` is
# idempotent (first definition wins), so registering them here is harmless and
# keeps this chapter self-contained when MODELOS does not render.
_TERM_DEFS = {
"outlier": (
"Valor atípico (outlier)",
"Una observación que se aparta mucho del grueso de los datos. Un atípico "
"NO es necesariamente un error: puede ser un fallo de medida o de "
"registro, pero también un dato real extremo (un cliente que gasta diez "
"veces la media, un día de ventas excepcional). Por eso se señalan para "
"revisarlos, no para borrarlos automáticamente.",
),
"tukey_fence": (
"Vallas de Tukey (1,5·IQR)",
"Regla clásica para marcar atípicos a partir de los cuartiles: se calcula "
"el rango intercuartílico IQR = P75 P25 y se trazan dos vallas, una "
"inferior en P25 1,5·IQR y otra superior en P75 + 1,5·IQR. Los valores "
"que caen fuera de esas vallas se consideran atípicos. Es robusta porque "
"se apoya en la mediana y los cuartiles, no en la media.",
),
"zscore": (
"z-score (puntuación típica)",
"Mide a cuántas desviaciones típicas está un valor de la media de su "
"columna: z = (valor media) / desviación típica. Un |z| grande (aquí > "
"3) señala un valor alejado del centro. A diferencia de las vallas de "
"Tukey, el z-score usa media y desviación, así que es más sensible a la "
"presencia de los propios atípicos.",
),
"isolation_forest": (
"Isolation Forest (anomalías multivariantes)",
"Algoritmo de detección de anomalías que considera TODAS las columnas a "
"la vez: construye árboles que parten el espacio con cortes aleatorios y "
"mide cuántos cortes hacen falta para aislar cada fila. Las filas raras "
"se aíslan con muy pocos cortes y se marcan como atípicas según un umbral "
"de contaminación. Detecta combinaciones de valores poco frecuentes que "
"ninguna columna por separado revelaría.",
),
}
# --------------------------------------------------------------------------- #
# Lazy registry delegations (each degrades to None / no-op on any failure).
# --------------------------------------------------------------------------- #
def _load_build_boxplot_stats():
try:
from datascience.build_boxplot_stats import build_boxplot_stats
return build_boxplot_stats
except Exception: # noqa: BLE001
return None
def _load_detect_outliers():
# detect_outliers lives in the monolithic ``datascience.datascience`` module
# (file_path datascience.py), not in its own submodule — try both shapes.
try:
from datascience.datascience import detect_outliers
return detect_outliers
except Exception: # noqa: BLE001
try:
from datascience import detect_outliers
return detect_outliers
except Exception: # noqa: BLE001
return None
def _load_isolation_forest():
try:
from datascience.isolation_forest_outliers import isolation_forest_outliers
return isolation_forest_outliers
except Exception: # noqa: BLE001
return None
def _load_summarize_dims():
try:
from datascience.summarize_outlier_dims import summarize_outlier_dims
return summarize_outlier_dims
except Exception: # noqa: BLE001
return None
# --------------------------------------------------------------------------- #
# Defensive formatters (own copy: the chapter never imports siblings).
# --------------------------------------------------------------------------- #
def _fmt_num(value, decimals: int = 3) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "" if value else "no"
if isinstance(value, int):
return f"{value:,}".replace(",", ".")
if isinstance(value, float):
if value != value: # NaN
return ""
if value in (float("inf"), float("-inf")):
return str(value)
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
return text if text else "0"
return model._safe_str(value)
def _fmt_int(value) -> str:
if value is None:
return ""
try:
return f"{int(round(float(value))):,}".replace(",", ".")
except (TypeError, ValueError):
return model._safe_str(value)
def _fmt_pct(value, decimals: int = 2) -> str:
"""Format an already-0-100 value as a percentage. None -> placeholder."""
if value is None:
return ""
try:
return f"{float(value):.{decimals}f}%"
except (TypeError, ValueError):
return model._safe_str(value)
def _term(mark: bool, key: str, text: str) -> str:
return f"[[term:{key}]]{text}[[/term]]" if mark else text
def _is_dict(v) -> bool:
return isinstance(v, dict)
# --------------------------------------------------------------------------- #
# Profile reads.
# --------------------------------------------------------------------------- #
def _numeric_columns(profile: dict) -> list:
"""Return [(name, numeric_dict)] for numeric columns with usable stats."""
out = []
for col in profile.get("columns") or []:
if not isinstance(col, dict):
continue
if col.get("inferred_type") != "numeric":
continue
num = col.get("numeric")
if not isinstance(num, dict) or not num:
continue
if num.get("mean") is None and num.get("median") is None:
continue
out.append((col.get("name") or "(columna)", num))
return out
def _clean_values(raw):
"""Return the finite float values of a raw column list (drop None/NaN/inf)."""
if not isinstance(raw, (list, tuple)):
return None
vals = []
for v in raw:
if v is None or isinstance(v, bool):
continue
try:
f = float(v)
except (TypeError, ValueError):
continue
if f != f or f in (float("inf"), float("-inf")):
continue
vals.append(f)
return vals
# --------------------------------------------------------------------------- #
# Per-column univariate summary.
# --------------------------------------------------------------------------- #
def _univariate_row(name, numeric, raw_vals, box_fn, detect_fn):
"""Compute one univariate summary row + boxplot inputs for a column.
Returns a dict with the table cells and, when raw values are available, the
exact Tukey/z counts and the list of atypical (flier) values; otherwise it
degrades to the profile's own z-score counts and the fence flags.
"""
box = {}
if box_fn is not None:
try:
box = box_fn(numeric) or {}
except Exception: # noqa: BLE001
box = {}
lf = box.get("lower_fence")
uf = box.get("upper_fence")
vals = _clean_values(raw_vals)
n_tukey = pct_tukey = None
n_z = pct_z = None
low_extreme = high_extreme = None
fliers = []
contamination = None # metric used to rank columns (prefer Tukey %).
if vals:
n = len(vals)
tukey_out = []
for v in vals:
below = (lf is not None and v < lf)
above = (uf is not None and v > uf)
if below or above:
tukey_out.append(v)
n_tukey = len(tukey_out)
pct_tukey = 100.0 * n_tukey / n if n else None
if tukey_out:
low_extreme = min(tukey_out)
high_extreme = max(tukey_out)
fliers = tukey_out[:_MAX_FLIERS]
# z-score rule via the registry function (returns parallel bools).
if detect_fn is not None:
try:
flags = detect_fn(vals, _Z_THRESH) or []
n_z = int(sum(1 for b in flags if b))
pct_z = 100.0 * n_z / n if n else None
except Exception: # noqa: BLE001
n_z = pct_z = None
contamination = pct_tukey
else:
# Degrade: no raw sample for this column. The profile's own outlier
# count/pct come from the z-score block (build_boxplot_stats note); the
# Tukey count is unknown, only the fence flags are.
n_z = numeric.get("n_outliers")
pct_z = numeric.get("outlier_pct")
if box.get("has_low_outliers") and box.get("min") is not None:
low_extreme = box.get("min")
if box.get("has_high_outliers") and box.get("max") is not None:
high_extreme = box.get("max")
contamination = pct_z if isinstance(pct_z, (int, float)) else None
# Compact "extremos atípicos" cell: down/up arrows for the low/high tail.
extremes = []
if low_extreme is not None:
extremes.append(f"{_fmt_num(low_extreme)}")
if high_extreme is not None:
extremes.append(f"{_fmt_num(high_extreme)}")
extremes_cell = " ".join(extremes) if extremes else ""
return {
"name": model._safe_str(name),
"n_tukey": n_tukey,
"pct_tukey": pct_tukey,
"n_z": n_z,
"pct_z": pct_z,
"lower_fence": lf,
"upper_fence": uf,
"extremes": extremes_cell,
"box": box,
"fliers": fliers,
"has_raw": bool(vals),
"contamination": contamination if isinstance(contamination, (int, float)) else -1.0,
}
def _univariate_table(rows: list) -> model.DataTable:
header = ["Columna", "Atípicos Tukey", "% Tukey", "Atípicos z", "% z",
"Valla inf.", "Valla sup.", "Extremos atípicos"]
table_rows = []
for r in rows:
table_rows.append([
r["name"],
_fmt_int(r["n_tukey"]) if r["n_tukey"] is not None else "",
_fmt_pct(r["pct_tukey"]) if r["pct_tukey"] is not None else "",
_fmt_int(r["n_z"]) if r["n_z"] is not None else "",
_fmt_pct(r["pct_z"]) if r["pct_z"] is not None else "",
_fmt_num(r["lower_fence"]),
_fmt_num(r["upper_fence"]),
r["extremes"],
])
return model.DataTable(
header=header, rows=table_rows,
title="Valores atípicos por columna",
note="Tukey = fuera de las vallas 1,5·IQR · z = |z-score| > 3 · "
"ordenado de más a menos contaminada")
# --------------------------------------------------------------------------- #
# Multivariate (Isolation Forest) section.
# --------------------------------------------------------------------------- #
def _resolve_multivariate(profile: dict, ctx: dict, raw_numeric):
"""Return (outliers_dict_or_None, source).
Prefers a LIVE Isolation Forest over ``raw_numeric`` so the detector and
``summarize_outlier_dims`` use EXACTLY the same numeric columns and the same
valid-row indexing — otherwise the precomputed ``profile['models']
['outliers']`` (run by MODELOS over a possibly different column subset) would
yield ``row_index`` values that no longer point at the rows
``summarize_outlier_dims`` reconstructs, mislabelling the "dimensions that
make each row rare". Falls back to the precomputed block when no raw sample
is available (e.g. the lite preset drops ``raw_numeric``)."""
if _is_dict(raw_numeric) and raw_numeric:
iso = _load_isolation_forest()
if iso is not None:
try:
out = iso(raw_numeric)
if _is_dict(out) and out.get("n_outliers") is not None and out.get("n_rows_used"):
return out, "live"
except Exception: # noqa: BLE001
pass
# Fallback: the model the MODELOS chapter already computed (no raw sample to
# recompute against, so no per-row dimension breakdown either).
models = profile.get("models") if _is_dict(profile.get("models")) else {}
pre = models.get("outliers") if _is_dict(models) else None
if _is_dict(pre) and pre.get("n_outliers") is not None and pre.get("n_rows_used"):
return pre, "precomputed"
return None, "none"
def _multivariate_blocks(outliers: dict, raw_numeric, mark: bool) -> list:
isof = _term(mark, "isolation_forest", "**Isolation Forest**")
blocks = [
model.Heading(text="Filas atípicas (multivariante)", level=2),
model.Markdown(text=(
f"Hasta aquí cada columna se ha mirado por separado. {isof} busca "
"filas raras considerando **todas las columnas a la vez**: una fila "
"puede ser normal en cada variable y aun así ser atípica por la "
"**combinación** de sus valores (p. ej. una edad baja con una tarifa "
"muy alta). La tabla resume cuántas filas se marcaron y el umbral de "
"decisión.")),
model.KVTable(rows=[
("Filas analizadas", _fmt_int(outliers.get("n_rows_used"))),
("Columnas consideradas", _fmt_int(outliers.get("n_features"))),
("Filas atípicas", _fmt_int(outliers.get("n_outliers"))),
("% filas atípicas", _fmt_pct(outliers.get("outlier_pct"))),
("Umbral de decisión", _fmt_num(outliers.get("threshold"), 4)),
], title="Anomalías multivariantes"),
]
rows_in = outliers.get("outlier_rows") or []
if not rows_in:
return blocks
# Enrich each anomalous row with the dimensions that make it rare, when the
# raw sample is available (summarize_outlier_dims reconstructs the same
# valid-row indexing as isolation_forest_outliers).
dims_by_row = {}
if _is_dict(raw_numeric) and raw_numeric:
summ = _load_summarize_dims()
if summ is not None:
try:
enriched = summ(raw_numeric, rows_in, top_k=3) or []
for e in enriched:
if _is_dict(e) and e.get("row_index") is not None:
dims_by_row[e.get("row_index")] = e.get("dims") or []
except Exception: # noqa: BLE001
dims_by_row = {}
has_dims = bool(dims_by_row)
header = ["Fila (entre válidas)", "Score"]
if has_dims:
header.append("Dimensiones que la hacen rara (col = valor, z)")
table_rows = []
for r in rows_in[:_TOP_ROWS]:
if not _is_dict(r):
continue
ridx = r.get("row_index")
cells = [_fmt_int(ridx), _fmt_num(r.get("score"), 4)]
if has_dims:
dims = dims_by_row.get(ridx) or []
parts = []
for d in dims:
if not _is_dict(d):
continue
parts.append(
f"{model._safe_str(d.get('col'))} = {_fmt_num(d.get('value'))} "
f"(z {_fmt_num(d.get('z'), 2)})")
cells.append("; ".join(parts) if parts else "")
table_rows.append(cells)
if table_rows:
shown = len(table_rows)
total = outliers.get("n_outliers")
note = "las filas más anómalas primero (score más bajo = más rara)"
if isinstance(total, int) and total > shown:
note += f" — top {shown} de {total}"
if not has_dims:
note += (" · no se pudo recuperar la muestra cruda para explicar las "
"dimensiones de cada fila")
blocks.append(model.DataTable(
header=header, rows=table_rows,
title="Filas más atípicas", note=note))
return blocks
# --------------------------------------------------------------------------- #
# Interpretation section.
# --------------------------------------------------------------------------- #
def _interpretation_block(mark: bool) -> model.Markdown:
outlier = _term(mark, "outlier", "atípico")
text = (
f"**Un {outlier} no es necesariamente un error.** Conviene distinguir "
"dos casos antes de actuar:\n\n"
"- **Error de dato** (medida, registro o unidad equivocada): una edad de "
"200 años, un importe negativo donde no puede haberlo, un decimal "
"desplazado. Estos sí se corrigen o se eliminan, idealmente en el origen.\n"
"- **Dato real extremo**: una observación legítima de la cola de la "
"distribución (un cliente que gasta mucho más, una tarifa de lujo, un día "
"de ventas excepcional). Borrarla sesga el análisis y oculta información "
"valiosa.\n\n"
"**Qué hacer.** Primero, **revisar** los valores señalados arriba contra "
"su origen para decidir cuál de los dos casos es. Si son errores, "
"corregirlos. Si son datos reales que distorsionan medias y modelos, hay "
"alternativas a borrarlos: **winsorizar** (recortar los extremos a un "
"percentil), o **re-expresar** la variable (por ejemplo una "
"transformación logarítmica o la escalera de re-expresión de Tukey que "
"este mismo perfil ya calcula para las columnas asimétricas), que suele "
"domar la cola sin perder ninguna fila. La elección depende del objetivo: "
"esta lectura es **exploratoria** —orienta dónde mirar—, no una regla "
"automática de limpieza.")
return model.Markdown(text=text)
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def build_outliers(profile: dict, ctx: dict):
"""Build the OUTLIERS Chapter, or None if the dataset has no numeric column."""
profile = profile or {}
ctx = ctx or {}
if not isinstance(profile, dict):
return None
numerics = _numeric_columns(profile)
if not numerics:
return None # chapter does not apply to a dataset with no numerics.
# Register glossary terms (if a collector is present) and mark them clickable.
glossary = ctx.get("glossary")
mark = False
if isinstance(glossary, model.GlossaryCollector):
for key, (label, definition) in _TERM_DEFS.items():
glossary.add(key, label, definition)
mark = True
raw_numeric = ctx.get("raw_numeric")
raw_numeric = raw_numeric if isinstance(raw_numeric, dict) else {}
box_fn = _load_build_boxplot_stats()
detect_fn = _load_detect_outliers()
# --- Univariate summary ------------------------------------------------- #
uni_rows = []
for name, numeric in numerics:
uni_rows.append(_univariate_row(
name, numeric, raw_numeric.get(name), box_fn, detect_fn))
# Rank columns by contamination (Tukey % when available, else z %).
uni_rows.sort(key=lambda r: r.get("contamination", -1.0), reverse=True)
intro = (
"Este capítulo reúne en un solo sitio el análisis de los **valores "
"atípicos** de la tabla, que en el resto del informe aparecen dispersos. "
f"Un {_term(mark, 'outlier', 'atípico')} es una observación que se aparta "
"mucho del grueso de los datos. Cada columna numérica se evalúa con dos "
f"criterios complementarios: las {_term(mark, 'tukey_fence', 'vallas de Tukey')} "
"(fuera de P251,5·IQR o P75+1,5·IQR, robusto a la propia cola) y el "
f"{_term(mark, 'zscore', 'z-score')} (|z| > 3, sensible a la media). La "
"tabla está ordenada de la columna más contaminada a la menos.")
blocks = [
model.Heading(text=CHAPTER_TITLE, level=1),
model.Markdown(text=intro),
_univariate_table(uni_rows),
]
# Flag the most contaminated columns explicitly.
flagged = [r["name"] for r in uni_rows
if r.get("contamination", -1.0) > 0][:_TOP_FLAGGED]
if flagged:
names = ", ".join(f"**{n}**" for n in flagged)
blocks.append(model.Markdown(text=(
f"Las columnas con mayor proporción de atípicos son {names}: "
"concentran el grueso de los valores fuera de las vallas y son las "
"primeras a revisar.")))
# --- Boxplots figure ---------------------------------------------------- #
box_entries = [
{"name": r["name"], "box": r["box"], "fliers": r.get("fliers")}
for r in uni_rows
if r.get("box")
][:_TOP_BOX]
if box_entries:
def _boxplots_make(entries=box_entries):
try:
from datascience.build_boxplots_figure import build_boxplots_figure
return build_boxplots_figure(
entries, title="Boxplots de Tukey por columna",
max_boxes=_TOP_BOX)
except Exception: # noqa: BLE001 — minimal fallback figure.
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
fig = Figure(figsize=(5.0, 2.2))
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, "(boxplots no disponibles)",
ha="center", va="center")
ax.axis("off")
return fig
blocks.append(model.Group(blocks=[
model.Heading(text="Boxplots", level=2),
model.Markdown(text=(
"Cada caja abarca del primer al tercer cuartil (P25P75), la línea "
"interior es la mediana y los bigotes llegan hasta 1,5·IQR; los "
"puntos son los valores que caen fuera de las vallas (atípicos por "
"Tukey).")),
model.Figure(
make=_boxplots_make,
caption="Boxplots de Tukey de las columnas más contaminadas."),
]))
# --- Multivariate ------------------------------------------------------- #
outliers, _src = _resolve_multivariate(profile, ctx, raw_numeric)
if outliers is not None:
blocks.extend(_multivariate_blocks(outliers, raw_numeric, mark))
else:
blocks.append(model.Heading(text="Filas atípicas (multivariante)", level=2))
blocks.append(model.Note(
"No se pudo analizar la anomalía multivariante: hacen falta al menos "
"dos columnas numéricas y la muestra cruda (o los modelos del perfil) "
"para correr Isolation Forest."))
# --- Interpretation ----------------------------------------------------- #
blocks.append(model.Heading(text="Cómo interpretar los atípicos", level=2))
blocks.append(_interpretation_block(mark))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -0,0 +1,304 @@
"""Tests for the OUTLIERS chapter — DoD: golden + edges + error path.
Self-contained: builds synthetic ``numeric`` blocks + a raw_numeric sample (no
DuckDB) so the suite is fast and deterministic. Verifies that the chapter emits
the univariate per-column table, a boxplots figure, the multivariate Isolation
Forest section and the outlier≠error interpretation; that the most contaminated
column is ranked first; that a profile with no numeric column yields None; that
None/empty never raises; that the glossary terms are registered; and that the
chapter renders into both PDF and PPTX without cutting its title.
"""
import math
import os
import re
import tempfile
from pypdf import PdfReader
from datascience.automatic_eda.chapters.outliers import (
build_outliers, CHAPTER_VERSION, CHAPTER_TITLE, _TERM_DEFS,
)
from datascience.automatic_eda import model
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
def _percentile(sorted_vals, q):
"""Linear-interpolation percentile (q in 0..1) on an already-sorted list."""
if not sorted_vals:
return None
if len(sorted_vals) == 1:
return float(sorted_vals[0])
pos = q * (len(sorted_vals) - 1)
lo = int(math.floor(pos))
hi = int(math.ceil(pos))
if lo == hi:
return float(sorted_vals[lo])
frac = pos - lo
return float(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
def _col_from_values(values, nbins=10):
"""Build a ``numeric`` sub-block shaped like describe_numeric's output from a
concrete list of raw values, so the profile percentiles and the raw sample
are consistent (the boxplot fences match the crudo)."""
vals = [float(v) for v in values]
s = sorted(vals)
n = len(s)
mean = sum(vals) / n
var = sum((v - mean) ** 2 for v in vals) / n
std = math.sqrt(var)
median = _percentile(s, 0.5)
p25 = _percentile(s, 0.25)
p75 = _percentile(s, 0.75)
mn, mx = s[0], s[-1]
# z-score outlier count (population), what the profile's n_outliers carries.
n_out = sum(1 for v in vals if std > 0 and abs((v - mean) / std) > 3.0)
width = (mx - mn) / nbins if mx > mn else 1.0
hist = [{"lo": mn + i * width, "hi": mn + (i + 1) * width, "count": 1}
for i in range(nbins)]
return {
"min": mn, "max": mx, "mean": mean, "median": median, "std": std,
"p25": p25, "p50": median, "p75": p75, "iqr": (p75 - p25),
"n_outliers": n_out, "outlier_pct": 100.0 * n_out / n,
"distribution_type": "right-skewed", "histogram": hist,
}
def _fare_values():
"""A heavy-tailed column (most ~10-30, a few 200-512): clear Tukey/z outliers."""
base = [7.0 + (i % 25) for i in range(120)] # bulk 7..31
tail = [180.0, 210.0, 263.0, 512.0] # extreme upper tail
return base + tail
def _age_values():
"""A roughly symmetric column with one extreme low value."""
base = [22.0 + (i % 40) for i in range(120)] # 22..61
return base + [80.0, 0.5, 74.0, 1.0]
def _quiet_values():
"""A clean column with no atypical values."""
return [50.0 + (i % 5) for i in range(124)]
def _profile_and_ctx(with_models=True, with_raw=True):
fare = _fare_values()
age = _age_values()
quiet = _quiet_values()
cols = [
{"name": "Fare", "inferred_type": "numeric", "numeric": _col_from_values(fare)},
{"name": "Age", "inferred_type": "numeric", "numeric": _col_from_values(age)},
{"name": "Quiet", "inferred_type": "numeric", "numeric": _col_from_values(quiet)},
{"name": "Sexo", "inferred_type": "categorical",
"categorical": {"top": [{"value": "male", "count": 80}]}},
]
profile = {"table": "titanic", "n_rows": len(fare), "n_cols": len(cols),
"columns": cols}
if with_models:
profile["models"] = {
"outliers": {
"n_outliers": 4, "outlier_pct": 3.2,
"outlier_rows": [
{"row_index": 123, "score": -0.21},
{"row_index": 121, "score": -0.15},
],
"threshold": -0.02, "n_rows_used": 124, "n_features": 3,
}
}
ctx = {}
if with_raw:
ctx["raw_numeric"] = {"Fare": fare, "Age": age, "Quiet": quiet}
return profile, ctx
def _pdf_text(path: str) -> str:
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
return re.sub(r"\s+", " ", txt)
def _flatten(blocks):
out = []
for b in blocks:
if getattr(b, "kind", "") == "group":
out.extend(_flatten(getattr(b, "blocks", []) or []))
else:
out.append(b)
return out
# --------------------------------------------------------------------------- #
# Golden.
# --------------------------------------------------------------------------- #
def test_golden_estructura_y_secciones():
profile, ctx = _profile_and_ctx()
ctx["glossary"] = model.GlossaryCollector()
ch = build_outliers(profile, ctx)
assert ch is not None
assert ch.id == "outliers"
assert ch.version == CHAPTER_VERSION
flat = _flatten(ch.blocks)
kinds = [b.kind for b in flat]
# Title heading + univariate DataTable + boxplots Figure + multivariate
# KVTable + interpretation Markdown.
assert kinds[0] == "heading" and flat[0].text == CHAPTER_TITLE
tables = [b for b in flat if b.kind == "data_table"]
titles = [t.title for t in tables]
assert any(t and "atípicos por columna" in t for t in titles)
assert any(b.kind == "figure" for b in flat), "falta la figura de boxplots"
assert any(b.kind == "kv_table" for b in flat), "falta el resumen multivariante"
# The boxplots figure maker yields a real matplotlib figure (or its fallback).
fig = next(b for b in flat if b.kind == "figure").make()
assert fig is not None
import matplotlib.pyplot as plt
plt.close(fig)
def test_golden_fare_es_la_mas_contaminada():
# The univariate table must rank Fare (heavy tail) first and report a
# non-zero Tukey percentage for it.
profile, ctx = _profile_and_ctx()
ch = build_outliers(profile, ctx)
table = next(b for b in _flatten(ch.blocks)
if b.kind == "data_table" and b.title
and "atípicos por columna" in b.title)
first_col = table.rows[0][0]
assert first_col == "Fare", f"esperaba Fare primera, fue {first_col}"
# % Tukey column (index 2) of the first row must be > 0.
pct_cell = table.rows[0][2]
assert pct_cell not in ("", "0%", "0.00%"), f"% Tukey de Fare vacío: {pct_cell}"
# The z-score rule (detect_outliers) must actually run with raw_numeric: at
# least one column reports a non-empty z count/percentage (regression guard
# for the detect_outliers import path).
z_pcts = [r[4] for r in table.rows]
assert any(c not in ("",) for c in z_pcts), f"columna z toda vacía: {z_pcts}"
z_counts = [r[3] for r in table.rows]
assert any(c not in ("",) for c in z_counts), f"conteo z vacío: {z_counts}"
def test_golden_interpretacion_outlier_no_es_error():
profile, ctx = _profile_and_ctx()
ch = build_outliers(profile, ctx)
md = " ".join(b.text for b in _flatten(ch.blocks) if b.kind == "markdown")
assert "no es necesariamente un error" in md.lower()
# Mentions the actionable options (winsorize / re-express).
assert "winsoriz" in md.lower()
assert "re-expres" in md.lower() or "logarítmic" in md.lower()
def test_golden_terminos_glosario_registrados():
profile, ctx = _profile_and_ctx()
gloss = model.GlossaryCollector()
ctx["glossary"] = gloss
build_outliers(profile, ctx)
for key in _TERM_DEFS:
assert gloss.has(key), f"término '{key}' no registrado en el glosario"
# Terms are marked clickable in the body text.
md = " ".join(b.text for b in _flatten(build_outliers(profile, ctx).blocks)
if b.kind == "markdown")
assert "[[term:outlier]]" in md and "[[term:tukey_fence]]" in md
# --------------------------------------------------------------------------- #
# Multivariate.
# --------------------------------------------------------------------------- #
def test_multivariante_live_con_raw_y_dims():
# With a raw sample the chapter runs Isolation Forest live (over the same
# columns summarize_outlier_dims uses) and lists the anomalous rows with the
# dimensions that make each one rare.
profile, ctx = _profile_and_ctx(with_models=False, with_raw=True)
ch = build_outliers(profile, ctx)
flat = _flatten(ch.blocks)
kv = next(b for b in flat if b.kind == "kv_table")
flat_kv = " ".join(f"{k} {v}" for (k, v) in kv.rows)
assert "Filas atípicas" in flat_kv
# A non-zero number of anomalous rows is reported.
n_cell = dict(kv.rows).get("Filas atípicas")
assert n_cell not in (None, "", "0"), f"sin filas atípicas: {n_cell}"
# The anomalous-rows table carries the per-row dimension breakdown.
tbls = [b for b in flat if b.kind == "data_table" and b.title
and "más atípicas" in b.title]
assert tbls, "falta la tabla de filas más atípicas"
assert any("hacen rara" in h for h in tbls[0].header), \
f"falta la columna de dimensiones: {tbls[0].header}"
def test_multivariante_precomputed_sin_raw():
# Without a raw sample the chapter falls back to profile['models']['outliers']
# (lite preset path); the precomputed n_outliers (4) surfaces in the KV table.
profile, ctx = _profile_and_ctx(with_models=True, with_raw=False)
ch = build_outliers(profile, ctx)
kv = next(b for b in _flatten(ch.blocks) if b.kind == "kv_table")
assert any("4" in str(v) for (k, v) in kv.rows)
def test_multivariante_ausente_degrada_a_nota():
# No models and no raw sample → an honest note, never a crash.
profile, ctx = _profile_and_ctx(with_models=False, with_raw=False)
ch = build_outliers(profile, ctx)
assert ch is not None
notes = [b.text for b in _flatten(ch.blocks) if b.kind == "note"]
assert any("Isolation Forest" in n for n in notes)
# --------------------------------------------------------------------------- #
# Edges / error path.
# --------------------------------------------------------------------------- #
def test_edge_sin_columnas_numericas_devuelve_none():
prof = {"columns": [{"name": "c", "inferred_type": "categorical",
"categorical": {"top": [{"value": "x", "count": 3}]}}]}
assert build_outliers(prof, {}) is None
def test_edge_solo_texto_sintetico_devuelve_none():
# A text-only synthetic table (no numeric column) yields None (does not break).
prof = {"table": "notas", "n_rows": 3, "n_cols": 1,
"columns": [{"name": "comentario", "inferred_type": "text",
"text": {"n_docs": 3}}]}
assert build_outliers(prof, {}) is None
def test_edge_profile_none_y_vacio_no_revienta():
assert build_outliers(None, None) is None
assert build_outliers({}, {}) is None
assert build_outliers({"columns": []}, {}) is None
def test_edge_sin_raw_numeric_degrada_a_perfil():
# Without raw_numeric the chapter still builds, using the profile z-score
# counts; the univariate table exists and Tukey counts degrade to '—'.
profile, ctx = _profile_and_ctx(with_models=True, with_raw=False)
ch = build_outliers(profile, ctx)
assert ch is not None
table = next(b for b in _flatten(ch.blocks)
if b.kind == "data_table" and b.title
and "atípicos por columna" in b.title)
# z column comes from the profile; Tukey count is unknown ('—').
assert all(len(r) == 8 for r in table.rows)
# --------------------------------------------------------------------------- #
# Anti-cut render.
# --------------------------------------------------------------------------- #
def test_render_pdf_y_pptx_incluyen_el_capitulo():
profile, ctx = _profile_and_ctx()
# The renderers build the whole document; the chapter is reached via the
# registry. Render the chapter standalone through a one-chapter document by
# passing the profile directly (the renderers run the full chapter registry).
with tempfile.TemporaryDirectory() as d:
pdf = os.path.join(d, "out.pdf")
res_pdf = render_automatic_eda_pdf(profile, pdf,
{"write_manifest": False, "ctx": ctx})
assert res_pdf["path"] == pdf
txt = _pdf_text(pdf)
assert CHAPTER_TITLE in txt, "el capítulo OUTLIERS no aparece en el PDF"
assert "Fare" in txt
pptx = os.path.join(d, "out.pptx")
res_pptx = render_automatic_eda_pptx(profile, pptx,
{"write_manifest": False, "ctx": ctx})
assert res_pptx["path"] == pptx
assert res_pptx["n_slides"] >= 1
@@ -0,0 +1,559 @@
"""Free-text / NLP distributions chapter (TEXT DISTR) for AutomaticEDA.
First chapter for **non-tabular** content: it profiles the linguistic content of
any column holding long free text (reviews, descriptions, comments, tickets) that
the categorical chapter cannot meaningfully summarize (high cardinality, many
words per value). It is the cheap, model-free counterpart to ``cat_distr`` for
columns that are prose rather than discrete labels.
Activation (returns ``None`` when it does not apply):
1. Cheap gate from the aggregated profile: at least one non-numeric column whose
``categorical.len_mean`` (mean character length) is ``>= _MIN_LEN_CHARS``.
A dataset whose only string columns are short labels (e.g. titanic's
``Name``, ~27 chars) never passes this gate, so the chapter disappears with
zero extra work and the existing report is untouched.
2. Confirmation from a raw sample: each candidate column is sampled (push-down
``extract_text_sample`` over ``ctx['db_path']``/``ctx['table']``, or an
in-memory ``ctx['text_raw']`` for tests) and kept only if the **median word
count is ``>= _MIN_WORDS``** — i.e. it is genuinely long text, not a long
single token. If no column survives, the chapter returns ``None``.
Per surviving column the chapter emits, kept together on its own page/slide
(``Group(page_break_before=...)``):
- a key/value summary (documents, length percentiles, vocabulary richness with
**[[term:ttr]]TTR[[/term]]** and **[[term:hapax]]hapax legomena[[/term]]**,
dominant language, exact-duplicate %, readability when available);
- a word-count histogram figure;
- a top-terms table + a horizontal bar figure;
- bigram and trigram frequency tables;
- a detected-language bar figure (when ``langdetect`` is available);
- an optional word-cloud figure (only when ``wordcloud`` is installed);
- a closing note on duplicates / readability degradation.
Every metric is delegated to pure ``eda`` registry functions
(``compute_text_length_stats``, ``compute_vocabulary_stats``,
``compute_top_ngrams``, ``detect_corpus_language``, ``compute_text_duplicates``,
``compute_text_readability``) and the raw sample to ``extract_text_sample``; all
are imported defensively so a missing function or optional library degrades that
single piece to a note instead of aborting the chapter. Optional libraries
(``langdetect``, ``textstat``, ``wordcloud``, ``datasketch``) are never required:
the piece is silently omitted when they are absent.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "text_distr"
CHAPTER_TITLE = "Texto libre (NLP)"
# Cheap activation gate (characters): a non-numeric column whose mean string
# length reaches this is a candidate for "long text". Short labels (titanic's
# Name ≈ 27 chars) stay below it, so the chapter does not fire on them.
_MIN_LEN_CHARS = 50
# Confirmation gate (words): a candidate is kept only if its median document has
# at least this many words — genuine prose, not a long id/URL token.
_MIN_WORDS = 20
# Bound the document so very wide datasets stay readable.
_MAX_TEXT_COLS = 5
# Raw text rows to sample per column when the chapter must extract them itself.
_SAMPLE_ROWS = 2000
# Rows shown in the frequency tables.
_TOP_TERMS = 15
_TOP_NGRAMS = 10
# Glossary terms this chapter explains (registered in the shared collector and
# marked clickable on first appearance — same mechanism as cat_distr's entropía).
_TERMS = {
"ttr": (
"TTR (type-token ratio)",
"Riqueza léxica de un texto: número de palabras distintas (tipos) "
"dividido por el número total de palabras (tokens). Vale 1 cuando no se "
"repite ninguna palabra (máxima variedad) y baja hacia 0 cuando el "
"vocabulario se repite mucho. Depende de la longitud del corpus, así que "
"compara mejor textos de tamaño parecido."),
"hapax": (
"Hapax legomena",
"Palabras que aparecen una sola vez en todo el corpus. Un porcentaje "
"alto de hapax indica vocabulario muy variado o, a veces, ruido "
"(erratas, identificadores, tokens raros). Se expresa como porcentaje "
"sobre el número de palabras distintas."),
}
def _fmt_int(value) -> str:
if value is None:
return ""
try:
return f"{int(value):,}".replace(",", ".")
except (TypeError, ValueError):
return str(value)
def _fmt_num(value, decimals: int = 2) -> str:
if value is None:
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return f"{value:,}".replace(",", ".")
if isinstance(value, float):
if value != value: # NaN
return "NaN"
if value in (float("inf"), float("-inf")):
return str(value)
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
return text if text else "0"
return str(value)
def _fmt_pct(value, decimals: int = 1) -> str:
if value is None:
return ""
try:
return f"{float(value):.{decimals}f}%"
except (TypeError, ValueError):
return str(value)
def _truncate(text, limit: int = 40) -> str:
s = model._safe_str(text)
return s if len(s) <= limit else s[: max(1, limit - 1)].rstrip() + ""
# --------------------------------------------------------------------------- #
# Defensive wrappers around the registry functions: each returns the function's
# output dict or a safe empty default, never raising and never importing at
# module load (so the chapter stays importable even if a function is missing).
# --------------------------------------------------------------------------- #
def _length_stats(texts) -> dict:
try:
from datascience.compute_text_length_stats import compute_text_length_stats
out = compute_text_length_stats(texts)
if isinstance(out, dict):
return out
except Exception: # noqa: BLE001
pass
return {}
def _vocab_stats(texts) -> dict:
try:
from datascience.compute_vocabulary_stats import compute_vocabulary_stats
out = compute_vocabulary_stats(texts, top_k=_TOP_TERMS)
if isinstance(out, dict):
return out
except Exception: # noqa: BLE001
pass
return {}
def _ngrams(texts, n) -> list:
try:
from datascience.compute_top_ngrams import compute_top_ngrams
out = compute_top_ngrams(texts, n=n, top_k=_TOP_NGRAMS)
if isinstance(out, dict):
return out.get("top") or []
except Exception: # noqa: BLE001
pass
return []
def _language(texts) -> dict:
try:
from datascience.detect_corpus_language import detect_corpus_language
out = detect_corpus_language(texts)
if isinstance(out, dict):
return out
except Exception: # noqa: BLE001
pass
return {"available": False, "distribution": [], "dominant": None}
def _duplicates(texts) -> dict:
try:
from datascience.compute_text_duplicates import compute_text_duplicates
out = compute_text_duplicates(texts)
if isinstance(out, dict):
return out
except Exception: # noqa: BLE001
pass
return {}
def _readability(texts) -> dict:
try:
from datascience.compute_text_readability import compute_text_readability
out = compute_text_readability(texts)
if isinstance(out, dict):
return out
except Exception: # noqa: BLE001
pass
return {"available": False, "flesch": {}}
# --------------------------------------------------------------------------- #
# Candidate detection + raw sample acquisition.
# --------------------------------------------------------------------------- #
def _candidate_columns(profile: dict) -> list:
"""Cheap gate: non-numeric columns whose mean char length reaches the
threshold. Returns the list of column names (possibly empty)."""
out = []
for col in profile.get("columns") or []:
if not isinstance(col, dict):
continue
if col.get("inferred_type") == "numeric":
continue
cat = col.get("categorical")
if not isinstance(cat, dict):
continue
len_mean = cat.get("len_mean")
if isinstance(len_mean, (int, float)) and not isinstance(len_mean, bool) \
and len_mean >= _MIN_LEN_CHARS:
name = col.get("name")
if name:
out.append(str(name))
return out
def _get_samples(profile: dict, ctx: dict, columns: list) -> dict:
"""Return {col: [str, ...]} raw text samples for the candidate columns.
Prefers an in-memory ``ctx['text_raw']`` (used by tests); otherwise pushes a
sample down to the database via ``extract_text_sample`` using ctx db_path /
table. Never raises: returns {} when no sample can be obtained."""
text_raw = ctx.get("text_raw")
if isinstance(text_raw, dict) and text_raw:
return {c: [str(v) for v in (text_raw.get(c) or []) if v is not None]
for c in columns if text_raw.get(c)}
db_path = ctx.get("db_path")
table = ctx.get("table")
if not db_path or not table:
return {}
backend = ctx.get("backend") or "duckdb"
sample = ctx.get("sample") or _SAMPLE_ROWS
try:
from datascience.extract_text_sample import extract_text_sample
out = extract_text_sample(db_path, table, columns, backend=backend,
sample=sample)
if isinstance(out, dict) and out.get("status") == "ok":
cols = out.get("columns")
if isinstance(cols, dict):
return {c: list(v) for c, v in cols.items() if v}
except Exception: # noqa: BLE001 — dict-no-throw: no sample → chapter omits.
pass
return {}
def _confirm_long_text(samples: dict) -> dict:
"""Keep only columns whose median word count reaches _MIN_WORDS. Returns
{col: length_stats_dict} for the survivors, in input order."""
survivors = {}
for col, texts in samples.items():
stats = _length_stats(texts)
words = stats.get("words") if isinstance(stats, dict) else None
median = words.get("p50") if isinstance(words, dict) else None
if isinstance(median, (int, float)) and not isinstance(median, bool) \
and median >= _MIN_WORDS:
survivors[col] = stats
return survivors
# --------------------------------------------------------------------------- #
# Figures (lazy matplotlib, scaled by the renderers — same style as num_distr).
# --------------------------------------------------------------------------- #
def _hist_figure(name: str, length_stats: dict):
def make():
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
fig = Figure(figsize=(6.2, 3.0))
ax = fig.add_subplot(111)
bins = (length_stats or {}).get("word_hist") or []
drew = False
for b in bins:
if not isinstance(b, dict):
continue
lo, hi, count = b.get("lo"), b.get("hi"), b.get("count") or 0
if lo is None or hi is None:
continue
width = (hi - lo) if hi > lo else max(abs(lo) * 1e-3, 1e-6)
ax.bar(lo, count, width=width, align="edge", color="#9ec6df",
edgecolor="#5b8aa6", linewidth=0.4)
drew = True
if not drew:
ax.text(0.5, 0.5, "(sin datos de longitud)", ha="center",
va="center", color="#8a8a8a", transform=ax.transAxes)
ax.set_xlabel("palabras por documento", fontsize=8)
ax.set_ylabel("nº de documentos", fontsize=8)
ax.tick_params(labelsize=7)
for spine in ("top", "right"):
ax.spines[spine].set_visible(False)
ax.set_title(f"Longitud de «{_truncate(name, 30)}»", fontsize=10,
loc="left")
fig.tight_layout()
return fig
return make
def _barh_figure(title: str, items: list, label_key: str, value_key: str,
xlabel: str):
"""Horizontal bar chart from [{label_key:..., value_key:...}, ...]."""
def make():
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
rows = [it for it in (items or []) if isinstance(it, dict)
and isinstance(it.get(value_key), (int, float))]
rows = rows[:12]
fig = Figure(figsize=(6.2, max(2.2, 0.32 * len(rows) + 0.8)))
ax = fig.add_subplot(111)
if not rows:
ax.text(0.5, 0.5, "(sin datos)", ha="center", va="center",
color="#8a8a8a", transform=ax.transAxes)
ax.axis("off")
return fig
labels = [_truncate(r.get(label_key), 28) for r in rows][::-1]
values = [float(r.get(value_key) or 0) for r in rows][::-1]
ypos = range(len(rows))
ax.barh(list(ypos), values, color="#9ec6df", edgecolor="#5b8aa6",
linewidth=0.4)
ax.set_yticks(list(ypos))
ax.set_yticklabels(labels, fontsize=7)
ax.set_xlabel(xlabel, fontsize=8)
ax.tick_params(labelsize=7)
for spine in ("top", "right"):
ax.spines[spine].set_visible(False)
ax.set_title(_truncate(title, 44), fontsize=10, loc="left")
fig.tight_layout()
return fig
return make
def _wordcloud_figure(texts):
"""Word-cloud figure callable, or None if wordcloud is not installed."""
try:
import wordcloud # noqa: F401
except Exception: # noqa: BLE001 — optional dependency: omit the figure.
return None
def make():
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
from wordcloud import WordCloud
fig = Figure(figsize=(6.2, 3.2))
ax = fig.add_subplot(111)
joined = " ".join(t for t in texts if isinstance(t, str))
try:
wc = WordCloud(width=800, height=400, background_color="white",
colormap="viridis").generate(joined)
ax.imshow(wc, interpolation="bilinear")
except Exception: # noqa: BLE001
ax.text(0.5, 0.5, "(nube de palabras no disponible)", ha="center",
va="center", color="#8a8a8a", transform=ax.transAxes)
ax.axis("off")
fig.tight_layout()
return fig
return make
# --------------------------------------------------------------------------- #
# Per-column block assembly.
# --------------------------------------------------------------------------- #
def _summary_kv(n_docs, length_stats, vocab, lang, dup, read):
chars = (length_stats or {}).get("chars") or {}
words = (length_stats or {}).get("words") or {}
sents = (length_stats or {}).get("sentences") or {}
rows = [
("Documentos", _fmt_int(n_docs)),
("Caracteres (media · p50 · p90 · p99)",
f"{_fmt_num(chars.get('mean'))} · {_fmt_int(chars.get('p50'))} · "
f"{_fmt_int(chars.get('p90'))} · {_fmt_int(chars.get('p99'))}"),
("Palabras (media · p50 · p90 · p99)",
f"{_fmt_num(words.get('mean'))} · {_fmt_int(words.get('p50'))} · "
f"{_fmt_int(words.get('p90'))} · {_fmt_int(words.get('p99'))}"),
("Frases (media · máx)",
f"{_fmt_num(sents.get('mean'))} · {_fmt_int(sents.get('max'))}"),
("Vocabulario (tokens · tipos · TTR)",
f"{_fmt_int(vocab.get('n_tokens'))} · {_fmt_int(vocab.get('n_types'))} "
f"· {_fmt_num(vocab.get('ttr'), 3)}"),
("Hapax legomena",
f"{_fmt_int(vocab.get('n_hapax'))} ({_fmt_pct(vocab.get('hapax_pct'))})"),
]
if isinstance(lang, dict) and lang.get("available"):
dom = lang.get("dominant")
n_langs = len(lang.get("distribution") or [])
rows.append(("Idioma dominante · nº idiomas",
f"{model._safe_str(dom) or ''} · {_fmt_int(n_langs)}"))
if isinstance(dup, dict) and dup.get("n_docs"):
rows.append(("Duplicados exactos",
f"{_fmt_int(dup.get('n_exact_dup'))} "
f"({_fmt_pct(dup.get('exact_dup_pct'))})"))
if isinstance(read, dict) and read.get("available"):
flesch = read.get("flesch") or {}
rows.append(("Legibilidad Flesch (media)",
_fmt_num(flesch.get("mean"), 1)))
return model.KVTable(rows=rows, title="Resumen del texto")
def _terms_table(vocab) -> "model.DataTable | None":
top = (vocab or {}).get("top_terms") or []
rows = [[_truncate(t.get("term"), 32), _fmt_int(t.get("count")),
_fmt_pct(t.get("pct"))]
for t in top[:_TOP_TERMS] if isinstance(t, dict)]
if not rows:
return None
return model.DataTable(header=["Término", "Conteo", "% tokens"], rows=rows,
title="Términos más frecuentes",
note="stopwords ES+EN eliminadas")
def _ngram_table(items, n_label) -> "model.DataTable | None":
rows = [[_truncate(it.get("ngram"), 40), _fmt_int(it.get("count"))]
for it in (items or [])[:_TOP_NGRAMS] if isinstance(it, dict)]
if not rows:
return None
return model.DataTable(header=[n_label, "Conteo"], rows=rows,
title=f"{n_label} más frecuentes")
def _dup_note(dup, lang, read) -> "model.Note | None":
bits = []
if isinstance(dup, dict):
nd = dup.get("near_dup") or {}
if nd.get("available"):
bits.append(
f"casi-duplicados detectados (MinHash, umbral "
f"{_fmt_num(nd.get('threshold'))}): "
f"{_fmt_int(nd.get('n_near_dup_docs'))} documentos")
else:
bits.append("near-duplicados no calculados (datasketch no instalado; "
"se reportan solo los duplicados exactos por hash)")
if isinstance(lang, dict) and not lang.get("available"):
bits.append("detección de idioma omitida (langdetect no instalado)")
if isinstance(read, dict) and not read.get("available"):
bits.append("legibilidad omitida (textstat no instalado)")
if not bits:
return None
return model.Note(" · ".join(bits))
def _column_group(name, texts, length_stats, idx, mark_terms):
vocab = _vocab_stats(texts)
lang = _language(texts)
dup = _duplicates(texts)
read = _readability(texts)
n_docs = (length_stats or {}).get("n_docs")
blocks = [
model.Heading(text=str(name), level=2),
_summary_kv(n_docs, length_stats, vocab, lang, dup, read),
model.Figure(make=_hist_figure(name, length_stats),
caption=f"Distribución de la longitud (palabras) de "
f"«{_truncate(name, 30)}»."),
]
terms_tbl = _terms_table(vocab)
if terms_tbl is not None:
blocks.append(terms_tbl)
blocks.append(model.Figure(
make=_barh_figure(f"Top términos de «{_truncate(name, 24)}»",
vocab.get("top_terms"), "term", "count",
"conteo"),
caption="Términos más frecuentes (barras)."))
bi_tbl = _ngram_table(_ngrams(texts, 2), "Bigrama")
if bi_tbl is not None:
blocks.append(bi_tbl)
tri_tbl = _ngram_table(_ngrams(texts, 3), "Trigrama")
if tri_tbl is not None:
blocks.append(tri_tbl)
if isinstance(lang, dict) and lang.get("available") \
and lang.get("distribution"):
blocks.append(model.Figure(
make=_barh_figure(f"Idiomas detectados en «{_truncate(name, 24)}»",
lang.get("distribution"), "lang", "count",
"documentos"),
caption="Distribución de idiomas detectados (langdetect)."))
wc = _wordcloud_figure(texts)
if wc is not None:
blocks.append(model.Figure(
make=wc, caption=f"Nube de palabras de «{_truncate(name, 30)}»."))
note = _dup_note(dup, lang, read)
if note is not None:
blocks.append(note)
return model.Group(blocks=blocks, page_break_before=(idx > 0))
def _intro_blocks(n_cols, mark_terms):
ttr = ("[[term:ttr]]TTR[[/term]]" if mark_terms else "TTR")
hapax = ("[[term:hapax]]hapax legomena[[/term]]" if mark_terms
else "hapax legomena")
text = (
f"Este capítulo perfila las columnas de **texto libre largo** del "
f"dataset (reseñas, descripciones, comentarios): contenido lingüístico "
f"que la distribución categórica no resume bien. Para cada columna se "
f"muestran la longitud de los documentos, la riqueza de vocabulario "
f"(incluido el {ttr} y el porcentaje de {hapax}), los términos y "
f"n-gramas más frecuentes, los idiomas detectados y el nivel de "
f"duplicación. Las métricas son baratas y sin modelos pesados; las "
f"piezas que dependen de una librería opcional se omiten si no está "
f"instalada.")
return [
model.Heading(text=CHAPTER_TITLE, level=1),
model.Markdown(text=text),
]
def build_text_distr(profile: dict, ctx: dict):
"""Build the free-text Chapter, or None if no long-text column applies."""
profile = profile or {}
ctx = ctx or {}
# 1) Cheap gate from the profile (no DB access yet).
candidates = _candidate_columns(profile)
if not candidates:
return None
# 2) Raw sample + 3) confirm genuine long text (median words >= threshold).
samples = _get_samples(profile, ctx, candidates)
if not samples:
return None
survivors = _confirm_long_text(samples)
if not survivors:
return None
# Register glossary terms (clickable) once we know the chapter applies.
glossary = ctx.get("glossary")
mark_terms = False
if isinstance(glossary, model.GlossaryCollector):
for key, (label, definition) in _TERMS.items():
glossary.add(key, label, definition)
mark_terms = True
blocks = list(_intro_blocks(len(survivors), mark_terms))
rendered = list(survivors.items())[:_MAX_TEXT_COLS]
for idx, (name, length_stats) in enumerate(rendered):
texts = samples.get(name) or []
blocks.append(_column_group(name, texts, length_stats, idx, mark_terms))
if len(survivors) > len(rendered):
omitted = len(survivors) - len(rendered)
blocks.append(model.Note(
f"Se muestran las primeras {len(rendered)} columnas de texto; "
f"quedan {omitted} sin mostrar para mantener acotado el informe."))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -0,0 +1,256 @@
"""Tests for the TEXT DISTR chapter — DoD: golden + edges + degradation.
Self-contained: builds synthetic TableProfiles and feeds the raw text sample
in-memory through ``ctx['text_raw']`` (no DuckDB needed), so the suite is fast
and deterministic. Verifies that ``build_text_distr``:
- GOLDEN: with a long-text column, emits the chapter with its key blocks
(length summary, word histogram, top-terms table, n-gram tables, language
bars) and registers the clickable glossary terms; and that it renders inside
the full document to both PDF and PPTX showing that content.
- EDGE (None): a dataset whose only string column is short labels (titanic-like
``Name``) yields ``None`` without raising — the existing report is untouched.
- EDGE (None): a column that passes the cheap char gate but whose documents are
short (median words below the threshold) is rejected at the confirmation step.
- DEGRADATION: with ``langdetect`` / ``textstat`` / ``wordcloud`` unavailable,
the chapter still builds (those pieces are omitted) and never raises.
"""
import builtins
import os
import tempfile
from pypdf import PdfReader
from pptx import Presentation
from datascience.automatic_eda.model import (
DataTable, Figure, GlossaryCollector, Group, Heading, KVTable, Markdown,
Note,
)
from datascience.automatic_eda.chapters.text_distr import (
CHAPTER_ID, CHAPTER_VERSION, build_text_distr,
)
from datascience.automatic_eda.chapters_registry import build_document
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
# --------------------------------------------------------------------------- #
# Synthetic corpus + profiles.
# --------------------------------------------------------------------------- #
_ES = [
"El producto llegó en perfecto estado y mucho antes de lo previsto por la tienda",
"La calidad de los materiales es realmente excelente y se nota la diferencia al usarlo",
"No me convenció del todo porque esperaba bastante más por el precio que pagué finalmente",
"El servicio de atención al cliente fue rápido amable y resolvió mi problema sin demora",
"Lo recomiendo totalmente ya que ha superado con creces todas mis expectativas iniciales",
]
_EN = [
"The product arrived in perfect condition and much earlier than the store had promised me",
"The build quality is genuinely outstanding and you can really feel the difference using it",
"I was not fully convinced because I expected quite a lot more for the price i finally paid",
"Customer support was fast friendly and solved my whole problem without any delay at all",
"I highly recommend it since it has exceeded by far every one of my initial expectations",
]
def _long_reviews(n=40) -> list:
"""A corpus of long multi-sentence reviews (>= 20 words each), mixing two
languages and including a few exact duplicates."""
out = []
for i in range(n):
base = _ES if i % 3 != 0 else _EN # mostly ES, some EN
a = base[i % len(base)]
b = base[(i + 2) % len(base)]
out.append(f"{a}. {b}.")
# Inject a couple of exact duplicates.
out.append(out[0])
out.append(out[1])
return out
def _text_profile() -> dict:
"""Profile with a long free-text column (review) + a numeric + a short cat."""
return {
"table": "reviews",
"source": "/data/reviews.duckdb",
"profiled_at": "2026-06-30T10:00:00+00:00",
"n_rows": 42,
"n_cols": 3,
"quality_score": 88.0,
"columns": [
{
"name": "review",
"inferred_type": "categorical",
"categorical": {
"top": [{"value": "x", "count": 2, "pct": 0.05}],
"n_distinct": 40,
"len_mean": 180.0,
"len_min": 80,
"len_max": 220,
},
},
{
"name": "rating",
"inferred_type": "numeric",
"numeric": {"mean": 3.1, "median": 3.0, "std": 1.2,
"min": 1, "max": 5},
},
{
"name": "product",
"inferred_type": "categorical",
"categorical": {
"top": [{"value": "teclado", "count": 10, "pct": 0.25}],
"n_distinct": 6,
"len_mean": 7.0,
"len_min": 5, "len_max": 11,
},
},
],
}
def _no_text_profile() -> dict:
"""titanic-like: the only string column is short labels (Name ≈ 27 chars)."""
return {
"table": "titanic",
"n_rows": 891,
"n_cols": 3,
"columns": [
{"name": "Age", "inferred_type": "numeric",
"numeric": {"mean": 29.7, "median": 28.0, "std": 14.5}},
{"name": "Name", "inferred_type": "categorical",
"categorical": {"top": [{"value": "Braund, Mr. Owen Harris",
"count": 1, "pct": 0.001}],
"n_distinct": 891, "len_mean": 27.0,
"len_min": 12, "len_max": 82}},
{"name": "Sex", "inferred_type": "categorical",
"categorical": {"top": [{"value": "male", "count": 577,
"pct": 0.65}],
"n_distinct": 2, "len_mean": 4.6,
"len_min": 4, "len_max": 6}},
],
}
def _flatten(blocks) -> list:
"""Recursively flatten Group blocks so tests can inspect leaf blocks."""
out = []
for b in blocks:
if isinstance(b, Group):
out.extend(_flatten(b.blocks))
else:
out.append(b)
return out
# --------------------------------------------------------------------------- #
# Golden.
# --------------------------------------------------------------------------- #
def test_golden_activa_con_texto():
glossary = GlossaryCollector()
ctx = {"text_raw": {"review": _long_reviews()}, "glossary": glossary}
ch = build_text_distr(_text_profile(), ctx)
assert ch is not None, "el capítulo debe activarse con una columna de texto largo"
assert ch.id == CHAPTER_ID
assert ch.version == CHAPTER_VERSION
leaves = _flatten(ch.blocks)
kinds = [b.kind for b in leaves]
assert "heading" in kinds
assert "kv_table" in kinds # summary
assert "figure" in kinds # histogram / bars
assert "data_table" in kinds # top terms + n-grams
# KV summary mentions vocabulary metrics.
kv = next(b for b in leaves if isinstance(b, KVTable))
labels = " ".join(str(r[0]) for r in kv.rows)
assert "TTR" in labels
assert "Hapax" in labels or "hapax" in labels
# There is a terms table and at least one n-gram table.
titles = [getattr(b, "title", "") or "" for b in leaves
if isinstance(b, DataTable)]
assert any("Términos" in t for t in titles)
assert any("Bigrama" in t for t in titles)
# Glossary terms were registered (clickable destinations).
assert glossary.has("ttr")
assert glossary.has("hapax")
def test_golden_render_pdf_pptx():
profile = _text_profile()
ctx = {"text_raw": {"review": _long_reviews()},
"dataset_name": "reviews"}
chapters = build_document(profile, ctx)
ids = [c.id for c in chapters]
assert "text_distr" in ids, f"text_distr ausente en {ids}"
with tempfile.TemporaryDirectory() as d:
pdf = os.path.join(d, "t.pdf")
pptx = os.path.join(d, "t.pptx")
rp = render_automatic_eda_pdf(profile, pdf, {"title": "EDA", "ctx": ctx})
rx = render_automatic_eda_pptx(profile, pptx, {"title": "EDA", "ctx": ctx})
assert rp.get("path") and os.path.exists(pdf)
assert rx.get("path") and os.path.exists(pptx)
text = "\n".join(p.extract_text() or "" for p in PdfReader(pdf).pages)
assert "Texto libre" in text or "TTR" in text
prs = Presentation(pptx)
ptext = []
for slide in prs.slides:
for shp in slide.shapes:
if shp.has_text_frame:
ptext.append(shp.text_frame.text)
joined = "\n".join(ptext)
assert "Texto libre" in joined or "TTR" in joined
# --------------------------------------------------------------------------- #
# Edges — None.
# --------------------------------------------------------------------------- #
def test_edge_none_sin_texto_largo():
# titanic-like: short labels only → chapter must not apply.
assert build_text_distr(_no_text_profile(), {}) is None
def test_edge_none_palabras_cortas():
# Char gate passes (len_mean high) but documents are short → confirmation
# rejects them (median words below threshold).
profile = _text_profile()
short = ["palabra " * 3] * 30 # 3 words each, < _MIN_WORDS
ctx = {"text_raw": {"review": short}}
assert build_text_distr(profile, ctx) is None
def test_edge_none_empty_profile():
assert build_text_distr({}, {}) is None
assert build_text_distr(None, None) is None
# --------------------------------------------------------------------------- #
# Degradation — optional libs absent.
# --------------------------------------------------------------------------- #
def test_degradacion_sin_libs(monkeypatch):
real_import = builtins.__import__
blocked = ("langdetect", "textstat", "wordcloud", "datasketch")
def fake_import(name, *a, **k):
if name in blocked or any(name.startswith(b + ".") for b in blocked):
raise ImportError(f"simulado: {name}")
return real_import(name, *a, **k)
monkeypatch.setattr(builtins, "__import__", fake_import)
ctx = {"text_raw": {"review": _long_reviews()}}
ch = build_text_distr(_text_profile(), ctx)
# Still builds (the cheap, stdlib-only pieces remain) and never raises.
assert ch is not None
leaves = _flatten(ch.blocks)
assert any(isinstance(b, KVTable) for b in leaves)
assert any(isinstance(b, DataTable) for b in leaves)
# A degradation note is present mentioning the missing optional libs.
notes = " ".join(b.text for b in leaves if isinstance(b, Note))
assert "langdetect" in notes or "textstat" in notes or "datasketch" in notes
@@ -31,7 +31,10 @@ CHAPTER_ORDER = [
"analisis_llm", # LLM interpretation — sits next to overview (user request)
"num_distr", # numeric distributions
"cat_distr", # categorical distributions
"text_distr", # free-text / NLP distributions (non-tabular content)
"calidad", # data quality
"missingness", # missing-data patterns (co-occurrence of absences; MCAR/MAR)
"outliers", # atypical values: univariate (Tukey/z) + multivariate (IsolationForest)
"correlacion", # correlations / associations
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
"modelos", # cheap models (PCA/KMeans/outliers)
@@ -70,24 +73,51 @@ def build_chapter(chapter_id: str, profile: dict, ctx: dict):
return model.as_chapter(result)
def build_document(profile: dict, ctx: dict = None) -> list:
"""Build the full ordered list of chapters for a TableProfile.
def build_document(profile: dict, ctx: dict = None, only: list = None) -> list:
"""Build the ordered list of chapters for a TableProfile.
Args:
profile: the ``eda`` group TableProfile dict (may be None/empty).
ctx: optional context dict carrying presentation metadata not present in
the profile (dataset_name, source_origin, storage, generated_at,
description, granularity, quality_criteria, head_rows, ...).
only: optional list of chapter ids to render. ``None`` (default) keeps
the historical behaviour — every implemented & applicable chapter in
canonical order. A list restricts the BODY to just those ids (in
canonical order), but the cover (``portada``) and glossary
(``glosario``) are ALWAYS included so the document stays valid and
the clickable terms keep a destination — so passing ``only=["x"]``
yields portada + x + glosario. Unknown ids are simply skipped (the
caller is responsible for strict validation). ``only=[]`` yields the
minimal document (portada + glosario only). This argument is additive
and backward-compatible: the signature is unchanged for existing
callers (default ``None``).
Returns:
list[Chapter] in canonical order, containing only the chapters that are
implemented and applicable. Never raises.
implemented, applicable and selected. Never raises.
"""
if not isinstance(profile, dict):
profile = {}
# Copy ctx so the shared collector / summary we add do not leak to the caller.
ctx = dict(ctx) if isinstance(ctx, dict) else {}
# only=None -> all body chapters (historical). only=list -> restrict body to
# that selection (portada/glosario are added unconditionally below). The
# renderers call build_document(profile, meta['ctx']) without an `only`
# argument, so the pipeline forwards the selection through a reserved ctx key
# (``_only_chapters``); an explicit `only` argument always wins. The key is
# popped from the local ctx copy so it never reaches the chapters.
if only is None:
_carried = ctx.pop("_only_chapters", None)
if isinstance(_carried, (list, tuple, set)):
only = list(_carried)
else:
ctx.pop("_only_chapters", None)
# A set makes the membership test cheap; the iteration order stays
# CHAPTER_ORDER. only=[] is a valid (empty) selection -> minimal document.
only_set = set(only) if isinstance(only, (list, tuple, set)) else None
# A single glossary collector is shared by every chapter via ctx['glossary'].
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
@@ -103,6 +133,10 @@ def build_document(profile: dict, ctx: dict = None) -> list:
for cid in CHAPTER_ORDER:
if cid in (_PORTADA, _GLOSARIO):
continue
# When a selection is given, skip body chapters outside it. portada and
# glosario are never filtered (handled out of this loop).
if only_set is not None and cid not in only_set:
continue
ch = build_chapter(cid, profile, ctx)
if ch is not None and ch.blocks:
body.append(ch)
@@ -0,0 +1,253 @@
"""Tests for the Markdown completeness appendix (report 2053).
The AutomaticEDA Markdown is the output meant to be *pasted into an LLM*, so it
must carry EVERYTHING the engine computed — even the numbers the human-facing
chapters (shared with the PDF/PPTX) drop for readability. ``render_md`` appends a
full-data appendix built from ``meta['profile']`` that closes the six losses the
evaluation found:
1. the complete association matrix (every pair, incl. correlation_ratio /
cramers_v) — not just the top extremes;
2. every numeric statistic for every numeric column (skew/kurtosis/percentiles);
3. the concrete recommended re-expression;
4. KMeans ``scores_by_k``;
5. the normality test statistics;
6. correct headers for bar/scree figure tables (not ``Desde/Hasta/Frecuencia``).
Self-contained: a synthetic profile, no DuckDB, no heavy renderer.
"""
import os
import sys
import pytest # noqa: F401
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
from datascience.automatic_eda import model # noqa: E402
from datascience.automatic_eda.render_md_impl import ( # noqa: E402
_bars_table,
_is_histogram_caption,
_profile_appendix,
render_md,
)
# --------------------------------------------------------------------------- #
# Synthetic profile fixtures.
# --------------------------------------------------------------------------- #
def _numeric(skew, kurtosis):
"""A numeric stat block with every key the appendix serializes."""
return {
"count": 100, "min": 0.0, "max": 10.0, "mean": 5.0, "median": 5.0,
"mode": 4.0, "std": 2.0, "variance": 4.0, "cv": 0.4,
"p1": 0.1, "p5": 0.5, "p25": 2.5, "p50": 5.0, "p75": 7.5,
"p95": 9.5, "p99": 9.9, "iqr": 5.0, "skew": skew, "kurtosis": kurtosis,
"n_outliers": 1, "distribution_type": "normal",
}
def _profile():
"""A small but structurally faithful TableProfile (3 numeric, 2 categorical)."""
pairs = [
{"a": "A", "b": "B", "a_type": "numeric", "b_type": "numeric",
"method": "pearson/spearman", "value": 0.8,
"p_value": 1e-9, "p_value_adjusted": 2e-9, "significant": True},
{"a": "A", "b": "C", "a_type": "numeric", "b_type": "numeric",
"method": "pearson/spearman", "value": -0.3,
"p_value": 0.01, "p_value_adjusted": 0.02, "significant": True},
{"a": "A", "b": "Cat1", "a_type": "numeric", "b_type": "categorical",
"method": "correlation_ratio", "value": 0.45,
"p_value": 0.001, "p_value_adjusted": 0.002, "significant": True},
# The single cat-cat pair the human chapter never shows.
{"a": "Cat1", "b": "Cat2", "a_type": "categorical",
"b_type": "categorical", "method": "cramers_v", "value": 0.11,
"p_value": 0.04, "p_value_adjusted": 0.05, "significant": False},
]
return {
"correlations": {
"pairs": pairs,
"multiple_testing": {"method": "bh", "n_tests": 4, "n_rejected": 3},
},
"columns": [
{"name": "A", "count": 100, "numeric": _numeric(0.0, -1.2),
"reexpression": {"recommended": "none", "ladder_power": 1.0,
"reason": "symmetric", "alternatives": []}},
{"name": "B", "count": 100, "numeric": _numeric(4.77, 33.1),
"reexpression": {"recommended": "log1p", "ladder_power": 0.0,
"reason": "skew 4.77 with zeros",
"alternatives": [{"transform": "yeo-johnson"},
{"transform": "sqrt"}]}},
{"name": "C", "count": 100, "numeric": _numeric(-0.6, 0.2)},
{"name": "Cat1", "categorical": {"top": [], "mode": "x"}},
{"name": "Cat2", "categorical": {"top": [], "mode": "y"}},
],
"models": {
"kmeans": {
"best_k": 3,
"scores_by_k": [
{"k": 2, "silhouette": 0.46, "inertia": 900.0},
{"k": 3, "silhouette": 0.50, "inertia": 550.0},
{"k": 4, "silhouette": 0.38, "inertia": 430.0},
],
"cluster_sizes": [40, 35, 25],
},
"normality": {
"A": {"n": 100,
"jarque_bera": {"stat": 18.7, "p": 8e-5, "normal": False},
"dagostino": {"stat": 18.1, "p": 1e-4, "normal": False},
"shapiro": {"stat": 0.98, "p": 7e-8, "normal": False},
"is_normal": False},
"C": {"n": 100,
"jarque_bera": {"stat": 2.1, "p": 0.35, "normal": True},
"dagostino": {"stat": 1.9, "p": 0.38, "normal": True},
"shapiro": {"stat": 0.99, "p": 0.12, "normal": True},
"is_normal": True},
},
},
}
def _dummy_chapters():
"""A minimal one-chapter document so render_md does not early-return empty."""
return model.as_chapters([
{"id": "intro", "title": "Intro",
"blocks": [{"kind": "markdown", "text": "cuerpo del informe"}]},
])
def _render(tmp_path, profile):
out = os.path.join(str(tmp_path), "out.md")
res = render_md(_dummy_chapters(), out, {"title": "EDA — t", "profile": profile})
assert res["path"] == out
return open(out, encoding="utf-8").read()
def _table_rows(md, section_title):
"""Count data rows of the first Markdown table under ``section_title``."""
seg = md.split(section_title, 1)[1]
rows, in_t, seen_sep = 0, False, False
for ln in seg.splitlines():
if ln.startswith("|"):
in_t = True
stripped = ln.replace("|", "").replace(" ", "")
if stripped and set(stripped) == {"-"}:
seen_sep = True
continue
if seen_sep:
rows += 1
elif in_t and not ln.strip():
break
return rows
# --------------------------------------------------------------------------- #
# Golden: every datum the profile holds reaches the .md.
# --------------------------------------------------------------------------- #
def test_appendix_lists_all_correlation_pairs(tmp_path):
md = _render(tmp_path, _profile())
assert "## Apéndice — Datos completos del perfil" in md
# All 4 pairs (the real titanic profile has 28; here 4 synthetic).
assert _table_rows(md, "### Matriz de asociación") == 4
# The cat-cat Cramér's V pair the human chapter drops is present.
assert "Cat1 ↔ Cat2" in md
assert "cramers_v" in md
assert "correlation_ratio" in md
def test_appendix_has_skew_kurtosis_for_every_numeric(tmp_path):
md = _render(tmp_path, _profile())
seg = md.split("### Estadísticos numéricos completos", 1)[1].split("###", 1)[0]
lines = [l for l in seg.splitlines() if l.startswith("|")]
header = [h.strip() for h in lines[0].strip("|").split("|")]
assert "skew" in header and "kurtosis" in header
ski, kui = header.index("skew"), header.index("kurtosis")
data = lines[2:] # skip header + separator
assert len(data) == 3 # exactly the 3 numeric columns
for row in data:
cells = [c.strip() for c in row.strip("|").split("|")]
assert cells[ski] != "", f"missing skew in {cells[0]}"
assert cells[kui] != "", f"missing kurtosis in {cells[0]}"
def test_appendix_has_extended_percentiles(tmp_path):
md = _render(tmp_path, _profile())
seg = md.split("### Estadísticos numéricos completos", 1)[1]
header = [h.strip() for h in seg.splitlines()[2].strip("|").split("|")]
for p in ("p1", "p5", "p25", "p75", "p95", "p99"):
assert p in header, f"percentile {p} missing from describe header"
def test_appendix_names_concrete_reexpression(tmp_path):
md = _render(tmp_path, _profile())
assert "### Re-expresión recomendada" in md
assert "log1p" in md # the concrete transform, not just "consider re-expressing"
assert "yeo-johnson" in md # alternatives listed too
def test_appendix_has_kmeans_scores_by_k(tmp_path):
md = _render(tmp_path, _profile())
assert "scores_by_k" in md
assert _table_rows(md, "#### KMeans — selección de k") == 3 # k=2,3,4
def test_appendix_has_normality_statistics(tmp_path):
md = _render(tmp_path, _profile())
assert "JB stat" in md # the statistic, not only the p-value
assert "Shapiro stat" in md
assert _table_rows(md, "#### Tests de normalidad") == 2 # cols A and C
# --------------------------------------------------------------------------- #
# Edge: a profile missing models / correlations degrades, never raises.
# --------------------------------------------------------------------------- #
def test_lite_profile_without_models(tmp_path):
prof = _profile()
prof.pop("models") # lite: no KMeans/normality
md = _render(tmp_path, prof)
assert "scores_by_k" not in md # section skipped
assert "Matriz de asociación" in md # correlations still dumped
assert "## Apéndice" in md
def test_profile_without_correlations(tmp_path):
prof = _profile()
prof.pop("correlations")
md = _render(tmp_path, prof) # must not raise
assert "Matriz de asociación" not in md
assert "Estadísticos numéricos completos" in md # numeric section still there
def test_no_profile_means_no_appendix(tmp_path):
out = os.path.join(str(tmp_path), "noprof.md")
res = render_md(_dummy_chapters(), out, {"title": "x"})
assert res["path"] == out
assert "## Apéndice" not in open(out, encoding="utf-8").read()
def test_appendix_helper_is_defensive():
assert _profile_appendix(None) == ""
assert _profile_appendix({}) == ""
assert _profile_appendix({"columns": []}) == ""
# --------------------------------------------------------------------------- #
# Loss #6: bar/scree figure tables get a non-misleading header.
# --------------------------------------------------------------------------- #
def test_histogram_caption_detection():
assert _is_histogram_caption("Histograma de Age")
assert _is_histogram_caption("Distribución de Fare")
assert not _is_histogram_caption("Media de Survived por Sex")
assert not _is_histogram_caption("Varianza explicada (scree PCA)")
def test_bars_table_custom_header():
bars = [(0.0, 1.0, 5.0), (1.0, 2.0, 3.0)]
hist = _bars_table(bars) # default histogram header
assert "| Desde | Hasta | Frecuencia |" in hist
bar = _bars_table(bars, ("Inicio", "Fin", "Valor"))
assert "| Inicio | Fin | Valor |" in bar
assert "Frecuencia" not in bar
@@ -178,9 +178,17 @@ def _md_data_table(block) -> str:
return "\n".join(lines)
def _bars_table(bars: list) -> str:
"""Render extracted bar/histogram data as a Markdown table (Desde/Hasta/Frec)."""
lines = ["| Desde | Hasta | Frecuencia |", "| --- | --- | --- |"]
def _bars_table(bars: list, header: tuple = ("Desde", "Hasta", "Frecuencia")) -> str:
"""Render extracted bar/histogram data as a Markdown table.
``header`` is the 3-column header to use. Histogram bars are
``(Desde, Hasta, Frecuencia)``; bar/scree charts (means by group, PCA
explained variance) are *not* bins, so the caller passes a semantically
correct header (e.g. ``(Inicio, Fin, Valor)``) to avoid the misleading
"Frecuencia" label — see report 2053, loss #6.
"""
h0, h1, h2 = header
lines = [f"| {h0} | {h1} | {h2} |", "| --- | --- | --- |"]
shown = bars[:_MAX_BAR_ROWS]
for x0, x1, h in shown:
lines.append(f"| {_fmt_num(x0)} | {_fmt_num(x1)} | {_fmt_num(h)} |")
@@ -191,6 +199,18 @@ def _bars_table(bars: list) -> str:
return out
def _is_histogram_caption(caption: str) -> bool:
"""True when a figure caption describes a histogram (genuine numeric bins).
Histograms are the only figures whose bars are real ``[Desde, Hasta)`` bins
with a frequency count. Bar charts (means by group) and the PCA scree plot
carry per-category / per-component values, not bins — they must not inherit
the ``Desde/Hasta/Frecuencia`` header.
"""
c = (caption or "").lower()
return "histograma" in c or "distribución" in c or "distribucion" in c
def _extract_bars(fig) -> list:
"""Collect (x_from, x_to, height) of the rectangular bars of a matplotlib fig.
@@ -253,7 +273,13 @@ def _md_figure(block, meta: dict, out_path: str, counter: list) -> str:
if fig is not None:
bars = _extract_bars(fig)
if bars:
parts.append(_bars_table(bars))
# A histogram's bars are genuine numeric bins (Desde/Hasta/
# Frecuencia). Bar charts and the PCA scree plot are not bins —
# give them a header that does not lie about "Frecuencia".
header = (("Desde", "Hasta", "Frecuencia")
if _is_histogram_caption(caption)
else ("Inicio", "Fin", "Valor"))
parts.append(_bars_table(bars, header))
if meta.get("embed_figures"):
png = _embed_png(fig, out_path, counter)
if png:
@@ -354,6 +380,258 @@ def _serialize_block(block, meta: dict, out_path: str, counter: list) -> str:
return _md_note(model.Note(text=model._safe_str(block)))
# --------------------------------------------------------------------------- #
# Profile appendix — the data the human-facing chapters drop.
#
# The chapter document (shared with the PDF/PPTX renderers) is designed for human
# reading and intentionally omits raw numbers: the correlation matrix shows only
# the top extremes, the numeric blocks skip skew/kurtosis/extended percentiles,
# the model chapter does not list ``scores_by_k`` or the normality test
# statistics. But the Markdown is meant to be *pasted into an LLM*, so it should
# carry EVERYTHING the engine computed. This appendix serializes the full
# ``profile`` (passed via ``meta['profile']``) as Markdown tables, additively:
# the PDF/PPTX are untouched, the .md simply has more than they do. Each section
# is emitted only when its source data is present, so a ``lite`` profile (no
# models) or a profile without correlations degrades cleanly instead of raising.
# See report 2053 for the six losses this closes.
# --------------------------------------------------------------------------- #
def _pair_types(a_type, b_type) -> str:
"""Short ``num↔cat`` label for an association pair's variable types."""
def short(t):
t = model._safe_str(t).lower()
if t.startswith("num"):
return "num"
if t.startswith("cat"):
return "cat"
return t or "?"
return f"{short(a_type)}{short(b_type)}"
def _app_correlations(corr: dict) -> str:
"""Loss #1 — every association pair (not just the top extremes).
Dumps all of ``correlations['pairs']`` as a table (pair · types · method ·
value · p · p-FDR · significant), ordered by |value| desc so the strongest
associations lead while nothing is cut. Includes the ``correlation_ratio``
(num↔cat) and ``cramers_v`` (cat↔cat) pairs the human chapter never shows.
"""
pairs = list(corr.get("pairs", []) or [])
if not pairs:
return ""
def keyfn(p):
try:
return -abs(float(p.get("value")))
except Exception: # noqa: BLE001
return 0.0
pairs_sorted = sorted(pairs, key=keyfn)
lines = ["### Matriz de asociación — todos los pares",
"",
("| Par | Tipos | Método | Valor | p-value | p-ajustado (FDR) "
"| ¿Sig? |"),
"| --- | --- | --- | --- | --- | --- | --- |"]
for p in pairs_sorted:
par = f"{_cell(p.get('a'))}{_cell(p.get('b'))}"
types = _pair_types(p.get("a_type"), p.get("b_type"))
method = _cell(p.get("method"))
val = _fmt_num(p.get("value"))
pv = _fmt_num(p.get("p_value")) if p.get("p_value") is not None else ""
padj = (_fmt_num(p.get("p_value_adjusted"))
if p.get("p_value_adjusted") is not None else "")
sig = "" if p.get("significant") else "no"
lines.append(
f"| {par} | {types} | {method} | {val} | {pv} | {padj} | {sig} |")
mt = corr.get("multiple_testing") or {}
n_tests = mt.get("n_tests", corr.get("n_tests"))
n_rej = mt.get("n_rejected")
note_bits = [f"{len(pairs)} pares en total"]
if n_tests is not None and n_rej is not None:
note_bits.append(
f"{n_rej} de {n_tests} significativos tras corrección "
f"{model._safe_str(mt.get('method', 'FDR')).upper()}")
lines.append("")
lines.append(f"*{'; '.join(note_bits)}.*")
return "\n".join(lines)
# Numeric statistics, in serialization order: (profile key, column header).
_NUM_STATS = [
("count", "n"), ("mean", "mean"), ("median", "median"), ("mode", "mode"),
("std", "std"), ("variance", "variance"), ("cv", "cv"),
("skew", "skew"), ("kurtosis", "kurtosis"),
("min", "min"), ("p1", "p1"), ("p5", "p5"), ("p25", "p25"), ("p50", "p50"),
("p75", "p75"), ("p95", "p95"), ("p99", "p99"), ("iqr", "iqr"),
("max", "max"), ("n_outliers", "outliers"),
("distribution_type", "distribución"),
]
def _app_numeric_describe(columns: list) -> str:
"""Loss #2 — every numeric statistic for every numeric column.
One row per numeric column with the full describe: mean/median/mode/std/
variance/cv, skew & kurtosis (for ALL columns, not only the skewed ones),
p1/p5/p25/p50/p75/p95/p99, iqr, min/max, outliers and distribution_type.
"""
rows = []
for info in (columns or []):
num = info.get("numeric") if isinstance(info, dict) else None
if not num:
continue
name = _cell(info.get("name"))
cells = [name]
for key, _hdr in _NUM_STATS:
v = num.get("count" if key == "count" else key)
if key == "count":
v = num.get("count", info.get("count"))
if key == "distribution_type":
cells.append(_cell(v))
else:
cells.append(_fmt_num(v) if v is not None else "")
rows.append(cells)
if not rows:
return ""
header = ["Columna"] + [hdr for _k, hdr in _NUM_STATS]
lines = ["### Estadísticos numéricos completos (describe)",
"",
"| " + " | ".join(header) + " |",
"| " + " | ".join(["---"] * len(header)) + " |"]
for cells in rows:
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
def _app_reexpression(columns: list) -> str:
"""Loss #3 — the concrete recommended re-expression per column.
Names the transform (log1p/sqrt/yeo-johnson/none) instead of a vague
"consider re-expressing", with the ladder power, reason and alternatives.
"""
rows = []
for info in (columns or []):
rx = info.get("reexpression") if isinstance(info, dict) else None
if not rx or not isinstance(rx, dict):
continue
rec = model._safe_str(rx.get("recommended")).strip()
if not rec:
continue
alts = rx.get("alternatives") or []
alt_txt = ", ".join(
model._safe_str(a.get("transform")) for a in alts
if isinstance(a, dict) and a.get("transform")) or ""
rows.append([
_cell(info.get("name")), _cell(rec),
_fmt_num(rx.get("ladder_power")) if rx.get("ladder_power") is not None else "",
_cell(rx.get("reason")), _cell(alt_txt),
])
if not rows:
return ""
lines = ["### Re-expresión recomendada (escalera de Tukey)",
"",
"| Columna | Recomendada | Potencia | Razón | Alternativas |",
"| --- | --- | --- | --- | --- |"]
for r in rows:
lines.append("| " + " | ".join(r) + " |")
return "\n".join(lines)
def _app_kmeans_scores(kmeans: dict) -> str:
"""Loss #4 — KMeans silhouette + inertia per k (justifies the chosen k)."""
scores = list(kmeans.get("scores_by_k", []) or [])
if not scores:
return ""
best_k = kmeans.get("best_k")
lines = ["#### KMeans — selección de k (`scores_by_k`)",
"",
"| k | Silhouette | Inercia | Elegido |",
"| --- | --- | --- | --- |"]
for s in scores:
if not isinstance(s, dict):
continue
k = s.get("k")
chosen = "" if best_k is not None and k == best_k else ""
lines.append(
f"| {_fmt_num(k)} | {_fmt_num(s.get('silhouette'))} "
f"| {_fmt_num(s.get('inertia'))} | {chosen} |")
return "\n".join(lines)
def _app_normality(normality: dict) -> str:
"""Loss #5 — each normality test's statistic next to its p-value."""
if not isinstance(normality, dict) or not normality:
return ""
lines = ["#### Tests de normalidad (estadístico + p-value)",
"",
("| Columna | n | JB stat | JB p | D'Agostino stat | D'Agostino p "
"| Shapiro stat | Shapiro p | ¿Normal? |"),
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
any_row = False
for col, res in normality.items():
if not isinstance(res, dict):
continue
jb = res.get("jarque_bera") or {}
da = res.get("dagostino") or {}
sh = res.get("shapiro") or {}
is_norm = "" if res.get("is_normal") else "no"
lines.append(
f"| {_cell(col)} | {_fmt_num(res.get('n')) if res.get('n') is not None else ''} "
f"| {_fmt_num(jb.get('stat'))} | {_fmt_num(jb.get('p'))} "
f"| {_fmt_num(da.get('stat'))} | {_fmt_num(da.get('p'))} "
f"| {_fmt_num(sh.get('stat'))} | {_fmt_num(sh.get('p'))} | {is_norm} |")
any_row = True
return "\n".join(lines) if any_row else ""
def _profile_appendix(profile: dict) -> str:
"""Build the full-data appendix from a TableProfile dict (additive).
Returns a Markdown ``## Apéndice`` section with one sub-table per loss the
human chapters drop, or ``""`` when the profile carries none of them. Never
raises: a missing/oddly-shaped section is skipped, not fatal.
"""
if not isinstance(profile, dict):
return ""
sections: list = []
try:
corr = profile.get("correlations") or {}
seg = _app_correlations(corr) if isinstance(corr, dict) else ""
if seg:
sections.append(seg)
except Exception: # noqa: BLE001
pass
try:
columns = profile.get("columns") or []
seg = _app_numeric_describe(columns)
if seg:
sections.append(seg)
seg = _app_reexpression(columns)
if seg:
sections.append(seg)
except Exception: # noqa: BLE001
pass
try:
models = profile.get("models") or {}
if isinstance(models, dict):
model_segs = []
seg = _app_kmeans_scores(models.get("kmeans") or {})
if seg:
model_segs.append(seg)
seg = _app_normality(models.get("normality") or {})
if seg:
model_segs.append(seg)
if model_segs:
sections.append(
"### Modelos — detalle\n\n" + "\n\n".join(model_segs))
except Exception: # noqa: BLE001
pass
if not sections:
return ""
intro = ("Volcado completo de los datos que el motor computó y que los "
"capítulos (pensados para lectura humana / PDF) resumen. "
"Pensado para que un LLM reconstruya el análisis entero.")
return ("## Apéndice — Datos completos del perfil\n\n"
f"*{intro}*\n\n" + "\n\n".join(sections))
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
@@ -437,6 +715,18 @@ def render_md(chapters: list, out_path: str, meta: dict = None) -> dict:
segments.append(seg)
chapters_meta.append({"id": ch.id, "version": ch.version})
# Full-data appendix: dump everything the profile holds that the human
# chapters drop (additive — the .md ends up with more than the PDF/PPTX).
# Emitted only when a profile is supplied via meta['profile']; never fatal.
try:
appendix = _profile_appendix(meta.get("profile"))
except Exception as e: # noqa: BLE001
appendix = ""
notes.append(f"apéndice de perfil omitido: {e}")
if appendix:
segments.append("---")
segments.append(appendix)
content = "\n\n".join(segments) + "\n"
note = f"{len(content)} caracteres"
if notes:
@@ -0,0 +1,125 @@
---
id: build_boxplots_figure_py_datascience
name: build_boxplots_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def build_boxplots_figure(boxes: list, title: str = \"\", max_boxes: int = 12) -> \"matplotlib.figure.Figure\""
description: "Construye una unica figura matplotlib con boxplots de Tukey HORIZONTALES (uno por columna) usando ax.bxp: caja Q1-Q3, bigotes hasta 1.5*IQR, linea de mediana y puntos atipicos. Consume la salida de build_boxplot_stats (un dict box por columna, leido con .get) mas una lista opcional de outliers crudos por columna; si vienen los dibuja como puntos (showfliers), si no marca solo box[min]/box[max] cuando hay outliers de cola (igual que num_distr). Dibuja como mucho max_boxes cajas (las primeras, ya ordenadas por contaminacion por el caller) y avisa de la truncacion con (mostrando N de M). Backend Agg sin pyplot global; alto adaptativo al nº de cajas. Defensiva: omite entradas invalidas y NUNCA lanza — sin cajas validas devuelve una figura placeholder (sin boxplots). Es la version small-multiples del capitulo num_distr para responder que columnas tienen mas outliers de un vistazo."
tags: [eda, outliers, boxplot, tukey, iqr, bxp, matplotlib, figure, visualization, small-multiples, datascience, impure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib]
example: |
from datascience.build_boxplot_stats import build_boxplot_stats
from datascience.build_boxplots_figure import build_boxplots_figure
boxes = [
{"name": "ingresos", "box": build_boxplot_stats({"min": 1.0, "max": 9e3,
"p25": 1e3, "median": 2e3, "p75": 3e3, "n_outliers": 7}), "fliers": None},
{"name": "edad", "box": build_boxplot_stats({"min": 0.0, "max": 99.0,
"p25": 25.0, "median": 38.0, "p75": 52.0}), "fliers": None},
]
fig = build_boxplots_figure(boxes, title="Outliers por columna", max_boxes=12)
tested: true
tests:
- "test_returns_figure_with_axes"
- "test_empty_list_returns_placeholder_figure"
- "test_invalid_box_is_skipped_not_raised"
- "test_all_invalid_returns_placeholder"
- "test_raw_fliers_are_drawn"
- "test_max_boxes_truncates_and_does_not_raise"
test_file_path: "python/functions/datascience/build_boxplots_figure_test.py"
file_path: "python/functions/datascience/build_boxplots_figure.py"
params:
- name: boxes
desc: "Lista de dicts, cada uno {\"name\": str, \"box\": dict, \"fliers\": list|None}. box es EXACTAMENTE la salida de build_boxplot_stats (claves leidas con .get: q1, median, q3, whisker_lo, whisker_hi, min, max, has_low_outliers, has_high_outliers, lower_fence, upper_fence, n_outliers). fliers es la lista opcional de outliers crudos: si viene se dibuja como puntos; si es None/ausente solo se marcan los extremos box[min]/box[max] cuando hay outliers de cola. Entradas que no son dict, sin box dict, o sin q1/median/q3 se omiten. El caller las pasa ya ordenadas por contaminacion (la mayor primera)."
- name: title
desc: "Titulo de la figura (fig.suptitle, alineado a la izquierda). Vacio => sin titulo. Si len(boxes) > max_boxes se le anade una nota \"(mostrando N de M)\" para que la truncacion no sea silenciosa. Default \"\"."
- name: max_boxes
desc: "Numero maximo de cajas a dibujar (las primeras de la lista). Default 12. Un valor no entero o <= 0 cae a 12. Si la lista trae mas entradas, las sobrantes se descartan pero se reporta en el titulo con (mostrando N de M)."
output: "Un matplotlib.figure.Figure (figsize 7.0 x alto adaptativo = max(2.0, 0.5*n + 1.0), dpi 150) con un unico Axes que apila boxplots horizontales de Tukey (ax.bxp, orientation=horizontal con fallback vert=False), uno por columna valida, de arriba a abajo en el orden recibido. Cada caja: relleno #9ec6df, borde/bigotes/caps #5b8aa6, mediana #2e8b57, atipicos #c0392b. Etiquetas del eje Y = nombres de columna; eje X etiquetado \"valor\". Outliers dibujados desde fliers crudos (showfliers) o, si faltan, marcados en box[min]/box[max] segun has_low/high_outliers. Si no queda ninguna caja valida (lista vacia o todas invalidas) devuelve una Figure placeholder con texto centrado \"(sin boxplots)\"; cualquier error inesperado se captura y devuelve una Figure con el mensaje de error. NUNCA lanza. El caller rasteriza/cierra la figura; la funcion no la muestra ni la guarda."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.build_boxplot_stats import build_boxplot_stats
from datascience.build_boxplots_figure import build_boxplots_figure
# Un `box` por columna numérica, derivado del sub-bloque `numeric` del profile
# (salida de describe_numeric). El caller los pasa ya ordenados por outlier_pct.
boxes = [
{
"name": "ingresos",
"box": build_boxplot_stats({
"min": 1.0, "max": 9000.0,
"p25": 1000.0, "median": 2000.0, "p75": 3000.0,
"n_outliers": 7,
}),
"fliers": None, # valores crudos desconocidos -> se marca solo el extremo.
},
{
"name": "edad",
"box": build_boxplot_stats({
"min": 0.0, "max": 99.0,
"p25": 25.0, "median": 38.0, "p75": 52.0,
}),
"fliers": [88.0, 95.0, 99.0], # outliers crudos -> se dibujan como puntos.
},
]
fig = build_boxplots_figure(boxes, title="Outliers por columna", max_boxes=12)
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
fig.savefig("/tmp/boxplots.png")
```
## Cuando usarla
Úsala en el capítulo de outliers de un informe EDA cuando quieras comparar de un
vistazo *qué columnas están más contaminadas por valores atípicos*: a diferencia
de `num_distr` (que dibuja un histograma+boxplot por columna en figuras
separadas), aquí apilas todos los boxplots horizontales en **una sola figura**
(small multiples). Primero deriva el `box` de cada columna con
`build_boxplot_stats`, ordénalas por `outlier_pct` descendente, envuélvelas como
`{"name", "box", "fliers"}` y pásaselas. Si tienes los valores crudos fuera de
las vallas, métele la lista `fliers` y se dibujarán como puntos; si no, la
función marca solo los extremos `min`/`max` cuando hay cola.
## Gotchas
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
es thread-safe; esta función construye el `Figure` directamente, así que es
segura de llamar en bucle desde el renderer.
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
guarda. Quien la consume debe rasterizarla y luego liberarla
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
- **`fliers` opcional, semántica distinta.** Si pasas la lista de outliers
crudos se dibujan todos como puntos (`showfliers=True`). Si es `None`/ausente
los valores son desconocidos y solo se marca un punto en `box["min"]` /
`box["max"]` cuando `has_low_outliers` / `has_high_outliers` — mismo criterio
que `num_distr`. No inventes fliers a partir del profile: el `box` no trae los
valores crudos, solo si los extremos superan las vallas.
- **API de orientación de `ax.bxp`.** matplotlib reciente usa
`orientation="horizontal"`; las versiones antiguas usan `vert=False`. La
función prueba la primera y cae a la segunda en `except TypeError`, así que
funciona en ambas. Si `bxp` falla del todo, el Axes degrada a un texto
"(boxplot no disponible)" en vez de propagar.
- **Truncación visible.** `max_boxes` (default 12) limita el nº de cajas para que
ninguna se solape; si la lista trae más, las sobrantes se descartan pero se
avisa en el título con "(mostrando N de M)". Pasa las columnas ya ordenadas por
contaminación para que las descartadas sean las menos relevantes.
- **Defensiva, nunca lanza.** Lista vacía, entradas no-dict, sin `box`, o sin
`q1`/`median`/`q3` se omiten sin propagar; sin cajas válidas devuelve un
placeholder "(sin boxplots)" y cualquier error inesperado se captura en una
figura con el texto del error. No envuelvas la llamada en try/except por miedo
a un raise — no lo hay.
@@ -0,0 +1,250 @@
"""Impure EDA helper: a single figure of horizontal Tukey boxplots (`eda` group).
Draws, in one ``matplotlib.figure.Figure``, a stack of horizontal Tukey boxplots
(one per column) using ``ax.bxp``: each carries its box (Q1Q3), whiskers (up to
1.5·IQR), the median line and its outlier points. It consumes the output of the
pure registry function ``build_boxplot_stats`` (one ``box`` dict per column) plus
an optional list of raw outlier values per column; it never recomputes anything.
It is the "small-multiples" companion of ``num_distr`` (which draws one
histogram+boxplot per column): here every column shares a single figure so the
caller can show, at a glance, *which* columns are the most contaminated by
outliers (the caller passes them already ordered by contamination).
Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer. It is fully
defensive and NEVER raises: invalid entries are skipped and, if nothing valid
remains, it returns a placeholder figure carrying a centered "(sin boxplots)".
"""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure # noqa: E402
# Blue palette shared with the ``num_distr`` chapter so the report stays coherent.
_BOX_FACE = "#9ec6df" # box fill.
_BOX_EDGE = "#5b8aa6" # box / whisker / cap border.
_MEDIAN = "#2e8b57" # median line (sea green).
_OUTLIER = "#c0392b" # outlier points (soft red).
# Muted gray for the placeholder / fallback message text.
_MUTED_TEXT = "#5f6b7a"
# Soft red for the error fallback message.
_ERROR_TEXT = "#b00020"
def _num(value):
"""Coerce ``value`` to float defensively; None for None/bool/non-numeric/NaN."""
# bool is a subclass of int; a stat value is never a real bool, so treat
# True/False as missing instead of silently coercing to 1.0/0.0.
if value is None or isinstance(value, bool):
return None
try:
f = float(value)
except (TypeError, ValueError):
return None
if f != f: # NaN guard.
return None
return f
def _placeholder_figure(message: str, color: str = _MUTED_TEXT) -> "Figure":
"""Return a fallback ``Figure`` carrying a single centered message."""
fig = Figure(figsize=(7.0, 2.4), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=12,
color=color,
wrap=True,
transform=ax.transAxes,
)
fig.tight_layout()
return fig
def build_boxplots_figure(
boxes: list,
title: str = "",
max_boxes: int = 12,
) -> "matplotlib.figure.Figure":
"""Build one figure of stacked horizontal Tukey boxplots (one per column).
For each entry the function builds a ``bxp`` stats record (``med, q1, q3,
whislo, whishi, fliers, label``) from its ``box`` sub-dict (the output of
``build_boxplot_stats``) and draws all of them as horizontal boxplots sharing
the X axis, top-to-bottom in the order received (the caller is expected to
pass them already sorted by contamination).
Outliers are shown two ways:
- If an entry carries a ``fliers`` list (the raw out-of-fence values), they
are drawn as red points via ``ax.bxp(..., showfliers=True)``.
- If ``fliers`` is ``None``/absent, the raw values are unknown, so only the
extremes are marked: a red point at ``box["min"]`` when
``box["has_low_outliers"]`` and at ``box["max"]`` when
``box["has_high_outliers"]`` (same convention as ``num_distr``).
The function is fully defensive and NEVER raises. Entries that are not dicts,
lack a ``box`` dict, or miss any of ``q1``/``median``/``q3`` are skipped. If
after filtering no valid box remains it returns a placeholder ``Figure`` with
a centered "(sin boxplots)"; any unexpected error is caught and turned into a
fallback figure carrying the error text. It always returns a ``Figure``.
Args:
boxes: List of dicts ``{"name": str, "box": dict, "fliers": list|None}``.
``box`` is exactly the output of ``build_boxplot_stats`` (read with
``.get``: ``q1, median, q3, whisker_lo, whisker_hi, min, max,
has_low_outliers, has_high_outliers, ...``). ``fliers`` is the
optional list of raw outlier values; when present they are plotted,
otherwise only the extremes are marked.
title: Figure title (``fig.suptitle``). Empty => no title. When the list
is longer than ``max_boxes`` a "(mostrando N de M)" note is appended.
max_boxes: Draw at most the first ``max_boxes`` entries (default 12). The
rest are dropped but their omission is surfaced in the title note, so
the truncation is never silent.
Returns:
A ``matplotlib.figure.Figure`` with a single Axes holding the horizontal
boxplots (height adaptive to the box count so none overlap). The caller is
responsible for rasterizing/closing it; this function never shows nor
saves it.
"""
try:
if not isinstance(boxes, (list, tuple)) or len(boxes) == 0:
return _placeholder_figure("(sin boxplots)")
total = len(boxes)
# Cap the number of boxes; tolerate a non-int / non-positive max_boxes.
try:
cap = int(max_boxes)
except (TypeError, ValueError):
cap = 12
if cap <= 0:
cap = 12
candidates = list(boxes)[:cap]
stats_list = [] # bxp stats records, in draw order.
labels = [] # Y tick labels (column names).
manual_markers = [] # (position, box) for entries without raw fliers.
any_fliers = False # whether to enable showfliers in the bxp call.
for entry in candidates:
if not isinstance(entry, dict):
continue
box = entry.get("box")
if not isinstance(box, dict):
continue
q1 = _num(box.get("q1"))
med = _num(box.get("median"))
q3 = _num(box.get("q3"))
# Without the three quartiles a boxplot cannot be drawn — skip it.
if q1 is None or med is None or q3 is None:
continue
# Whisker extremes fall back to the quartiles when missing.
whislo = _num(box.get("whisker_lo"))
whishi = _num(box.get("whisker_hi"))
if whislo is None:
whislo = q1
if whishi is None:
whishi = q3
name = entry.get("name")
label = "" if name is None else str(name)
position = len(stats_list) + 1 # bxp positions are 1-indexed.
fliers_raw = entry.get("fliers")
if isinstance(fliers_raw, (list, tuple)):
fliers = [v for v in (_num(x) for x in fliers_raw) if v is not None]
if fliers:
any_fliers = True
else:
# Raw values unknown: draw no bxp fliers, mark min/max by hand.
fliers = []
manual_markers.append((position, box))
stats_list.append({
"med": med,
"q1": q1,
"q3": q3,
"whislo": whislo,
"whishi": whishi,
"fliers": fliers,
"label": label,
})
labels.append(label)
if not stats_list:
return _placeholder_figure("(sin boxplots)")
n = len(stats_list)
positions = list(range(1, n + 1))
# Height grows with the box count so none of them overlap.
height = max(2.0, 0.5 * n + 1.0)
fig = Figure(figsize=(7.0, height), dpi=150)
ax = fig.add_subplot(111)
bxp_kw = dict(
showfliers=any_fliers, widths=0.5, patch_artist=True,
boxprops={"facecolor": _BOX_FACE, "edgecolor": _BOX_EDGE},
medianprops={"color": _MEDIAN, "linewidth": 1.6},
whiskerprops={"color": _BOX_EDGE},
capprops={"color": _BOX_EDGE},
flierprops={"marker": "o", "markersize": 3.5,
"markerfacecolor": _OUTLIER, "markeredgecolor": _OUTLIER,
"linestyle": "none"})
try:
# ``orientation`` is the current API; older matplotlib uses ``vert``.
try:
ax.bxp(stats_list, positions=positions,
orientation="horizontal", **bxp_kw)
except TypeError:
ax.bxp(stats_list, positions=positions, vert=False, **bxp_kw)
except Exception: # noqa: BLE001 — never let bxp kill the whole figure.
ax.text(0.5, 0.5, "(boxplot no disponible)", ha="center",
va="center", fontsize=10, color=_MUTED_TEXT,
transform=ax.transAxes)
# For entries without raw fliers, mark only the out-of-fence extremes.
for position, box in manual_markers:
mn = _num(box.get("min"))
mx = _num(box.get("max"))
if box.get("has_low_outliers") and mn is not None:
ax.plot([mn], [position], marker="o", markersize=3.5,
color=_OUTLIER, zorder=5)
if box.get("has_high_outliers") and mx is not None:
ax.plot([mx], [position], marker="o", markersize=3.5,
color=_OUTLIER, zorder=5)
# Pin the Y tick labels explicitly so they work across matplotlib
# versions regardless of whether ``bxp`` consumed the ``label`` key.
ax.set_yticks(positions)
ax.set_yticklabels(labels, fontsize=8)
ax.set_xlabel("valor", fontsize=9)
ax.tick_params(labelsize=7)
ax.margins(y=0.15)
for spine in ("top", "right"):
ax.spines[spine].set_visible(False)
# Surface truncation in the title instead of silently dropping boxes.
note = f"(mostrando {n} de {total})" if total > cap else ""
heading = " ".join(p for p in (title, note) if p)
if heading:
fig.suptitle(heading, fontsize=12, x=0.02, ha="left")
fig.tight_layout()
return fig
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
return _placeholder_figure(
f"error al dibujar boxplots: {exc}", color=_ERROR_TEXT)
@@ -0,0 +1,109 @@
"""Tests para build_boxplots_figure (boxplots horizontales de Tukey, grupo eda).
Usa el backend Agg sin display; no muestra ni guarda figuras. Cada test cierra
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
estado entre tests.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from build_boxplots_figure import build_boxplots_figure
def _box(name, q1, median, q3, mn, mx, low=False, high=False, fliers=None):
"""Construye una entrada {name, box, fliers} con un box estilo build_boxplot_stats."""
iqr = q3 - q1
return {
"name": name,
"box": {
"q1": q1,
"median": median,
"q3": q3,
"iqr": iqr,
"lower_fence": q1 - 1.5 * iqr,
"upper_fence": q3 + 1.5 * iqr,
"whisker_lo": max(mn, q1 - 1.5 * iqr),
"whisker_hi": min(mx, q3 + 1.5 * iqr),
"min": mn,
"max": mx,
"has_low_outliers": low,
"has_high_outliers": high,
"n_outliers": 0,
},
"fliers": fliers,
}
def test_returns_figure_with_axes():
boxes = [
_box("edad", 10.0, 25.0, 40.0, 1.0, 100.0, high=True),
_box("ingresos", 100.0, 200.0, 300.0, 50.0, 400.0),
_box("score", -1.0, 0.0, 1.0, -5.0, 5.0, low=True, high=True),
]
fig = build_boxplots_figure(boxes, title="Boxplots", max_boxes=12)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
# Tres cajas -> tres etiquetas en el eje Y.
ax = fig.axes[0]
assert len(ax.get_yticks()) == 3
plt.close(fig)
def test_empty_list_returns_placeholder_figure():
fig = build_boxplots_figure([], title="vacío")
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_invalid_box_is_skipped_not_raised():
boxes = [
{"name": "rota", "box": {"q1": None, "median": None, "q3": None}},
{"name": "sin_box"}, # falta la clave box.
"no_es_dict", # entrada no-dict.
_box("buena", 1.0, 2.0, 3.0, 0.0, 10.0, high=True),
]
fig = build_boxplots_figure(boxes)
assert isinstance(fig, Figure)
ax = fig.axes[0]
# Solo la caja válida sobrevive al filtrado.
assert len(ax.get_yticks()) == 1
plt.close(fig)
def test_all_invalid_returns_placeholder():
boxes = [
{"name": "a", "box": {"q1": None, "median": 1.0, "q3": 2.0}},
{"name": "b"},
]
fig = build_boxplots_figure(boxes)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_raw_fliers_are_drawn():
boxes = [
_box("con_fliers", 10.0, 20.0, 30.0, 5.0, 200.0,
high=True, fliers=[150.0, 180.0, 200.0]),
]
fig = build_boxplots_figure(boxes)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_max_boxes_truncates_and_does_not_raise():
boxes = [_box(f"c{i}", float(i), float(i + 1), float(i + 2),
float(i - 5), float(i + 10)) for i in range(20)]
fig = build_boxplots_figure(boxes, title="muchos", max_boxes=5)
assert isinstance(fig, Figure)
ax = fig.axes[0]
# Solo se dibujan las primeras 5 cajas.
assert len(ax.get_yticks()) == 5
plt.close(fig)
@@ -0,0 +1,68 @@
---
name: classify_relationship_type
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def classify_relationship_type(xs: list, ys: list) -> dict"
description: "Clasifica el TIPO de relacion entre dos variables numericas pareadas por indice para el EDA automatico del grupo eda. Limpia los pares de forma defensiva (descarta None/bool/NaN/inf), reusa pearson y spearman_corr del registry y ajusta polinomios de grado 2 y 3 con numpy.polyfit (R^2 manual), y a partir de esas senales etiqueta la forma: 'lineal', 'polinomica (grado 2/3)', 'monotona no-lineal' o 'debil/sin forma'. Orden de decision: debil -> monotona -> polinomica -> lineal (la primera que matchea gana), con umbrales calibrados para datos reales discretos/ruidosos. Devuelve ademas los coeficientes del mejor modelo en orden de numpy.polyval para pintar la curva de ajuste sobre el scatter. Funcion pura no-throw: ante datos insuficientes (menos de 5 pares validos o varianza ~0) o cualquier fallo devuelve el dict canonico con tipo='debil/sin forma' y el resto a None."
tags: [eda, correlation, relationship, classification, polyfit, datascience, pure]
params:
- name: xs
desc: "Lista (o tupla) de valores numericos de la primera variable, pareada por indice con ys. Cada par xs[i],ys[i] se descarta si cualquiera de los dos es None, bool, NaN o inf. Lectura defensiva."
- name: ys
desc: "Lista (o tupla) de valores numericos de la segunda variable, pareada por indice con xs. Mismas reglas de limpieza que xs."
output: "Dict con SIEMPRE las mismas 8 claves: tipo (str: 'lineal' | 'polinómica (grado 2)' | 'polinómica (grado 3)' | 'monótona no-lineal' | 'débil/sin forma'); pearson (float|None: coeficiente de Pearson r); r2_linear (float|None: r**2 del ajuste lineal); spearman (float|None: rho de Spearman); r2_poly2 (float|None: R^2 del ajuste polinomico de grado 2); r2_poly3 (float|None: R^2 del ajuste de grado 3); best_degree (int|None: grado del modelo elegido — 1 lineal, 2/3 polinomico, None si monotona/debil); coeffs (list|None: coeficientes del mejor modelo en orden de numpy.polyval para pintar la curva, o None). Ante datos insuficientes o error: tipo='débil/sin forma' y el resto de claves a None."
uses_functions: [pearson_py_datascience, spearman_corr_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [numpy]
tested: true
tests: ["test_lineal", "test_polinomica_cuadratica", "test_monotona_no_lineal", "test_monotona_exponencial", "test_debil_sin_forma", "test_lista_vacia_no_lanza", "test_longitudes_distintas_no_lanza", "test_todos_none_no_lanza", "test_entradas_none_no_lanza", "test_constante_no_lanza", "test_filtra_nan_inf_bool"]
test_file_path: "python/functions/datascience/classify_relationship_type_test.py"
file_path: "python/functions/datascience/classify_relationship_type.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.classify_relationship_type import classify_relationship_type
import numpy as np
# Relacion claramente cuadratica (forma de parabola) sobre dominio simetrico.
x = list(np.linspace(-10, 10, 60))
y = [v * v for v in x]
res = classify_relationship_type(x, y)
print(res["tipo"]) # 'polinómica (grado 2)'
print(res["best_degree"]) # 2
print(res["r2_linear"]) # 0.0 -> el Pearson lineal no ve la parabola
print(res["r2_poly2"]) # 1.0
print(res["coeffs"]) # [1.0, -0.0, -0.0] -> numpy.polyval(coeffs, x) ~ x**2
# El capitulo pinta la curva de ajuste cuando coeffs no es None:
# if res["coeffs"] is not None:
# xs_fit = np.linspace(min(x), max(x), 200)
# ys_fit = np.polyval(res["coeffs"], xs_fit)
# ax.plot(xs_fit, ys_fit) # curva sobre el ax.scatter(x, y)
```
## Cuando usarla
- Usala en el capitulo de relaciones/correlaciones del EDA automatico, despues de detectar dos columnas numericas con alguna asociacion, para decidir QUE curva de ajuste pintar sobre el scatter (recta, parabola, cubica o ninguna) y poner una etiqueta legible al tipo de relacion.
- Cuando un Pearson bajo no signifique "sin relacion": esta funcion cruza Pearson con Spearman y con ajustes polinomicos para distinguir una relacion lineal debil de una monotona no-lineal (que el rango si capta) o de una curva polinomica.
- Cuando necesites un punto de entrada determinista y no-throw que, con los mismos datos, devuelva siempre el mismo `tipo` y los mismos `coeffs` listos para `numpy.polyval` sin tener que ajustar modelos a mano en el capitulo.
## Gotchas
- Funcion pura, deterministica y no-throw: ante menos de 5 pares validos, varianza ~0 (xs o ys constante) o cualquier excepcion interna devuelve el dict canonico `tipo="débil/sin forma"` con el resto de claves a `None`. El dict SIEMPRE trae las 8 claves: nunca compruebes existencia, comprueba `None`.
- El orden de decision importa: `débil -> monótona -> polinómica -> lineal` (la primera que matchee gana). La monotonia se evalua ANTES que el ajuste polinomico, asi que una curva monotona suave (exp, log, potencias) sale `monótona no-lineal` aunque un cubico tambien la ajuste — la dominancia del rango (Spearman >> Pearson) es la senal mas interpretable. Solo cae en `polinómica` una forma curva NO monotona (p.ej. una parabola, Spearman ~0 pero R^2 polinomico alto).
- Umbrales fijos (calibrados para EDA con datos discretos/ruidosos, no para inferencia formal): `débil/sin forma` si las tres senales son bajas a la vez (`abs(pearson) < 0.3` y `abs(spearman) < 0.3` y `mejor_poly < 0.3`); `monótona no-lineal` si `abs(spearman) - abs(pearson) >= 0.1` y `abs(spearman) >= 0.4`; `polinómica (grado N)` si el mejor polinomico mejora `>= 0.1` sobre el lineal y su R^2 `>= 0.3`; en cualquier otro caso con senal (no debil) `lineal`. El suelo de 0.3 evita llamar "debil" a relaciones reales pero discretas (conteos, escalas ordinales) con R^2 bajo pero direccion clara.
- `coeffs` va en orden de `numpy.polyval` (grado descendente). Para `lineal` es `[pendiente, intercepto]` (grado 1); para `polinómica` los del grado elegido; para `monótona no-lineal` y `débil/sin forma` es `None` (el scatter pintara una curva suavizada o nada — lo decide el capitulo, no esta funcion).
- `best_degree` prefiere el grado 2 sobre el 3 cuando empatan dentro de 0.02 de R^2 (parsimonia): no esperes grado 3 salvo que mejore claramente.
- Los pares con `None`, `bool`, `NaN` o `inf` se descartan por indice en silencio; `bool` cuenta como no-numerico (un `True` no es `1`). El dominio de los datos afecta al resultado: una parabola sobre un dominio simetrico da Pearson ~0 (sale `polinómica`), pero sobre un dominio asimetrico el Pearson sube y puede salir `lineal`.
@@ -0,0 +1,187 @@
"""Clasifica el TIPO de relacion entre dos variables numericas pareadas.
Funcion pura del grupo eda. Dadas dos listas numericas pareadas por indice,
limpia los pares de forma defensiva, calcula correlaciones lineal (Pearson) y de
rangos (Spearman) y ajustes polinomicos de grado 2 y 3, y a partir de esas
senales etiqueta la forma de la relacion para el EDA automatico:
"lineal" | "polinómica (grado 2)" | "polinómica (grado 3)" |
"monótona no-lineal" | "débil/sin forma"
Ademas devuelve los coeficientes del mejor modelo (en orden de numpy.polyval)
para que el capitulo pinte la curva de ajuste sobre el scatter. Reusa las
funciones del registry `pearson` y `spearman_corr` en vez de reimplementarlas.
NUNCA lanza: ante cualquier fallo o dato insuficiente devuelve el dict canonico
con tipo="débil/sin forma" y el resto de claves a None.
"""
import math
import warnings
import numpy as np
from datascience.datascience import pearson
from datascience.spearman_corr import spearman_corr
# Forma canonica de la respuesta cuando no se puede clasificar (datos
# insuficientes, varianza nula o error interno). Siempre las mismas claves.
_WEAK = {
"tipo": "débil/sin forma",
"pearson": None,
"r2_linear": None,
"spearman": None,
"r2_poly2": None,
"r2_poly3": None,
"best_degree": None,
"coeffs": None,
}
def _is_num(v) -> bool:
"""True si v es un numero real finito (int/float, no bool, no NaN, no inf)."""
return (
isinstance(v, (int, float))
and not isinstance(v, bool)
and not (isinstance(v, float) and (math.isnan(v) or math.isinf(v)))
)
def _poly_r2(coeffs, x_arr, y_arr, ss_tot: float) -> float:
"""R^2 de un ajuste polinomico: 1 - SS_res/SS_tot. 0 si SS_tot==0."""
if ss_tot == 0.0:
return 0.0
pred = np.polyval(coeffs, x_arr)
ss_res = float(np.sum((y_arr - pred) ** 2))
return 1.0 - ss_res / ss_tot
def classify_relationship_type(xs: list, ys: list) -> dict:
"""Clasifica el tipo de relacion entre dos variables numericas pareadas.
Empareja xs[i],ys[i] por indice y descarta el par si cualquiera de los dos
es None, bool, NaN o inf. Sobre los pares limpios calcula Pearson r
(r2_linear = r**2), Spearman rho y los R^2 de ajustes polinomicos de grado 2
y 3 (con numpy.polyfit + R^2 manual). Con esas senales decide la etiqueta.
Orden de evaluacion de la etiqueta (la primera que matchee gana). Los
umbrales estan calibrados para datos reales, a menudo discretos y ruidosos
(conteos, escalas ordinales): una relacion con |r| >= 0.3, |rho| >= 0.3 o un
polinomio con R^2 >= 0.3 ya tiene FORMA y no debe etiquetarse como "debil".
1. "débil/sin forma" — todas las senales bajas a la vez:
abs(pearson) < 0.3 y abs(spearman) < 0.3 y mejor_poly < 0.3.
2. "monótona no-lineal" — el rango (Spearman) capta una monotonia que el
Pearson lineal no: abs(spearman) - abs(pearson) >= 0.1 y
abs(spearman) >= 0.4. No se fuerza un polinomio (coeffs/best_degree =
None); el capitulo dibuja la tendencia ordenada sobre el scatter.
3. "polinómica (grado N)" — el mejor polinomico mejora claramente sobre
el lineal (mejor_poly - r2_linear >= 0.1) y mejor_poly >= 0.3. N es el
grado (2 o 3) con mejor R^2, prefiriendo el 2 si empatan dentro de 0.02
(parsimonia).
4. "lineal" — el resto: hay senal (no es debil) y la forma que existe es
esencialmente lineal. best_degree=1, coeffs del ajuste de grado 1.
Si hay menos de 5 pares validos, o la varianza de xs o de ys es ~0
(constante), devuelve directamente "débil/sin forma".
Args:
xs: lista (o tupla) de valores numericos de la primera variable,
pareada por indice con ys. Pares con None/bool/NaN/inf se descartan.
ys: lista (o tupla) de valores numericos de la segunda variable,
pareada por indice con xs.
Returns:
dict con SIEMPRE las mismas claves:
tipo (str), pearson (float|None), r2_linear (float|None),
spearman (float|None), r2_poly2 (float|None), r2_poly3 (float|None),
best_degree (int|None: 1, 2, 3 o None),
coeffs (list|None: coeficientes en orden de numpy.polyval, o None).
Nunca lanza: ante fallo o datos insuficientes devuelve el dict debil.
"""
try:
if xs is None or ys is None:
return dict(_WEAK)
pairs = [
(float(x), float(y))
for x, y in zip(xs, ys)
if _is_num(x) and _is_num(y)
]
# Datos insuficientes para hablar de forma de la relacion.
if len(pairs) < 5:
return dict(_WEAK)
clean_x = [p[0] for p in pairs]
clean_y = [p[1] for p in pairs]
# Varianza ~0 en cualquiera de las series => relacion indefinida.
if len(set(clean_x)) < 2 or len(set(clean_y)) < 2:
return dict(_WEAK)
x_arr = np.asarray(clean_x, dtype=float)
y_arr = np.asarray(clean_y, dtype=float)
if float(np.var(x_arr)) < 1e-15 or float(np.var(y_arr)) < 1e-15:
return dict(_WEAK)
# Correlaciones reutilizando las funciones del registry.
r = pearson(clean_x, clean_y)
spearman = spearman_corr(clean_x, clean_y)
r2_linear = r ** 2
# Ajustes polinomicos grado 2 y 3 con R^2 manual.
ss_tot = float(np.sum((y_arr - float(np.mean(y_arr))) ** 2))
with warnings.catch_warnings():
warnings.simplefilter("ignore")
c1 = np.polyfit(x_arr, y_arr, 1)
c2 = np.polyfit(x_arr, y_arr, 2)
c3 = np.polyfit(x_arr, y_arr, 3)
r2_poly2 = _poly_r2(c2, x_arr, y_arr, ss_tot)
r2_poly3 = _poly_r2(c3, x_arr, y_arr, ss_tot)
mejor_poly = max(r2_poly2, r2_poly3)
# Grado del mejor polinomico, con preferencia por la parsimonia: solo se
# elige el grado 3 si supera al grado 2 por mas de 0.02.
best_poly_degree = 3 if (r2_poly3 - r2_poly2) > 0.02 else 2
abs_s = abs(spearman)
abs_p = abs(r)
# Decision en orden: debil-temprano -> monotona -> polinomica -> lineal.
if abs_p < 0.3 and abs_s < 0.3 and mejor_poly < 0.3:
# Ninguna senal supera el suelo de forma: relacion debil/sin forma.
tipo = "débil/sin forma"
best_degree = None
coeffs = None
elif (abs_s - abs_p) >= 0.1 and abs_s >= 0.4:
# Spearman (rango) capta una monotonia que el Pearson lineal no:
# relacion monotona no-lineal. No se fuerza un polinomio que tal vez
# no ajusta bien; el capitulo dibuja la tendencia ordenada.
tipo = "monótona no-lineal"
best_degree = None
coeffs = None
elif (mejor_poly - r2_linear) >= 0.1 and mejor_poly >= 0.3:
tipo = "polinómica (grado {})".format(best_poly_degree)
best_degree = best_poly_degree
best_coeffs = c2 if best_poly_degree == 2 else c3
coeffs = [float(c) for c in best_coeffs]
else:
# Hay senal (no es debil) y no es ni monotona-pura ni polinomica:
# la correlacion que existe es esencialmente lineal.
tipo = "lineal"
best_degree = 1
coeffs = [float(c) for c in c1]
return {
"tipo": tipo,
"pearson": round(float(r), 6),
"r2_linear": round(float(r2_linear), 6),
"spearman": round(float(spearman), 6),
"r2_poly2": round(float(r2_poly2), 6),
"r2_poly3": round(float(r2_poly3), 6),
"best_degree": best_degree,
"coeffs": (
[round(c, 8) for c in coeffs] if coeffs is not None else None
),
}
except Exception:
return dict(_WEAK)
@@ -0,0 +1,174 @@
"""Tests para classify_relationship_type."""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(__file__))
from classify_relationship_type import classify_relationship_type
# Claves que el dict de salida debe contener SIEMPRE.
_EXPECTED_KEYS = {
"tipo", "pearson", "r2_linear", "spearman",
"r2_poly2", "r2_poly3", "best_degree", "coeffs",
}
def _assert_shape(r):
"""Toda salida tiene exactamente las 8 claves canonicas."""
assert isinstance(r, dict)
assert set(r.keys()) == _EXPECTED_KEYS
def test_lineal():
"""Golden: y = 2x + 1 con ruido pequeno -> 'lineal', best_degree=1."""
rng = np.random.default_rng(42)
x = np.linspace(0.0, 10.0, 50)
y = 2.0 * x + 1.0 + rng.normal(0.0, 0.3, 50)
r = classify_relationship_type(list(x), list(y))
_assert_shape(r)
assert r["tipo"] == "lineal"
assert r["best_degree"] == 1
assert r["r2_linear"] >= 0.5
# coeffs ~ [pendiente, intercepto] del ajuste de grado 1.
assert r["coeffs"] is not None and len(r["coeffs"]) == 2
assert abs(r["coeffs"][0] - 2.0) < 0.1 # pendiente ~2
assert abs(r["coeffs"][1] - 1.0) < 0.3 # intercepto ~1
def test_polinomica_cuadratica():
"""Golden: y = x**2 sobre [-10, 10] -> 'polinómica', best_degree in (2, 3)."""
x = np.linspace(-10.0, 10.0, 60)
y = x ** 2
r = classify_relationship_type(list(x), list(y))
_assert_shape(r)
assert r["tipo"].startswith("polinómica")
assert r["best_degree"] in (2, 3)
# Una parabola perfecta queda capturada por el grado 2 (parsimonia).
assert r["best_degree"] == 2
assert r["r2_poly2"] > 0.99
assert r["coeffs"] is not None and len(r["coeffs"]) == r["best_degree"] + 1
def test_monotona_no_lineal():
"""Golden: monotona convexa de cola pesada -> 'monótona no-lineal'.
y = 1/(N+1-i)**2 es estrictamente creciente (Spearman ~ 1) pero su cola
explosiva hace que ni la recta ni un polinomio de grado 2/3 la ajusten
(R^2 polinomico < 0.5), de modo que el Pearson lineal NO capta la relacion
que el rango (Spearman) si ve. Construccion deterministica (sin azar).
"""
n = 200
i = np.arange(n, dtype=float)
y = 1.0 / (n + 1 - i) ** 2
r = classify_relationship_type(list(i), list(y))
_assert_shape(r)
assert r["tipo"] == "monótona no-lineal"
assert r["best_degree"] is None
assert r["coeffs"] is None
# Spearman fuerte y claramente por encima del Pearson.
assert abs(r["spearman"]) >= 0.5
assert abs(r["spearman"]) - abs(r["pearson"]) >= 0.15
def test_monotona_exponencial():
"""DoD literal: y = exp(x) (monotona no-lineal) -> 'monótona no-lineal'.
exp es estrictamente creciente (Spearman = 1) pero el Pearson lineal queda
claramente por debajo (~0.86), así que la dominancia del rango la marca como
monótona no-lineal en vez de lineal o polinómica.
"""
x = np.linspace(0.0, 5.0, 80)
y = np.exp(x)
r = classify_relationship_type(list(x), list(y))
_assert_shape(r)
assert r["tipo"] == "monótona no-lineal"
assert r["best_degree"] is None and r["coeffs"] is None
assert abs(r["spearman"]) >= 0.9
assert abs(r["spearman"]) - abs(r["pearson"]) >= 0.1
def test_debil_sin_forma():
"""Golden: x e y independientes (semilla fija) -> 'débil/sin forma'."""
rng = np.random.default_rng(0)
x = rng.normal(0.0, 1.0, 200)
y = rng.normal(0.0, 1.0, 200)
r = classify_relationship_type(list(x), list(y))
_assert_shape(r)
assert r["tipo"] == "débil/sin forma"
assert r["best_degree"] is None
assert r["coeffs"] is None
# Todas las senales son bajas.
assert abs(r["pearson"]) < 0.3
assert r["r2_linear"] < 0.1
def test_lista_vacia_no_lanza():
"""Edge: listas vacias -> dict debil canonico, sin lanzar."""
r = classify_relationship_type([], [])
_assert_shape(r)
assert r["tipo"] == "débil/sin forma"
assert r["pearson"] is None
assert r["r2_linear"] is None
assert r["spearman"] is None
assert r["r2_poly2"] is None
assert r["r2_poly3"] is None
assert r["best_degree"] is None
assert r["coeffs"] is None
def test_longitudes_distintas_no_lanza():
"""Edge: listas de distinta longitud -> empareja por indice, no lanza."""
# zip trunca a la longitud minima: solo 3 pares (< 5) -> debil.
r = classify_relationship_type([1, 2, 3, 4, 5, 6, 7, 8], [1.0, 2.0, 3.0])
_assert_shape(r)
assert r["tipo"] == "débil/sin forma"
assert r["best_degree"] is None
def test_todos_none_no_lanza():
"""Edge: todos los valores None -> ningun par valido -> debil, no lanza."""
r = classify_relationship_type([None, None, None, None, None, None],
[None, None, None, None, None, None])
_assert_shape(r)
assert r["tipo"] == "débil/sin forma"
assert r["coeffs"] is None
def test_entradas_none_no_lanza():
"""Edge: xs/ys None directamente -> debil, no lanza."""
assert classify_relationship_type(None, None)["tipo"] == "débil/sin forma"
assert classify_relationship_type([1.0, 2.0], None)["tipo"] == "débil/sin forma"
def test_constante_no_lanza():
"""Edge: ys constante (varianza ~0) -> debil, no lanza."""
r = classify_relationship_type([1, 2, 3, 4, 5, 6, 7], [5, 5, 5, 5, 5, 5, 5])
_assert_shape(r)
assert r["tipo"] == "débil/sin forma"
def test_filtra_nan_inf_bool():
"""Edge: pares con NaN/inf/bool/None se descartan por indice."""
nan = float("nan")
inf = float("inf")
# Solo i=0,1,2,3,4 quedan validos (5 pares) y forman una recta perfecta.
xs = [0.0, 1.0, 2.0, 3.0, 4.0, nan, inf, True, None]
ys = [1.0, 3.0, 5.0, 7.0, 9.0, 1.0, 2.0, 3.0, 4.0]
r = classify_relationship_type(xs, ys)
_assert_shape(r)
# Los 5 pares validos son y = 2x + 1 exacto -> lineal.
assert r["tipo"] == "lineal"
assert r["best_degree"] == 1
@@ -0,0 +1,102 @@
---
id: compute_text_duplicates_py_datascience
name: compute_text_duplicates
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def compute_text_duplicates(texts, near_threshold=0.85, sample_max=2000) -> dict"
description: "Detecta documentos duplicados en un corpus de texto. Los duplicados EXACTOS se calculan siempre con la stdlib: cada documento se normaliza (colapsa espacios, strip, lower) y se hashea con SHA-1; n_exact_dup es cuántos docs repiten uno ya visto y exact_dup_pct su porcentaje. Los CASI-duplicados (near-dup) usan la dependencia OPCIONAL datasketch (MinHash + LSH sobre 3-shingles de palabras); si no está instalada, esa parte degrada a available:False sin afectar al resto. Estilo dict-no-throw del grupo eda — nunca lanza."
tags: [eda, datascience, text, nlp, duplicates, minhash, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [hashlib, re]
example: |
from datascience.compute_text_duplicates import compute_text_duplicates
texts = ["El gato come pescado", "El gato come pescado", "Un perro ladra"]
result = compute_text_duplicates(texts)
# {"n_docs": 3, "n_exact_dup": 1, "exact_dup_pct": 33.33, "n_unique": 2,
# "near_dup": {"available": False, "n_near_dup_docs": 0}}
tested: true
tests:
- "test_duplicados_exactos"
- "test_sin_duplicados"
- "test_vacio"
- "test_near_dup_degrada"
test_file_path: "python/functions/datascience/compute_text_duplicates_test.py"
file_path: "python/functions/datascience/compute_text_duplicates.py"
params:
- name: texts
desc: "Lista de documentos de texto. Los elementos None o que no sean str se descartan silenciosamente; n_docs cuenta solo los documentos válidos. None como argumento se trata como lista vacía."
- name: near_threshold
desc: "Umbral de similitud Jaccard (01) para considerar dos documentos casi-duplicados en el cálculo near-dup vía MinHashLSH. Solo aplica si datasketch está instalada. Default 0.85."
- name: sample_max
desc: "Número máximo de documentos muestreados (los primeros) para el cálculo near-dup, que es O(n) en memoria de MinHashes. No afecta al conteo de duplicados exactos, que siempre recorre todo el corpus. Default 2000."
output: "Dict con exactamente 5 claves, siempre presentes: n_docs (int, docs válidos), n_exact_dup (int, docs que repiten un texto normalizado ya visto = n_docs - n_unique), exact_dup_pct (float a 2 decimales = n_exact_dup/n_docs*100, o None si el corpus está vacío), n_unique (int, nº de textos normalizados distintos), y near_dup (sub-dict con available:bool y n_near_dup_docs:int; cuando available es True incluye además threshold con el near_threshold usado). La función nunca lanza: captura toda excepción y degrada."
---
## Ejemplo
```python
from datascience.compute_text_duplicates import compute_text_duplicates
# Tres copias del mismo texto (con espacios/casing distintos) + dos únicos.
texts = [
"El gato come pescado",
"El gato come pescado",
"el GATO come pescado", # mismo tras normalizar
"Un perro ladra",
"La luna brilla",
]
compute_text_duplicates(texts)
# {
# "n_docs": 5,
# "n_exact_dup": 2, # 3 copias del primer texto => 2 repeticiones
# "exact_dup_pct": 40.0, # 2 / 5 * 100
# "n_unique": 3, # 3 textos normalizados distintos
# "near_dup": {"available": False, "n_near_dup_docs": 0}, # datasketch ausente
# }
# Corpus vacío: contrato estable, exact_dup_pct None, sin excepción.
compute_text_duplicates([])
# {"n_docs": 0, "n_exact_dup": 0, "exact_dup_pct": None, "n_unique": 0,
# "near_dup": {"available": False, "n_near_dup_docs": 0}}
```
## Cuando usarla
Úsala en la fase de calidad de un EDA de texto, cuando quieras saber cuánto de
tu corpus es ruido duplicado antes de entrenar, vectorizar o muestrear: te da
el porcentaje de duplicados exactos (`exact_dup_pct`), el número de documentos
únicos (`n_unique`) y, si tienes `datasketch` instalada, una estimación de
casi-duplicados (paráfrasis, copias con pequeñas ediciones) vía MinHash + LSH.
Pásale directamente la columna/lista de textos crudos; la función filtra None y
no-str por ti y nunca lanza, así que es segura para encadenar en pipelines de
perfilado.
## Gotchas
- **Near-dup requiere `datasketch` (opcional).** Si la librería no está
instalada, `near_dup` degrada a `{"available": False, "n_near_dup_docs": 0}`
(sin clave `threshold`) y el resto del resultado se calcula igual. Los
duplicados **exactos** funcionan siempre porque solo usan la stdlib (hash).
- **Normalización de exactos.** Dos textos cuentan como el mismo duplicado
exacto si coinciden tras `" ".join(doc.split()).strip().lower()`: se colapsan
espacios/tabuladores/saltos, se recortan extremos y se ignora el caso. Cambios
de puntuación o acentos SÍ los distinguen (no se eliminan).
- **`n_exact_dup` cuenta repeticiones, no grupos.** Con 3 copias de un mismo
texto, `n_exact_dup` es 2 (las dos copias extra), no 1. Equivale a
`n_docs - n_unique`.
- **`exact_dup_pct` es `None` con corpus vacío** (no `ZeroDivisionError`); en
cualquier otro caso es un float redondeado a 2 decimales.
- **`sample_max` solo limita el near-dup.** El conteo de duplicados exactos
recorre todo el corpus; el near-dup muestrea los primeros `sample_max`
documentos para acotar memoria. Si el corpus está ordenado, considera barajar
antes para que la muestra sea representativa.
- **Elementos no-str se descartan.** `True`/`False` no cuentan como str y se
ignoran igual que `None`; `n_docs` refleja solo los documentos válidos.
@@ -0,0 +1,128 @@
"""Detección de documentos duplicados en un corpus de texto.
Función pura, estilo dict-no-throw del grupo `eda`: nunca lanza, siempre
devuelve el mismo contrato de claves. Los duplicados EXACTOS se calculan
siempre con la stdlib (normalización + hash SHA-1). Los CASI-duplicados
(near-dup) requieren la dependencia opcional `datasketch`; si no está
instalada, esa parte degrada limpiamente a ``available: False`` sin afectar
al resto del cálculo.
"""
import hashlib
import re
def _compute_near_dup(valid, near_threshold, sample_max):
"""Cuenta documentos con al menos otro casi-duplicado vía MinHash + LSH.
Import perezoso de ``datasketch``. Si la librería no está disponible (o
cualquier paso falla), degrada a ``{"available": False, "n_near_dup_docs": 0}``
sin propagar la excepción.
Args:
valid: lista de str ya filtrada (sin None ni no-str).
near_threshold: umbral de similitud Jaccard para LSH.
sample_max: número máximo de documentos a muestrear.
Returns:
dict con ``available`` (bool) y ``n_near_dup_docs`` (int). Cuando
``available`` es True, incluye además ``threshold``.
"""
try:
from datasketch import MinHash, MinHashLSH
except Exception:
return {"available": False, "n_near_dup_docs": 0}
try:
docs = valid[:sample_max]
num_perm = 128
lsh = MinHashLSH(threshold=near_threshold, num_perm=num_perm)
minhashes = {}
for i, doc in enumerate(docs):
tokens = re.findall(r"\w+", doc.lower())
shingles = set()
for j in range(len(tokens) - 2):
shingles.add(" ".join(tokens[j:j + 3]))
# Documentos con menos de 3 tokens no generan 3-shingles: caemos a
# los tokens sueltos para no perderlos del todo.
if not shingles:
shingles = set(tokens)
if not shingles:
# Documento sin tokens (cadena vacía / solo símbolos): se omite.
continue
m = MinHash(num_perm=num_perm)
for sh in shingles:
m.update(sh.encode("utf-8"))
key = "d{}".format(i)
minhashes[key] = m
lsh.insert(key, m)
n_near = 0
for key, m in minhashes.items():
matches = lsh.query(m)
if len(matches) > 1:
n_near += 1
return {
"available": True,
"n_near_dup_docs": int(n_near),
"threshold": near_threshold,
}
except Exception:
return {"available": False, "n_near_dup_docs": 0}
def compute_text_duplicates(texts, near_threshold=0.85, sample_max=2000) -> dict:
"""Detecta duplicados exactos y casi-duplicados en un corpus de texto.
Args:
texts: lista de documentos. Los elementos None o que no sean str se
descartan; ``n_docs`` cuenta solo los válidos.
near_threshold: umbral de similitud Jaccard para considerar dos
documentos casi-duplicados (solo near-dup, requiere datasketch).
sample_max: tope de documentos muestreados para el cálculo near-dup.
Returns:
dict con las claves ``n_docs``, ``n_exact_dup``, ``exact_dup_pct``
(float redondeado a 2 decimales, o None si el corpus está vacío),
``n_unique`` y ``near_dup`` (sub-dict con ``available`` y
``n_near_dup_docs``, más ``threshold`` cuando está disponible).
Nunca lanza: captura toda excepción y degrada.
"""
# Filtrado defensivo de documentos válidos.
try:
valid = [t for t in texts if isinstance(t, str)] if texts is not None else []
except Exception:
valid = []
n_docs = len(valid)
# Duplicados exactos: normalizar + hash SHA-1 (stdlib, siempre disponible).
try:
seen = set()
n_exact_dup = 0
for doc in valid:
norm = " ".join(doc.split()).strip().lower()
digest = hashlib.sha1(norm.encode("utf-8")).hexdigest()
if digest in seen:
n_exact_dup += 1
else:
seen.add(digest)
n_unique = len(seen)
except Exception:
n_exact_dup = 0
n_unique = 0
exact_dup_pct = round(n_exact_dup / n_docs * 100, 2) if n_docs > 0 else None
# Casi-duplicados: opcional vía datasketch, degrada solo.
near_dup = _compute_near_dup(valid, near_threshold, sample_max)
return {
"n_docs": n_docs,
"n_exact_dup": n_exact_dup,
"exact_dup_pct": exact_dup_pct,
"n_unique": n_unique,
"near_dup": near_dup,
}
@@ -0,0 +1,77 @@
"""Tests para compute_text_duplicates.
Importa el modulo hoja directamente (`datascience.compute_text_duplicates`)
para no depender de que el paquete reexporte la funcion en su __init__.
datasketch normalmente NO esta instalada en el venv, asi que near_dup
degrada a available=False; los tests no requieren la libreria.
"""
from datascience.compute_text_duplicates import compute_text_duplicates
EXPECTED_KEYS = {"n_docs", "n_exact_dup", "exact_dup_pct", "n_unique", "near_dup"}
def test_duplicados_exactos():
"""3 copias del mismo texto + 2 únicos: n_exact_dup=2, pct>0."""
texts = [
"El gato come pescado",
"El gato come pescado",
"el GATO come pescado", # mismo tras normalizar (espacios + case)
"Un perro ladra",
"La luna brilla",
]
result = compute_text_duplicates(texts)
assert set(result.keys()) == EXPECTED_KEYS
assert result["n_docs"] == 5
# 3 copias del primer texto (2 son repeticion) + 2 textos unicos.
assert result["n_exact_dup"] == 2
assert result["n_unique"] == 3
assert result["exact_dup_pct"] is not None
assert result["exact_dup_pct"] > 0
# 2 / 5 * 100 = 40.0
assert abs(result["exact_dup_pct"] - 40.0) < 1e-9
def test_sin_duplicados():
"""Corpus sin repeticiones: n_exact_dup=0, n_unique==n_docs."""
texts = [
"primero documento distinto",
"segundo documento distinto",
"tercero documento distinto",
]
result = compute_text_duplicates(texts)
assert result["n_docs"] == 3
assert result["n_exact_dup"] == 0
assert result["n_unique"] == 3
assert abs(result["exact_dup_pct"] - 0.0) < 1e-9
def test_vacio():
"""Corpus vacio: n_docs 0, exact_dup_pct None, no lanza."""
result = compute_text_duplicates([])
assert set(result.keys()) == EXPECTED_KEYS
assert result["n_docs"] == 0
assert result["n_exact_dup"] == 0
assert result["exact_dup_pct"] is None
assert result["n_unique"] == 0
assert result["near_dup"]["n_near_dup_docs"] == 0
def test_near_dup_degrada():
"""near_dup expone 'available' (bool) y no lanza aunque falte datasketch."""
texts = ["uno dos tres cuatro", "uno dos tres cuatro cinco", "algo distinto"]
result = compute_text_duplicates(texts)
near = result["near_dup"]
assert "available" in near
assert isinstance(near["available"], bool)
assert "n_near_dup_docs" in near
assert isinstance(near["n_near_dup_docs"], int)
# Tambien tolera None y entradas no-str sin lanzar.
mixed = compute_text_duplicates(["hola", None, 123, "hola"])
assert mixed["n_docs"] == 2
assert mixed["n_exact_dup"] == 1
@@ -0,0 +1,86 @@
---
id: compute_text_length_stats_py_datascience
name: compute_text_length_stats
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def compute_text_length_stats(texts, n_bins=20) -> dict"
description: "Profiles the length distribution of a corpus of text documents for EDA: per-document characters, words (unicode \\w+ tokens) and sentences (segments split on .!?… with a minimum of 1 per non-empty doc), each summarized with mean/p50/p90/p99/min/max (nearest-rank percentiles), plus an equal-width histogram of per-document word counts. None and non-str items are discarded. Dict-no-throw: never raises. Stdlib only (re)."
tags: [eda, datascience, text, nlp, length, statistics, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re, math]
example: |
from datascience.compute_text_length_stats import compute_text_length_stats
result = compute_text_length_stats(["Hola mundo.", "Una frase mas larga aqui."], n_bins=5)
tested: true
tests:
- "test_basico"
- "test_vacio"
- "test_descarta_none"
- "test_un_documento"
test_file_path: "python/functions/datascience/compute_text_length_stats_test.py"
file_path: "python/functions/datascience/compute_text_length_stats.py"
params:
- name: texts
desc: "List of text documents (str). None entries and any non-str items (ints, floats, etc.) are discarded before any computation. An empty string \"\" is kept (chars 0, words 0, sentences 0)."
- name: n_bins
desc: "Number of equal-width bins for the per-document word-count histogram. Default 20. When all docs have the same word count, there are <2 docs, or n_bins < 1, a single covering bin is returned instead."
output: "Dict with keys n_docs (int), chars, words, sentences and word_hist. Each of the three axis sub-dicts has the exact keys mean (float, 2 decimals), p50, p90, p99, min, max (ints). When there are no valid documents, n_docs is 0, every axis statistic is None and word_hist is []. word_hist is a list of {lo: float, hi: float, count: int} bins; the sum of all bin counts equals n_docs."
---
## Ejemplo
```python
from datascience.compute_text_length_stats import compute_text_length_stats
compute_text_length_stats(
[
"Hola mundo.",
"Una frase mas larga con varias palabras aqui.",
"Esto. Tiene. Tres frases distintas!",
],
n_bins=5,
)
# {
# "n_docs": 3,
# "chars": {"mean": 30.33, "p50": 35, "p90": 45, "p99": 45, "min": 11, "max": 45},
# "words": {"mean": 5.0, "p50": 5, "p90": 8, "p99": 8, "min": 2, "max": 8},
# "sentences": {"mean": 1.67, "p50": 1, "p90": 3, "p99": 3, "min": 1, "max": 3},
# "word_hist": [
# {"lo": 2.0, "hi": 3.2, "count": 1},
# {"lo": 3.2, "hi": 4.4, "count": 0},
# {"lo": 4.4, "hi": 5.6, "count": 1},
# {"lo": 5.6, "hi": 6.8, "count": 0},
# {"lo": 6.8, "hi": 8.0, "count": 1},
# ],
# }
```
## Cuando usarla
Úsala al perfilar una columna o corpus de texto libre en un EDA: cuando
necesites saber lo largos que son los documentos (en caracteres, palabras y
frases) y cómo se reparte esa longitud antes de tokenizar, vectorizar o decidir
truncados/ventanas para un modelo. Pásale la lista de strings crudos de la
columna; `None` y valores no-texto se descartan solos. Encaja en el grupo `eda`
como bloque de longitud junto a `summarize_categorical`.
## Gotchas
- Función pura, solo stdlib (`re`). No usa numpy, pandas ni sklearn.
- Percentiles por método **nearest-rank** (devuelven un valor real de la lista,
no interpolan); por eso p50/p90/p99/min/max son enteros y `mean` es el único
float (redondeado a 2 decimales).
- El conteo de frases es una **aproximación** por puntuación (`.!?…`): un texto
sin esa puntuación cuenta como 1 frase si no está vacío; abreviaturas o
ellipsis pueden inflar o reducir el conteo.
- `word_hist` es equal-width entre min y max de palabras: con todos los docs
del mismo tamaño, menos de 2 docs, o `n_bins < 1`, devuelve un único bin.
- Dict-no-throw: ante input inesperado devuelve la forma vacía
(`n_docs` 0, ejes `None`, `word_hist` []) en vez de lanzar.
@@ -0,0 +1,168 @@
"""Pure EDA helper: document length distribution for the `eda` group.
Given a list of text documents, computes the length distribution along three
axes (characters, words and sentences) plus an equal-width histogram of the
per-document word counts. Stdlib only (``re`` + ``statistics`` semantics via a
hand-rolled nearest-rank percentile). No numpy, no sklearn.
The function is dict-no-throw: it never raises. On any unexpected input it
degrades to the empty-shape result.
"""
import math
import re
_WORD_RE = re.compile(r"\w+", re.UNICODE)
_SENT_RE = re.compile(r"[.!?…]+")
def _empty_axis() -> dict:
"""Return an axis sub-dict with every statistic set to ``None``."""
return {"mean": None, "p50": None, "p90": None, "p99": None, "min": None, "max": None}
def _pct(sorted_vals, q):
"""Nearest-rank percentile of an already-sorted list.
Args:
sorted_vals: List of numbers sorted ascending.
q: Percentile in the 0..100 range.
Returns:
The value at the nearest rank, or ``None`` for an empty list.
"""
n = len(sorted_vals)
if n == 0:
return None
if q <= 0:
return sorted_vals[0]
rank = math.ceil(q / 100.0 * n)
if rank < 1:
rank = 1
if rank > n:
rank = n
return sorted_vals[rank - 1]
def _axis_stats(values) -> dict:
"""Compute mean/p50/p90/p99/min/max over a list of integer counts.
``mean`` is rounded to 2 decimals; every other statistic is an integer
(they are counts). Returns an all-``None`` axis for an empty list.
"""
if not values:
return _empty_axis()
sv = sorted(values)
return {
"mean": round(sum(sv) / len(sv), 2),
"p50": int(_pct(sv, 50)),
"p90": int(_pct(sv, 90)),
"p99": int(_pct(sv, 99)),
"min": int(sv[0]),
"max": int(sv[-1]),
}
def _word_hist(word_counts, n_bins) -> list:
"""Equal-width histogram of per-document word counts.
Builds ``n_bins`` bins between ``min`` and ``max`` of the word counts. When
every document has the same number of words, there are fewer than 2
documents, or ``n_bins`` is not at least 1, a single covering bin is
returned. With no documents the result is ``[]``. The sum of bin ``count``
always equals ``len(word_counts)``.
"""
if not word_counts:
return []
wmin = min(word_counts)
wmax = max(word_counts)
if wmax == wmin or len(word_counts) < 2 or n_bins < 1:
return [{"lo": float(wmin), "hi": float(wmax), "count": len(word_counts)}]
width = (wmax - wmin) / n_bins
bins = []
for i in range(n_bins):
lo = wmin + i * width
hi = wmin + (i + 1) * width
bins.append({"lo": float(lo), "hi": float(hi), "count": 0})
# Pin the last upper edge to the real maximum to avoid float drift.
bins[-1]["hi"] = float(wmax)
for wc in word_counts:
if wc >= wmax:
idx = n_bins - 1
else:
idx = int((wc - wmin) / width)
if idx < 0:
idx = 0
elif idx >= n_bins:
idx = n_bins - 1
bins[idx]["count"] += 1
return bins
def compute_text_length_stats(texts, n_bins=20) -> dict:
"""Summarize the length distribution of a corpus of text documents.
For each document three lengths are measured: characters (``len(doc)``),
words (count of ``\\w+`` unicode tokens) and sentences (non-empty segments
after splitting on ``.!?…``, with a minimum of 1 for any non-empty
document). For each axis the mean, p50, p90, p99, min and max are reported,
plus an equal-width histogram of the per-document word counts.
``None`` entries and any non-``str`` items in ``texts`` are discarded.
The function never raises: on empty/``None`` input or any internal error it
returns the empty-shape result (``n_docs`` 0, all-``None`` axes, ``[]``
histogram).
Args:
texts: List of text documents (``str``). ``None`` and non-``str``
items are dropped.
n_bins: Number of equal-width bins for the word-count histogram.
Default 20.
Returns:
Dict with keys ``n_docs``, ``chars``, ``words``, ``sentences`` and
``word_hist``. Each of the three axes is a sub-dict with ``mean``
(float, 2 decimals), ``p50``, ``p90``, ``p99``, ``min`` and ``max``
(ints), all ``None`` when there are no documents. ``word_hist`` is a
list of ``{lo, hi, count}`` bins whose ``count`` sums to ``n_docs``.
"""
empty_axis = _empty_axis()
fallback = {
"n_docs": 0,
"chars": dict(empty_axis),
"words": dict(empty_axis),
"sentences": dict(empty_axis),
"word_hist": [],
}
try:
if not texts:
return fallback
docs = [t for t in texts if isinstance(t, str)]
n_docs = len(docs)
if n_docs == 0:
return fallback
char_counts = [len(d) for d in docs]
word_counts = [len(_WORD_RE.findall(d)) for d in docs]
sent_counts = []
for d in docs:
segments = [s for s in _SENT_RE.split(d) if s.strip()]
n = len(segments)
if d and n == 0:
# Non-empty document with no detectable sentence: count as 1.
n = 1
sent_counts.append(n)
return {
"n_docs": n_docs,
"chars": _axis_stats(char_counts),
"words": _axis_stats(word_counts),
"sentences": _axis_stats(sent_counts),
"word_hist": _word_hist(word_counts, n_bins),
}
except Exception:
return fallback
@@ -0,0 +1,70 @@
"""Tests para compute_text_length_stats.
Inserta `python/functions` en sys.path (relativo a este archivo) para importar
el modulo hoja por su paquete `datascience`, sin depender de que el paquete lo
reexporte en su __init__.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from datascience.compute_text_length_stats import compute_text_length_stats
def test_basico():
"""Varios textos de longitudes distintas: stats y histograma coherentes."""
texts = [
"Hola mundo.", # 2 words, 1 sentence
"Una frase mas larga con varias palabras aqui.", # 8 words, 1 sentence
"Corto.", # 1 word, 1 sentence
"Esto. Tiene. Tres frases distintas!", # 5 words, 3 sentences
]
result = compute_text_length_stats(texts)
assert result["n_docs"] == 4
# Diferentes longitudes en palabras -> max estrictamente mayor que min.
assert result["words"]["max"] > result["words"]["min"]
# El histograma de palabras no esta vacio.
assert result["word_hist"] != []
# La suma de counts del histograma cubre todos los documentos.
assert sum(b["count"] for b in result["word_hist"]) == result["n_docs"]
# mean es float redondeado; min/max son enteros.
assert isinstance(result["words"]["mean"], float)
assert isinstance(result["words"]["min"], int)
assert isinstance(result["words"]["max"], int)
# El documento con 3 frases empuja el max de sentences a >= 3.
assert result["sentences"]["max"] >= 3
def test_vacio():
"""Lista vacia: n_docs 0, subdicts None, word_hist []."""
result = compute_text_length_stats([])
assert result["n_docs"] == 0
for axis in ("chars", "words", "sentences"):
for key in ("mean", "p50", "p90", "p99", "min", "max"):
assert result[axis][key] is None
assert result["word_hist"] == []
def test_descarta_none():
"""None y valores no-str se descartan del computo."""
result = compute_text_length_stats(["hello world", None, 123, 4.5, "foo bar baz"])
# Solo dos strings validos.
assert result["n_docs"] == 2
assert result["words"]["min"] == 2 # "hello world"
assert result["words"]["max"] == 3 # "foo bar baz"
assert sum(b["count"] for b in result["word_hist"]) == 2
def test_un_documento():
"""Un solo documento: word_hist tiene exactamente un bin con count 1."""
result = compute_text_length_stats(["solo un documento aqui"])
assert result["n_docs"] == 1
assert len(result["word_hist"]) == 1
assert result["word_hist"][0]["count"] == 1
# Con un unico documento, p50 == min == max == su numero de palabras (4).
assert result["words"]["min"] == 4
assert result["words"]["max"] == 4
assert result["words"]["p50"] == 4
@@ -0,0 +1,88 @@
---
id: compute_text_readability_py_datascience
name: compute_text_readability
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def compute_text_readability(texts, sample_max=500) -> dict"
description: "Calcula la legibilidad Flesch Reading Ease de un corpus de texto usando textstat con import perezoso y degradación. Filtra None/no-str/vacíos, muestrea hasta sample_max documentos (los primeros) y agrega los scores Flesch en {mean, p50, min, max}. Si textstat no está instalada devuelve available=False sin lanzar. Estilo dict-no-throw del grupo eda — nunca lanza."
tags: [eda, datascience, text, nlp, readability, flesch, textstat, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, textstat]
example: |
from datascience.compute_text_readability import compute_text_readability
out = compute_text_readability(["The cat sat on the mat. It was warm and sunny."])
# {"available": True, "n_scored": 1, "flesch": {"mean": 109.0, "p50": 109.0, "min": 108.96..., "max": 108.96...}}
tested: true
tests:
- "test_prosa_ingles"
- "test_vacio"
- "test_degradacion"
test_file_path: "python/functions/datascience/compute_text_readability_test.py"
file_path: "python/functions/datascience/compute_text_readability.py"
params:
- name: texts
desc: "Lista de str (documentos del corpus). Los elementos None, no-str o vacíos tras strip() se descartan silenciosamente. El orden se respeta: el muestreo toma los primeros documentos válidos."
- name: sample_max
desc: "Número máximo de documentos válidos a puntuar (los primeros). Default 500. Acota el coste en corpus grandes. Valores no convertibles a int caen a 500; negativos se tratan como 0."
output: "Dict con exactamente 3 claves siempre presentes: available (bool: True si textstat se pudo importar), n_scored (int: nº de documentos efectivamente puntuados), flesch (dict con mean, p50, min, max). mean y p50 redondeados a 1 decimal; p50 por nearest-rank sobre los scores ordenados; min/max son los scores extremos sin redondear. Todos los valores de flesch son None cuando n_scored es 0. La función nunca lanza: cualquier excepción global (incluida ImportError de textstat) degrada a available=False, n_scored=0 y flesch todo None."
---
## Ejemplo
```python
from datascience.compute_text_readability import compute_text_readability
textos = [
"The cat sat on the mat. It was a warm and sunny day in the park.",
"Reading is a wonderful habit. Books open doors to new worlds and ideas.",
"He ran quickly to the store to buy some fresh bread and a bottle of milk.",
]
compute_text_readability(textos)
# {
# "available": True,
# "n_scored": 3,
# "flesch": {"mean": 91.4, "p50": 95.4, "min": 70.08..., "max": 108.83...}
# }
# Corpus vacío (textstat presente): available True pero nada que puntuar.
compute_text_readability([])
# {"available": True, "n_scored": 0,
# "flesch": {"mean": None, "p50": None, "min": None, "max": None}}
```
## Cuando usarla
Úsala en un EDA de texto cuando necesites una métrica única y comparable de
**lo fácil que es de leer** un corpus de documentos (descripciones, reviews,
artículos, tickets). Devuelve el resumen Flesch Reading Ease agregado
(`mean`/`p50`/`min`/`max`) listo para un report o un bloque del notebook, sin
tener que iterar `textstat` a mano. Pásale la lista de textos crudos y, si el
corpus es grande, limita el coste con `sample_max`. El estilo dict-no-throw
permite incrustarla en pipelines del grupo `eda` sin envolver en try/except.
## Gotchas
- **`textstat` es una dependencia opcional.** Si no está instalada (o falla al
importar) la función NO lanza: devuelve `available=False`, `n_scored=0` y
`flesch` todo `None`. Comprueba `available` antes de interpretar los números.
- **Flesch Reading Ease está pensado para prosa en inglés.** Aplicado a otros
idiomas o a texto no-prosa (código, listas, tablas, cadenas muy cortas) los
scores no son interpretables, aunque se calculen sin error.
- **Escala Flesch:** valores **altos** = más fácil de leer (≈90100 muy fácil),
valores **bajos** = más difícil (puede ser negativo en texto muy denso). No
se recortan a ningún rango: se reportan tal cual los devuelve `textstat`.
- **`available=True` con `n_scored=0`** significa que `textstat` está presente
pero el corpus no aportó documentos puntuables (vacío, solo None/no-str, o
todos los docs fallaron al puntuar). Es distinto de `available=False`.
- **Muestreo = los primeros `sample_max`**, no aleatorio. Si el orden del corpus
está sesgado, el resumen reflejará ese sesgo.
- **`mean` y `p50` redondean a 1 decimal**; `min`/`max` se devuelven sin
redondear (los scores extremos reales).
@@ -0,0 +1,121 @@
"""Legibilidad Flesch Reading Ease de un corpus de texto.
Función pura del grupo `eda`, estilo dict-no-throw: nunca lanza. Usa la
librería `textstat` con import perezoso y degradación: si `textstat` no está
instalada (o falla al importar), devuelve un resultado con `available=False`
en lugar de propagar el error.
"""
def _percentile_nearest_rank(sorted_values, pct):
"""Percentil por nearest-rank sobre una lista ya ordenada ascendente.
rank = ceil(pct/100 * n); índice 1-based recortado a [1, n].
Devuelve None si la lista está vacía.
"""
n = len(sorted_values)
if n == 0:
return None
import math
rank = math.ceil((pct / 100.0) * n)
if rank < 1:
rank = 1
if rank > n:
rank = n
return sorted_values[rank - 1]
def compute_text_readability(texts, sample_max=500) -> dict:
"""Calcula la legibilidad Flesch Reading Ease de un corpus.
Args:
texts: lista de str. Los elementos None, no-str o vacíos (tras strip)
se descartan. Se muestrean los primeros `sample_max` documentos
válidos.
sample_max: número máximo de documentos a puntuar (los primeros).
Returns:
Dict con la forma exacta::
{"available": bool, "n_scored": int,
"flesch": {"mean": float|None, "p50": float|None,
"min": float|None, "max": float|None}}
`available` es True si `textstat` se pudo importar. La función nunca
lanza: cualquier excepción global degrada a `available=False`.
"""
empty = {
"available": False,
"n_scored": 0,
"flesch": {"mean": None, "p50": None, "min": None, "max": None},
}
try:
# Import perezoso con degradación: textstat es una dependencia opcional.
try:
import textstat
except Exception:
return {
"available": False,
"n_scored": 0,
"flesch": {"mean": None, "p50": None, "min": None, "max": None},
}
# Filtrar y muestrear documentos válidos (los primeros sample_max).
docs = []
if texts is not None:
try:
limit = int(sample_max)
except Exception:
limit = 500
if limit < 0:
limit = 0
for item in texts:
if not isinstance(item, str):
continue
if item.strip() == "":
continue
docs.append(item)
if len(docs) >= limit:
break
scores = []
for doc in docs:
try:
score = textstat.flesch_reading_ease(doc)
except Exception:
continue
try:
score = float(score)
except Exception:
continue
scores.append(score)
n_scored = len(scores)
if n_scored == 0:
# textstat presente pero corpus vacío / sin puntuar.
return {
"available": True,
"n_scored": 0,
"flesch": {"mean": None, "p50": None, "min": None, "max": None},
}
mean_val = round(sum(scores) / n_scored, 1)
sorted_scores = sorted(scores)
p50_raw = _percentile_nearest_rank(sorted_scores, 50)
p50_val = round(p50_raw, 1) if p50_raw is not None else None
min_val = sorted_scores[0]
max_val = sorted_scores[-1]
return {
"available": True,
"n_scored": n_scored,
"flesch": {
"mean": mean_val,
"p50": p50_val,
"min": min_val,
"max": max_val,
},
}
except Exception:
return empty
@@ -0,0 +1,74 @@
"""Tests para compute_text_readability."""
import sys
import os
import builtins
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from datascience.compute_text_readability import compute_text_readability
EXPECTED_KEYS = {"available", "n_scored", "flesch"}
FLESCH_KEYS = {"mean", "p50", "min", "max"}
def test_prosa_ingles():
"""Varios textos en prosa inglesa: available True, n_scored>0, mean no None."""
texts = [
"The cat sat on the mat. It was a warm and sunny day in the park.",
"She sells sea shells by the sea shore. The shells she sells are surely sea shells.",
"Reading is a wonderful habit. Books open doors to new worlds and ideas.",
"He ran quickly to the store to buy some fresh bread and a bottle of milk.",
]
out = compute_text_readability(texts)
assert set(out.keys()) == EXPECTED_KEYS
assert out["available"] is True
assert out["n_scored"] > 0
assert set(out["flesch"].keys()) == FLESCH_KEYS
assert out["flesch"]["mean"] is not None
assert out["flesch"]["p50"] is not None
assert out["flesch"]["min"] is not None
assert out["flesch"]["max"] is not None
# min <= mean/p50 <= max coherente.
assert out["flesch"]["min"] <= out["flesch"]["max"]
def test_vacio():
"""Corpus vacío con textstat presente: available True, n_scored 0, flesch None."""
out = compute_text_readability([])
assert set(out.keys()) == EXPECTED_KEYS
assert out["available"] is True
assert out["n_scored"] == 0
assert out["flesch"]["mean"] is None
assert out["flesch"]["p50"] is None
assert out["flesch"]["min"] is None
assert out["flesch"]["max"] is None
# Elementos no-str / vacíos también se descartan -> n_scored 0.
out2 = compute_text_readability([None, "", " ", 123])
assert out2["available"] is True
assert out2["n_scored"] == 0
def test_degradacion(monkeypatch):
"""Sin textstat (ImportError forzado): degrada a available False sin lanzar."""
import datascience.compute_text_readability as m
real = builtins.__import__
def fake(name, *a, **k):
if name == "textstat" or name.startswith("textstat."):
raise ImportError("simulado")
return real(name, *a, **k)
monkeypatch.setattr(builtins, "__import__", fake)
out = m.compute_text_readability(["The cat sat on the mat. It was happy and warm."])
assert out["available"] is False
assert out["n_scored"] == 0
assert out["flesch"]["mean"] is None
assert out["flesch"]["p50"] is None
assert out["flesch"]["min"] is None
assert out["flesch"]["max"] is None
@@ -0,0 +1,103 @@
---
id: compute_top_ngrams_py_datascience
name: compute_top_ngrams
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def compute_top_ngrams(texts, n=2, top_k=15, remove_stopwords=True) -> dict"
description: "Calcula los n-gramas de palabras más frecuentes de un corpus de texto (n=1 unigramas, 2 bigramas, 3 trigramas...). Tokeniza a minúsculas con re.findall(r'\\w+', ...), descarta tokens numéricos y, si remove_stopwords=True, elimina stopwords ES+EN ANTES de formar los n-gramas (n-gramas contiguos sobre la secuencia de tokens de contenido, sin cruzar documentos). Pura y autocontenida con collections.Counter, sin sklearn. Estilo dict-no-throw del grupo eda: nunca lanza."
tags: [eda, datascience, text, nlp, ngrams, bigrams, trigrams, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re, collections]
example: |
from datascience.compute_top_ngrams import compute_top_ngrams
texts = ["machine learning rocks", "we love machine learning"]
compute_top_ngrams(texts, n=2, top_k=5)
# {"n": 2, "top": [{"ngram": "machine learning", "count": 2}, ...]}
tested: true
tests:
- "test_bigramas"
- "test_trigramas"
- "test_vacio"
- "test_stopwords"
test_file_path: "python/functions/datascience/compute_top_ngrams_test.py"
file_path: "python/functions/datascience/compute_top_ngrams.py"
params:
- name: texts
desc: "Lista (o tupla) de cadenas. Los elementos None o que no sean str se descartan silenciosamente. Cada documento se tokeniza por separado; los n-gramas no cruzan la frontera entre documentos."
- name: n
desc: "Tamaño del n-grama: 1 unigramas, 2 bigramas, 3 trigramas, etc. Valores < 1 o no enteros producen top vacío (se conserva tal cual en la clave 'n' del retorno)."
- name: top_k
desc: "Número máximo de n-gramas a devolver, ordenados por frecuencia descendente con desempate alfabético determinista. Default 15. Valores negativos se tratan como 0."
- name: remove_stopwords
desc: "Si True (default) elimina las stopwords ES+EN de una lista inline (~130 términos de altísima frecuencia) ANTES de formar los n-gramas, de modo que los n-gramas se construyen sobre la secuencia de tokens de contenido."
output: "Dict con exactamente 2 claves: n (el n recibido, sin normalizar) y top (lista de dicts {'ngram': str, 'count': int} ordenada por count descendente, longitud <= top_k). ngram es la unión de los tokens del n-grama por un espacio. Corpus vacío, tokens insuficientes para formar n-gramas o cualquier excepción interna degradan a {'n': n, 'top': []}. La función nunca lanza."
---
## Ejemplo
```python
from datascience.compute_top_ngrams import compute_top_ngrams
texts = [
"machine learning rocks",
"machine learning is fun",
"we love machine learning",
]
# Bigramas (n=2): "machine learning" aparece en los 3 documentos.
compute_top_ngrams(texts, n=2, top_k=5)
# {
# "n": 2,
# "top": [
# {"ngram": "machine learning", "count": 3},
# {"ngram": "learning fun", "count": 1},
# {"ngram": "learning rocks", "count": 1},
# {"ngram": "love machine", "count": 1},
# ],
# }
# Unigramas con stopwords fuera (default): solo palabras de contenido.
compute_top_ngrams(["the cat sat on the mat"], n=1, top_k=3)
# {"n": 1, "top": [{"ngram": "cat", "count": 1},
# {"ngram": "mat", "count": 1},
# {"ngram": "sat", "count": 1}]}
```
## Cuando usarla
Úsala en la fase de EDA de texto cuando, además del vocabulario suelto, necesites
ver qué **combinaciones de palabras contiguas** dominan un corpus: colocaciones,
frases técnicas recurrentes ("machine learning", "data analyst"), o patrones de
trigramas en titulares/descripciones. Es el complemento natural de un perfil de
vocabulario: pasa de "qué palabras aparecen" a "qué secuencias aparecen". Llámala
con `n=1` para unigramas, `n=2` para bigramas y `n=3` para trigramas, y ajusta
`top_k` al tamaño de la tabla que vas a renderizar. Deja `remove_stopwords=True`
para que los n-gramas reflejen contenido y no conectores gramaticales.
## Gotchas
- **Las stopwords se eliminan ANTES de formar los n-gramas.** Con
`remove_stopwords=True` la frase "data of analysis" produce el bigrama
"data analysis" (el "of" intermedio desaparece y los tokens de contenido se
vuelven contiguos), no "data of" ni "of analysis". Si quieres preservar la
adyacencia literal del texto original, pasa `remove_stopwords=False`.
- **Los n-gramas NO cruzan documentos.** Cada elemento de `texts` se tokeniza y
recorre por separado; el último token de un documento nunca se combina con el
primero del siguiente.
- **Tokens puramente numéricos se descartan** (`tok.isdigit()`), pero los
alfanuméricos mixtos no: "3d" o "covid19" sí cuentan como tokens. Un decimal
como "3.5" se parte en "3" y "5" por `\w+` y ambos se descartan por numéricos.
- **La lista de stopwords es inline ES+EN**, pensada para textos generales en
esos dos idiomas. Para otros idiomas o jerga específica de dominio puede dejar
pasar conectores; en ese caso filtra el corpus aguas arriba o usa
`remove_stopwords=False` y posfiltra.
- **`top` puede tener menos de `top_k` elementos** si el corpus no tiene tantos
n-gramas distintos. El desempate por frecuencia es alfabético (determinista),
no por orden de aparición.
@@ -0,0 +1,94 @@
"""Top n-gramas de palabras más frecuentes de un corpus de texto.
Función pura, autocontenida (solo stdlib: re + collections.Counter). No depende
de scikit-learn ni de ninguna otra librería externa. Estilo dict-no-throw del
grupo `eda`: ante cualquier entrada degenerada o excepción interna devuelve
``{"n": n, "top": []}`` en vez de lanzar.
"""
import re
from collections import Counter
# Lista inline de stopwords ES + EN (~80 términos de altísima frecuencia).
# Se eliminan ANTES de formar los n-gramas: los n-gramas se construyen sobre la
# secuencia de tokens de contenido, no sobre el texto original.
_STOPWORDS = frozenset({
# Español
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por",
"un", "para", "con", "no", "una", "su", "al", "lo", "como", "más", "mas",
"pero", "sus", "le", "ya", "o", "este", "", "si", "porque", "esta",
"entre", "cuando", "muy", "sin", "sobre", "también", "tambien", "me",
"hasta", "hay", "donde", "quien", "desde", "todo", "nos", "durante",
"todos", "uno", "les", "ni", "contra", "otros", "ese", "eso", "ante",
"ellos", "e", "esto", "", "antes", "algunos", "qué", "unos", "yo",
"otro", "otras", "otra", "él", "tanto", "esa", "estos", "mucho", "quienes",
"nada", "muchos", "cual", "poco", "ella", "estar", "estas", "algunas",
"algo", "nosotros",
# Inglés
"the", "of", "and", "to", "in", "is", "it", "for", "on", "with", "as",
"are", "was", "be", "this", "that", "by", "an", "or", "at", "from", "but",
"not", "have", "has", "had", "they", "you", "we", "he", "she", "his",
"her", "their", "its", "i", "my", "me", "our", "us", "do", "does", "did",
"will", "would", "can", "could", "should", "there", "which", "who", "what",
"when", "where", "how", "all", "if", "so", "than", "then", "out", "up",
})
def compute_top_ngrams(texts, n=2, top_k=15, remove_stopwords=True) -> dict:
"""Calcula los n-gramas de palabras más frecuentes de un corpus.
Args:
texts: lista de cadenas. Los elementos ``None`` o que no sean ``str`` se
descartan silenciosamente.
n: tamaño del n-grama (1 = unigramas, 2 = bigramas, 3 = trigramas...).
Valores < 1 o no enteros producen ``top`` vacío.
top_k: número máximo de n-gramas a devolver, ordenados por frecuencia
descendente (con desempate alfabético determinista).
remove_stopwords: si ``True`` elimina las stopwords ES+EN ANTES de
formar los n-gramas, de modo que los n-gramas se construyen sobre la
secuencia de tokens de contenido (no cruzando documentos).
Returns:
``{"n": n, "top": [{"ngram": "w1 w2", "count": int}, ...]}``. Corpus
vacío, sin tokens suficientes o cualquier excepción interna degrada a
``{"n": n, "top": []}``. Nunca lanza.
"""
try:
if not isinstance(n, int) or n < 1:
return {"n": n, "top": []}
try:
limit = int(top_k)
except (TypeError, ValueError):
limit = 0
if limit < 0:
limit = 0
if not isinstance(texts, (list, tuple)):
return {"n": n, "top": []}
counter = Counter()
for doc in texts:
if not isinstance(doc, str):
continue
tokens = [
tok
for tok in re.findall(r"\w+", doc.lower(), re.UNICODE)
if not tok.isdigit()
]
if remove_stopwords:
tokens = [tok for tok in tokens if tok not in _STOPWORDS]
if len(tokens) < n:
continue
for i in range(len(tokens) - n + 1):
ngram = " ".join(tokens[i:i + n])
counter[ngram] += 1
if not counter:
return {"n": n, "top": []}
ordered = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
top = [{"ngram": ngram, "count": count} for ngram, count in ordered[:limit]]
return {"n": n, "top": top}
except Exception:
return {"n": n, "top": []}
@@ -0,0 +1,65 @@
"""Tests para compute_top_ngrams."""
import sys
import os
# sys.path estándar: añade `python/functions/` para importar por paquete raíz.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from datascience.compute_top_ngrams import compute_top_ngrams
def test_bigramas():
# "machine learning" se repite en cada documento -> bigrama más frecuente.
texts = [
"machine learning rocks",
"machine learning is fun",
"we love machine learning",
]
result = compute_top_ngrams(texts, n=2, top_k=5)
assert result["n"] == 2
assert result["top"], "esperaba al menos un bigrama"
assert result["top"][0]["ngram"] == "machine learning"
assert result["top"][0]["count"] == 3
# Cada entrada respeta el contrato {"ngram": str, "count": int}.
for item in result["top"]:
assert isinstance(item["ngram"], str)
assert isinstance(item["count"], int)
def test_trigramas():
texts = [
"alpha beta gamma delta",
"alpha beta gamma omega",
]
# Con stopwords desactivadas para no descartar tokens de contenido.
result = compute_top_ngrams(texts, n=3, top_k=5, remove_stopwords=False)
assert result["n"] == 3
ngrams = {item["ngram"]: item["count"] for item in result["top"]}
# "alpha beta gamma" aparece en ambos documentos.
assert ngrams.get("alpha beta gamma") == 2
# Trigramas únicos de cada documento.
assert ngrams.get("beta gamma delta") == 1
assert ngrams.get("beta gamma omega") == 1
def test_vacio():
assert compute_top_ngrams([], n=2) == {"n": 2, "top": []}
# Documentos no-str / None se descartan -> corpus efectivamente vacío.
assert compute_top_ngrams([None, 123, {"a": 1}], n=2) == {"n": 2, "top": []}
def test_stopwords():
# "the cat" debería desaparecer al quitar stopwords ("the" es stopword EN).
texts = ["the cat the cat the cat"]
con = compute_top_ngrams(texts, n=2, top_k=10, remove_stopwords=True)
sin = compute_top_ngrams(texts, n=2, top_k=10, remove_stopwords=False)
con_ngrams = {item["ngram"] for item in con["top"]}
sin_ngrams = {item["ngram"] for item in sin["top"]}
# Sin filtrar, el bigrama dominante es "the cat".
assert "the cat" in sin_ngrams
# Al filtrar stopwords, ya no aparece "the cat" (queda solo "cat cat").
assert "the cat" not in con_ngrams
assert con_ngrams != sin_ngrams
@@ -0,0 +1,91 @@
---
id: compute_vocabulary_stats_py_datascience
name: compute_vocabulary_stats
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def compute_vocabulary_stats(texts: list, top_k: int = 20, remove_stopwords: bool = True) -> dict"
description: "Profiles the vocabulary of a text corpus for EDA: tokenises a list of documents, counts term frequencies and derives lexical-richness measures — total tokens, unique types, type-token ratio (TTR), hapax legomena and the top-k most frequent terms. Pure, stdlib only (re + collections.Counter); no nltk, no sklearn. Inline ES+EN stopword list, opt-out via remove_stopwords. Never raises: empty/degenerate input returns the zeroed result."
tags: [eda, datascience, text, nlp, vocabulary, ttr, hapax, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re, collections]
example: |
from datascience.compute_vocabulary_stats import compute_vocabulary_stats
result = compute_vocabulary_stats(["el gato y el perro", "gato veloz"], top_k=5)
tested: true
tests:
- "test_basico"
- "test_vacio"
- "test_stopwords_quitadas"
- "test_stopwords_conservadas"
test_file_path: "python/functions/datascience/compute_vocabulary_stats_test.py"
file_path: "python/functions/datascience/compute_vocabulary_stats.py"
params:
- name: texts
desc: "List of documents (strings) forming the corpus. Entries that are None or not a str are silently discarded. Tokens are extracted per document with re.findall(r'\\w+', doc.lower(), re.UNICODE); purely numeric tokens (tok.isdigit()) are dropped."
- name: top_k
desc: "Maximum number of most-frequent terms to return in top_terms. Default 20. Does not affect n_tokens/n_types/ttr/hapax — only the length of the top_terms list."
- name: remove_stopwords
desc: "When True (default) common Spanish+English stopwords from the inline _STOPWORDS set (~120 entries) are removed from the token stream before any counting. Set False to keep every word (raw lexical profile)."
output: "Dict with the exact keys n_tokens (int), n_types (int), ttr (float|None, n_types/n_tokens rounded to 4 dp), n_hapax (int, terms occurring exactly once), hapax_pct (float|None, n_hapax/n_types*100 rounded to 2 dp) and top_terms (list of {term, count, pct} sorted by count descending, pct = count/n_tokens*100 rounded to 2 dp). For an empty corpus (no tokens after filtering): n_tokens=0, n_types=0, ttr=None, n_hapax=0, hapax_pct=None, top_terms=[]. Any exception degrades to that same empty result — the function never throws."
---
## Ejemplo
```python
from datascience.compute_vocabulary_stats import compute_vocabulary_stats
compute_vocabulary_stats(
["el gato y el perro", "gato veloz corre", "perro perro perro"],
top_k=5,
)
# {
# "n_tokens": 6, # stopwords (el, y) eliminadas por defecto
# "n_types": 3, # gato, perro, veloz, corre -> tras quitar stopwords
# "ttr": 0.5, # n_types / n_tokens
# "n_hapax": 2, # veloz, corre (1 aparicion cada uno)
# "hapax_pct": 50.0, # n_hapax / n_types * 100
# "top_terms": [
# {"term": "perro", "count": 4, "pct": 44.44},
# {"term": "gato", "count": 2, "pct": 22.22},
# ...
# ],
# }
# Perfil lexico crudo (sin filtrar stopwords):
compute_vocabulary_stats(["the cat and the dog"], remove_stopwords=False)
```
## Cuando usarla
Úsala al perfilar una columna o corpus de texto libre en un EDA del grupo `eda`:
cuando necesites medir la riqueza léxica (cuántos tokens y cuántas palabras
distintas, type-token ratio, porcentaje de palabras que solo aparecen una vez) y
ver qué términos dominan el vocabulario (top-k frecuencias). Pásale la lista de
documentos crudos (filas de la columna); `None` y valores no-string se ignoran
solos. Es el equivalente para texto largo de `summarize_categorical`, que perfila
categorías cortas.
## Gotchas
- Función pura y stdlib-only, pero el resultado depende del **idioma**: la lista
`_STOPWORDS` cubre español e inglés. Para otros idiomas pon
`remove_stopwords=False` o filtra fuera, o el perfil mezclará stopwords no
reconocidas en `top_terms`.
- La tokenización es `\w+` con `re.UNICODE`: separa por puntuación y conserva
acentos/ñ, pero NO hace stemming ni lematización — "gato" y "gatos" cuentan
como tipos distintos. Tampoco hace stripping de acentos, así que "más" (con
tilde) y "mas" son tokens diferentes (ambos están en la stoplist).
- Los tokens **puramente numéricos** (`"123"`) se descartan siempre; un token
alfanumérico mixto (`"covid19"`) se conserva.
- `ttr` baja artificialmente en corpus grandes (más texto, más repetición): no
compares TTR entre corpus de tamaños muy distintos sin normalizar.
- Nunca lanza: entrada vacía, `None`, o cualquier excepción interna devuelven el
resultado con ceros/`None`/`[]`. Comprueba `n_tokens == 0` para detectar el
caso degenerado.
@@ -0,0 +1,99 @@
"""Profile the vocabulary of a text corpus for EDA (pure, stdlib only).
Tokenises a list of documents, counts term frequencies and derives lexical
richness measures (type-token ratio, hapax legomena) plus the top-k terms.
No external NLP dependencies (no nltk, no sklearn) — only ``re`` and
``collections`` from the standard library.
"""
import re
from collections import Counter
# Common Spanish + English stopwords. Inline, lowercase, no accents stripped
# beyond what already appears here. Filtering is opt-in via remove_stopwords.
_STOPWORDS = {
# Spanish
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por",
"un", "para", "con", "no", "una", "su", "al", "es", "lo", "como", "mas",
"más", "pero", "sus", "le", "ya", "o", "este", "si", "", "porque",
"esta", "entre", "cuando", "muy", "sin", "sobre", "tambien", "también",
"me", "hasta", "hay", "donde", "quien", "desde", "todo", "nos", "durante",
"todos", "uno", "les", "ni", "contra", "otros", "ese", "eso", "ante",
"ellos", "e", "esto", "antes", "algunos", "que", "unos", "yo", "otro",
"otras", "otra", "el", "tanto", "esa", "estos", "mucho", "nada", "muchos",
# English
"the", "of", "and", "to", "in", "is", "it", "for", "on", "with", "as",
"was", "but", "are", "this", "that", "an", "be", "by", "or", "not", "at",
"from", "my", "i", "you", "he", "she", "we", "they", "his", "her", "its",
"our", "their", "what", "which", "who", "whom", "has", "have", "had", "do",
"does", "did", "will", "would", "can", "could", "should", "may", "might",
"must", "if", "then", "than", "so", "too", "very", "just", "also", "were",
"been", "being", "there", "here", "all", "any", "some", "more", "most",
"out", "up", "down", "into", "over", "such", "only", "own", "same",
}
def compute_vocabulary_stats(texts, top_k=20, remove_stopwords=True) -> dict:
"""Profile the vocabulary of a corpus of documents.
Args:
texts: List of strings (the corpus). Entries that are None or not a
string are discarded silently.
top_k: Maximum number of most-frequent terms to include in
``top_terms``. Default 20. Does not affect the other measures.
remove_stopwords: When True (default) common ES+EN stopwords are
dropped from the token stream before any counting.
Returns:
A dict with the exact keys ``n_tokens``, ``n_types``, ``ttr``,
``n_hapax``, ``hapax_pct`` and ``top_terms``. For an empty corpus (no
tokens after filtering): n_tokens=0, n_types=0, ttr=None, n_hapax=0,
hapax_pct=None, top_terms=[]. Never raises — any exception degrades to
the empty-corpus result.
"""
empty = {
"n_tokens": 0,
"n_types": 0,
"ttr": None,
"n_hapax": 0,
"hapax_pct": None,
"top_terms": [],
}
try:
tokens = []
for doc in texts or []:
if not isinstance(doc, str):
continue
for tok in re.findall(r"\w+", doc.lower(), re.UNICODE):
if tok.isdigit():
continue
if remove_stopwords and tok in _STOPWORDS:
continue
tokens.append(tok)
n_tokens = len(tokens)
if n_tokens == 0:
return dict(empty)
counts = Counter(tokens)
n_types = len(counts)
ttr = round(n_types / n_tokens, 4)
n_hapax = sum(1 for c in counts.values() if c == 1)
hapax_pct = round(n_hapax / n_types * 100, 2)
top_terms = [
{"term": term, "count": count, "pct": round(count / n_tokens * 100, 2)}
for term, count in counts.most_common(top_k)
]
return {
"n_tokens": n_tokens,
"n_types": n_types,
"ttr": ttr,
"n_hapax": n_hapax,
"hapax_pct": hapax_pct,
"top_terms": top_terms,
}
except Exception:
return dict(empty)
@@ -0,0 +1,74 @@
"""Tests para compute_vocabulary_stats."""
import os
import sys
sys.path.insert(
0, os.path.join(os.path.dirname(__file__), "..", "..", "functions")
)
from datascience.compute_vocabulary_stats import compute_vocabulary_stats
def test_basico():
# Corpus con repeticiones y hapax. Stopwords desactivadas para controlar
# exactamente que tokens entran.
texts = ["gato gato perro", "perro perro raton", "elefante"]
r = compute_vocabulary_stats(texts, top_k=10, remove_stopwords=False)
# n_types < n_tokens cuando hay repeticiones.
assert r["n_types"] < r["n_tokens"]
assert r["n_tokens"] == 7
assert r["n_types"] == 4 # gato, perro, raton, elefante
# ttr en (0, 1].
assert 0 < r["ttr"] <= 1
assert r["ttr"] == round(4 / 7, 4)
# top_terms ordenado por count descendente.
counts = [t["count"] for t in r["top_terms"]]
assert counts == sorted(counts, reverse=True)
assert r["top_terms"][0]["term"] == "perro"
assert r["top_terms"][0]["count"] == 3
# hapax: raton y elefante aparecen exactamente una vez.
assert r["n_hapax"] == 2
assert r["hapax_pct"] == round(2 / 4 * 100, 2)
# pct coherente con count/n_tokens.
assert r["top_terms"][0]["pct"] == round(3 / 7 * 100, 2)
def test_vacio():
# Sin documentos validos -> ceros / None / [].
for arg in ([], None, [None, 123, ""], ["123 456"]):
r = compute_vocabulary_stats(arg)
assert r["n_tokens"] == 0
assert r["n_types"] == 0
assert r["ttr"] is None
assert r["n_hapax"] == 0
assert r["hapax_pct"] is None
assert r["top_terms"] == []
def test_stopwords_quitadas():
texts = ["the gato the perro", "de la casa azul"]
r = compute_vocabulary_stats(texts, remove_stopwords=True)
terms = {t["term"] for t in r["top_terms"]}
# Stopwords ES+EN no deben aparecer.
assert "the" not in terms
assert "de" not in terms
assert "la" not in terms
# Palabras de contenido si.
assert "gato" in terms
assert "casa" in terms
def test_stopwords_conservadas():
texts = ["the gato the perro", "de la casa azul"]
r = compute_vocabulary_stats(texts, remove_stopwords=False)
terms = {t["term"] for t in r["top_terms"]}
# Con el filtro desactivado, las stopwords se conservan.
assert "the" in terms
assert "de" in terms
assert "la" in terms
@@ -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,80 @@
---
name: detect_corpus_language
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def detect_corpus_language(texts, top_k=10, sample_max=1000) -> dict"
description: "Estima la distribucion de idiomas de un corpus de textos con la libreria langdetect (import perezoso). Funcion pura y defensiva del grupo eda: filtra documentos None/no-str/vacios, muestrea hasta sample_max docs, clasifica cada uno con detect() ignorando los que langdetect no puede resolver (LangDetectException), y devuelve la distribucion top_k por frecuencia mas el idioma dominante. Si langdetect no esta instalada o algo falla, degrada a {available: False, ...} y NUNCA lanza (dict-no-throw). Seed fija (DetectorFactory.seed=0) para deteccion determinista."
tags: [eda, datascience, text, nlp, language-detection, langdetect, pure, python]
params:
- name: texts
desc: "Lista de strings (documentos). Los elementos None, no-str o vacios tras strip se descartan antes de clasificar."
- name: top_k
desc: "Numero maximo de idiomas a devolver en distribution, ordenados por count descendente (desempate por codigo ISO ascendente). Default 10."
- name: sample_max
desc: "Numero maximo de documentos a clasificar (se toman los primeros del corpus) para acotar el coste. Default 1000."
output: >
Dict con forma fija (dict-no-throw, nunca lanza):
{"available": bool, "n_detected": int,
"distribution": [{"lang": str, "count": int, "pct": float}, ...],
"dominant": str|None}.
available=True si langdetect es importable; lang son codigos ISO 639-1 ("es","en","fr",...);
pct = count/n_detected*100 redondeado a 2 decimales; n_detected = docs clasificados con exito;
dominant = idioma mas frecuente (None si no hubo detecciones). Corpus vacio con langdetect
presente -> available True, n_detected 0, distribution [], dominant None. Sin langdetect (o
fallo global) -> available False y el resto de campos a su valor vacio.
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [langdetect]
tested: true
tests: ["test_mixto_es_en", "test_vacio", "test_degradacion"]
test_file_path: "python/functions/datascience/detect_corpus_language_test.py"
file_path: "python/functions/datascience/detect_corpus_language.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.detect_corpus_language import detect_corpus_language
corpus = [
"este es un texto bastante largo en español para detectar el idioma correctamente",
"la inteligencia artificial transforma la manera en que trabajamos cada dia",
"this is a fairly long english text to detect the language correctly without issues",
]
out = detect_corpus_language(corpus)
# {"available": True, "n_detected": 3,
# "distribution": [{"lang": "es", "count": 2, "pct": 66.67},
# {"lang": "en", "count": 1, "pct": 33.33}],
# "dominant": "es"}
```
## Cuando usarla
Cuando perfiles una columna o corpus de texto en un EDA y necesites saber en
que idioma(s) esta escrito antes de elegir tokenizadores, stopwords, modelos
NLP o stemmers. Util tambien como check de calidad: detectar corpus mezclados
o un idioma inesperado. Llamala con la lista de textos crudos; la funcion
limpia, muestrea y resume sola.
## Gotchas
- `langdetect` es **opcional**: si no esta instalada, la funcion no lanza —
devuelve `{"available": False, "n_detected": 0, "distribution": [], "dominant": None}`.
Comprueba `out["available"]` antes de usar la distribucion.
- **Textos cortos** (pocas palabras o sin features lingüisticas) pueden no
detectarse: langdetect lanza `LangDetectException`, que se ignora y el doc no
cuenta en `n_detected`. Pasa frases razonablemente largas para resultados fiables.
- **Determinismo**: se fija `DetectorFactory.seed = 0` en cada llamada para que la
deteccion sea reproducible; sin esa semilla langdetect puede dar resultados
ligeramente distintos entre ejecuciones.
- `distribution` esta truncada a `top_k`; si el corpus tiene mas idiomas que
`top_k`, la suma de los `count` mostrados puede ser menor que `n_detected`
(pero `dominant` siempre refleja el idioma mas frecuente del corpus completo).
@@ -0,0 +1,91 @@
"""Detecta la distribucion de idiomas de un corpus de textos.
Funcion pura y defensiva: el computo es determinista y local (sin I/O de red).
La libreria opcional `langdetect` se importa de forma perezosa dentro de la
funcion; si no esta instalada (o cualquier paso falla), la funcion degrada
limpiamente a `available=False` y NUNCA lanza excepciones.
"""
def detect_corpus_language(texts, top_k=10, sample_max=1000) -> dict:
"""Estima la distribucion de idiomas de un corpus con `langdetect`.
Args:
texts: lista de strings (documentos). Los elementos None, no-str o
vacios tras strip se descartan.
top_k: numero maximo de idiomas a devolver en `distribution`,
ordenados por frecuencia descendente.
sample_max: numero maximo de documentos a clasificar (se toman los
primeros) para acotar el coste.
Returns:
dict con la forma fija (dict-no-throw):
{
"available": bool, # True si langdetect es importable
"n_detected": int, # documentos clasificados con exito
"distribution": [{"lang": str, "count": int, "pct": float}, ...],
"dominant": str | None,
}
"""
degraded = {
"available": False,
"n_detected": 0,
"distribution": [],
"dominant": None,
}
try:
# Import perezoso con degradacion: si langdetect no esta disponible,
# devolvemos el dict degradado sin lanzar.
try:
from langdetect import detect, DetectorFactory
# Semilla fija -> deteccion determinista entre ejecuciones.
DetectorFactory.seed = 0
except Exception:
return dict(degraded)
# Normaliza y filtra el corpus.
docs = []
if texts:
for t in texts:
if isinstance(t, str):
s = t.strip()
if s:
docs.append(s)
# Muestreo de los primeros `sample_max` documentos.
if sample_max is not None and sample_max >= 0:
docs = docs[:sample_max]
# Conteo por idioma; langdetect lanza LangDetectException en textos
# sin features detectables -> se ignora y se sigue.
counts: dict = {}
for doc in docs:
try:
lang = detect(doc)
except Exception:
continue
counts[lang] = counts.get(lang, 0) + 1
n_detected = sum(counts.values())
# Orden estable: por count descendente, desempate por codigo de idioma.
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
k = top_k if (top_k is not None and top_k >= 0) else len(ordered)
distribution = []
for lang, count in ordered[:k]:
pct = round(count / n_detected * 100, 2) if n_detected else 0.0
distribution.append({"lang": lang, "count": count, "pct": pct})
dominant = ordered[0][0] if ordered else None
return {
"available": True,
"n_detected": n_detected,
"distribution": distribution,
"dominant": dominant,
}
except Exception:
# Cualquier fallo global degrada a available False sin lanzar.
return dict(degraded)
@@ -0,0 +1,58 @@
"""Tests para detect_corpus_language."""
import builtins
import os
import sys
# Anade python/functions a sys.path para importar el paquete `datascience`.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from datascience.detect_corpus_language import detect_corpus_language
_ES = [
"este es un texto bastante largo en español para detectar el idioma correctamente sin problemas",
"la inteligencia artificial transforma la manera en que trabajamos cada dia en muchos sectores",
]
_EN = [
"this is a fairly long english text to detect the language correctly without any length issues",
"machine learning models can classify documents into many different categories quite reliably",
]
def test_mixto_es_en():
"""Golden: corpus mixto ES+EN claro -> available True, >=2 idiomas, counts coherentes."""
out = detect_corpus_language(_ES + _EN)
assert out["available"] is True
assert out["dominant"] in {"es", "en"}
assert len(out["distribution"]) >= 2
total = sum(item["count"] for item in out["distribution"])
assert total == out["n_detected"]
assert out["n_detected"] == 4
def test_vacio():
"""Edge: lista vacia con langdetect presente -> available True, sin detecciones."""
out = detect_corpus_language([])
assert out["available"] is True
assert out["n_detected"] == 0
assert out["distribution"] == []
assert out["dominant"] is None
def test_degradacion(monkeypatch):
"""Error path: si langdetect no es importable -> degrada a available False sin lanzar."""
import datascience.detect_corpus_language as m
real_import = builtins.__import__
def fake_import(name, *a, **k):
if name == "langdetect" or name.startswith("langdetect."):
raise ImportError("simulado")
return real_import(name, *a, **k)
monkeypatch.setattr(builtins, "__import__", fake_import)
out = m.detect_corpus_language(["hola mundo", "hello world"])
assert out["available"] is False
assert out["n_detected"] == 0
assert out["distribution"] == []
assert out["dominant"] is None
@@ -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
@@ -0,0 +1,97 @@
---
name: extract_null_mask
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def extract_null_mask(query_fn, table: str, columns: list, max_rows: int = 5000) -> dict"
description: "Extrae la mascara de nulos (1=falta / 0=presente) de una muestra de filas de una tabla, una lista 0/1 por columna alineada por fila, para alimentar el capitulo de calidad / patron de nulos de AutomaticEDA sin que el capitulo toque la base de datos. Recibe un lector read-only inyectado `query_fn(sql) -> dict` (mismo contrato que duckdb_query_readonly / pg_query / el `_q` de profile_table) y NO abre ninguna conexion por su cuenta. Construye UNA sola query que proyecta por cada columna `CASE WHEN \"col\" IS NULL THEN 1 ELSE 0 END` con identificadores escapados y LIMIT. Devuelve dict dict-no-throw: columns (efectivamente leidas, en orden), mask (lista int 0/1 por columna, misma longitud todas) y n. Una celda None se cuenta defensivamente como 1 (falta)."
tags: [eda, nulls, missing, datascience, automatic-eda, extraction, read-only, duckdb, postgres, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: query_fn
desc: "callable lector read-only del backend activo. Recibe un string SQL y devuelve un dict {'status':'ok','rows':[{col:val,...},...]} (mismo contrato que duckdb_query_readonly o el `_q` de profile_table). NO se abre ninguna conexion dentro de la funcion: toda la lectura pasa por query_fn. Si es None -> error."
- name: table
desc: "nombre de la tabla de la que muestrear la mascara de nulos. Se escapa con comillas dobles en la query. Vacio o None -> status error."
- name: columns
desc: "lista de nombres de columna a evaluar. Cada una produce una entrada en `mask` con una lista 0/1 paralela por fila (1=IS NULL, 0=presente). Cada nombre se escapa con comillas dobles. Vacia o None -> status error."
- name: max_rows
desc: "limite de filas a muestrear (clausula LIMIT). Default 5000. Protege frente a tablas enormes; con LIMIT obtienes el primer tramo, no un muestreo uniforme."
output: "dict (nunca lanza). En exito: {'status':'ok','table':str,'columns':[str,...] (en orden),'mask':{col:[int 0/1,...],...} (1=falta/IS NULL, 0=presente; todas las listas con misma longitud = n),'n':int}. En error (sin lanzar): {'status':'error','error':str,'table':str,'columns':[],'mask':{},'n':0}. Errores: query_fn None, table vacia, columns vacia, o query_fn devuelve status!='ok' (se propaga su error)."
tested: true
tests: ["test_golden_mask_alineada", "test_celda_none_cuenta_como_falta", "test_columns_vacia_status_error", "test_query_fn_status_error_propaga", "test_query_fn_none_da_error_sin_reventar", "test_sql_contiene_case_y_limit"]
test_file_path: "python/functions/datascience/extract_null_mask_test.py"
file_path: "python/functions/datascience/extract_null_mask.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.extract_null_mask import extract_null_mask
from infra import duckdb_query_readonly
# El lector read-only se inyecta como closure (igual que el `_q` de profile_table).
db = "data/clientes.duckdb"
def _q(sql):
return duckdb_query_readonly(db, sql)
res = extract_null_mask(_q, "clientes", ["email", "telefono", "edad"])
# res == {
# "status": "ok",
# "table": "clientes",
# "columns": ["email", "telefono", "edad"],
# "mask": {
# "email": [0, 0, 1, 0, ...], # fila 2 sin email
# "telefono": [1, 0, 1, 0, ...],
# "edad": [0, 0, 0, 1, ...],
# },
# "n": 5000,
# }
# % de nulos por columna a partir de la muestra:
pct = {c: 100 * sum(bits) / max(res["n"], 1) for c, bits in res["mask"].items()}
# Se entrega al capitulo de calidad sin que este toque la BD:
ctx = {"null_mask": res}
```
## Cuando usarla
Cuando el capitulo de calidad / patron de nulos de AutomaticEDA necesita saber
DONDE faltan los valores (no solo cuantos) y NO debe abrir la base de datos por
su cuenta: extraes aqui la mascara 0/1 por columna alineada por fila y se la pasas
en `ctx['null_mask']`. Usala siempre que quieras detectar co-ocurrencia de nulos
(filas que fallan en varias columnas a la vez), calcular el % de nulos sobre una
muestra, o pintar un heatmap de missingness reutilizando un unico lector read-only
inyectado, en vez de hacer N `COUNT(*) WHERE col IS NULL` por separado.
## Gotchas
- **Impura**: lee de la base de datos a traves de `query_fn`. No abre conexiones
por su cuenta — depende por completo del lector inyectado. Sigue el estilo
dict-no-throw del grupo `eda`: nunca lanza; ante cualquier fallo devuelve
`{"status":"error","error":...}` con `columns=[]`, `mask={}`, `n=0`.
- **`error_type` en el frontmatter es `error_go_core` por convencion del registry**
(toda funcion impura debe declararlo y el indexer lo exige), pero el codigo
NO lanza esa excepcion: degrada al dict de error. Es metadata, no comportamiento.
- **Muestra, no censo**: con `LIMIT max_rows` obtienes el primer tramo de filas que
devuelva el backend, no un muestreo uniforme ni la tabla entera. El % de nulos
derivado es una estimacion sobre esa muestra; para el conteo exacto usa un
agregado `COUNT(*)`/`COUNT(col)` aparte.
- **Alineacion por fila**: `mask[col][i]` corresponde a la misma fila `i` que
`mask[otra_col][i]`. Todas las listas tienen longitud `n`, asi que puedes cruzar
columnas por indice (co-ocurrencia de nulos) sin re-alinear.
- **Defensa None -> 1**: el SQL ya devuelve 0/1, pero si una celda llega como `None`
(CASE no aplicado, columna ausente en la fila, backend que nulifica) se cuenta
como 1 (falta). Un valor inesperado no convertible a int se trata como presente (0).
- **No loguear los datos crudos**: aunque `mask` es solo 0/1, los nombres de columna
pueden revelar el esquema. En trazas usa `n` y el numero de columnas, no el dict
completo.
@@ -0,0 +1,101 @@
"""extract_null_mask — extrae la mascara de nulos (1=falta / 0=presente) de una tabla.
Lector read-only inyectado: recibe `query_fn(sql) -> dict` con el mismo contrato
que duckdb_query_readonly / pg_query (y que el `_q` de profile_table):
`{"status": "ok", "rows": [{col: val, ...}, ...]}`. Esta funcion NO abre ninguna
conexion por su cuenta — solo usa `query_fn`. Construye UNA sola query que, por
cada columna pedida, evalua `CASE WHEN "col" IS NULL THEN 1 ELSE 0 END` y devuelve
una muestra de filas con esos bits. El resultado es un dict `mask` con una lista
0/1 por columna, alineada por fila (1 = el valor falta / IS NULL, 0 = presente),
listo para alimentar el capitulo de calidad / patron de nulos de AutomaticEDA sin
que el capitulo toque la base de datos.
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier excepcion y
degrada a `{"status": "error", "error": str, ...}`.
"""
def _to_bit(value):
"""Coacciona el valor 0/1 del CASE a int de forma defensiva.
El SQL ya devuelve 0 (presente) o 1 (falta). Por si una celda llega como None
(el CASE no se aplico o el backend la nulifico), se cuenta como 1 (falta). El
resto se reduce a int: un entero distinto de 0 cuenta como 1 (falta), 0 como
presente. Un valor no convertible se trata como presente (0) — nunca lanza.
"""
if value is None:
return 1
try:
return 1 if int(value) != 0 else 0
except (TypeError, ValueError):
return 0
def extract_null_mask(query_fn, table, columns, max_rows=5000):
"""Extrae la mascara de nulos (1=falta / 0=presente) de una muestra de la tabla.
Args:
query_fn: callable lector read-only del backend activo. Recibe un string
SQL y devuelve un dict {"status": "ok", "rows": [{col: val, ...}]}
(mismo contrato que duckdb_query_readonly / el `_q` de profile_table).
No se abre ninguna conexion aqui: toda la lectura pasa por query_fn.
table: nombre de la tabla. Se escapa con comillas dobles en la query.
columns: lista de nombres de columna a evaluar. Cada una produce una
entrada en `mask` con una lista 0/1 paralela por fila. Vacia o None ->
status error.
max_rows: limite de filas a muestrear (clausula LIMIT). Default 5000.
Returns:
dict (nunca lanza):
{
"status": "ok" | "error",
"error": str, # solo si status == "error"
"table": str,
"columns": [str, ...], # columnas efectivamente leidas, en orden
"mask": {col: [int 0/1, ...], ...}, # alineada por fila, 1=falta, 0=presente
"n": int # nº de filas muestreadas
}
Todas las listas de `mask` tienen la misma longitud (= n).
"""
base = {"status": "ok", "table": table, "columns": [], "mask": {}, "n": 0}
try:
if query_fn is None:
return {**base, "status": "error", "error": "query_fn es None"}
if not table:
return {**base, "status": "error", "error": "table es obligatorio"}
if not columns:
return {**base, "status": "error", "error": "columns vacío"}
# Identificadores escapados con comillas dobles (como hace profile_table)
# para tolerar nombres con mayusculas/espacios/palabras reservadas. Cada
# columna se proyecta como su propio bit IS NULL conservando el alias.
select_sql = ", ".join(
f'(CASE WHEN "{c}" IS NULL THEN 1 ELSE 0 END) AS "{c}"' for c in columns
)
sql = f'SELECT {select_sql} FROM "{table}" LIMIT {int(max_rows)}'
q = query_fn(sql)
if not isinstance(q, dict) or q.get("status") != "ok":
err = (
q.get("error", "query_fn fallo")
if isinstance(q, dict)
else "query_fn no devolvio un dict"
)
return {**base, "status": "error", "error": err}
rows = q.get("rows", []) or []
mask = {c: [] for c in columns}
for row in rows:
for c in columns:
# row.get tolera filas que no traigan la columna (None -> falta).
mask[c].append(_to_bit(row.get(c) if isinstance(row, dict) else None))
return {
"status": "ok",
"table": table,
"columns": list(columns),
"mask": mask,
"n": len(rows),
}
except Exception as e: # noqa: BLE001 - dict-no-throw: degradar, nunca lanzar
return {**base, "status": "error", "error": str(e)}
@@ -0,0 +1,116 @@
"""Tests para extract_null_mask.
No usa DuckDB real: inyecta un query_fn FAKE (closure) que devuelve filas
predefinidas (simulando el SELECT de bits 0/1) y, opcionalmente, captura el SQL
recibido para verificar la query generada (CASE WHEN ... IS NULL + LIMIT). Asi el
test es autocontenido y no depende de ningun backend.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from extract_null_mask import extract_null_mask
def _fake_query(rows, captured=None, status="ok", error=None):
"""Crea un query_fn FAKE.
`captured` (lista opcional) recibe el SQL ejecutado para poder inspeccionarlo.
`status`/`error` permiten simular un fallo del backend.
"""
def _q(sql):
if captured is not None:
captured.append(sql)
if status != "ok":
return {"status": "error", "error": error or "boom"}
return {"status": "ok", "rows": rows}
return _q
def test_golden_mask_alineada():
"""Golden: mask 0/1 por columna alineada por fila, n correcto, status ok."""
# Cada fila simula el SELECT (CASE WHEN col IS NULL THEN 1 ELSE 0 END) AS col.
rows = [
{"email": 0, "telefono": 1, "edad": 0},
{"email": 0, "telefono": 0, "edad": 1},
{"email": 1, "telefono": 1, "edad": 0},
]
res = extract_null_mask(_fake_query(rows), "clientes", ["email", "telefono", "edad"])
assert res["status"] == "ok"
assert res["table"] == "clientes"
assert res["columns"] == ["email", "telefono", "edad"]
assert res["n"] == 3
assert res["mask"]["email"] == [0, 0, 1]
assert res["mask"]["telefono"] == [1, 0, 1]
assert res["mask"]["edad"] == [0, 1, 0]
# Todas las listas con la misma longitud.
assert all(len(v) == res["n"] for v in res["mask"].values())
def test_celda_none_cuenta_como_falta():
"""Una celda None se cuenta defensivamente como 1 (falta)."""
rows = [
{"email": 0, "telefono": None},
{"email": None, "telefono": 1},
{"email": 1, "telefono": 0},
]
res = extract_null_mask(_fake_query(rows), "clientes", ["email", "telefono"])
assert res["status"] == "ok"
assert res["mask"]["email"] == [0, 1, 1]
assert res["mask"]["telefono"] == [1, 1, 0]
assert res["n"] == 3
def test_columns_vacia_status_error():
"""columns vacia -> status error con columns/mask/n vacios."""
res = extract_null_mask(_fake_query([]), "clientes", [])
assert res["status"] == "error"
assert "columns" in res["error"]
assert res["table"] == "clientes"
assert res["columns"] == []
assert res["mask"] == {}
assert res["n"] == 0
def test_query_fn_status_error_propaga():
"""query_fn que devuelve status != ok -> se propaga como error, mask {}."""
res = extract_null_mask(
_fake_query([], status="error", error="db locked"),
"clientes",
["email"],
)
assert res["status"] == "error"
assert "db locked" in res["error"]
assert res["mask"] == {}
assert res["n"] == 0
def test_query_fn_none_da_error_sin_reventar():
"""query_fn None -> error degradado, sin excepcion."""
res = extract_null_mask(None, "clientes", ["email"])
assert res["status"] == "error"
assert res["columns"] == []
assert res["mask"] == {}
assert res["n"] == 0
def test_sql_contiene_case_y_limit():
"""La query genera un CASE WHEN IS NULL por columna escapada + LIMIT sobre la tabla."""
captured = []
rows = [{"email": 0}]
extract_null_mask(
_fake_query(rows, captured),
"clientes_tbl",
["email"],
max_rows=123,
)
assert len(captured) == 1
sql = captured[0]
assert 'CASE WHEN "email" IS NULL THEN 1 ELSE 0 END' in sql
assert 'AS "email"' in sql
assert 'FROM "clientes_tbl"' in sql
assert "LIMIT 123" in sql
@@ -0,0 +1,102 @@
---
name: extract_text_sample
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def extract_text_sample(db_path: str, table: str, columns: list, backend: str = 'duckdb', sample: int = 2000) -> dict"
description: "Muestrea columnas de texto de una tabla DuckDB/Postgres con push-down SQL (LIMIT sample), SIN traer la tabla entera a RAM. Funcion impura del grupo de capacidad `eda`: la usan los capitulos de texto/NLP del AutomaticEDA que necesitan valores crudos de texto (longitudes, tokens, ejemplos) sobre una muestra acotada. Construye el lector read-only query_fn(sql)->dict igual que build_eda_render_ctx (closure sobre duckdb_query_readonly / pg_query importados perezosamente desde infra). Escapa los identificadores con comillas dobles y lanza una sola query SELECT \"c1\", \"c2\" FROM \"table\" LIMIT n. Por columna, la lista de strings solo contiene valores NO None y NO vacios: cada celda no nula se convierte con str(...) y se descarta si queda cadena vacia. Estilo dict-no-throw del grupo eda: NUNCA lanza; ante cualquier fallo (query, conversion, backend desconocido) devuelve {status:'error', error:str, columns:{}, n:0}. La clave n reporta el numero de FILAS leidas por la query (antes de filtrar None/vacios)."
tags: [eda, datascience, text, nlp, extraction, read-only, duckdb, postgres, python]
uses_functions: [duckdb_query_readonly_py_infra, pg_query_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: db_path
desc: "ruta al archivo DuckDB, o DSN PostgreSQL si backend='postgres'. Se inyecta en el closure query_fn. No se valida aqui: si la base no existe o el DSN es invalido, la query devuelve status error y el resultado es {status:'error', ...} (no lanza)."
- name: table
desc: "nombre de la tabla. Se escapa con comillas dobles en la query (SELECT ... FROM \"table\")."
- name: columns
desc: "lista de nombres de columna de texto a muestrear. Se filtra a las entradas que sean str no vacio; cada nombre se escapa con comillas dobles. Si tras filtrar queda vacia -> {status:'ok', columns:{}, n:0} sin tocar la base."
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el lector read-only del registry (duckdb_query_readonly / pg_query). Cualquier otro valor -> {status:'error', error:'backend desconocido: <valor>', columns:{}, n:0}."
- name: sample
desc: "maximo de filas a muestrear (clausula LIMIT). Default 2000. Acota memoria y tiempo: con tablas grandes obtienes el primer tramo por orden fisico (sin ORDER BY), no un muestreo uniforme."
output: "dict dict-no-throw (NUNCA lanza): {status:'ok'|'error', columns:{col_name:[str,...]}, n:int, error:str}. En exito (status='ok') columns mapea cada columna pedida a la lista de sus valores de texto NO None y NO vacios (cada celda convertida con str(...)); n es el numero de FILAS leidas por la query (antes de filtrar None/vacios). columns vacio -> {status:'ok', columns:{}, n:0}. En error (backend desconocido, query con status!='ok', o cualquier excepcion) -> {status:'error', error:str, columns:{}, n:0}; la clave error solo aparece en este caso."
tested: true
tests: ["test_extract_basic", "test_backend_desconocido", "test_columns_vacio", "test_sample_limit"]
test_file_path: "python/functions/datascience/extract_text_sample_test.py"
file_path: "python/functions/datascience/extract_text_sample.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
# Import directo del submodulo (no requiere export en datascience/__init__.py).
from datascience.extract_text_sample import extract_text_sample
# Muestrea hasta 2000 filas de dos columnas de texto de una tabla DuckDB.
res = extract_text_sample(
"data/reviews.duckdb", "reviews", ["title", "body"],
backend="duckdb", sample=2000,
)
# res == {
# "status": "ok",
# "columns": {
# "title": ["Gran producto", "No funciona", ...], # solo no-None, no-""
# "body": ["Lo uso a diario...", ...],
# },
# "n": 2000, # filas leidas por la query (antes de filtrar None/vacios)
# }
# Postgres: db_path es el DSN.
res_pg = extract_text_sample(
"postgresql://user:pass@localhost:5433/trends", "comentarios", ["texto"],
backend="postgres", sample=500,
)
```
## Cuando usarla
Cuando necesites valores CRUDOS de texto de una o varias columnas para analisis
NLP/texto (distribucion de longitudes, conteo de tokens, ejemplos representativos,
deteccion de idioma) pero NO quieras cargar la tabla entera en memoria. Es el
muestreador de texto del grupo `eda`: una sola llamada con push-down `LIMIT`
devuelve listas de strings por columna, limpias de None y vacios, listas para
alimentar un capitulo de texto del AutomaticEDA o cualquier rutina de tokenizado.
Usala junto a `profile_table` / `build_eda_render_ctx` cuando el perfil agregado
no basta y hace falta el texto real.
## Gotchas
- **Impura**: lee de la base de datos a traves de `query_fn` (closure sobre
`duckdb_query_readonly` / `pg_query`). No abre conexiones fuera de esos wrappers
del registry. Estilo dict-no-throw del grupo `eda`: NUNCA lanza; ante cualquier
fallo devuelve `{status:'error', error:str, columns:{}, n:0}`.
- **`error_type` en el frontmatter es `error_go_core` por convencion del registry**
(toda funcion impura debe declararlo y el indexer lo exige), pero el codigo NO
lanza esa excepcion: degrada al dict de error. Es metadata, no comportamiento.
- **Backend desconocido**: con un `backend` que no sea `duckdb` ni `postgres`
devuelve `{status:'error', error:'backend desconocido: <valor>', columns:{},
n:0}` sin tocar la base.
- **Las listas NO incluyen None ni cadenas vacias**: cada celda no nula se pasa
por `str(...)` y se descarta si queda `""`. Por eso `len(columns[col])` puede ser
menor que `n` (que cuenta las filas leidas). Si necesitas alineacion por fila
(una entrada por fila aunque sea None), usa `build_eda_render_ctx` (raw_numeric),
no esta funcion.
- **`LIMIT sample` sin `ORDER BY`**: con tablas grandes obtienes el primer tramo
por orden fisico del backend, no un muestreo uniforme ni reproducible. Sube
`sample` para mas cobertura, o pre-ordena/aleatoriza la tabla si necesitas
representatividad.
- **DuckDB en sandbox por defecto**: `duckdb_query_readonly` abre la conexion con
`enable_external_access=False`, asi que la query solo puede leer la propia base
(no `read_csv`/`httpfs`/`ATTACH` a paths externos). Lee tablas ya existentes en
el archivo DuckDB sin problema.
- **No loguear los datos crudos**: las listas de `columns` pueden contener texto
sensible (reviews, comentarios, PII). En trazas usa solo conteos (`n`,
`len(columns[col])`) y nombres de columna, no el dict completo.
@@ -0,0 +1,112 @@
"""extract_text_sample — muestrea columnas de texto de una tabla sin cargarla en RAM.
Funcion impura (lee de la base de datos) del grupo de capacidad `eda`. Dado un
``db_path`` + ``table`` (DuckDB o PostgreSQL) y una lista de ``columns`` de texto,
trae una MUESTRA de esas columnas con push-down SQL (``LIMIT sample``), nunca la
tabla entera. La usan los capitulos de texto/NLP del AutomaticEDA que necesitan
valores crudos de texto (longitudes, tokens, ejemplos) sin materializar millones
de filas en memoria.
El lector read-only ``query_fn(sql) -> dict`` se construye igual que en
``build_eda_render_ctx`` / ``profile_table``: un closure sobre el wrapper del
registry (``duckdb_query_readonly`` / ``pg_query``), importado perezosamente
dentro de la funcion para no crear ciclos al cargar el ``__init__`` del paquete
``datascience``. Nunca abre conexiones fuera de esos wrappers.
Estilo dict-no-throw del grupo `eda`: la funcion NUNCA lanza. Captura cualquier
excepcion (query, conversion) y devuelve ``{"status":"error", "error":str(e),
"columns":{}, "n":0}``. Si la query subyacente devuelve ``status != "ok"``, se
propaga como error con el mensaje del wrapper.
Por columna, la lista de strings solo contiene valores NO nulos y NO vacios:
cada celda no-None se convierte con ``str(...)`` y se descarta si queda ``""``.
La clave ``n`` reporta el numero de FILAS leidas por la query (antes de filtrar
los None/vacios), util para saber cuanto se muestreo realmente.
"""
def extract_text_sample(db_path, table, columns, backend="duckdb", sample=2000):
"""Muestrea columnas de texto de una tabla DuckDB/Postgres con push-down SQL.
Args:
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
Se inyecta en el closure query_fn. No se valida aqui: si la base no
existe o el DSN es invalido, la query devuelve status error y el
resultado es {status:'error', ...} (no lanza).
table: nombre de la tabla. Se escapa con comillas dobles en la query.
columns: lista de nombres de columna de texto a muestrear. Se filtra a las
entradas que sean str no vacio; cada nombre se escapa con comillas
dobles. Si tras filtrar queda vacia -> {status:'ok', columns:{}, n:0}.
backend: "duckdb" (default) o "postgres". Selecciona el lector read-only
del registry (duckdb_query_readonly / pg_query). Cualquier otro valor
-> {status:'error', error:'backend desconocido: ...', columns:{}, n:0}.
sample: maximo de filas a muestrear (clausula LIMIT). Default 2000. Acota
memoria y tiempo: con tablas grandes obtienes el primer tramo por
orden fisico, no un muestreo uniforme.
Returns:
dict (dict-no-throw, NUNCA lanza):
{"status": "ok"|"error",
"columns": {col_name: [str, str, ...], ...}, # solo no-None, no-""
"n": int, # nº de filas leidas por la query (antes de filtrar)
"error": str} # solo presente si status == "error"
"""
try:
# 1) Lector read-only del backend activo, construido como en
# build_eda_render_ctx (closure sobre el wrapper del registry). Imports
# perezosos: este modulo vive en el paquete `datascience`, importar a
# `infra` a nivel de modulo crearia un ciclo al cargar el __init__.
if backend == "duckdb":
from infra import duckdb_query_readonly
def query_fn(sql):
return duckdb_query_readonly(db_path, sql)
elif backend == "postgres":
from infra import pg_query
def query_fn(sql):
return pg_query(db_path, sql)
else:
return {
"status": "error",
"error": f"backend desconocido: {backend}",
"columns": {},
"n": 0,
}
# 2) Columnas validas (str no vacio). Si no queda ninguna, nada que
# muestrear: ok con columns vacio.
cols = []
if isinstance(columns, (list, tuple)):
cols = [c for c in columns if isinstance(c, str) and c != ""]
if not cols:
return {"status": "ok", "columns": {}, "n": 0}
# 3) Push-down: una sola query con LIMIT. Identificadores escapados con
# comillas dobles, igual que build_eda_render_ctx.
cols_sql = ", ".join(f'"{c}"' for c in cols)
sql = f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
q = query_fn(sql)
if not isinstance(q, dict) or q.get("status") != "ok":
err = q.get("error") if isinstance(q, dict) else "query sin resultado"
return {"status": "error", "error": str(err), "columns": {}, "n": 0}
rows = q.get("rows") or []
out = {c: [] for c in cols}
for row in rows:
if not isinstance(row, dict):
continue
for c in cols:
value = row.get(c)
if value is None:
continue
s = str(value)
if s == "":
continue
out[c].append(s)
return {"status": "ok", "columns": out, "n": len(rows)}
except Exception as exc: # noqa: BLE001 - dict-no-throw del grupo eda
return {"status": "error", "error": str(exc), "columns": {}, "n": 0}
@@ -0,0 +1,83 @@
"""Tests para extract_text_sample.
Self-contained: crea un DuckDB temporal pequeño con una columna de texto (algunas
filas con NULL) y una numerica, y verifica que la muestra de texto trae solo los
valores no nulos, que el backend desconocido y la lista de columnas vacia se
manejan dict-no-throw, y que sample acota el numero de filas leidas.
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
import duckdb # noqa: E402
from datascience.extract_text_sample import extract_text_sample # noqa: E402
_TABLE = "t"
# 6 filas: txt VARCHAR con dos NULL, other INT siempre presente.
_ROWS = [
("alpha", 1),
("beta", 2),
(None, 3),
("gamma", 4),
(None, 5),
("delta", 6),
]
_TXT_NON_NULL = {"alpha", "beta", "gamma", "delta"}
def _make_db(tmp_path):
"""Crea un DuckDB temporal con la tabla de prueba y devuelve su ruta."""
db_path = os.path.join(str(tmp_path), "text_sample.duckdb")
con = duckdb.connect(db_path)
try:
con.execute(f'CREATE TABLE "{_TABLE}" (txt VARCHAR, other INTEGER)')
con.executemany(f'INSERT INTO "{_TABLE}" VALUES (?, ?)', _ROWS)
finally:
con.close()
return db_path
def test_extract_basic(tmp_path):
db_path = _make_db(tmp_path)
res = extract_text_sample(db_path, _TABLE, ["txt"])
assert res["status"] == "ok"
# n = filas leidas por la query (6), antes de filtrar None.
assert res["n"] == len(_ROWS)
# columns["txt"] trae solo los strings no nulos (los dos NULL fuera).
assert "txt" in res["columns"]
assert set(res["columns"]["txt"]) == _TXT_NON_NULL
assert len(res["columns"]["txt"]) == len(_TXT_NON_NULL)
# No se pidio "other", no debe aparecer.
assert "other" not in res["columns"]
def test_backend_desconocido(tmp_path):
db_path = _make_db(tmp_path)
res = extract_text_sample(db_path, _TABLE, ["txt"], backend="mysql")
assert res["status"] == "error"
assert "backend desconocido" in res["error"]
assert res["columns"] == {}
assert res["n"] == 0
def test_columns_vacio(tmp_path):
db_path = _make_db(tmp_path)
res = extract_text_sample(db_path, _TABLE, [])
assert res["status"] == "ok"
assert res["columns"] == {}
assert res["n"] == 0
def test_sample_limit(tmp_path):
db_path = _make_db(tmp_path)
res = extract_text_sample(db_path, _TABLE, ["txt"], sample=2)
assert res["status"] == "ok"
# sample=2 -> la query lee como mucho 2 filas.
assert res["n"] == 2
assert len(res["columns"]["txt"]) <= 2
+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,103 @@
---
id: missingness_corr_heatmap_figure_py_datascience
name: missingness_corr_heatmap_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def missingness_corr_heatmap_figure(matrix, labels, title=\"Co-ocurrencia de ausencias\") -> \"matplotlib.figure.Figure\""
description: "Construye una figura matplotlib (heatmap) de la matriz NxN de correlación de ausencias entre columnas: +1 = dos columnas suelen ser nulas a la vez, -1 = cuando una falta la otra está presente, 0 = ausencias independientes. Usa ax.imshow con coolwarm fijado a [-1,1], ticks con los labels truncados (X rotados 45º), colorbar y anota el valor de cada celda si N<=12. Devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA (capítulo de datos faltantes). Backend Agg sin pyplot global; defensivo ante matrix/labels vacíos o celdas no numéricas (nunca lanza)."
tags: [eda, missing, missingness, correlation, heatmap, matplotlib, figure, visualization, datascience, impure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib]
example: |
from datascience.missingness_corr_heatmap_figure import missingness_corr_heatmap_figure
matrix = [
[1.0, 0.82, -0.10],
[0.82, 1.0, 0.05],
[-0.10, 0.05, 1.0],
]
labels = ["telefono", "movil", "email"]
fig = missingness_corr_heatmap_figure(matrix, labels, title="Co-ocurrencia de ausencias")
tested: true
tests:
- "test_returns_figure_with_axes"
- "test_empty_matrix_does_not_raise_and_returns_figure"
- "test_empty_labels_returns_message_figure"
- "test_large_matrix_omits_annotations"
- "test_ragged_and_non_numeric_cells_are_handled"
test_file_path: "python/functions/datascience/missingness_corr_heatmap_figure_test.py"
file_path: "python/functions/datascience/missingness_corr_heatmap_figure.py"
params:
- name: matrix
desc: "Lista de listas (NxN) de floats en [-1,1]: la correlación de ausencias por pares de columnas. Puede venir vacía. Filas de longitud desigual se toleran (se rellenan/recortan a N); celdas None, NaN o no numéricas se coercen a 0.0. No se muta el original."
- name: labels
desc: "Lista de N nombres de columna, paralela a matrix. Puede venir vacía (devuelve figura \"sin columnas con ausencia variable\"). Se truncan a ~14 chars con elipsis para los ticks; los originales no se mutan."
- name: title
desc: "Título de la figura. Se trunca a ~60 chars con elipsis si es muy largo. Default \"Co-ocurrencia de ausencias\"."
output: "Un matplotlib.figure.Figure (figsize 6.4x5.2, dpi 150) con un Axes heatmap (imshow vmin=-1, vmax=1, cmap coolwarm) más una colorbar etiquetada \"correlación de ausencias\". Ticks en ambos ejes con los labels truncados (X rotados 45º). Si N<=12 cada celda lleva su valor numérico anotado (texto blanco sobre celdas saturadas, oscuro sobre pálidas); con N grande se omiten las anotaciones para no saturar. Si matrix o labels vienen vacíos devuelve una Figure con texto centrado \"sin columnas con ausencia variable\"; cualquier error inesperado se captura y devuelve una Figure con el mensaje de error (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
---
## Ejemplo
```python
from datascience.missingness_corr_heatmap_figure import missingness_corr_heatmap_figure
# Correlación de ausencias entre 3 columnas de contacto:
# telefono y movil tienden a faltar juntos (0.82); email es casi independiente.
matrix = [
[1.00, 0.82, -0.10],
[0.82, 1.00, 0.05],
[-0.10, 0.05, 1.00],
]
labels = ["telefono", "movil", "email"]
fig = missingness_corr_heatmap_figure(
matrix,
labels,
title="Co-ocurrencia de ausencias",
)
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
fig.savefig("/tmp/missingness_heatmap.png")
```
## Cuando usarla
Úsala en el capítulo de datos faltantes de un informe EDA cuando quieras ver de
un vistazo qué columnas faltan juntas (mismo formulario sin rellenar, mismo
proceso roto) frente a columnas cuyas ausencias son independientes. Pásale la
matriz de correlación de ausencias (calculada sobre la máscara de nulos, p. ej.
`df.isnull().corr()`) restringida a las columnas que de verdad tienen ausencia
variable, junto con sus nombres. Es la pareja "estructura" del ranking de % de
nulos: las barras dicen *cuánto* falta cada columna, este heatmap dice *si las
ausencias están relacionadas* entre columnas.
## Gotchas
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
directamente, así que es segura de llamar en bucle desde el renderer.
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
guarda. Quien la consume debe rasterizarla y luego liberarla
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
- **Escala de color fija en [-1, 1].** `vmin=-1`, `vmax=1` están fijados a
propósito para que el color sea comparable entre informes y entre columnas. No
se autoescala al rango real de la matriz; valores fuera de `[-1, 1]` se
saturan al extremo del colormap.
- **Anotaciones solo con N<=12.** Por encima de 12 columnas el grid de números
se vuelve ilegible y se omite; queda solo el color + la colorbar. Filtra a las
columnas con ausencia variable antes de llamar para no llegar a matrices
enormes.
- **Defensiva, nunca lanza.** `matrix=[]`, `labels=[]`, filas cortas, celdas
`None`/`NaN`/no numéricas o cualquier error inesperado se manejan sin propagar:
en el peor caso devuelve una `Figure` con "sin columnas con ausencia variable"
o con el texto del error. No envuelvas la llamada en try/except por miedo a un
raise — no lo hay.
@@ -0,0 +1,158 @@
"""Impure EDA helper: heatmap of missingness co-occurrence (`eda` group).
Builds a matplotlib heatmap of the pairwise missingness correlation matrix of a
dataset: a value near ``+1`` means two columns tend to be null together, near
``-1`` means when one is null the other tends to be present, and ``0`` means
their absences are independent. Returns a ready-to-rasterize
``matplotlib.figure.Figure``; it never shows nor saves it.
Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer.
"""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure # noqa: E402
# Muted gray for secondary text (no-data / fallback messages).
_MUTED_TEXT = "#5f6b7a"
# Soft red for the error fallback message (kept readable, not alarming).
_ERROR_TEXT = "#b00020"
def _truncate(text, width: int = 14) -> str:
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
s = "" if text is None else str(text)
if len(s) <= width:
return s
if width <= 1:
return s[:width]
return s[: width - 1] + ""
def _message_figure(message: str, color: str = _MUTED_TEXT) -> "Figure":
"""Return a fallback ``Figure`` carrying a single centered message."""
fig = Figure(figsize=(6.4, 4.0), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=12,
color=color,
wrap=True,
transform=ax.transAxes,
)
fig.tight_layout()
return fig
def missingness_corr_heatmap_figure(
matrix,
labels,
title: str = "Co-ocurrencia de ausencias",
) -> "matplotlib.figure.Figure":
"""Build a heatmap figure of a missingness correlation matrix.
Renders an ``NxN`` matrix of missingness correlations in ``[-1, 1]`` with a
diverging ``coolwarm`` colormap (fixed ``vmin=-1``, ``vmax=1`` so the color
scale is comparable across reports). Both axes are tick-labelled with the
column names (truncated to ~14 chars; the X labels rotated 45°). A colorbar
is attached. When the matrix is small (``N <= 12``) each cell is annotated
with its numeric value; for larger matrices the annotations are omitted to
avoid an unreadable grid.
The function is fully defensive: empty/ragged/non-numeric input never raises.
When there is nothing valid to draw it returns a ``Figure`` carrying a
centered "sin columnas con ausencia variable" message, and any unexpected
error is caught and turned into a fallback ``Figure`` carrying the error text.
Args:
matrix: List of lists (``NxN``) of floats in ``[-1, 1]`` — the pairwise
missingness correlation. May be empty; rows of unequal length are
tolerated by treating the matrix as invalid only when it is empty or
its label count does not match. Non-numeric/``None`` cells are
coerced to ``0.0``.
labels: List of ``N`` column names, parallel to ``matrix``. May be empty.
Truncated for display; the originals are not mutated.
title: Figure title. Default "Co-ocurrencia de ausencias".
Returns:
A ``matplotlib.figure.Figure`` with a single heatmap Axes plus a
colorbar. The caller is responsible for rasterizing/closing it.
"""
try:
# --- Validate shape: need a non-empty square-ish matrix with labels.
if (
not isinstance(matrix, (list, tuple))
or not isinstance(labels, (list, tuple))
or len(matrix) == 0
or len(labels) == 0
):
return _message_figure("sin columnas con ausencia variable")
n = len(labels)
# Build a clean NxN grid: coerce each cell to float, default 0.0, pad/clip
# rows so a ragged input never crashes imshow.
grid = []
for i in range(n):
row_src = matrix[i] if i < len(matrix) else []
if not isinstance(row_src, (list, tuple)):
row_src = []
row = []
for j in range(n):
cell = row_src[j] if j < len(row_src) else 0.0
try:
val = float(cell)
except (TypeError, ValueError):
val = 0.0
if val != val: # NaN guard.
val = 0.0
row.append(val)
grid.append(row)
fig = Figure(figsize=(6.4, 5.2), dpi=150)
ax = fig.add_subplot(111)
im = ax.imshow(grid, vmin=-1, vmax=1, cmap="coolwarm", aspect="equal")
short = [_truncate(lab, 14) for lab in labels]
ax.set_xticks(range(n))
ax.set_yticks(range(n))
ax.set_xticklabels(short, rotation=45, ha="right", fontsize=8)
ax.set_yticklabels(short, fontsize=8)
# Annotate each cell only when the grid is small enough to stay legible.
if n <= 12:
for i in range(n):
for j in range(n):
val = grid[i][j]
# White text over saturated (dark) cells, dark over pale.
txt_color = "white" if abs(val) >= 0.55 else "#202020"
ax.text(
j,
i,
f"{val:.2f}",
ha="center",
va="center",
fontsize=7,
color=txt_color,
)
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
cbar.ax.tick_params(labelsize=8)
cbar.set_label("correlación de ausencias", fontsize=8)
if title:
ax.set_title(_truncate(title, 60), fontsize=12, loc="center", pad=10)
fig.tight_layout()
return fig
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
return _message_figure(f"error al dibujar heatmap: {exc}", color=_ERROR_TEXT)
@@ -0,0 +1,62 @@
"""Tests para missingness_corr_heatmap_figure (heatmap de ausencias, grupo eda).
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
estado entre tests.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from missingness_corr_heatmap_figure import missingness_corr_heatmap_figure
def _identity_matrix(n):
"""Matriz NxN con diagonal 1.0 y resto 0.0 (correlación de ausencias)."""
return [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
def test_returns_figure_with_axes():
matrix = [[1.0, 0.3, -0.2], [0.3, 1.0, 0.5], [-0.2, 0.5, 1.0]]
labels = ["edad", "ingresos", "ciudad"]
fig = missingness_corr_heatmap_figure(matrix, labels, title="ausencias")
assert isinstance(fig, Figure)
# Heatmap (>=1 axes) + colorbar añade su propio Axes -> al menos 1.
assert len(fig.axes) >= 1
plt.close(fig)
def test_empty_matrix_does_not_raise_and_returns_figure():
fig = missingness_corr_heatmap_figure([], [], title="vacía")
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_empty_labels_returns_message_figure():
fig = missingness_corr_heatmap_figure([[1.0]], [], title="sin labels")
assert isinstance(fig, Figure)
plt.close(fig)
def test_large_matrix_omits_annotations():
n = 16
fig = missingness_corr_heatmap_figure(
_identity_matrix(n), [f"col_{i}" for i in range(n)]
)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_ragged_and_non_numeric_cells_are_handled():
# Fila corta + celda None + celda string -> se rellenan/coercen sin lanzar.
matrix = [[1.0, None], ["x", 1.0, 0.5]]
labels = ["a", "b"]
fig = missingness_corr_heatmap_figure(matrix, labels)
assert isinstance(fig, Figure)
plt.close(fig)
@@ -0,0 +1,68 @@
---
name: missingness_correlation
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def missingness_correlation(null_mask: dict, top_k: int = 20) -> dict"
description: "Co-ocurrencia de ausencias: nucleo del capitulo de missingness del grupo eda. Recibe la mascara binaria de nulos de una tabla (1 = falta, 0 = presente, alineada por fila) y mide hasta que punto las columnas faltan juntas. Calcula la matriz de correlacion de Pearson entre los vectores binarios de ausencia de las columnas con varianza (al menos un 1 y un 0), mas las cifras de solapamiento de conjuntos por par (co-missing, either-missing, Jaccard). Excluye las columnas constantes en su ausencia (correlacion indefinida) y reporta cuantas. Compone la funcion atomica pearson del registry; no la reimplementa. Lectura defensiva; NUNCA lanza."
tags: [eda, missingness, correlation, pearson, co-occurrence, jaccard, datascience]
params:
- name: null_mask
desc: "dict {col: [int 0/1, ...]} con la mascara de ausencias de la tabla, alineada por fila: 1 = el valor falta en esa fila, 0 = presente. Todas las listas se asumen de la misma longitud (numero de filas). Valores truthy distintos de 0 se tratan como ausencia; entradas no-lista se ignoran sin romper."
- name: top_k
desc: "Numero maximo de pares a devolver en `pairs`, ordenados por valor absoluto de correlacion descendente. Default 20. Solo limita la lista de pares; la matriz cubre siempre todas las columnas con varianza."
output: "dict con: columns (columnas con varianza en la ausencia, en orden de entrada); matrix (len(columns) x len(columns) de correlacion de Pearson entre las mascaras binarias, diagonal 1.0); pairs (hasta top_k pares i<j ordenados por |corr| desc, cada uno {a, b, corr, co_missing, either_missing, jaccard} donde co_missing = filas en que ambas faltan, either_missing = filas en que al menos una falta, jaccard = co_missing/either_missing o 0.0 si either_missing=0); n_excluded (nº de columnas con algun nulo pero sin varianza, constantes en la ausencia); excluded_cols (esas columnas en orden de entrada). Si hay <2 columnas con varianza, columns/matrix/pairs van vacios pero n_excluded/excluded_cols se rellenan. NUNCA lanza."
uses_functions: [pearson_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_co_ocurrencia_fuerte_corr_uno_jaccard_uno", "test_ausencias_disjuntas_corr_negativa_jaccard_cero", "test_columna_sin_varianza_se_excluye", "test_menos_de_dos_columnas_con_varianza_vacio_pero_cuenta_excluidas", "test_mask_vacio_todo_vacio", "test_top_k_limita_pares", "test_no_lanza_con_entradas_raras"]
test_file_path: "python/functions/datascience/missingness_correlation_test.py"
file_path: "python/functions/datascience/missingness_correlation.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.missingness_correlation import missingness_correlation
# Mascara de ausencias de 6 filas. 1 = falta, 0 = presente.
mask = {
"ingresos": [1, 0, 1, 0, 1, 0], # falta junto a "deducciones"
"deducciones": [1, 0, 1, 0, 1, 0], # mismas filas que "ingresos"
"telefono": [0, 0, 0, 1, 0, 0], # casi siempre presente
"verificado": [1, 1, 1, 1, 1, 1], # siempre ausente -> constante, excluida
}
out = missingness_correlation(mask, top_k=10)
print(out["columns"]) # ['ingresos', 'deducciones', 'telefono']
print(out["n_excluded"]) # 1
print(out["excluded_cols"]) # ['verificado']
# El par mas fuerte: ingresos y deducciones faltan siempre juntas.
top = out["pairs"][0]
print(top["a"], top["b"], round(top["corr"], 3)) # ingresos deducciones 1.0
print(top["co_missing"], top["either_missing"], top["jaccard"]) # 3 3 1.0
```
## Cuando usarla
- Usala en el capitulo de **missingness** de `AutomaticEDA` cuando ya tengas la mascara binaria de nulos por columna y quieras detectar **patrones de ausencia conjunta**: que columnas faltan siempre juntas (posible misma fuente/proceso roto) y cuales faltan de forma independiente.
- Cuando necesites ordenar los pares de columnas por fuerza de co-ocurrencia (|corr|) para priorizar que bloques de ausencia investigar o imputar juntos.
- Cuando quieras la cifra de solapamiento de conjuntos (Jaccard, co-missing) ademas de la correlacion lineal, para distinguir "faltan juntas" de "estan presentes juntas".
- Antes de elegir una estrategia de imputacion: dos columnas con corr de ausencia ~1.0 no aportan informacion independiente sobre por que falta la otra.
## Gotchas
- Funcion pura, sin I/O y determinista. Lectura defensiva: entradas no-dict, columnas no-lista o vacias se ignoran sin lanzar.
- Solo entran al calculo las columnas con **varianza en la ausencia** (al menos un 1 y al menos un 0). Una columna siempre-presente (todo 0) no aporta ausencia y **no** se cuenta como excluida; una columna siempre-ausente o constante con nulos (todo 1) tiene correlacion indefinida y se excluye, sumando a `n_excluded` / `excluded_cols`.
- Con menos de 2 columnas con varianza, `columns`/`matrix`/`pairs` quedan vacios pero `n_excluded`/`excluded_cols` se rellenan igual — el caller debe contemplar el caso "sin pares".
- La correlacion es la de Pearson sobre vectores binarios (equivale al coeficiente phi). El signo importa: corr negativa = las ausencias tienden a ser **complementarias** (cuando una falta, la otra suele estar presente).
- Asume todas las listas alineadas por fila y de la misma longitud. Si vienen de longitudes distintas, `pearson` opera sobre el solapamiento que permita `zip` y degrada a 0.0 cuando no hay varianza efectiva; alinea la mascara antes de llamar.
@@ -0,0 +1,120 @@
"""Co-ocurrencia de ausencias: matriz de correlacion de Pearson entre mascaras de nulos.
Funcion pura del grupo eda, nucleo del capitulo de missingness. Recibe la mascara
binaria de ausencias de una tabla (1 = falta, 0 = presente, alineada por fila) y
mide hasta que punto las columnas faltan juntas. Para cada par de columnas con
varianza en su ausencia calcula la correlacion de Pearson entre los vectores
binarios, mas las cifras de solapamiento de conjuntos (co-missing, either-missing,
Jaccard). Compone la funcion atomica `pearson` del registry; no reimplementa la
correlacion. Lectura defensiva; NUNCA lanza.
"""
from datascience import pearson
def missingness_correlation(null_mask, top_k=20) -> dict:
"""Correlacion de co-ocurrencia de ausencias entre columnas.
Args:
null_mask: dict {col: [int 0/1, ...]} alineado por fila (1 = el valor
falta en esa fila). Todas las listas se asumen de la misma longitud.
top_k: numero maximo de pares a devolver, ordenados por |corr| desc.
Returns:
dict con:
- columns: columnas con varianza en la ausencia (al menos un 1 y al
menos un 0), en orden de entrada.
- matrix: matriz len(columns) x len(columns) de correlacion de Pearson
entre las mascaras binarias, diagonal 1.0.
- pairs: lista de hasta top_k pares (i<j) ordenados por |corr| desc.
Cada par: {a, b, corr, co_missing, either_missing, jaccard}.
- n_excluded: numero de columnas con algun nulo pero sin varianza
(constantes en la ausencia: siempre presentes o siempre ausentes).
- excluded_cols: lista de esas columnas (en orden de entrada).
Si hay menos de 2 columnas con varianza, columns/matrix/pairs van vacios
pero n_excluded/excluded_cols se rellenan igualmente. NUNCA lanza.
"""
# Salida base, defensiva ante entradas no-dict.
result = {
"columns": [],
"matrix": [],
"pairs": [],
"n_excluded": 0,
"excluded_cols": [],
}
if not isinstance(null_mask, dict) or not null_mask:
return result
varying = [] # columnas con varianza en la ausencia
varying_vecs = [] # sus vectores binarios saneados (floats 0.0/1.0)
excluded_cols = [] # columnas con nulos pero sin varianza (constantes)
for col, raw in null_mask.items():
if not isinstance(raw, (list, tuple)):
continue
# Sanea a 0/1: cualquier valor truthy distinto de 0 cuenta como ausencia.
vec = [1 if bool(v) else 0 for v in raw]
if not vec:
continue
ones = sum(vec)
zeros = len(vec) - ones
if ones > 0 and zeros > 0:
varying.append(col)
varying_vecs.append([float(v) for v in vec])
elif ones > 0:
# Tiene nulos pero todos (constante en la ausencia): sin varianza.
excluded_cols.append(col)
# ones == 0 -> columna siempre presente, sin nulos: no se cuenta como
# excluida (no aporta ausencia al analisis de co-ocurrencia).
result["n_excluded"] = len(excluded_cols)
result["excluded_cols"] = excluded_cols
n = len(varying)
if n < 2:
return result
result["columns"] = list(varying)
# Matriz de correlacion de Pearson, diagonal 1.0.
matrix = [[0.0] * n for _ in range(n)]
for i in range(n):
matrix[i][i] = 1.0
for i in range(n):
for j in range(i + 1, n):
r = pearson(varying_vecs[i], varying_vecs[j])
matrix[i][j] = r
matrix[j][i] = r
result["matrix"] = matrix
# Pares con cifras de solapamiento de conjuntos.
pairs = []
for i in range(n):
vi = varying_vecs[i]
for j in range(i + 1, n):
vj = varying_vecs[j]
co_missing = 0
either_missing = 0
for a, b in zip(vi, vj):
a_miss = a != 0.0
b_miss = b != 0.0
if a_miss and b_miss:
co_missing += 1
if a_miss or b_miss:
either_missing += 1
jaccard = co_missing / either_missing if either_missing > 0 else 0.0
pairs.append({
"a": varying[i],
"b": varying[j],
"corr": matrix[i][j],
"co_missing": co_missing,
"either_missing": either_missing,
"jaccard": jaccard,
})
pairs.sort(key=lambda p: abs(p["corr"]), reverse=True)
result["pairs"] = pairs[:top_k] if top_k is not None and top_k >= 0 else pairs
return result
@@ -0,0 +1,115 @@
"""Tests para missingness_correlation."""
from datascience.missingness_correlation import missingness_correlation
def test_co_ocurrencia_fuerte_corr_uno_jaccard_uno():
# a y b faltan EXACTAMENTE en las mismas filas -> corr 1.0, jaccard 1.0.
mask = {
"a": [1, 0, 1, 0, 1, 0],
"b": [1, 0, 1, 0, 1, 0],
}
out = missingness_correlation(mask)
assert out["columns"] == ["a", "b"]
assert out["n_excluded"] == 0
# Diagonal 1.0, off-diagonal ~1.0.
assert out["matrix"][0][0] == 1.0
assert out["matrix"][1][1] == 1.0
assert abs(out["matrix"][0][1] - 1.0) < 1e-9
assert len(out["pairs"]) == 1
pair = out["pairs"][0]
assert {pair["a"], pair["b"]} == {"a", "b"}
assert abs(pair["corr"] - 1.0) < 1e-9
assert pair["co_missing"] == 3 # filas 0,2,4
assert pair["either_missing"] == 3 # mismas filas
assert abs(pair["jaccard"] - 1.0) < 1e-9
def test_ausencias_disjuntas_corr_negativa_jaccard_cero():
# a y b nunca faltan en la misma fila -> co_missing 0, jaccard 0, corr <= 0.
mask = {
"a": [1, 1, 0, 0],
"b": [0, 0, 1, 1],
}
out = missingness_correlation(mask)
assert out["columns"] == ["a", "b"]
pair = out["pairs"][0]
assert pair["co_missing"] == 0
assert pair["either_missing"] == 4
assert pair["jaccard"] == 0.0
# Solapamiento nulo + ausencias complementarias -> correlacion negativa.
assert pair["corr"] < 0.0
assert abs(pair["corr"] - out["matrix"][0][1]) < 1e-12
def test_columna_sin_varianza_se_excluye():
# c esta siempre presente (todo 0): no aporta ausencia -> no entra ni como
# excluida. d esta siempre ausente (todo 1): tiene nulos pero sin varianza
# -> excluida y n_excluded incrementa. a y b tienen varianza.
mask = {
"a": [1, 0, 1, 0],
"b": [1, 0, 0, 0],
"c": [0, 0, 0, 0], # siempre presente
"d": [1, 1, 1, 1], # siempre ausente, constante
}
out = missingness_correlation(mask)
assert out["columns"] == ["a", "b"]
assert "d" in out["excluded_cols"]
assert "c" not in out["excluded_cols"]
assert out["n_excluded"] == 1
# Matriz solo de las columnas con varianza.
assert len(out["matrix"]) == 2
assert len(out["matrix"][0]) == 2
def test_menos_de_dos_columnas_con_varianza_vacio_pero_cuenta_excluidas():
# Solo una columna con varianza (a) + una constante-ausente (d).
mask = {
"a": [1, 0, 1, 0],
"d": [1, 1, 1, 1],
}
out = missingness_correlation(mask)
assert out["columns"] == []
assert out["matrix"] == []
assert out["pairs"] == []
assert out["n_excluded"] == 1
assert out["excluded_cols"] == ["d"]
def test_mask_vacio_todo_vacio():
out = missingness_correlation({})
assert out == {
"columns": [],
"matrix": [],
"pairs": [],
"n_excluded": 0,
"excluded_cols": [],
}
def test_top_k_limita_pares():
# 4 columnas con varianza -> 6 pares; top_k=2 deja 2.
mask = {
"a": [1, 0, 1, 0, 0],
"b": [1, 0, 0, 1, 0],
"c": [0, 1, 1, 0, 1],
"d": [1, 1, 0, 0, 1],
}
out = missingness_correlation(mask, top_k=2)
assert len(out["columns"]) == 4
assert len(out["pairs"]) == 2
# Ordenados por |corr| desc.
assert abs(out["pairs"][0]["corr"]) >= abs(out["pairs"][1]["corr"])
def test_no_lanza_con_entradas_raras():
# Valores no-lista y no-dict no deben romper.
assert missingness_correlation(None)["columns"] == []
mask = {
"a": [1, 0, 1, 0],
"b": [1, 0, 1, 0],
"bad": "not a list",
"empty": [],
}
out = missingness_correlation(mask)
assert out["columns"] == ["a", "b"]
@@ -0,0 +1,99 @@
---
id: missingness_overview_py_datascience
name: missingness_overview
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def missingness_overview(null_mask) -> dict"
description: "Resumen de ausencias a nivel de dataset a partir de una máscara de nulos 0/1 por columna ({col: [1=falta, 0=presente]} alineada por fila). Calcula celdas y porcentaje de datos faltantes, cuántas columnas tienen algún nulo y cuántas filas son completas vs. incompletas. Estilo dict-no-throw del grupo eda: nunca lanza. Lectura defensiva — no-dict o dict vacío devuelve todo a 0; columnas no-lista se tratan como vacías; listas de longitud distinta se alinean a la longitud máxima rellenando la cola corta como presente (0); valores None/no-int cuentan como presente; sin ZeroDivisionError."
tags: [eda, missing, missingness, nulls, profiling, datascience, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience.missingness_overview import missingness_overview
mask = {
"a": [1, 0, 0, 0, 1],
"b": [1, 0, 1, 0, 0],
"c": [0, 0, 0, 0, 1],
}
missingness_overview(mask)
# n_missing_cells=5, missing_cell_pct≈33.33, complete_rows=2, incomplete_rows=3
tested: true
tests:
- "test_cooccurrence_three_cols_exact"
- "test_empty_dict_all_zero"
- "test_output_keys_contract"
- "test_not_a_dict_returns_zero"
- "test_no_nulls_all_complete"
- "test_none_values_treated_as_present"
- "test_unequal_lengths_pad_with_max"
- "test_columns_present_but_no_rows"
- "test_never_raises_on_garbage"
test_file_path: "python/functions/datascience/missingness_overview_test.py"
file_path: "python/functions/datascience/missingness_overview.py"
params:
- name: null_mask
desc: "Dict {col_name: [int 0/1, ...]} con la máscara de nulos por columna, alineada por fila (1 = el valor falta, 0 = el valor está presente). Normalmente todas las listas tienen la misma longitud = nº de filas. Lectura defensiva: si no es dict o está vacío se devuelve todo a 0; columnas cuyo valor no es lista/tupla se tratan como vacías; listas de longitud distinta se alinean a la longitud máxima (las posiciones inexistentes de las columnas más cortas cuentan como presentes, 0); valores None o no enteros cuentan como presentes."
output: "Dict con exactamente 9 claves, todas siempre presentes (la función nunca lanza): n_rows (longitud de fila = longitud máxima entre columnas, 0 si vacío), n_cols (nº de columnas), n_cols_with_null (columnas con >=1 falta), n_missing_cells (suma total de 1s), missing_cell_pct (0-100 = n_missing_cells / (n_rows*n_cols) * 100), complete_rows (filas sin ninguna falta), incomplete_rows (filas con >=1 falta), complete_pct (0-100), incomplete_pct (0-100). Los porcentajes son 0.0 cuando el denominador es 0 (sin ZeroDivisionError)."
---
## Ejemplo
```python
from datascience.missingness_overview import missingness_overview
# Máscara de nulos por columna: 1 = falta, 0 = presente, alineada por fila.
mask = {
"a": [1, 0, 0, 0, 1],
"b": [1, 0, 1, 0, 0],
"c": [0, 0, 0, 0, 1],
}
missingness_overview(mask)
# {
# "n_rows": 5,
# "n_cols": 3,
# "n_cols_with_null": 3, # a, b y c tienen al menos una falta
# "n_missing_cells": 5, # 2 (a) + 2 (b) + 1 (c)
# "missing_cell_pct": 33.33, # 5 / (5*3) * 100
# "complete_rows": 2, # filas 1 y 3 sin ninguna falta
# "incomplete_rows": 3, # filas 0 (a&b), 2 (b), 4 (a&c)
# "complete_pct": 40.0, # 2 / 5 * 100
# "incomplete_pct": 60.0, # 3 / 5 * 100
# }
missingness_overview({})
# Todo a 0: {"n_rows": 0, "n_cols": 0, "n_cols_with_null": 0,
# "n_missing_cells": 0, "missing_cell_pct": 0.0,
# "complete_rows": 0, "incomplete_rows": 0,
# "complete_pct": 0.0, "incomplete_pct": 0.0}
```
## Cuando usarla
Úsala al perfilar un dataset cuando ya tienes una máscara de nulos 0/1 por
columna (p. ej. derivada del paso de carga/perfilado del EDA) y quieres la foto
global de ausencias en una llamada: cuánta proporción de celdas falta, cuántas
columnas están afectadas y, sobre todo, cuántas filas quedan completas vs.
incompletas. Es el bloque resumen del capítulo de calidad/missingness de un EDA,
y la base para decidir estrategias de imputación o de borrado de filas. Como es
pura y dict-no-throw, puedes alimentarla con la máscara tal cual sin validarla
antes: entradas malformadas degradan a ceros en vez de romper el pipeline.
## Gotchas
- **`n_rows` es la longitud máxima entre columnas.** Con listas de longitud
desigual, las posiciones que faltan en las columnas más cortas se cuentan como
presentes (`0`); no se descartan filas. En el caso normal (todas las listas de
igual longitud) `n_rows` es simplemente esa longitud.
- **Solo el valor exacto `1` cuenta como falta.** `None`, `0`, cadenas y
cualquier otro valor se tratan como presentes. `True` (== 1) también cuenta
como falta por la igualdad.
- **Porcentajes en escala 0-100**, no fracciones. División por cero protegida:
con `n_rows*n_cols == 0` los porcentajes salen `0.0`.
@@ -0,0 +1,116 @@
"""Pure EDA helper: dataset-level missingness overview from a 0/1 null mask.
Part of the `eda` capability group. Consumes a per-column null mask
(``{col_name: [int 0/1, ...]}`` aligned by row, ``1`` = value is missing,
``0`` = value is present) and derives dataset-wide missingness metrics: cell
count and percentage of missing data, how many columns carry any null, and how
many rows are complete vs. incomplete.
Dict-no-throw style of the `eda` group: it NEVER raises. A non-dict, an empty
dict, malformed columns, ragged lists or non-int cell values all degrade
gracefully to the zero/contract output. Stdlib only.
Ragged-length policy: columns are allowed to have different lengths. ``n_rows``
is the **maximum** column length; positions that don't exist in a shorter
column are treated as present (``0``). This keeps the ``n_rows * n_cols`` cell
grid well defined without dropping rows.
"""
def _is_missing(value) -> int:
"""Return ``1`` iff ``value`` denotes a missing cell, else ``0``.
Only an exact equality to ``1`` (covers ``int`` ``1`` and ``float`` ``1.0``)
counts as missing. ``None``, ``0``, strings and any other value are treated
as present. The comparison cannot raise for standard inputs.
"""
try:
return 1 if value == 1 else 0
except Exception:
return 0
def missingness_overview(null_mask) -> dict:
"""Summarize dataset-level missingness from a 0/1 null mask.
Args:
null_mask: Dict ``{col_name: [int 0/1, ...]}`` where each list is aligned
by row (``1`` = missing, ``0`` = present). Lists are normally all the
same length (= number of rows). Defensive: a non-dict or empty dict
returns the all-zero contract; non-list columns are treated as empty;
ragged lists are aligned to the maximum length, padding the missing
tail of shorter columns as present (``0``); ``None`` / non-int cells
count as present.
Returns:
Dict with exactly these keys, all always present (the function never
raises): ``n_rows``, ``n_cols``, ``n_cols_with_null``,
``n_missing_cells``, ``missing_cell_pct`` (0-100), ``complete_rows``,
``incomplete_rows``, ``complete_pct`` (0-100), ``incomplete_pct``
(0-100). Percentages are ``0.0`` when the denominator is zero (no
``ZeroDivisionError``).
"""
zero = {
"n_rows": 0,
"n_cols": 0,
"n_cols_with_null": 0,
"n_missing_cells": 0,
"missing_cell_pct": 0.0,
"complete_rows": 0,
"incomplete_rows": 0,
"complete_pct": 0.0,
"incomplete_pct": 0.0,
}
if not isinstance(null_mask, dict) or not null_mask:
return dict(zero)
# Normalize every column to a list; non-list columns become empty.
cols = {}
for name, seq in null_mask.items():
cols[name] = seq if isinstance(seq, (list, tuple)) else []
n_cols = len(cols)
lengths = [len(seq) for seq in cols.values()]
n_rows = max(lengths) if lengths else 0
if n_rows == 0:
# Columns exist but carry no rows: everything zero except n_cols.
out = dict(zero)
out["n_cols"] = n_cols
return out
n_missing_cells = 0
n_cols_with_null = 0
row_has_missing = [False] * n_rows
for seq in cols.values():
col_len = len(seq)
col_has_null = False
for r in range(n_rows):
if r < col_len and _is_missing(seq[r]):
n_missing_cells += 1
row_has_missing[r] = True
col_has_null = True
if col_has_null:
n_cols_with_null += 1
incomplete_rows = sum(1 for flag in row_has_missing if flag)
complete_rows = n_rows - incomplete_rows
total_cells = n_rows * n_cols
missing_cell_pct = (n_missing_cells / total_cells * 100.0) if total_cells else 0.0
complete_pct = complete_rows / n_rows * 100.0
incomplete_pct = incomplete_rows / n_rows * 100.0
return {
"n_rows": n_rows,
"n_cols": n_cols,
"n_cols_with_null": n_cols_with_null,
"n_missing_cells": n_missing_cells,
"missing_cell_pct": missing_cell_pct,
"complete_rows": complete_rows,
"incomplete_rows": incomplete_rows,
"complete_pct": complete_pct,
"incomplete_pct": incomplete_pct,
}
@@ -0,0 +1,146 @@
"""Tests para missingness_overview."""
import sys
import os
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from missingness_overview import missingness_overview
# Output contract: every call returns exactly these 9 keys.
EXPECTED_KEYS = {
"n_rows",
"n_cols",
"n_cols_with_null",
"n_missing_cells",
"missing_cell_pct",
"complete_rows",
"incomplete_rows",
"complete_pct",
"incomplete_pct",
}
def test_cooccurrence_three_cols_exact():
# 3 columns, 5 rows. Hand-computed expectations:
# col a missing at rows 0, 4 -> 2
# col b missing at rows 0, 2 -> 2
# col c missing at row 4 -> 1
# n_missing_cells = 5, total_cells = 5*3 = 15 -> 33.333...%
# row 0 (a&b co-occur) -> incomplete
# row 1 (all present) -> complete
# row 2 (b only) -> incomplete
# row 3 (all present) -> complete
# row 4 (a&c co-occur) -> incomplete
mask = {
"a": [1, 0, 0, 0, 1],
"b": [1, 0, 1, 0, 0],
"c": [0, 0, 0, 0, 1],
}
out = missingness_overview(mask)
assert out["n_rows"] == 5
assert out["n_cols"] == 3
assert out["n_cols_with_null"] == 3
assert out["n_missing_cells"] == 5
assert out["missing_cell_pct"] == pytest.approx(33.33333333, abs=1e-6)
assert out["complete_rows"] == 2
assert out["incomplete_rows"] == 3
assert out["complete_pct"] == pytest.approx(40.0)
assert out["incomplete_pct"] == pytest.approx(60.0)
def test_empty_dict_all_zero():
out = missingness_overview({})
assert out == {
"n_rows": 0,
"n_cols": 0,
"n_cols_with_null": 0,
"n_missing_cells": 0,
"missing_cell_pct": 0.0,
"complete_rows": 0,
"incomplete_rows": 0,
"complete_pct": 0.0,
"incomplete_pct": 0.0,
}
def test_output_keys_contract():
# The 9-key contract holds even for the garbage/zero path.
assert set(missingness_overview({}).keys()) == EXPECTED_KEYS
assert set(missingness_overview({"a": [1, 0]}).keys()) == EXPECTED_KEYS
def test_not_a_dict_returns_zero():
for bad in (None, [1, 0, 1], 42, "nope", 3.14):
out = missingness_overview(bad)
assert out["n_rows"] == 0
assert out["n_cols"] == 0
assert out["n_missing_cells"] == 0
assert out["missing_cell_pct"] == 0.0
def test_no_nulls_all_complete():
mask = {"a": [0, 0, 0], "b": [0, 0, 0]}
out = missingness_overview(mask)
assert out["n_rows"] == 3
assert out["n_cols"] == 2
assert out["n_cols_with_null"] == 0
assert out["n_missing_cells"] == 0
assert out["missing_cell_pct"] == 0.0
assert out["complete_rows"] == 3
assert out["incomplete_rows"] == 0
assert out["complete_pct"] == pytest.approx(100.0)
assert out["incomplete_pct"] == pytest.approx(0.0)
def test_none_values_treated_as_present():
# None and other non-1 values count as present (0).
mask = {"a": [None, 1, None, "x", 0]}
out = missingness_overview(mask)
assert out["n_rows"] == 5
assert out["n_cols"] == 1
assert out["n_missing_cells"] == 1 # only the explicit 1 at row 1
assert out["n_cols_with_null"] == 1
assert out["complete_rows"] == 4
assert out["incomplete_rows"] == 1
def test_unequal_lengths_pad_with_max():
# Ragged lists: n_rows = max length; shorter column padded as present.
# a = [1, 1] -> missing at rows 0, 1
# b = [0] -> row 1 padded to present
# n_rows = 2, n_cols = 2, total_cells = 4, n_missing_cells = 2 -> 50%
mask = {"a": [1, 1], "b": [0]}
out = missingness_overview(mask)
assert out["n_rows"] == 2
assert out["n_cols"] == 2
assert out["n_cols_with_null"] == 1
assert out["n_missing_cells"] == 2
assert out["missing_cell_pct"] == pytest.approx(50.0)
assert out["complete_rows"] == 0
assert out["incomplete_rows"] == 2
assert out["incomplete_pct"] == pytest.approx(100.0)
def test_columns_present_but_no_rows():
# Columns exist but all empty -> zero metrics, n_cols preserved.
out = missingness_overview({"a": [], "b": []})
assert out["n_rows"] == 0
assert out["n_cols"] == 2
assert out["n_missing_cells"] == 0
assert out["missing_cell_pct"] == 0.0
assert out["complete_pct"] == 0.0
def test_never_raises_on_garbage():
# Non-list column values, mixed junk -> must not raise.
mask = {"a": "not a list", "b": 123, "c": [1, 0, 1]}
out = missingness_overview(mask)
assert set(out.keys()) == EXPECTED_KEYS
assert out["n_rows"] == 3
assert out["n_cols"] == 3
assert out["n_missing_cells"] == 2 # only col c contributes
assert out["n_cols_with_null"] == 1
@@ -0,0 +1,93 @@
---
id: missingness_rank_bar_figure_py_datascience
name: missingness_rank_bar_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def missingness_rank_bar_figure(names, pcts, title=\"% de valores faltantes por columna\") -> \"matplotlib.figure.Figure\""
description: "Construye una figura matplotlib de barras horizontales que ordena las columnas de un dataset por su porcentaje de valores faltantes (0-100), la mayor arriba, etiquetando cada barra con su NN.N% al final. Usa ax.barh, eje X fijo 0-100 y labels truncados a ~22 chars. Devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA (capítulo de datos faltantes). Backend Agg sin pyplot global; defensivo ante listas vacías, longitudes desiguales o valores no numéricos (nunca lanza)."
tags: [eda, missing, missingness, ranking, bar, barh, matplotlib, figure, visualization, datascience, impure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib]
example: |
from datascience.missingness_rank_bar_figure import missingness_rank_bar_figure
names = ["edad", "ingresos", "ciudad", "email"]
pcts = [12.5, 40.0, 3.2, 0.0]
fig = missingness_rank_bar_figure(names, pcts, title="% de valores faltantes por columna")
tested: true
tests:
- "test_returns_figure_with_axes"
- "test_sorted_descending_largest_on_top"
- "test_empty_lists_do_not_raise_and_returns_figure"
- "test_xlim_is_zero_to_hundred"
- "test_length_mismatch_and_non_numeric_are_handled"
test_file_path: "python/functions/datascience/missingness_rank_bar_figure_test.py"
file_path: "python/functions/datascience/missingness_rank_bar_figure.py"
params:
- name: names
desc: "Lista de nombres de columna. Puede venir vacía (devuelve figura \"sin datos faltantes\"). Los items se convierten a str y se truncan a ~22 chars con elipsis para las etiquetas del eje Y; los originales no se mutan."
- name: pcts
desc: "Lista paralela a names con el % de nulos en [0,100]. Valores None, NaN o no numéricos se coercen a 0.0 y los negativos se recortan a 0. Si len(names) != len(pcts) se recorta al menor de ambos para no romper."
- name: title
desc: "Título de la figura. Se trunca a ~60 chars con elipsis si es muy largo. Default \"% de valores faltantes por columna\"."
output: "Un matplotlib.figure.Figure (figsize 6.4 x alto adaptativo según nº de barras, dpi 150) con un Axes de barras horizontales (ax.barh) ordenadas por % descendente, la mayor arriba. Eje X fijado a [0,100] con label \"% faltante\", etiquetas del eje Y truncadas a ~22 chars, y cada barra anotada con su NN.N% al final. Si names o pcts vienen vacíos devuelve una Figure con texto centrado \"sin datos faltantes\"; cualquier error inesperado se captura y devuelve una Figure con el mensaje de error (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
---
## Ejemplo
```python
from datascience.missingness_rank_bar_figure import missingness_rank_bar_figure
# % de nulos por columna (p. ej. (df.isnull().mean() * 100).
names = ["edad", "ingresos", "ciudad", "email"]
pcts = [12.5, 40.0, 3.2, 0.0]
fig = missingness_rank_bar_figure(
names,
pcts,
title="% de valores faltantes por columna",
)
# ingresos (40.0%) queda arriba; email (0.0%) abajo.
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
fig.savefig("/tmp/missingness_rank.png")
```
## Cuando usarla
Úsala al abrir el capítulo de datos faltantes de un informe EDA para responder
"¿qué columnas están más incompletas?" de un vistazo. Pásale los nombres de
columna y el % de nulos de cada una (`(df.isnull().mean() * 100).round(1)`); la
función se encarga de ordenar de mayor a menor y poner la peor arriba. Es la
pareja "magnitud" del heatmap de co-ocurrencia: las barras dicen *cuánto* falta
en cada columna, el heatmap dice *si esas ausencias están relacionadas* entre
columnas.
## Gotchas
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
directamente, así que es segura de llamar en bucle desde el renderer.
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
guarda. Quien la consume debe rasterizarla y luego liberarla
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
- **Espera porcentajes 0-100, no fracciones 0-1.** El eje X está fijado a
`[0, 100]`. Si pasas fracciones (`0.4` en vez de `40.0`) las barras saldrán
pegadas al origen. Multiplica por 100 antes de llamar.
- **Alto adaptativo.** La altura de la figura crece con el número de barras
(hasta un tope) para que reports con muchas columnas sigan legibles; aun así,
conviene filtrar a las columnas con algún nulo antes de llamar para no listar
decenas de barras a 0%.
- **Defensiva, nunca lanza.** Listas vacías, longitudes desiguales, valores
`None`/`NaN`/no numéricos o cualquier error inesperado se manejan sin propagar:
en el peor caso devuelve una `Figure` con "sin datos faltantes" o con el texto
del error. No envuelvas la llamada en try/except por miedo a un raise — no lo
hay.
@@ -0,0 +1,150 @@
"""Impure EDA helper: ranked bar figure of missing-value share (`eda` group).
Builds a horizontal bar chart ranking the columns of a dataset by their
percentage of missing values (0-100), largest at the top, each bar labelled with
its ``NN.N%`` at the end. Returns a ready-to-rasterize
``matplotlib.figure.Figure``; it never shows nor saves it.
Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer.
"""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure # noqa: E402
# Muted gray for secondary text (no-data / fallback messages).
_MUTED_TEXT = "#5f6b7a"
# Soft red for the error fallback message.
_ERROR_TEXT = "#b00020"
# Bar fill — a calm blue that reads well on white at report size.
_BAR_COLOR = "#4C72B0"
def _truncate(text, width: int = 22) -> str:
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
s = "" if text is None else str(text)
if len(s) <= width:
return s
if width <= 1:
return s[:width]
return s[: width - 1] + ""
def _message_figure(message: str, color: str = _MUTED_TEXT) -> "Figure":
"""Return a fallback ``Figure`` carrying a single centered message."""
fig = Figure(figsize=(6.4, 4.0), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=12,
color=color,
wrap=True,
transform=ax.transAxes,
)
fig.tight_layout()
return fig
def missingness_rank_bar_figure(
names,
pcts,
title: str = "% de valores faltantes por columna",
) -> "matplotlib.figure.Figure":
"""Build a horizontal ranked bar figure of missing-value share per column.
Pairs each column name with its missing percentage, sorts by percentage
descending and draws horizontal bars with the largest at the top. The X axis
is pinned to ``[0, 100]`` so bars are comparable across reports, each bar is
annotated with its ``NN.N%`` at the end, and the Y tick labels are truncated
to ~22 chars.
The function is fully defensive: empty/mismatched/non-numeric input never
raises. When there is nothing valid to draw it returns a ``Figure`` carrying
a centered "sin datos faltantes" message, and any unexpected error is caught
and turned into a fallback ``Figure`` carrying the error text.
Args:
names: List of column names. May be empty. Items are stringified and
truncated for display; the originals are not mutated.
pcts: List parallel to ``names`` of missing-value percentages in
``[0, 100]``. Non-numeric/``None`` values are coerced to ``0.0`` and
negatives are clamped to ``0``. The list is truncated to
``min(len(names), len(pcts))`` so a length mismatch never crashes.
title: Figure title. Default "% de valores faltantes por columna".
Returns:
A ``matplotlib.figure.Figure`` with a single horizontal-bar Axes. The
caller is responsible for rasterizing/closing it.
"""
try:
if (
not isinstance(names, (list, tuple))
or not isinstance(pcts, (list, tuple))
or len(names) == 0
or len(pcts) == 0
):
return _message_figure("sin datos faltantes")
# --- Pair names with coerced percentages, tolerating length mismatch.
pairs = []
for name, pct in zip(names, pcts):
try:
val = float(pct)
except (TypeError, ValueError):
val = 0.0
if val != val: # NaN guard.
val = 0.0
val = max(0.0, val)
pairs.append((name, val))
if not pairs:
return _message_figure("sin datos faltantes")
# Sort by percentage descending; barh draws bottom-up, so the largest
# ends at the top when we reverse the order before plotting.
pairs.sort(key=lambda p: p[1], reverse=True)
ordered = list(reversed(pairs)) # smallest first -> largest on top.
labels = [_truncate(name, 22) for name, _ in ordered]
values = [val for _, val in ordered]
y_pos = range(len(ordered))
# Height scales with the number of bars so dense reports stay readable.
height = max(2.4, min(0.4 * len(ordered) + 1.2, 14.0))
fig = Figure(figsize=(6.4, height), dpi=150)
ax = fig.add_subplot(111)
ax.barh(list(y_pos), values, color=_BAR_COLOR, edgecolor="white")
ax.set_yticks(list(y_pos))
ax.set_yticklabels(labels, fontsize=8)
ax.set_xlim(0, 100)
ax.set_xlabel("% faltante", fontsize=9)
# Annotate each bar with its percentage at the end of the bar.
for y, val in zip(y_pos, values):
ax.text(
min(val + 1.5, 99.0),
y,
f"{val:.1f}%",
va="center",
ha="left" if val < 90 else "right",
fontsize=7,
color="#202020",
)
if title:
ax.set_title(_truncate(title, 60), fontsize=12, loc="left", pad=10)
fig.tight_layout()
return fig
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
return _message_figure(f"error al dibujar barras: {exc}", color=_ERROR_TEXT)
@@ -0,0 +1,64 @@
"""Tests para missingness_rank_bar_figure (barras de % faltante, grupo eda).
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
estado entre tests.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from missingness_rank_bar_figure import missingness_rank_bar_figure
def test_returns_figure_with_axes():
names = ["edad", "ingresos", "ciudad"]
pcts = [12.5, 40.0, 3.2]
fig = missingness_rank_bar_figure(names, pcts, title="faltantes")
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_sorted_descending_largest_on_top():
names = ["a", "b", "c"]
pcts = [10.0, 50.0, 25.0]
fig = missingness_rank_bar_figure(names, pcts)
ax = fig.axes[0]
# barh dibuja de abajo arriba; la mayor (50, "b") debe quedar arriba (mayor y).
bars = ax.patches
# El último parche (mayor índice y) corresponde a la barra superior.
widths = [b.get_width() for b in bars]
assert max(widths) == 50.0
# La barra con la mayor anchura es la de mayor coordenada y (arriba).
top_bar = max(bars, key=lambda b: b.get_y())
assert top_bar.get_width() == 50.0
plt.close(fig)
def test_empty_lists_do_not_raise_and_returns_figure():
fig = missingness_rank_bar_figure([], [], title="vacía")
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_xlim_is_zero_to_hundred():
fig = missingness_rank_bar_figure(["a"], [42.0])
ax = fig.axes[0]
assert ax.get_xlim() == (0.0, 100.0)
plt.close(fig)
def test_length_mismatch_and_non_numeric_are_handled():
# Más names que pcts + un pct None -> zip recorta y None se coacciona a 0.
names = ["a", "b", "c"]
pcts = [None, 30.0]
fig = missingness_rank_bar_figure(names, pcts)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
@@ -0,0 +1,65 @@
---
name: missingness_row_patterns
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def missingness_row_patterns(null_mask, top_n=10) -> dict"
description: "Agrupa las filas de un dataset por su patron de ausencias (estilo matriz de missingno): para cada fila, el patron es la tupla ORDENADA de columnas que faltan en esa fila (las que tienen 1 en el null_mask). Cuenta la frecuencia de cada patron distinto, incluido el patron vacio (fila completa). Devuelve el top_n por frecuencia con su pct sobre el total. Pura, lectura defensiva, NUNCA lanza; {} -> n_rows 0."
tags: [eda, missingness, missingno, patterns, profiling, datascience, data-quality]
params:
- name: null_mask
desc: "Dict {col: [0/1, ...]} alineado por fila, donde 1 = la celda falta en esa fila y 0 = presente. Todas las columnas deberian tener la misma longitud (una entrada por fila); si difieren, n_rows es la lista mas larga y las celdas fuera de rango cuentan como presentes. Las claves se ordenan por str(col) para canonizar el patron. {} (o no-dict) -> n_rows 0."
- name: top_n
desc: "Maximo de patrones devueltos en `patterns`, rankeados por n_rows desc (desempate: menos columnas primero, luego nombres de columna). El recuento total de patrones distintos siempre se reporta en `n_patterns`, no se trunca. Default 10. Valores negativos -> 0; no-int -> 10."
output: "Dict {n_rows: int (filas totales), n_patterns: int (patrones distintos, incluye el patron vacio = fila completa), complete_rows: int (filas con patron vacio, nada falta), patterns: lista del top_n ordenada por n_rows desc con [{missing_cols: [col,...] (vacio = fila completa), n_rows: int, pct: float 0-100 sobre n_rows total, redondeado a 2 decimales}]}. Para {} devuelve n_rows 0 y patterns []. NUNCA lanza."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_patron_dominante_completas_singleton", "test_mask_vacio", "test_top_n_trunca_pero_cuenta_todos"]
test_file_path: "python/functions/datascience/missingness_row_patterns_test.py"
file_path: "python/functions/datascience/missingness_row_patterns.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.missingness_row_patterns import missingness_row_patterns
# null_mask alineado por fila: 1 = la celda falta en esa fila.
null_mask = {
"A": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
"B": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
"C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
}
out = missingness_row_patterns(null_mask, top_n=10)
print(out["n_rows"], out["n_patterns"], out["complete_rows"]) # 10 3 5
for p in out["patterns"]:
label = p["missing_cols"] or "(fila completa)"
print(label, p["n_rows"], p["pct"])
# (fila completa) 5 50.0
# ['A', 'B'] 4 40.0
# ['C'] 1 10.0
```
## Cuando usarla
- Usala en el capitulo de calidad/ausencias de `AutomaticEDA` para mostrar la "matriz de patrones de missingno": en vez de pintar celda a celda, resume que combinaciones de columnas se quedan en blanco juntas y con que frecuencia.
- Cuando ya tengas el null_mask por columna (1=falta) y quieras detectar co-ausencia estructural ("A y B siempre faltan juntas") antes de decidir una imputacion o un drop conjunto de columnas.
- Cuando necesites una tabla compacta "patron -> nº filas -> pct" para un report o un grafico de barras de los patrones de ausencia mas comunes, separando ademas cuantas filas estan completas (`complete_rows`).
## Gotchas
- Funcion pura, sin I/O y determinista. Lectura defensiva: `{}` o un no-dict devuelven `n_rows` 0 con `patterns` []. NUNCA lanza.
- El patron vacio (fila completa, `missing_cols=[]`) SI cuenta como patron: aparece en `n_patterns` y puede aparecer en `patterns`. El consumidor lo etiqueta como "(fila completa)".
- `pct` es sobre `n_rows` total (0-100), redondeado a 2 decimales. La suma de los `pct` de TODOS los patrones es 100; si `top_n` trunca, los `pct` mostrados sumaran menos.
- Las columnas se ordenan por `str(col)` para canonizar cada patron, asi `{A,B}` y `{B,A}` colapsan al mismo patron `["A", "B"]`.
- Una celda cuenta como ausente solo si vale 1 (`int(cell) == 1`); 0, None y valores no numericos se tratan como presentes.
- Si las listas de columnas tienen longitudes distintas, `n_rows` es la mas larga y las posiciones fuera de rango de una columna corta cuentan como presentes (0).
@@ -0,0 +1,107 @@
"""missingness_row_patterns — distinct per-row missingness patterns (missingno matrix style).
Pure function: no I/O, deterministic, NEVER raises. Given a per-column null mask
aligned by row ({col: [0/1, ...]}, 1 = missing), it groups rows by their missing
"pattern" the sorted tuple of column names that are missing in that row and
counts how often each distinct pattern occurs.
This mirrors the missingno matrix idea: instead of plotting per-cell nullity, it
collapses each row to the SET of columns it lacks, surfacing co-missing structure
(e.g. "A and B always go missing together"). The empty pattern (a fully complete
row) is a first-class pattern and may appear in the result with missing_cols=[];
the caller labels it "(fila completa)".
"""
def _is_missing(cell) -> bool:
"""A cell counts as missing when it equals 1 (truthy 0/1 mask).
None / 0 / non-numeric are treated as present. Defensive: never raises.
"""
try:
return int(cell) == 1
except (TypeError, ValueError):
return bool(cell)
def missingness_row_patterns(null_mask, top_n=10) -> dict:
"""Count distinct per-row missingness patterns from a column null mask.
For each row, its pattern is the sorted tuple of column names missing in that
row (the columns whose value is 1). The frequency of each distinct pattern is
counted, including the empty pattern (a complete row with nothing missing).
Args:
null_mask: Dict {col: [0/1, ...]} aligned by row, where 1 means the cell
is missing in that row. Read defensively; columns with differing
lengths are tolerated (n_rows is the longest list; out-of-range cells
count as present). Empty dict -> n_rows 0.
top_n: Maximum number of patterns returned in `patterns`, ranked by
n_rows desc (tiebreak: fewer columns first, then column names). The
full count of distinct patterns is always reported in `n_patterns`.
Returns:
Dict:
{
"n_rows": int, # total rows
"n_patterns": int, # distinct patterns (incl. the empty pattern)
"complete_rows": int, # rows with the empty pattern (nothing missing)
"patterns": [ # top_n patterns, n_rows desc
{"missing_cols": [col, ...], "n_rows": int, "pct": float} # [] = complete row
],
}
For {} (or a non-dict) returns n_rows 0 and patterns []. NEVER raises.
"""
empty = {"n_rows": 0, "n_patterns": 0, "complete_rows": 0, "patterns": []}
if not isinstance(null_mask, dict) or not null_mask:
return empty
# Stable, canonical column order so each row's pattern tuple is sorted.
items = sorted(null_mask.items(), key=lambda kv: str(kv[0]))
names = [str(k) for k, _ in items]
lists = [v if isinstance(v, (list, tuple)) else [] for _, v in items]
n_rows = max((len(lst) for lst in lists), default=0)
if n_rows == 0:
return empty
# Defensive parsing of top_n.
try:
limit = int(top_n)
except (TypeError, ValueError):
limit = 10
if limit < 0:
limit = 0
counts: dict = {}
n_cols = len(names)
for r in range(n_rows):
# names is sorted, so iterating in order yields an already-sorted tuple.
pattern = tuple(
names[c]
for c in range(n_cols)
if r < len(lists[c]) and _is_missing(lists[c][r])
)
counts[pattern] = counts.get(pattern, 0) + 1
complete_rows = counts.get((), 0)
n_patterns = len(counts)
# Rank: n_rows desc, then fewer columns first, then column names (deterministic).
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], len(kv[0]), kv[0]))
patterns = [
{
"missing_cols": list(pat),
"n_rows": cnt,
"pct": round(100.0 * cnt / n_rows, 2),
}
for pat, cnt in ordered[:limit]
]
return {
"n_rows": n_rows,
"n_patterns": n_patterns,
"complete_rows": complete_rows,
"patterns": patterns,
}
@@ -0,0 +1,87 @@
"""Tests para missingness_row_patterns."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from missingness_row_patterns import missingness_row_patterns
_EXPECTED_KEYS = {"n_rows", "n_patterns", "complete_rows", "patterns"}
def test_patron_dominante_completas_singleton():
"""Golden: {A,B} co-faltan en 4 filas + 5 filas completas + 1 singleton {C}."""
# 10 filas. A y B faltan juntas en las filas 0-3; filas 4-8 completas;
# la fila 9 solo le falta C.
null_mask = {
"A": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
"B": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
"C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
}
out = missingness_row_patterns(null_mask)
assert set(out.keys()) == _EXPECTED_KEYS
assert out["n_rows"] == 10
# 3 patrones distintos: (A,B), () y (C,).
assert out["n_patterns"] == 3
# 5 filas completas (filas 4-8).
assert out["complete_rows"] == 5
# Orden: n_rows desc; desempate menos columnas primero.
# () tiene 5 filas, (A,B) 4, (C,) 1.
pats = out["patterns"]
assert len(pats) == 3
assert pats[0]["missing_cols"] == []
assert pats[0]["n_rows"] == 5
assert pats[0]["pct"] == 50.0
assert pats[1]["missing_cols"] == ["A", "B"]
assert pats[1]["n_rows"] == 4
assert pats[1]["pct"] == 40.0
assert pats[2]["missing_cols"] == ["C"]
assert pats[2]["n_rows"] == 1
assert pats[2]["pct"] == 10.0
# Tipos de salida.
assert isinstance(out["n_rows"], int)
assert isinstance(pats[0]["pct"], float)
def test_mask_vacio():
"""{} -> n_rows 0, sin patrones, nunca lanza."""
out = missingness_row_patterns({})
assert out == {
"n_rows": 0,
"n_patterns": 0,
"complete_rows": 0,
"patterns": [],
}
# No dict / None tambien degradan a vacio sin lanzar.
assert missingness_row_patterns(None)["n_rows"] == 0
# Columnas presentes pero listas vacias -> n_rows 0.
assert missingness_row_patterns({"A": [], "B": []})["patterns"] == []
def test_top_n_trunca_pero_cuenta_todos():
"""top_n limita `patterns`, pero n_patterns reporta TODOS los distintos."""
null_mask = {
"A": [0, 1, 1, 0, 1],
"B": [0, 0, 0, 1, 1],
"C": [0, 0, 0, 0, 1],
}
# Filas: () (A,) (A,) (B,) (A,B,C)
out = missingness_row_patterns(null_mask, top_n=2)
assert out["n_rows"] == 5
assert out["n_patterns"] == 4 # (), (A,), (B,), (A,B,C)
assert out["complete_rows"] == 1
# Solo 2 patrones devueltos pese a haber 4.
assert len(out["patterns"]) == 2
# (A,) domina con 2 filas; desempate del 2o entre los de 1 fila -> () (0 cols).
assert out["patterns"][0]["missing_cols"] == ["A"]
assert out["patterns"][0]["n_rows"] == 2
assert out["patterns"][1]["missing_cols"] == []
assert out["patterns"][1]["n_rows"] == 1
@@ -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,122 @@
---
id: relationship_scatter_figure_py_datascience
name: relationship_scatter_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def relationship_scatter_figure(xs: list, ys: list, x_label: str = \"\", y_label: str = \"\", classification: dict = None, max_points: int = 2000) -> \"matplotlib.figure.Figure\""
description: "Construye una figura matplotlib scatter de un par de variables numéricas con su curva/recta de ajuste y una anotación del tipo de relación (lineal, polinómica grado 2/3, monótona no-lineal, etc.) más sus métricas (r, ρ, R²lin, R²poly). Consume el dict de classify_relationship_type; si es None lo calcula internamente reusando esa función. Devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA (PDF/PPTX). Backend Agg sin pyplot global; downsample determinista de los puntos dibujados; defensivo ante vacío/None."
tags: [eda, correlation, scatter, relationship, matplotlib, figure, visualization, datascience, impure]
uses_functions: [classify_relationship_type_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib, numpy]
example: |
from relationship_scatter_figure import relationship_scatter_figure
xs = [float(i) for i in range(100)]
ys = [0.5 * x * x - x + 3 for x in xs]
classification = {
"tipo": "polinómica (grado 2)", "pearson": 0.97, "spearman": 0.99,
"r2_linear": 0.92, "r2_poly2": 0.999, "r2_poly3": 0.999,
"best_degree": 2, "coeffs": [0.5, -1.0, 3.0],
}
fig = relationship_scatter_figure(xs, ys, x_label="dosis", y_label="efecto", classification=classification)
tested: true
tests:
- "test_returns_figure"
- "test_downsample_determinista"
- "test_empty_no_lanza"
- "test_classification_none"
test_file_path: "python/functions/datascience/relationship_scatter_figure_test.py"
file_path: "python/functions/datascience/relationship_scatter_figure.py"
params:
- name: xs
desc: "Lista (o tupla) de valores x. Se emparejan por índice con ys. Valores None, bool, NaN o inf descartan ese par (lectura defensiva)."
- name: ys
desc: "Lista (o tupla) de valores y, paralela a xs. Mismas reglas defensivas que xs."
- name: x_label
desc: "Etiqueta del eje/título para la variable x. Default \"\" (en el título cae a \"x\")."
- name: y_label
desc: "Etiqueta del eje/título para la variable y. Default \"\" (en el título cae a \"y\")."
- name: classification
desc: "Opcional. Dict de classify_relationship_type con claves tipo, pearson, r2_linear, spearman, r2_poly2, r2_poly3, best_degree, coeffs. Si es None se calcula internamente importando y llamando a classify_relationship_type sobre los pares limpios (self-contained). Si el módulo hermano no está disponible, se dibuja el scatter sin curva de ajuste ni anotación. Default None."
- name: max_points
desc: "Tope del nº de puntos DIBUJADOS. Si los pares limpios superan el tope, la nube se submuestrea por paso fijo ceil(n/max_points) tomando pairs[::step] — DETERMINISTA, no aleatorio, reproducible. La clasificación/ajuste usa SIEMPRE todos los pares limpios; el downsample solo adelgaza el dibujo. Valor no-positivo o no-int desactiva el downsample. Default 2000."
output: "Un matplotlib.figure.Figure (figsize 6.4x4.0, dpi 150) con un Axes scatter (puntos semitransparentes alpha 0.5, color #4C72B0), la curva/recta de ajuste (numpy.polyval sobre coeffs, color #C44E52) cuando hay un ajuste polinómico disponible, título \"{x_label} ↔ {y_label}\", labels de ejes y una caja de anotación en la esquina superior izquierda con el tipo de relación y las métricas disponibles (r, ρ, R²lin, R²poly; se omiten las None). Si tras la limpieza hay menos de 2 pares válidos, devuelve igualmente una Figure con un texto centrado \"Sin datos suficientes para el scatter\" (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
---
## Ejemplo
```python
from relationship_scatter_figure import relationship_scatter_figure
# Par numérico con relación cuadrática y su clasificación (de
# classify_relationship_type). Pasándola explícita evitas recomputarla.
xs = [float(i) for i in range(100)]
ys = [0.5 * x * x - x + 3 for x in xs]
classification = {
"tipo": "polinómica (grado 2)",
"pearson": 0.97,
"spearman": 0.99,
"r2_linear": 0.92,
"r2_poly2": 0.999,
"r2_poly3": 0.999,
"best_degree": 2,
"coeffs": [0.5, -1.0, 3.0],
}
fig = relationship_scatter_figure(
xs, ys, x_label="dosis", y_label="efecto", classification=classification
)
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
fig.savefig("/tmp/scatter_dosis_efecto.png")
# Con classification=None la función la calcula internamente (self-contained):
fig2 = relationship_scatter_figure(xs, ys, x_label="dosis", y_label="efecto")
```
## Cuando usarla
Úsala dentro del informe EDA automático cuando quieras visualizar de un vistazo
la relación entre dos variables numéricas: la nube de puntos, la curva que mejor
la ajusta y una etiqueta legible del tipo de relación con sus métricas. Es la
pareja "vista humana" de `classify_relationship_type`: esa función decide el
tipo y los coeficientes; esta los pinta en una `Figure` que el renderer del
informe rasteriza a PDF/PPTX. Pásale el dict de clasificación si ya lo tienes
calculado (evitas recomputar el ajuste); si no, déjalo en `None` y la función lo
resuelve sola sobre los pares limpios. Pensada para móvil: anotación pequeña
(fontsize 8) y nube adelgazada por `max_points` para que el PDF no pese.
## Gotchas
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
es thread-safe; esta función lo evita construyendo el `Figure` directamente,
así que es segura de llamar en bucle desde el renderer.
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
guarda. Quien la consume debe rasterizarla y luego liberarla
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes de
pares de columnas.
- **Downsample determinista, solo del dibujo.** Cuando los pares limpios superan
`max_points`, la nube DIBUJADA se adelgaza por paso fijo `pairs[::step]`
(reproducible, no aleatorio). La clasificación y el ajuste usan SIEMPRE todos
los pares limpios; el downsample no altera las métricas ni la curva.
- **`classification=None` ⇒ se calcula sola.** Importa y llama a
`classify_relationship_type` sobre los pares limpios. Si ese módulo hermano no
está disponible (entorno incompleto), NO lanza: dibuja el scatter sin curva de
ajuste ni anotación. Pasar la clasificación explícita es más barato (no
recomputa el ajuste).
- **Sin curva para `monótona no-lineal`.** Cuando `coeffs` es `None` o
`best_degree` es `None` (p.ej. tipo "monótona no-lineal"), no se pinta recta
polinómica — solo la nube y la anotación. Tampoco se dibuja la curva si el
rango de x es nulo (todos los x iguales). Nunca falla por esto.
- **Defensiva, nunca lanza.** `xs=[]`, `ys=[]`, menos de 2 pares válidos, ends
`None`/`bool`/`NaN`/`inf` o `coeffs` malformado se manejan sin error: en el
peor caso devuelve una `Figure` con "Sin datos suficientes para el scatter".
No envuelvas la llamada en try/except por miedo a un raise — no lo hay.
@@ -0,0 +1,322 @@
"""Impure EDA helper: scatter figure of a numeric pair with its fit (`eda` group).
Builds a matplotlib scatter of two numeric variables, overlays the fitted
curve/line implied by the relationship classification (linear, polynomial of
degree 2/3, etc.) and annotates the relationship type with its available
metrics. Returns a ready-to-rasterize ``matplotlib.figure.Figure``; it never
shows nor saves it.
Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer.
To keep the rendered PDF/PPTX light on phones, when the number of valid pairs
exceeds ``max_points`` the *plotted* points are down-sampled DETERMINISTICALLY by
a fixed step (``pairs[::step]``), never randomly, so the output is reproducible.
The classification/fit always uses every clean pair; the down-sample only thins
the drawn cloud.
"""
import math
import matplotlib
matplotlib.use("Agg")
import numpy as np # noqa: E402
from matplotlib.figure import Figure # noqa: E402
# Sober blue for the scatter cloud and red for the fitted curve (Tufte: the
# data points are the primary ink, the fit is the secondary highlight).
_POINT_COLOR = "#4C72B0"
_FIT_COLOR = "#C44E52"
# Muted gray for the no-data fallback message.
_MUTED_TEXT = "#5f6b7a"
def _finite(value):
"""Coerce ``value`` to a finite float, or return None when not usable.
bool is a subclass of int, but a real numeric measurement is never a bool,
so True/False are treated as missing instead of coercing to 1.0/0.0. NaN and
+/-infinity are never valid either.
"""
if value is None or isinstance(value, bool):
return None
try:
f = float(value)
except (TypeError, ValueError):
return None
if math.isnan(f) or math.isinf(f):
return None
return f
def _clean_pairs(xs, ys):
"""Pair ``xs[i], ys[i]`` by index, dropping any pair with a non-finite end."""
pairs = []
if isinstance(xs, (list, tuple)) and isinstance(ys, (list, tuple)):
n = min(len(xs), len(ys))
for i in range(n):
x = _finite(xs[i])
y = _finite(ys[i])
if x is None or y is None:
continue
pairs.append((x, y))
return pairs
def _ordered_trend(xs_clean, ys_clean, n_bins: int = 12):
"""Return (x_trend, y_trend): the ordered trend of y over x for a monotonic
relationship that has no polynomial fit.
When x has few distinct values (an ordinal/discrete scale) the trend is the
mean of y per distinct x value. Otherwise x is split into ``n_bins`` ordered
quantile bins and each point is (mean x, mean y) of the bin. Returns
``(None, None)`` when there is nothing meaningful to draw.
"""
x_arr = np.asarray(xs_clean, dtype=float)
y_arr = np.asarray(ys_clean, dtype=float)
if x_arr.size < 2:
return None, None
uniq = np.unique(x_arr)
if uniq.size <= max(2, n_bins):
# Discrete x: one trend point per distinct value (mean y).
xt = uniq
yt = np.array([float(np.mean(y_arr[x_arr == ux])) for ux in uniq])
return xt, yt
# Continuous x: ordered quantile bins, (mean x, mean y) per bin.
order = np.argsort(x_arr, kind="stable")
x_sorted = x_arr[order]
y_sorted = y_arr[order]
chunks_x = np.array_split(x_sorted, n_bins)
chunks_y = np.array_split(y_sorted, n_bins)
xt = np.array([float(np.mean(cx)) for cx in chunks_x if cx.size])
yt = np.array([float(np.mean(cy)) for cy in chunks_y if cy.size])
return xt, yt
def _no_data_figure(message: str) -> "matplotlib.figure.Figure":
"""A bare Figure carrying a centered muted message (defensive fallback)."""
fig = Figure(figsize=(6.4, 4.0), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=12,
color=_MUTED_TEXT,
transform=ax.transAxes,
)
fig.tight_layout()
return fig
def _metrics_caption(classification: dict) -> str:
"""Format the available metrics of a classification dict into one line.
Omits the metrics that are None. Keys consumed (any may be absent/None):
``pearson`` (r), ``spearman`` (rho), ``r2_linear`` (R²lin) and the best
polynomial (``r2_poly3`` if a cubic was the best fit, else ``r2_poly2``).
"""
parts = []
r = _finite(classification.get("pearson"))
if r is not None:
parts.append(f"r={r:.2f}")
rho = _finite(classification.get("spearman"))
if rho is not None:
parts.append(f"ρ={rho:.2f}")
r2_lin = _finite(classification.get("r2_linear"))
if r2_lin is not None:
parts.append(f"R²lin={r2_lin:.2f}")
# Prefer the R² of the best polynomial degree when it is a poly fit.
best_degree = classification.get("best_degree")
r2_poly = None
if best_degree == 3:
r2_poly = _finite(classification.get("r2_poly3"))
elif best_degree == 2:
r2_poly = _finite(classification.get("r2_poly2"))
if r2_poly is None:
# Fall back to whichever poly R² is present (cubic first).
r2_poly = _finite(classification.get("r2_poly3"))
if r2_poly is None:
r2_poly = _finite(classification.get("r2_poly2"))
if r2_poly is not None:
parts.append(f"R²poly={r2_poly:.2f}")
return " ".join(parts)
def relationship_scatter_figure(
xs: list,
ys: list,
x_label: str = "",
y_label: str = "",
classification: dict = None,
max_points: int = 2000,
) -> "matplotlib.figure.Figure":
"""Build a scatter figure of a numeric pair with its fit and a type label.
Cleans the pairs defensively (drops any pair with a None/bool/NaN/inf end),
plots a semi-transparent scatter cloud (down-sampled deterministically when
it exceeds ``max_points``), overlays the polynomial fit implied by
``classification`` and annotates the relationship type plus its available
metrics in a corner box.
The fit and classification always use every clean pair; only the drawn cloud
is thinned by the down-sample. When ``classification`` is None it is computed
internally by reusing ``classify_relationship_type`` over the clean pairs, so
the function is self-contained.
The function is fully defensive: empty input, fewer than 2 clean pairs, a
missing/None ``coeffs`` or a missing sibling classifier never raise. When
there is nothing valid to draw it still returns a ``Figure`` carrying a
centered "Sin datos suficientes para el scatter" message.
Args:
xs: List (or tuple) of x values. Paired by index with ``ys``. Values that
are None, bool, NaN or infinite discard that pair. Read defensively.
ys: List (or tuple) of y values, parallel to ``xs``. Same defensive rules.
x_label: Axis/title label for the x variable. Default "" (falls back to
"x" in the title).
y_label: Axis/title label for the y variable. Default "" (falls back to
"y" in the title).
classification: Optional dict from ``classify_relationship_type`` with
keys ``tipo, pearson, r2_linear, spearman, r2_poly2, r2_poly3,
best_degree, coeffs``. When None, it is computed internally by
importing and calling ``classify_relationship_type`` over the clean
pairs. When that sibling module is unavailable, the scatter is still
drawn (no fit curve, no annotation).
max_points: Cap on the number of *plotted* points. When the number of
clean pairs exceeds this cap, the drawn cloud is down-sampled by a
fixed step ``ceil(n/max_points)`` taking ``pairs[::step]``
DETERMINISTIC, not random, so the figure is reproducible. A
non-positive or non-int value disables down-sampling. Default 2000.
Returns:
A ``matplotlib.figure.Figure`` (figsize 6.4x4.0, dpi 150) with a single
scatter Axes, the fitted curve (when a polynomial fit is available) and a
corner annotation with the relationship type and metrics. When there are
fewer than 2 clean pairs it returns a Figure with a centered "Sin datos
suficientes para el scatter" message. The caller rasterizes/closes it.
"""
pairs = _clean_pairs(xs, ys)
if len(pairs) < 2:
return _no_data_figure("Sin datos suficientes para el scatter")
# Full clean coordinates feed the classification/fit; the plotted cloud is
# what gets thinned.
xs_clean = [p[0] for p in pairs]
ys_clean = [p[1] for p in pairs]
# Resolve the classification. If not provided, reuse the sibling classifier
# over ALL clean pairs (self-contained). Missing module => no fit/annotation.
cls = classification
if cls is None:
try:
from classify_relationship_type import classify_relationship_type
cls = classify_relationship_type(xs_clean, ys_clean)
except Exception:
cls = None
if not isinstance(cls, dict):
cls = {}
# --- Deterministic down-sampling of the DRAWN points only.
n_total = len(pairs)
if (
isinstance(max_points, int)
and not isinstance(max_points, bool)
and max_points > 0
and n_total > max_points
):
step = math.ceil(n_total / max_points)
sampled = pairs[::step]
else:
sampled = pairs
x_plot = [p[0] for p in sampled]
y_plot = [p[1] for p in sampled]
fig = Figure(figsize=(6.4, 4.0), dpi=150)
ax = fig.add_subplot(111)
ax.scatter(
x_plot,
y_plot,
s=12,
alpha=0.5,
color=_POINT_COLOR,
edgecolors="none",
rasterized=True,
)
# --- Fitted curve/line over the full clean x range.
coeffs = cls.get("coeffs")
best_degree = cls.get("best_degree")
tipo = cls.get("tipo")
x_min, x_max = min(xs_clean), max(xs_clean)
drew_fit = False
if coeffs is not None and best_degree is not None and x_max > x_min:
try:
coeff_arr = np.asarray(coeffs, dtype=float)
if coeff_arr.ndim == 1 and coeff_arr.size > 0 and np.all(np.isfinite(coeff_arr)):
x_line = np.linspace(x_min, x_max, 200)
y_line = np.polyval(coeff_arr, x_line)
if np.all(np.isfinite(y_line)):
ax.plot(x_line, y_line, color=_FIT_COLOR, linewidth=2)
drew_fit = True
except Exception:
# Never fail the figure because of a malformed coeffs array.
pass
# A monotonic non-linear relationship has no fitted polynomial (coeffs is
# None by design — a low-degree polynomial would mislead). Draw instead the
# ordered trend of y over x so the reader still sees the shape: y averaged
# within ordered x-bins (or per distinct x value when x is discrete with few
# levels, e.g. an ordinal scale). Defensive: any failure leaves the cloud.
if (not drew_fit and isinstance(tipo, str) and "monóton" in tipo.lower()
and x_max > x_min):
try:
xt, yt = _ordered_trend(xs_clean, ys_clean)
if xt is not None and len(xt) >= 2:
ax.plot(xt, yt, color=_FIT_COLOR, linewidth=2, marker="o",
markersize=3)
except Exception:
pass
# --- Labels and title.
tx = x_label if x_label else "x"
ty = y_label if y_label else "y"
ax.set_title(f"{tx}{ty}", fontsize=12, loc="left", pad=8)
ax.set_xlabel(x_label)
ax.set_ylabel(y_label)
# --- Corner annotation: relationship type + available metrics.
caption_lines = []
if tipo:
caption_lines.append(str(tipo))
metrics_line = _metrics_caption(cls)
if metrics_line:
caption_lines.append(metrics_line)
if caption_lines:
ax.text(
0.03,
0.97,
"\n".join(caption_lines),
transform=ax.transAxes,
ha="left",
va="top",
fontsize=8,
bbox=dict(
boxstyle="round,pad=0.35",
facecolor="white",
edgecolor="#cccccc",
alpha=0.85,
),
)
fig.tight_layout()
return fig
@@ -0,0 +1,100 @@
"""Tests para relationship_scatter_figure (scatter de un par numérico, grupo eda).
Usa el backend Agg sin pyplot global; no muestra ni guarda figuras. Cada test
cierra explícitamente la Figure construida (matplotlib.pyplot.close) para no
acumular estado entre tests.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.collections import PathCollection # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from relationship_scatter_figure import relationship_scatter_figure
def _scatter_offsets(fig):
"""Return the plotted points of the first PathCollection (scatter) found."""
for ax in fig.axes:
for coll in ax.collections:
if isinstance(coll, PathCollection):
return coll.get_offsets()
return None
def test_returns_figure():
xs = [float(i) for i in range(20)]
ys = [2.0 * x + 1.0 for x in xs] # y = 2x + 1
classification = {
"tipo": "lineal",
"pearson": 1.0,
"r2_linear": 1.0,
"spearman": 1.0,
"r2_poly2": 1.0,
"r2_poly3": 1.0,
"best_degree": 1,
"coeffs": [2.0, 1.0],
}
fig = relationship_scatter_figure(
xs, ys, x_label="a", y_label="b", classification=classification
)
assert hasattr(fig, "savefig")
assert len(fig.axes) >= 1
plt.close(fig)
def test_downsample_determinista():
n = 5000
xs = [float(i) for i in range(n)]
ys = [0.5 * x for x in xs]
classification = {
"tipo": "lineal",
"pearson": 1.0,
"r2_linear": 1.0,
"spearman": 1.0,
"r2_poly2": 1.0,
"r2_poly3": 1.0,
"best_degree": 1,
"coeffs": [0.5, 0.0],
}
fig = relationship_scatter_figure(
xs, ys, x_label="x", y_label="y", classification=classification, max_points=1000
)
assert isinstance(fig, Figure)
offsets = _scatter_offsets(fig)
assert offsets is not None
# El nº de puntos dibujados no debe exceder el cap.
assert len(offsets) <= 1000
plt.close(fig)
def test_empty_no_lanza():
fig = relationship_scatter_figure([], [], x_label="x", y_label="y")
assert isinstance(fig, Figure)
plt.close(fig)
def test_classification_none():
# Solo se ejecuta si el módulo hermano classify_relationship_type existe.
try:
import classify_relationship_type # noqa: F401
except Exception:
import pytest
pytest.skip("classify_relationship_type aún no disponible")
xs = [float(i) for i in range(30)]
ys = [3.0 * x - 2.0 for x in xs]
fig = relationship_scatter_figure(
xs, ys, x_label="a", y_label="b", classification=None
)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
@@ -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 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, 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"])
@@ -0,0 +1,79 @@
---
name: summarize_outlier_dims
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def summarize_outlier_dims(raw_numeric: dict, outlier_rows: list, top_k: int = 3) -> list"
description: "Explica QUE columnas hacen rara cada fila anomala detectada por isolation_forest_outliers. Para cada {row_index, score} reconstruye la fila valida (mismo filtro de columnas numericas y mismo descarte de filas con None que el detector, asi row_index coincide) y devuelve las top_k columnas de mayor |z-score| poblacional (ddof=0). Capa de explicabilidad del paso de outliers multivariante en EDA. Pura y determinista; ante entradas vacias/invalidas o sin filas validas devuelve [] sin petar."
tags: [eda, models, outliers, anomaly-detection, explainability, z-score, multivariate]
params:
- name: raw_numeric
desc: "dict {nombre_columna: [valores]} alineado por fila (como ctx['raw_numeric'] del motor AutomaticEDA). Solo se usan columnas con todos los valores numericos (None permitido por fila; bool/str/NaN/Inf descartan la columna entera) — filtro IDENTICO al de isolation_forest_outliers para que row_index coincida."
- name: outlier_rows
desc: "Lista de {row_index, score} tal cual la devuelve isolation_forest_outliers. row_index cuenta SOLO las filas validas (sin None) en orden de aparicion, base 0. Entradas fuera de rango o malformadas se ignoran defensivamente."
- name: top_k
desc: "Numero de columnas (las de mayor |z-score|) a reportar por outlier. Default 3. Valores invalidos (no-int, bool, <1) caen a 3."
output: "Lista paralela a outlier_rows (mismo orden) de dicts {row_index: int, score: float, dims: [{col: str, value: float, z: float}, ...]}. dims trae hasta top_k columnas ordenadas por |z| descendente, con z (z-score poblacional, ddof=0) redondeado a 3 decimales; si una columna tiene std==0 su z es 0. Las entradas de outlier_rows fuera de rango/malformadas se omiten. Ante raw_numeric vacio/no-dict, outlier_rows no-lista, 0 columnas numericas o 0 filas validas devuelve []."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_row_index_skips_none_rows", "test_extreme_row_flagged_via_isolation", "test_out_of_range_row_index_is_ignored", "test_degrades_to_empty_on_invalid_inputs"]
test_file_path: "python/functions/datascience/summarize_outlier_dims_test.py"
file_path: "python/functions/datascience/summarize_outlier_dims.py"
---
## Ejemplo
```python
from datascience import isolation_forest_outliers, summarize_outlier_dims
# Nube densa alrededor del origen + 1 fila con un valor extremo en "c".
raw_numeric = {
"a": [0.1, 0.2, -0.1, 0.0, 0.3, -0.2, 0.15, -0.05, 0.25, 0.2, -0.3, 0.1],
"b": [1.0, 1.1, 0.9, 1.2, 0.8, 1.0, 1.1, 0.95, 1.05, 0.9, 1.15, 1.0],
"c": [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 4.95, 5.05, 4.9, 500.0, 5.1, 5.0],
}
result = isolation_forest_outliers(raw_numeric, contamination=0.1)
summary = summarize_outlier_dims(raw_numeric, result["outlier_rows"], top_k=3)
for item in summary:
top = item["dims"][0]
print(item["row_index"], top["col"], top["value"], top["z"])
# La fila del valor 500 sale con dim top "c" y |z| alto: es lo que la hace rara.
```
## Cuando usarla
Justo **despues** de `isolation_forest_outliers`, cuando ya sabes QUE filas son
anomalas y quieres explicar POR QUE: en que columnas se desvian mas respecto al
resto. Util para rellenar la seccion de outliers de un report/notebook EDA con
"la fila 9 es rara sobre todo por `c` (z=+3.3)" en lugar de solo un row_index
opaco. Pasa el mismo `raw_numeric` que diste al detector y su `outlier_rows`
intacto; el `row_index` apunta a la misma fila porque ambas funciones aplican el
mismo filtro de columnas y el mismo descarte de filas con None.
## Gotchas
- **Mismo `raw_numeric` que el detector**: el `row_index` solo coincide si pasas
el mismo dict de columnas (mismo orden, mismas listas) con el que llamaste a
`isolation_forest_outliers`. Si cambias las columnas o el orden, los indices
dejan de mapear.
- **`row_index` es relativo a las filas validas**: las filas con `None` en
cualquier columna usada se descartan y los indices se recalculan sobre las que
quedan (base 0, orden de aparicion). No mapea 1:1 con las listas de entrada si
hay None.
- **z-score poblacional (ddof=0)**: se usa la desviacion tipica poblacional,
consistente con el escalado del detector. Columnas con `std==0` (todos los
valores iguales) dan `z=0`, asi que nunca aparecen como "raras".
- **Devuelve `[]` en vez de petar**: entrada no-dict/no-lista, 0 columnas
numericas, 0 filas validas, o todas las entradas fuera de rango -> lista vacia.
No lanza excepciones.
- **No llama a `isolation_forest_outliers`**: solo consume su salida. Es una
funcion independiente (no la importa), por eso `uses_functions` esta vacio.
@@ -0,0 +1,144 @@
"""Explica que dimensiones (columnas) hacen rara cada fila anomala.
Toma la salida multivariante de `isolation_forest_outliers` (lista de
`{row_index, score}`) y, para cada outlier, devuelve las columnas con mayor
|z-score| respecto a la distribucion de las filas validas. Es la capa de
"explicabilidad" del paso de outliers multivariante en la fase EDA: el
Isolation Forest dice QUE filas son raras, esta funcion dice POR QUE (en que
columnas se desvian mas).
Pura y determinista: reconstruye EXACTAMENTE las mismas "filas validas" que usa
`isolation_forest_outliers` (mismo filtro de columnas numericas y mismo descarte
de filas con None), de modo que el `row_index` apunta a la misma fila en ambas
funciones. No hace I/O ni depende de estado.
"""
import math
import numpy as np
def _is_finite_number(v) -> bool:
"""True si v es int/float finito. bool NO cuenta; NaN/Inf tampoco."""
if isinstance(v, bool):
return False
if not isinstance(v, (int, float)):
return False
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
return False
return True
def summarize_outlier_dims(
raw_numeric: dict,
outlier_rows: list,
top_k: int = 3,
) -> list:
"""Resume las dimensiones que mas desvian a cada fila anomala.
Args:
raw_numeric: dict {nombre_columna: [valores]} alineado por fila (como
ctx['raw_numeric'] del motor AutomaticEDA). Solo se usan columnas
cuyos valores sean todos numericos (None permitido por fila; bool,
str, NaN e Inf descartan la columna entera) filtro identico al de
isolation_forest_outliers.
outlier_rows: lista de {row_index, score} tal como la devuelve
isolation_forest_outliers. row_index cuenta SOLO las filas validas
(sin None) en orden de aparicion, empezando en 0.
top_k: numero de columnas (las de mayor |z-score|) a reportar por cada
outlier. Default 3. Valores invalidos caen a 3.
Returns:
Lista paralela a outlier_rows (mismo orden) de dicts
{row_index, score, dims}, donde dims es la lista de hasta top_k columnas
ordenadas por |z| descendente: [{col, value, z}, ...] con z redondeado a
3 decimales. Las entradas de outlier_rows fuera de rango o malformadas se
omiten (defensivo). Ante raw_numeric vacio/no-dict, outlier_rows
no-lista, 0 columnas numericas o 0 filas validas devuelve [].
"""
# Validacion defensiva de los argumentos principales.
if not isinstance(raw_numeric, dict) or not isinstance(outlier_rows, list):
return []
if not isinstance(top_k, int) or isinstance(top_k, bool) or top_k < 1:
top_k = 3
# Seleccion de columnas numericas: identica a isolation_forest_outliers.
# Una columna entra solo si todos sus valores son numericos (None permitido
# por fila); cualquier bool/str/NaN/Inf descarta la columna completa.
numeric_cols: dict[str, list] = {}
for name, values in raw_numeric.items():
if not isinstance(values, (list, tuple)):
continue
ok = True
for v in values:
if v is None:
continue
if not _is_finite_number(v):
ok = False
break
if ok:
numeric_cols[name] = list(values)
if len(numeric_cols) < 1:
return []
col_names = list(numeric_cols.keys())
try:
n_rows_total = min(len(numeric_cols[c]) for c in col_names)
except ValueError:
return []
# Reconstruye las filas validas con el MISMO criterio que el detector: la
# fila i toma un valor por columna; si cualquier valor es None, la fila se
# descarta y NO incrementa el indice valido. Asi row_index de outlier_rows
# apunta a esta misma secuencia (base 0, orden de aparicion).
valid_rows: list[list[float]] = []
for i in range(n_rows_total):
row = [numeric_cols[c][i] for c in col_names]
if any(v is None for v in row):
continue
valid_rows.append([float(v) for v in row])
if not valid_rows:
return []
matrix = np.asarray(valid_rows, dtype=float)
n_valid = matrix.shape[0]
means = matrix.mean(axis=0)
stds = matrix.std(axis=0, ddof=0) # poblacional (ddof=0)
out: list = []
for entry in outlier_rows:
if not isinstance(entry, dict):
continue
ri = entry.get("row_index")
# bool es subclase de int: lo excluimos explicitamente.
if not isinstance(ri, int) or isinstance(ri, bool):
continue
if ri < 0 or ri >= n_valid:
continue
try:
score = float(entry.get("score"))
except (TypeError, ValueError):
score = 0.0
row = matrix[ri]
dims = []
for j, name in enumerate(col_names):
std = stds[j]
if std == 0.0:
z = 0.0
else:
z = float((row[j] - means[j]) / std)
dims.append({"col": name, "value": float(row[j]), "z": z})
# Mayor |z| primero; sort estable, empates por orden de columna.
dims.sort(key=lambda d: abs(d["z"]), reverse=True)
dims = dims[:top_k]
for d in dims:
d["z"] = round(d["z"], 3)
out.append({"row_index": int(ri), "score": score, "dims": dims})
return out
@@ -0,0 +1,93 @@
"""Tests para summarize_outlier_dims."""
from isolation_forest_outliers import isolation_forest_outliers
from summarize_outlier_dims import summarize_outlier_dims
# Dataset compartido: 3 columnas, 13 filas. La fila ORIGINAL 6 tiene None en "a"
# (se descarta), de modo que la fila ORIGINAL 10 -- con un valor extremo en "c"
# -- queda en el indice VALIDO 9 (no 10). Esto verifica el salto de None.
A = [0.1, 0.2, -0.1, 0.0, 0.3, -0.2, None, 0.15, -0.05, 0.25, 0.2, -0.3, 0.1]
B = [1.0, 1.1, 0.9, 1.2, 0.8, 1.0, 1.3, 1.1, 0.95, 1.05, 0.9, 1.15, 1.0]
C = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.3, 4.95, 5.05, 4.9, 500.0, 5.1, 5.0]
RAW = {"a": A, "b": B, "c": C}
# Mapa original -> valido (saltando original 6):
# orig: 0 1 2 3 4 5 7 8 9 10 11 12
# valid: 0 1 2 3 4 5 6 7 8 9 10 11
# => el extremo en "c" (original 10) esta en el indice valido 9.
EXTREME_VALID_INDEX = 9
def test_row_index_skips_none_rows():
# Mapeo directo (sin depender de la aleatoriedad de IsolationForest): el
# indice valido 9 debe corresponder a la fila con c == 500 -> el None de la
# fila original 6 se salto correctamente.
summary = summarize_outlier_dims(
RAW, [{"row_index": EXTREME_VALID_INDEX, "score": -0.5}], top_k=3
)
assert len(summary) == 1
entry = summary[0]
assert entry["row_index"] == EXTREME_VALID_INDEX
assert entry["score"] == -0.5
# La dimension dominante es "c", con su valor extremo y |z| alto.
top = entry["dims"][0]
assert top["col"] == "c"
assert top["value"] == 500.0
assert abs(top["z"]) > 2.0
# top_k respetado: como mucho 3 dims.
assert len(entry["dims"]) <= 3
def test_extreme_row_flagged_via_isolation():
# Integracion real: detectar outliers y explicarlos.
result = isolation_forest_outliers(RAW, contamination=0.1)
assert "note" not in result
outlier_rows = result["outlier_rows"]
assert outlier_rows # al menos un outlier
summary = summarize_outlier_dims(RAW, outlier_rows, top_k=3)
# Paralela a outlier_rows (todos los indices estan en rango).
assert len(summary) == len(outlier_rows)
by_index = {e["row_index"]: e for e in summary}
# El punto extremo debe estar entre los outliers detectados...
assert EXTREME_VALID_INDEX in by_index
# ...y su dimension top debe ser "c" (donde se desvia ~muchas sigmas).
extreme = by_index[EXTREME_VALID_INDEX]
assert extreme["dims"][0]["col"] == "c"
assert abs(extreme["dims"][0]["z"]) > 2.0
def test_out_of_range_row_index_is_ignored():
# Indices fuera de rango se omiten en lugar de petar.
summary = summarize_outlier_dims(
RAW,
[
{"row_index": 999, "score": -1.0},
{"row_index": -1, "score": -1.0},
{"row_index": EXTREME_VALID_INDEX, "score": -0.5},
],
top_k=2,
)
# Solo sobrevive el indice valido; los otros dos se descartan.
assert len(summary) == 1
assert summary[0]["row_index"] == EXTREME_VALID_INDEX
assert len(summary[0]["dims"]) <= 2
def test_degrades_to_empty_on_invalid_inputs():
# raw_numeric vacio + outlier_rows vacio.
assert summarize_outlier_dims({}, [], 3) == []
# raw_numeric no es dict.
assert summarize_outlier_dims("not a dict", [{"row_index": 0}], 3) == []
# outlier_rows no es lista.
assert summarize_outlier_dims(RAW, "not a list", 3) == []
# Sin columnas numericas (todas con strings) -> [].
assert summarize_outlier_dims(
{"s": ["x", "y", "z"]}, [{"row_index": 0, "score": -1.0}], 3
) == []
# Entradas malformadas dentro de outlier_rows se ignoran (no petan).
assert summarize_outlier_dims(
RAW, ["nope", 42, {"no_row_index": 1}], 3
) == []
@@ -4,8 +4,8 @@ kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.1.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = None, run_models: bool = None, run_series: bool = None, run_llm: bool = None, profile_level: str = \"standard\", out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None) -> dict"
version: "1.2.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = None, run_models: bool = None, run_series: bool = None, run_llm: bool = None, profile_level: str = \"standard\", out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None, emit_md: bool = True, only_chapters: list = None) -> dict"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. El parametro profile_level es un preset de consumo CPU/LLM (lite/standard/full) que mapea a los flags run_models/run_series/run_llm/sample; un flag explicito siempre prima sobre el preset. lite=bajo consumo (sin LLM, sin serie, modelos solo PCA+normalidad sin KMeans/IsolationForest, sample reducido); standard=comportamiento historico; full=standard+narrativa LLM. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
tags: [eda, duckdb, postgres, profiling, pipeline, dataops, report, pdf, pptx]
uses_functions:
@@ -46,6 +46,10 @@ params:
desc: "Nombre base de los archivos sin extension. Default 'aeda_<table>_<timestamp>'."
- name: ctx_extra
desc: "Dict opcional con claves de presentacion/contexto extra que se mezclan en el ctx (dataset_name, description, source_origin, ...); no pisan las claves de datos calculadas por build_eda_render_ctx."
- name: emit_md
desc: "Ademas del PDF y el PPTX, emite un Markdown autocontenido del mismo documento por capitulos (texto + tablas markdown, sin binarios) para pegar a un LLM. Default True. La ruta sale en aeda_md_path."
- name: only_chapters
desc: "Lista opcional de ids de capitulo a renderizar (subconjunto de CHAPTER_ORDER) para iterar/testear un capitulo suelto sin generar el documento entero. Default None => documento COMPLETO (retrocompatible). Cuando se pasa una lista: (1) se VALIDA contra CHAPTER_ORDER, un id desconocido o lista vacia devuelve error claro listando los validos; (2) se RESUELVEN las dependencias de computo de esos capitulos (automatic_eda.chapter_deps) activando los flags que necesiten (run_models/run_series/run_llm) aunque el caller no los pidiera y construyendo SOLO las piezas de ctx que leen, de modo que el capitulo suelto SIEMPRE llega poblado (p.ej. ['outliers'] activa run_models y conserva raw_numeric -> Isolation Forest completo) sin malgastar CPU/LLM en lo que ningun capitulo pedido usa; (3) el documento y su manifest contienen SOLO esos capitulos MAS portada (primera) y glosario (ultima, cuando hay terminos clicables). Un flag explicito del caller prima sobre la resolucion de dependencias."
output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None, n_pages:int, n_slides:int, pdf_note:str, pptx_note:str, profile:<TableProfile>} o {status:'error', error:str} (dict-no-throw)."
---
@@ -69,6 +73,21 @@ r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="full")
# Precedencia: el flag explicito SIEMPRE prima sobre el preset. lite pero con LLM:
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
profile_level="lite", run_llm=True) # el LLM SI se ejecuta
# Capitulo SUELTO: itera/testea un capitulo sin generar el documento entero. La
# resolucion de dependencias activa el computo que el capitulo necesita aunque no
# se pase explicito. Pedir solo 'outliers' activa run_models y conserva
# raw_numeric -> el bloque Isolation Forest sale COMPLETO. Documento = portada +
# outliers + glosario.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", only_chapters=["outliers"])
# Varios capitulos sueltos a la vez (se unen sus dependencias):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
only_chapters=["correlacion", "missingness"])
# id desconocido -> error claro listando los validos (dict-no-throw, no lanza):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", only_chapters=["nope"])
# {'status': 'error', 'error': 'only_chapters con ids desconocidos: nope. Capitulos validos: portada, overview, ...'}
```
## Cuando usarla
@@ -86,6 +105,16 @@ Para un EDA **barato/rapido** (CI, vistazo previo, maquina sin GPU o sin red) us
temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
`profile_level="full"`. El default `"standard"` mantiene el comportamiento previo.
Cuando estes **iterando o testeando UN capitulo concreto** (afinar el render de
outliers, comprobar el mapa geoespacial, depurar la agregacion) usa
`only_chapters=[...]`: genera el documento con solo esos capitulos (+ portada y
glosario), pero **resuelve sus dependencias de computo** para que el capitulo
suelto nunca salga degradado — pedir `['outliers']` activa run_models y conserva
`raw_numeric` aunque no los pases, y a la vez no malgasta CPU/LLM en lo que ningun
capitulo pedido necesita (pedir `['geospatial']` no corre modelos). Es mucho mas
rapido que renderizar el informe entero en cada iteracion. El mapa central de
dependencias vive en `automatic_eda/chapter_deps.py` (fuente de verdad).
## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
@@ -111,9 +140,29 @@ temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
- Los datos crudos del ctx se muestrean con `sample` (LIMIT), no se trae la tabla
entera a RAM; con tablas enormes sube `sample` si quieres mas representatividad
(coste: mas memoria).
- **`only_chapters` y el glosario**: el glosario (ultimo capitulo) solo aparece si
algun capitulo del cuerpo registro terminos clicables. Un capitulo suelto que no
registra terminos (p.ej. `timeseries`, `geospatial`) sale como portada + ese
capitulo, sin glosario, porque no hay nada que enlazar — es correcto, no un fallo.
- **`only_chapters` con `profile_level="lite"`**: en capitulos sueltos el preset
solo gobierna `sample`; los modelos NO usan el camino "lite" (que podaria
`ctx['raw_numeric']` y dejaria a outliers sin su multivariante en vivo). Quien
manda en capitulos sueltos es la resolucion de dependencias, no el preset de
coste de modelos.
## Capability growth log
- v1.2.0 (2026-06-30) — anade el parametro `only_chapters`: renderiza un
SUBCONJUNTO de capitulos (para iterar/testear uno suelto) resolviendo sus
dependencias de computo via `automatic_eda/chapter_deps.py` (mapa central
CHAPTER_DEPS): activa los flags de coste que el capitulo necesita (run_models/
run_series/run_llm) aunque el caller no los pase y construye solo las piezas de
ctx que lee, de modo que el capitulo suelto SIEMPRE llega poblado (golden:
['outliers'] -> Isolation Forest completo) sin malgastar en lo que no usa. La
seleccion viaja a build_document por la clave reservada `ctx['_only_chapters']`
(los renderers no cambian). Valida ids (error claro dict-no-throw). Cambio
aditivo y retro-compatible: `only_chapters=None` produce el documento completo
identico a v1.1.0.
- v1.1.0 (2026-06-30) — anade el parametro `profile_level` (lite/standard/full),
preset de consumo CPU/LLM que mapea a los flags run_models/run_series/run_llm/
sample. lite limita los modelos a PCA+normalidad (cableado a run_eda_models con
@@ -99,6 +99,7 @@ def render_automatic_eda(
basename: str = None,
ctx_extra: dict = None,
emit_md: bool = True,
only_chapters: list = None,
) -> dict:
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
@@ -150,6 +151,29 @@ def render_automatic_eda(
MISMO documento por capítulos (texto plano + tablas markdown, sin
binarios), pensado para pegar a un LLM. Default True. La ruta sale en
la clave de retorno ``aeda_md_path``. No altera las demás salidas.
only_chapters: lista opcional de ids de capítulo a renderizar (un
SUBCONJUNTO de CHAPTER_ORDER) para iterar/testear un capítulo concreto
sin generar el documento entero. Default None => documento COMPLETO,
idéntico al de hoy (retrocompatible). Cuando se pasa una lista:
- Se VALIDA contra CHAPTER_ORDER; un id desconocido devuelve un error
claro listando los válidos (dict-no-throw, no lanza). Lista vacía
``[]`` también devuelve error (pasa al menos un capítulo o None).
- Se RESUELVEN las dependencias de cómputo de esos capítulos
(``automatic_eda.chapter_deps``): se activan los flags de coste que
necesiten (run_models / run_series / run_llm) AUNQUE el caller no
los pidiera, y se construyen SOLO las piezas de ``ctx`` que esos
capítulos leen. Así un capítulo suelto SIEMPRE llega poblado
p.ej. ``only_chapters=['outliers']`` activa run_models y conserva
``ctx['raw_numeric']`` para que el bloque IsolationForest salga
completo y a la vez no se malgasta CPU/LLM en lo que ningún
capítulo pedido usa (pedir solo ``geospatial`` no corre modelos).
- El documento (PDF/PPTX/MD) y su manifest contienen SOLO esos
capítulos, MÁS la portada (primera) y el glosario (última), que se
incluyen siempre para que el documento sea válido y los términos
clicables tengan destino.
- Un flag explícito del caller (run_models/run_series/run_llm != None)
SIEMPRE prima sobre lo que resuelvan las dependencias.
Returns:
dict (nunca lanza). En éxito::
@@ -169,11 +193,56 @@ def render_automatic_eda(
# "standard" (comportamiento histórico), sin lanzar.
preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"])
sample = preset["sample"] if sample is None else sample
run_models = preset["run_models"] if run_models is None else run_models
run_series = preset["run_series"] if run_series is None else run_series
run_llm = preset["run_llm"] if run_llm is None else run_llm
model_opts = preset["model_opts"]
# 0.bis) Modo "capítulos sueltos": valida la selección y RESUELVE sus
# dependencias de cómputo. Es lo que garantiza que un capítulo pedido
# llegue completo (activa lo que necesita) sin malgastar en lo que no.
# Cuando only_chapters es None se conserva el camino histórico (preset).
if only_chapters is not None:
from datascience.automatic_eda import CHAPTER_ORDER
from datascience.automatic_eda.chapter_deps import (
needs_render_ctx,
resolve_ctx_data_keys,
resolve_requirements,
validate_chapter_ids,
)
if not isinstance(only_chapters, (list, tuple)):
return {"status": "error",
"error": "only_chapters debe ser una lista de ids de "
"capítulo o None (documento completo)."}
only_chapters = [c for c in only_chapters]
if not only_chapters:
return {"status": "error",
"error": "only_chapters=[] está vacío. Pasa al menos un "
"capítulo, o None para el documento completo. "
"Capítulos válidos: " + ", ".join(CHAPTER_ORDER)}
checked = validate_chapter_ids(only_chapters, CHAPTER_ORDER)
if checked["unknown"]:
return {"status": "error",
"error": "only_chapters con ids desconocidos: "
+ ", ".join(checked["unknown"])
+ ". Capítulos válidos: "
+ ", ".join(CHAPTER_ORDER)}
only_chapters = checked["valid"]
# Las dependencias fijan el DEFAULT de cada flag de coste (eficiencia:
# lo que ningún capítulo pedido necesita queda en False); un flag
# explícito del caller (!= None) sigue primando.
dep_flags = resolve_requirements(only_chapters)["profile_flags"]
run_models = ("run_models" in dep_flags) if run_models is None else run_models
run_series = ("run_series" in dep_flags) if run_series is None else run_series
run_llm = ("run_llm" in dep_flags) if run_llm is None else run_llm
# En capítulos sueltos no se usa el camino "modelos baratos" (lite),
# que poda ctx['raw_numeric']: un capítulo como outliers lo necesita
# para su multivariante en vivo. El preset solo gobierna `sample`.
model_opts = None
else:
run_models = preset["run_models"] if run_models is None else run_models
run_series = preset["run_series"] if run_series is None else run_series
run_llm = preset["run_llm"] if run_llm is None else run_llm
# En el camino "modelos baratos" (lite) profile_table NO corre los
# modelos: los ejecuta este pipeline con run_eda_models y la granularidad
# del preset, evitando pagar el coste CPU de KMeans + IsolationForest.
@@ -217,10 +286,25 @@ def render_automatic_eda(
if ctx_extra:
base_ctx.update(ctx_extra)
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx,
)
# En modo capítulos sueltos, si NINGÚN capítulo pedido necesita datos
# crudos del ctx, se salta build_eda_render_ctx por completo (ahorro real
# de I/O): solo se conservan presentación + db_path/table. Si sí los
# necesita, se construye el ctx y luego se PODAN las piezas de datos que
# ningún capítulo pedido usa (db_path/table nunca se podan).
if only_chapters is not None and not needs_render_ctx(only_chapters):
ctx = dict(base_ctx)
ctx["db_path"] = db_path
ctx["table"] = table
else:
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx,
)
if only_chapters is not None and isinstance(ctx, dict):
keep = resolve_ctx_data_keys(only_chapters)
for k in ("head_rows", "raw_numeric", "timeseries_raw", "geo_points"):
if k not in keep:
ctx.pop(k, None)
# 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
# IsolationForest). profile_table no corrió los modelos; aquí se corren
@@ -245,6 +329,13 @@ def render_automatic_eda(
ctx.pop("raw_numeric", None)
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
# En modo capítulos sueltos, la selección viaja a build_document por una
# clave reservada del ctx (los renderers llaman build_document sin pasar
# `only`): build_document filtra el cuerpo a esos capítulos y siempre
# añade portada (primera) + glosario (última). build_document la consume
# y la quita, así que no llega a los capítulos.
if only_chapters is not None and isinstance(ctx, dict):
ctx["_only_chapters"] = list(only_chapters)
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base = basename or f"aeda_{table}_{ts}"
@@ -261,7 +352,15 @@ def render_automatic_eda(
md_path = None
if emit_md:
md_path = os.path.join(out_dir, base + ".md")
rmd = render_automatic_eda_markdown(prof, md_path, meta) or {}
# El Markdown es la salida MÁS completa: además del documento por
# capítulos (compartido con PDF/PPTX) volca un apéndice con TODOS los
# datos numéricos del perfil (matriz de asociación completa, describe
# con skew/kurtosis/percentiles, re-expresiones, scores_by_k de
# KMeans, estadísticos de normalidad). Se le pasa el `prof` vía
# meta['profile']; un meta propio evita alterar el de PDF/PPTX.
md_meta = dict(meta)
md_meta["profile"] = prof
rmd = render_automatic_eda_markdown(prof, md_path, md_meta) or {}
return {
"status": "ok",
@@ -275,6 +374,7 @@ def render_automatic_eda(
"pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"),
"md_note": rmd.get("note"),
"only_chapters": only_chapters,
"profile": prof,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
@@ -0,0 +1,235 @@
"""Tests del modo `only_chapters` del pipeline render_automatic_eda.
Cubre la tarea de "capítulos sueltos con resolución de dependencias":
- Golden (DuckDB real): pedir SOLO un capítulo genera un documento con solo
portada + ese capítulo + glosario, y el capítulo llega COMPLETO porque la
resolución de dependencias activó el cómputo que necesita aunque el caller
no lo pidiera (outliers run_models + raw_numeric IsolationForest poblado;
timeseries run_series; correlacion raw_numeric).
- Eficiencia: pedir un capítulo que NO necesita flags caros (geospatial) no los
activa, y un capítulo puramente agregado (num_distr) ni siquiera construye el
ctx de datos crudos.
- Edge: id desconocido / lista vacía / no-lista devuelven error claro sin
lanzar; only_chapters=None mantiene el comportamiento histórico.
"""
import json
import os
import random
import sys
from datetime import date, timedelta
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
import duckdb # noqa: E402
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
def _make_db_models(path):
"""DB con fecha + 3 numéricas continuas en 3 clusters gaussianos.
Garantiza material para outliers/modelos (>=2 numéricas IsolationForest),
timeseries (columna DATE) y correlacion (numéricas). Mismo shape que el
fixture del test del pipeline base.
"""
con = duckdb.connect(path)
con.execute("CREATE TABLE pts (d DATE, grp VARCHAR, x1 DOUBLE, x2 DOUBLE, x3 DOUBLE)")
random.seed(42)
centers = [(0.0, 0.0, 0.0), (10.0, 10.0, 10.0), (20.0, 5.0, 15.0)]
d0 = date(2024, 1, 1)
rows = []
for i in range(150):
cx, cy, cz = centers[i % 3]
rows.append((
d0 + timedelta(days=i), f"g{i % 3}",
round(cx + random.gauss(0, 1.0), 4),
round(cy + random.gauss(0, 1.0), 4),
round(cz + random.gauss(0, 1.0), 4),
))
con.executemany("INSERT INTO pts VALUES (?,?,?,?,?)", rows)
con.close()
def _manifest_chapters(result):
with open(result["manifest_path"], encoding="utf-8") as fh:
return set((json.load(fh).get("chapters") or {}).keys())
# --------------------------------------------------------------------------- #
# GOLDEN — outliers suelto: IsolationForest poblado por resolución de deps.
# --------------------------------------------------------------------------- #
def test_only_outliers_isolation_forest_populated_without_explicit_run_models(tmp_path):
"""El corazón de la tarea: pedir SOLO 'outliers' sin run_models explícito
activa run_models por dependencias y conserva ctx['raw_numeric'], de modo que
el bloque multivariante (Isolation Forest) sale con datos, no degradado."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
# NB: no se pasa run_models — la resolución de dependencias debe activarlo.
r = render_automatic_eda(db, "pts", only_chapters=["outliers"],
out_dir=out, basename="only_outliers")
assert r["status"] == "ok", r.get("error")
assert r["only_chapters"] == ["outliers"]
# Documento = portada + outliers + glosario, nada más.
assert _manifest_chapters(r) == {"portada", "outliers", "glosario"}
# El multivariante salió POBLADO (no la nota de degradación). Se comprueba en
# el Markdown (mismo documento por capítulos, texto plano fiable).
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Filas atípicas (multivariante)" in md
assert "Filas analizadas" in md, "el Isolation Forest no trae su tabla poblada"
assert "No se pudo analizar la anomalía multivariante" not in md, \
"el bloque multivariante salió degradado pese a resolver las deps"
# La resolución activó run_models → el perfil trae el bloque de modelos.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is not None
# --------------------------------------------------------------------------- #
# GOLDEN — timeseries suelto activa run_series.
# --------------------------------------------------------------------------- #
def test_only_timeseries_activates_run_series(tmp_path):
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", only_chapters=["timeseries"],
out_dir=out, basename="only_ts")
assert r["status"] == "ok", r.get("error")
assert "timeseries" in _manifest_chapters(r)
assert "modelos" not in _manifest_chapters(r)
# run_series resuelto por deps → el perfil trae el análisis de serie.
assert (r["profile"] or {}).get("series") is not None, \
"only_chapters=['timeseries'] debe activar run_series"
# --------------------------------------------------------------------------- #
# GOLDEN — correlacion suelto construye raw_numeric (sin activar modelos).
# --------------------------------------------------------------------------- #
def test_only_correlacion_builds_raw_numeric_without_models(tmp_path):
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", only_chapters=["correlacion"],
out_dir=out, basename="only_corr")
assert r["status"] == "ok", r.get("error")
assert _manifest_chapters(r) == {"portada", "correlacion", "glosario"}
# Eficiencia: correlacion no necesita los modelos → no se corrieron.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is None
assert (r["profile"] or {}).get("series") is None
# --------------------------------------------------------------------------- #
# Eficiencia y precedencia — vía stub (sin DuckDB).
# --------------------------------------------------------------------------- #
def _patch(monkeypatch, cap):
import pipelines.render_automatic_eda as mod
def fake_pt(db, t, **kw):
cap["run_models"] = kw.get("run_models")
cap["run_series"] = kw.get("run_series")
cap["run_llm"] = kw.get("run_llm")
return {"status": "ok", "profile": {"columns": []}}
def fake_ctx(db, t, prof, **kw):
cap["ctx_called"] = True
return {"db_path": db, "table": t}
cap["ctx_called"] = False
monkeypatch.setattr(mod, "profile_table", fake_pt)
monkeypatch.setattr(mod, "build_eda_render_ctx", fake_ctx)
monkeypatch.setattr(mod, "render_automatic_eda_pdf",
lambda *a, **k: {"path": "x.pdf", "n_pages": 1,
"manifest_path": "m.json"})
monkeypatch.setattr(mod, "render_automatic_eda_pptx",
lambda *a, **k: {"path": "x.pptx", "n_slides": 1})
monkeypatch.setattr(mod, "render_automatic_eda_markdown",
lambda *a, **k: {"path": "x.md", "n_chars": 1})
def test_only_geospatial_does_not_activate_cost_flags(monkeypatch):
"""Eficiencia: pedir solo geospatial NO corre modelos/serie/LLM."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["geospatial"])
assert cap["run_models"] is False
assert cap["run_series"] is False
assert cap["run_llm"] is False
def test_only_outliers_activates_run_models_via_deps(monkeypatch):
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"])
assert cap["run_models"] is True
assert cap["run_series"] is False
def test_explicit_flag_overrides_dependency_resolution(monkeypatch):
"""run_models=False explícito gana, aunque outliers lo pediría por deps."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"], run_models=False)
assert cap["run_models"] is False
def test_purely_aggregated_chapter_skips_render_ctx(monkeypatch):
"""num_distr solo lee el profile → build_eda_render_ctx no se llama."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["num_distr"])
assert cap["ctx_called"] is False, \
"num_distr no necesita datos crudos: el ctx no debe construirse"
def test_chapter_that_needs_ctx_builds_it(monkeypatch):
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"])
assert cap["ctx_called"] is True
# --------------------------------------------------------------------------- #
# EDGE — errores claros sin lanzar.
# --------------------------------------------------------------------------- #
def test_unknown_chapter_id_returns_clear_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t",
only_chapters=["no_existe"])
assert r["status"] == "error"
assert "no_existe" in r["error"]
assert "Capítulos válidos" in r["error"]
# Algún id válido conocido aparece en la lista.
assert "outliers" in r["error"]
def test_empty_only_list_returns_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t", only_chapters=[])
assert r["status"] == "error"
assert "vac" in r["error"].lower()
def test_only_chapters_not_a_list_returns_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t",
only_chapters="outliers")
assert r["status"] == "error"
def test_only_none_keeps_full_document(tmp_path):
"""Retro-compat: only_chapters=None genera el documento completo."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", out_dir=out, basename="full")
assert r["status"] == "ok", r.get("error")
chapters = _manifest_chapters(r)
# Documento completo: muchos más capítulos que portada/glosario.
assert {"portada", "glosario", "overview", "correlacion"} <= chapters
assert len(chapters) > 4
+2
View File
@@ -18,6 +18,7 @@ dependencies = [
"google-cloud-bigquery-storage>=2.27",
"google-cloud-storage>=3.10.1",
"httpx",
"langdetect>=1.0.9",
"matplotlib>=3.10.9",
"opencv-contrib-python-headless>=4.13.0.92",
"openpyxl>=3.1.5",
@@ -40,6 +41,7 @@ dependencies = [
"seaborn>=0.13.2",
"shapely>=2.1.2",
"statsmodels>=0.14.6",
"textstat>=0.7.13",
"trimesh>=4.12.2",
"xlrd>=2.0.2",
]
+96
View File
@@ -899,6 +899,7 @@ dependencies = [
{ name = "google-cloud-bigquery-storage" },
{ name = "google-cloud-storage" },
{ name = "httpx" },
{ name = "langdetect" },
{ name = "matplotlib" },
{ name = "opencv-contrib-python-headless" },
{ name = "openpyxl" },
@@ -906,9 +907,11 @@ dependencies = [
{ name = "polars" },
{ name = "pymeshlab" },
{ name = "pymssql" },
{ name = "pymupdf" },
{ name = "pypdf" },
{ name = "pyproj" },
{ name = "python-docx" },
{ name = "python-pptx" },
{ name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] },
{ name = "rapidfuzz" },
@@ -919,6 +922,7 @@ dependencies = [
{ name = "seaborn" },
{ name = "shapely" },
{ name = "statsmodels" },
{ name = "textstat" },
{ name = "trimesh" },
{ name = "xlrd" },
]
@@ -959,6 +963,7 @@ requires-dist = [
{ name = "jupyter-collaboration", marker = "extra == 'jupyter'", specifier = ">=2.0" },
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
{ name = "langdetect", specifier = ">=1.0.9" },
{ name = "matplotlib", specifier = ">=3.10.9" },
{ name = "opencv-contrib-python-headless", specifier = ">=4.13.0.92" },
{ name = "openpyxl", specifier = ">=3.1.5" },
@@ -966,9 +971,11 @@ requires-dist = [
{ name = "polars", specifier = ">=1.40.1" },
{ name = "pymeshlab", specifier = ">=2025.7.post1" },
{ name = "pymssql", specifier = ">=2.3.13" },
{ name = "pymupdf", specifier = ">=1.28.0" },
{ name = "pypdf", specifier = ">=6.10.0" },
{ name = "pyproj", specifier = ">=3.7.2" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-pptx", specifier = ">=1.0.2" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
{ name = "rapidfuzz", specifier = ">=3.14.5" },
@@ -979,6 +986,7 @@ requires-dist = [
{ name = "seaborn", specifier = ">=0.13.2" },
{ name = "shapely", specifier = ">=2.1.2" },
{ name = "statsmodels", specifier = ">=0.14.6" },
{ name = "textstat", specifier = ">=0.7.13" },
{ name = "trimesh", specifier = ">=4.12.2" },
{ name = "xlrd", specifier = ">=2.0.2" },
]
@@ -2198,6 +2206,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
]
[[package]]
name = "langdetect"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" }
[[package]]
name = "lark"
version = "1.3.1"
@@ -2699,6 +2716,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]]
name = "nltk"
version = "3.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "joblib" },
{ name = "regex" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
]
[[package]]
name = "notebook-shim"
version = "0.2.4"
@@ -3750,6 +3782,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/50/4be9bd9cf4b43208a7175117a533ece200cfe4131a39f9909bdc7560ddeb/pymssql-2.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:7d7037d2b5b907acc7906d0479924db2935a70c720450c41339146a4ada2b93d", size = 2049139, upload-time = "2026-02-14T05:00:23.951Z" },
]
[[package]]
name = "pymupdf"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/e9/6d6c5d6c0a3551bffd47681a6240caf941727f195b45593cf20ab36f018f/pymupdf-1.28.0.tar.gz", hash = "sha256:e53f3567403a92da15caa9e7ae0164327fff48817e9f40175367fb9de524258d", size = 87637751, upload-time = "2026-06-29T09:08:47.547Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/b7/88043e38cc7529de070f0c9bd267fa258035cca0b4ad5260536b994594a7/pymupdf-1.28.0-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:892b89ba88e8f98b53133b62877a9dc9b5e7dc6a4aeb837b612db56a8d2e03ac", size = 24597385, upload-time = "2026-06-29T09:03:30.608Z" },
{ url = "https://files.pythonhosted.org/packages/33/f4/23775bbda0781b61fc398cc75079a2b0e64696d8fcf93271748883e9627e/pymupdf-1.28.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4d692dcf44d3566ae96bc6f6346c6ad432274a29ba617bf7a9fe18009e24adb4", size = 23828292, upload-time = "2026-06-29T09:03:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f5/bf75fc7a415722f8b33662054f82d88520c0cbfd4c36d0e08aeaec605e49/pymupdf-1.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:47a5c29ed4eb0744de9c4e37bb49b1259b18d4d75fcc8a7c130f7c9fa15956f6", size = 25045507, upload-time = "2026-06-29T09:04:03.86Z" },
{ url = "https://files.pythonhosted.org/packages/58/69/5d12c9f1f2d76f28383d6110a069c79fbfced5a4f97bb1ee6e8354f52bb7/pymupdf-1.28.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44f0973f5e5edbaec95bc34b64e71d1959d4ee90b1328de1b4f4f5b4fa78673f", size = 25716599, upload-time = "2026-06-29T09:04:19.367Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b4/ec0e017bc42857cc86bd651441dbc41cc18be48d4698ecd27aac491e0c9a/pymupdf-1.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4d61ec323a706e153a12e262e51febfb43eeaa20977785ace135d18d48bcdc83", size = 25940489, upload-time = "2026-06-29T09:04:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/06/86/f831fef09013f33b3c9c09fb3923f2ff53e1e437f6ace14b8ae46392f558/pymupdf-1.28.0-cp310-abi3-win32.whl", hash = "sha256:caea2b3b67347fd79e5d15ed7929b0e886aac594ea228073b6d39de0078189da", size = 18489703, upload-time = "2026-06-29T20:50:30.599Z" },
{ url = "https://files.pythonhosted.org/packages/2e/5d/1a03f53eb0449900469335fcfc742ca28e3ba159b7d650e0921d50b8b308/pymupdf-1.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:e01e90fd86abfeb37ceb921eddb951f988a11d45ff6ce6b7664f2039849068ec", size = 19773102, upload-time = "2026-06-29T09:04:49.773Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/1e52ce243ca792254f6223b4017c5667194c146ce9b88baf37bc5eb3d1c9/pymupdf-1.28.0-cp313-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:74c6d00ba2a9aad3a635db73b07c15db462b480741d831a34a75a56535ebc22b", size = 18357011, upload-time = "2026-06-29T20:50:50.353Z" },
{ url = "https://files.pythonhosted.org/packages/62/b1/46b5b3d8ef3cc71114667cf10c4d8b33f39af97253af32e9a0986775b638/pymupdf-1.28.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b3e1399c7a64c6914239116a369efcdaac4cfb9e838bde2656d7accc4a85c72d", size = 25753599, upload-time = "2026-06-29T09:05:09.398Z" },
]
[[package]]
name = "pyogrio"
version = "0.12.1"
@@ -3811,6 +3860,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
]
[[package]]
name = "pyphen"
version = "0.17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" },
]
[[package]]
name = "pyproj"
version = "3.7.2"
@@ -3935,6 +3993,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" },
]
[[package]]
name = "python-pptx"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "pillow" },
{ name = "typing-extensions" },
{ name = "xlsxwriter" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -4936,6 +5009,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" },
]
[[package]]
name = "textstat"
version = "0.7.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nltk" },
{ name = "pyphen" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/0f/b673fcec5ad6e976b2e8368ef3651fe0fea3348a1191bacfcd41a17ddec6/textstat-0.7.13.tar.gz", hash = "sha256:a88d1da76287cd27ca4ce7bcba1ebaf2890544a5f0bb6a5758fa84cef3bceccb", size = 138932, upload-time = "2026-02-18T21:07:39.525Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/0eb4cc5bb021b4ceaaa602c59ba16ce99256b9dd30981bef3f3a53d8555f/textstat-0.7.13-py3-none-any.whl", hash = "sha256:04b1ec995d1e8b2e628759497e6b23204a9ec91dcd652447d8cbba9478f25471", size = 177050, upload-time = "2026-02-18T21:07:38.163Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -5312,6 +5399,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" },
]
[[package]]
name = "xlsxwriter"
version = "3.2.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
]
[[package]]
name = "xxhash"
version = "3.7.0"