Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 792b890195 | |||
| 9886e2905d | |||
| bebbd05de5 | |||
| 6fb6ef6cfe | |||
| 857c3d8637 | |||
| e5abc18211 | |||
| 4f1530797e | |||
| 9da1ee6533 | |||
| 9c1b7dd0f3 | |||
| 5d4a48ec5e | |||
| 7fa19d65db | |||
| 6e3c3cf2a2 | |||
| 105e56cf05 | |||
| eaca41a532 | |||
| 6a1520f458 | |||
| e815f5b3b9 | |||
| 7ec2bb1b45 | |||
| a1e2e3567c | |||
| 833597c831 | |||
| 7158be8142 | |||
| 9be84a48ea | |||
| fd63261444 | |||
| 4099d88eaf | |||
| 48de3ce3da | |||
| ab21e5d90b | |||
| da60211826 | |||
| aa5aa67d50 | |||
| 68f4ddabce | |||
| 43821ab11d | |||
| 32054ad781 | |||
| a2074a0167 | |||
| d001d90306 | |||
| 7045f37554 | |||
| fa8db01059 | |||
| 048781df3f | |||
| a421f13d2e | |||
| 13c82be780 |
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: paper-reviewer
|
||||||
|
description: "Revisor académico adversarial (read-only) para los papers del subsistema `papers/`. Recibe el directorio de un paper (`papers/<slug>/`) y su `preregistration.md`, y lo juzga sin piedad: puntúa novedad, rigor, reproducibilidad y validez (0-5 cada uno), intenta REFUTAR cada claim contra la evidencia citada, detecta HARKing contra el pre-registro, y emite un veredicto estructurado (accept|major_revision|reject) con default conservador. Es el gate anti paper-mill: NO modifica el paper, solo lo evalúa."
|
||||||
|
model: opus
|
||||||
|
tools: Read, Grep, Glob, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agente Paper-Reviewer — peer review adversarial
|
||||||
|
|
||||||
|
Eres un revisor académico **hostil pero justo**. Tu trabajo NO es ayudar al autor a sentirse bien: es proteger la integridad del registro científico. Asumes la posición de un revisor de conferencia top que ha visto cientos de papers inflados y sabe oler el humo. Por defecto **desconfías** de cada afirmación hasta que la evidencia citada la sostenga. Eres específico, citas líneas y archivos, y no rellenas con elogios.
|
||||||
|
|
||||||
|
Este agente es el **gate anti paper-mill** del subsistema `papers/`. El riesgo que combates: papers que *parecen* rigurosos (estructura IMRaD impecable, lenguaje académico, tablas bonitas) pero sin sustancia — hipótesis que no podían fallar, estadística de teatro, claims que exceden la evidencia, análisis inventados después de ver los datos. Si no hubo riesgo real de refutación, no es un paper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REGLA FUNDAMENTAL: read-only, solo juzgas
|
||||||
|
|
||||||
|
- **Lectura:** `paper.md`, `preregistration.md`, `references.md`/`.bib`, y todo lo que haya en `experiments/`, `data/`, `figures/`, `reviews/` del paper.
|
||||||
|
- **Escritura:** NINGUNA. No tienes Edit ni Write. No modificas el paper, no arreglas su prosa, no corriges sus tablas. Solo emites un veredicto.
|
||||||
|
- **Bash es read-only:** úsalo para inspeccionar evidencia (`ls`, `cat`, `head`, `wc`, `grep`, re-correr un script de análisis que YA exista en `experiments/` para verificar un número reportado, contar filas de un dataset, comprobar que una figura referenciada existe). NUNCA escribas archivos, NUNCA borres, NUNCA mutes estado externo (sin red con efectos, sin deploys).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
Recibes el path de un directorio de paper:
|
||||||
|
|
||||||
|
- `paper_dir` (ej. `papers/0001-bucle-reactivo-calls`). Dentro esperas al menos `paper.md`; idealmente también `preregistration.md`, `experiments/`, `data/`, `figures/`.
|
||||||
|
|
||||||
|
Si falta `paper.md`, reporta que no hay paper que revisar y sal. Si falta `preregistration.md`, NO es excusa para aprobar: la ausencia de pre-registro es en sí misma una **amenaza grave a la validez** (no puedes distinguir análisis confirmatorios de exploratorios) y debe bajar el eje de rigor y reproducibilidad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Algoritmo de revisión
|
||||||
|
|
||||||
|
### 1. Lee todo el material primero
|
||||||
|
|
||||||
|
- `paper.md` completo (frontmatter + cuerpo IMRaD).
|
||||||
|
- `preregistration.md` (H0/H1, plan de análisis congelado, timestamp/hash si lo tiene).
|
||||||
|
- Inventaria la evidencia: `ls -R experiments/ data/ figures/`. Anota qué tablas, figuras, scripts y datasets existen REALMENTE en disco.
|
||||||
|
- Si hay `reviews/` previos, léelos para no repetir y para ver si el autor respondió a críticas anteriores.
|
||||||
|
|
||||||
|
No puntúes nada hasta haber leído el material. Una revisión sin abrir la evidencia es la enfermedad que combates.
|
||||||
|
|
||||||
|
### 2. Extrae y enumera los CLAIMS
|
||||||
|
|
||||||
|
Recorre Results y Discussion. Lista cada **afirmación de resultado** verificable (no las de contexto). Ejemplos de claim: "el método A reduce el error un 23%", "la diferencia es significativa (p<0.01)", "el efecto es grande (d=0.8)", "el patrón se mantiene en los 3 datasets". Para cada claim anota la evidencia que el paper cita (tabla X, figura Y, sección de `experiments/`).
|
||||||
|
|
||||||
|
### 3. Intenta REFUTAR cada claim
|
||||||
|
|
||||||
|
Para cada claim, posición de partida: **"no soportada"**. Solo lo marcas "soportada" si:
|
||||||
|
|
||||||
|
- La evidencia citada EXISTE en disco (la tabla/figura/dato está realmente ahí, no solo mencionada).
|
||||||
|
- El número del texto COINCIDE con el de la evidencia (si puedes re-derivarlo de un script o un CSV en `experiments/`/`data/`, hazlo con Bash y compáralo).
|
||||||
|
- La inferencia es válida: el claim no extrapola más allá de lo que el dato muestra (no confunde correlación con causalidad sin diseño que lo permita; no generaliza fuera de la población muestreada).
|
||||||
|
|
||||||
|
Si la evidencia no aparece, si el número no cuadra, o si no puedes reproducir el cálculo con lo descrito → claim **no soportada**. Apúntala en `claims_unsupported` con el motivo concreto (qué falta, qué no cuadra).
|
||||||
|
|
||||||
|
### 4. Puntúa los 4 ejes (0-5 cada uno)
|
||||||
|
|
||||||
|
Sé tacaño. 5 es excepcional y raro; 3 es "aceptable con reservas"; 0-2 es rechazo en ese eje. Justifica cada número con una frase concreta.
|
||||||
|
|
||||||
|
- **novelty (novedad):** ¿el paper aporta algo que no se sabía? ¿El gap está articulado y la contribución es explícita y real, o es un resultado obvio/ya conocido revestido de novedad? Related work honesto (reconoce lo que ya existe) sube; reinventar la rueda baja.
|
||||||
|
- **rigor:** método reproducible y estadística correcta. Exige: **effect size + intervalos de confianza**, no solo `p<0.05`; **corrección por comparaciones múltiples** (Holm-Bonferroni o similar) si se testean varias hipótesis; N justificado (no insuficiente); ausencia de p-hacking/cherry-picking. Estadística de teatro (p-valor suelto sin tamaño de efecto, "tendencia hacia la significancia", N=3 presentado como concluyente) hunde este eje.
|
||||||
|
- **reproducibility (reproducibilidad):** ¿otra persona puede re-correr el experimento con lo descrito? Exige protocolo, datos accesibles (o su descripción), código en `experiments/`, semillas/versiones. Si tú mismo no podrías reproducirlo con lo que hay, el eje es bajo. Pre-registro presente y seguido sube; ausente baja.
|
||||||
|
- **validity (validez):** las cuatro validez de Shadish/Cook/Campbell — **interna** (¿la causa es realmente la causa, o hay confusores?), **externa** (¿generaliza fuera de esta muestra?), **de constructo** (¿se mide lo que se dice medir?), **estadística** (¿las inferencias estadísticas son legítimas?). El paper debe DECLARAR sus amenazas a la validez. Amenazas no declaradas que tú detectas → bajan el eje y van a `gaps`.
|
||||||
|
|
||||||
|
### 5. Chequea coherencia con el pre-registro (HARKing)
|
||||||
|
|
||||||
|
Compara los análisis REPORTADOS en Results contra los PRE-REGISTRADOS en `preregistration.md`:
|
||||||
|
|
||||||
|
- ¿Los análisis confirmatorios presentados son exactamente los pre-registrados? Si aparecen análisis NO declarados presentados como si fueran confirmatorios → **HARKing** (Hypothesizing After Results are Known). Marca `harking_detected: true`.
|
||||||
|
- ¿Hay análisis pre-registrados que desaparecieron del paper (resultados incómodos enterrados)? Eso es cherry-picking — anótalo en `gaps`.
|
||||||
|
- Análisis exploratorios son legítimos SOLO si el paper los etiqueta honestamente como exploratorios (generan hipótesis, no las confirman). Presentar exploratorio como confirmatorio = HARKing.
|
||||||
|
- Si no hay `preregistration.md`, no puedes verificar esto: anótalo como amenaza grave y trata todos los resultados como potencialmente exploratorios.
|
||||||
|
|
||||||
|
### 6. Verifica honestidad: limitaciones y overclaiming
|
||||||
|
|
||||||
|
- ¿Hay una sección de **limitaciones / amenazas a la validez** declarada honestamente? Su ausencia es una bandera roja: ningún estudio real está libre de limitaciones.
|
||||||
|
- ¿Las **claims ≤ evidencia**? Compara el lenguaje de las conclusiones con lo que los datos permiten. "demostramos que X causa Y" sobre un diseño correlacional = **overclaiming**. "el método es superior" sobre un solo dataset = overclaiming. Lista cada overclaim en `gaps`.
|
||||||
|
|
||||||
|
### 7. Emite el veredicto
|
||||||
|
|
||||||
|
Default conservador. Reglas de decisión:
|
||||||
|
|
||||||
|
- **reject** si: hay claims no soportadas centrales al paper, O HARKing detectado, O rigor ≤ 2, O validez ≤ 2, O no hay riesgo real de refutación (la hipótesis no podía fallar).
|
||||||
|
- **major_revision** si: el núcleo es salvable pero hay gaps serios (evidencia incompleta, estadística mejorable, amenazas no declaradas, pre-registro ausente) — el caso por defecto cuando algo falta pero no es fraude.
|
||||||
|
- **accept** SOLO si: los 4 ejes ≥ 3, cero claims no soportadas centrales, sin HARKing, limitaciones declaradas, claims ≤ evidencia, reproducible. Es raro y hay que ganárselo.
|
||||||
|
|
||||||
|
Ante la duda, baja, no subas. Es preferible un major_revision injusto que dejar pasar un paper-mill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output (formato obligatorio)
|
||||||
|
|
||||||
|
Devuelve un bloque JSON con EXACTAMENTE esta forma, seguido de un párrafo corto de justificación en prosa (crítico y específico, sin elogios de relleno):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scores": {
|
||||||
|
"novelty": 0,
|
||||||
|
"rigor": 0,
|
||||||
|
"reproducibility": 0,
|
||||||
|
"validity": 0
|
||||||
|
},
|
||||||
|
"claims_unsupported": [
|
||||||
|
"Claim '<texto>': <por qué no está soportada — evidencia ausente / número no cuadra / inferencia inválida>"
|
||||||
|
],
|
||||||
|
"harking_detected": false,
|
||||||
|
"gaps": [
|
||||||
|
"<amenaza a la validez no declarada / overclaim / estadística faltante / dato no reproducible>"
|
||||||
|
],
|
||||||
|
"verdict": "reject"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas del output:
|
||||||
|
|
||||||
|
- `scores`: enteros 0-5. Tacaño por defecto.
|
||||||
|
- `claims_unsupported`: una entrada por claim que no superó la refutación, con el motivo concreto. Lista vacía solo si TODAS las claims se sostuvieron contra la evidencia.
|
||||||
|
- `harking_detected`: `true` en cuanto detectes un análisis confirmatorio no pre-registrado, o si la ausencia de pre-registro impide descartarlo (en ese caso explícalo en `gaps`).
|
||||||
|
- `gaps`: amenazas a la validez no declaradas, overclaims, estadística de teatro, datos no reproducibles. Concreto y accionable.
|
||||||
|
- `verdict`: `accept` | `major_revision` | `reject`. Default conservador según las reglas de la sección 7.
|
||||||
|
|
||||||
|
El párrafo de prosa que sigue al JSON resume el veredicto en lenguaje directo: qué hunde el paper o qué falta para subir de nivel. Sin "buen trabajo", sin "interesante contribución" de relleno — solo señal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tono y anti-patrones
|
||||||
|
|
||||||
|
- **Crítico y específico.** "La tabla 2 reporta p=0.03 pero no da tamaño de efecto ni CI; con N=4 esto no sostiene el claim de la sección 4.2" — no "la estadística podría mejorarse".
|
||||||
|
- **Cita evidencia.** Siempre `archivo:línea` o `tabla/figura X`. Una crítica sin cita es ruido.
|
||||||
|
- **No inventes mérito.** Si el paper no aporta novedad, dilo. El sesgo de complacencia es el que alimenta los paper-mills.
|
||||||
|
- **No arregles el paper.** No es tu trabajo (no tienes Write). Tu trabajo es el veredicto. Sugiere QUÉ falta, no escribas el fix.
|
||||||
|
- **Default a fallar.** Evidencia ausente = claim no soportada. Pre-registro ausente = no se puede descartar HARKing. Duda = baja la nota.
|
||||||
|
|
||||||
|
## Relación con el ecosistema
|
||||||
|
|
||||||
|
- Es la materialización del **paso 9 (peer review)** del proceso de 10 pasos del subsistema `papers/` (ver `reports/0001-2026-06-30-papers-system-design.md`), heredando el patrón de **verificador adversarial** del modo orquestador (`.claude/rules/orchestration.md`): un juez independiente que por defecto refuta y solo aprueba con evidencia.
|
||||||
|
- Sus outputs se guardan en `papers/<slug>/reviews/` para trazar la evolución del paper entre revisiones.
|
||||||
|
- Complementa el `preregister_hypothesis` (rigor experimental, congela la hipótesis antes de los datos) y `render_paper_pdf` (entrega): este agente es el control de calidad que decide si el paper merece convertirse en PDF entregable o volver a revisión.
|
||||||
@@ -54,6 +54,13 @@ reports/*
|
|||||||
!reports/.gitkeep
|
!reports/.gitkeep
|
||||||
projects/*/reports/
|
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 / pnpm
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: next_numbered_dir
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: io
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "next_numbered_dir(parent_dir: string, [width: int]) -> string"
|
||||||
|
description: "Calcula el siguiente prefijo numerico NNNN- para un directorio numerado incremental. Escanea los subdirectorios directos de parent_dir cuyo nombre empiece por NNNN- (4+ digitos seguidos de guion), toma el maximo, le suma 1 y lo imprime con zero-padding al ancho width (default 4). Si parent_dir no existe o no tiene subdirs que matcheen, imprime 0001."
|
||||||
|
tags: [papers, io, scaffold]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: parent_dir
|
||||||
|
desc: "directorio padre cuyos subdirectorios numerados (NNNN-...) se escanean; obligatorio"
|
||||||
|
- name: width
|
||||||
|
desc: "ancho del zero-padding del numero impreso (default 4); opcional"
|
||||||
|
output: "el siguiente numero como string con zero-padding a width digitos a stdout (ej. 0003); usage a stderr y exit 1 si falta parent_dir"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/io/next_numbered_dir.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/io/next_numbered_dir.sh
|
||||||
|
|
||||||
|
# Sobre un papers/ que ya contiene 0001-foo y 0002-bar
|
||||||
|
mkdir -p /tmp/papers/{0001-foo,0002-bar}
|
||||||
|
next_numbered_dir /tmp/papers
|
||||||
|
# -> 0003
|
||||||
|
|
||||||
|
# Directorio vacio o inexistente -> primer numero
|
||||||
|
next_numbered_dir /tmp/papers_nuevo
|
||||||
|
# -> 0001
|
||||||
|
|
||||||
|
# Ancho de padding distinto
|
||||||
|
next_numbered_dir /tmp/papers 6
|
||||||
|
# -> 000003
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando scaffoldees un artefacto numerado incremental (papers/, reports/, issues/) y necesites el siguiente NNNN sin colision: escanea lo que ya existe en disco y te da el numero libre listo para crear `<NNNN>-<slug>`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: lee el filesystem (estado del directorio en el momento de la llamada). No crea nada — solo calcula e imprime el numero.
|
||||||
|
- **Octal**: los numeros con cero a la izquierda (`08`, `09`) se interpretan como octal en aritmetica bash y romperian el calculo. La funcion fuerza base 10 con `10#$num` para evitarlo.
|
||||||
|
- **Solo subdirectorios**: cuenta unicamente subdirs directos. Archivos sueltos (`.gitkeep`, `notas.md`) y subdirs que no matcheen el patron se ignoran. No es recursivo.
|
||||||
|
- **Patron estricto**: el prefijo debe ser `NNNN-` (minimo 4 digitos seguidos de guion). Un subdir `12-foo` o `0001foo` (sin guion) NO se cuenta.
|
||||||
|
- No hay deteccion de huecos: devuelve `max+1`, no el primer numero libre intermedio. Si tienes `0001` y `0003`, devuelve `0004`, no `0002`.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# next_numbered_dir — Compute the next NNNN- prefix for a numbered directory.
|
||||||
|
#
|
||||||
|
# Scans the DIRECT subdirectories of <parent_dir> whose names start with a
|
||||||
|
# numeric prefix of the form `NNNN-` (4+ digits followed by a hyphen), takes
|
||||||
|
# the maximum number, adds 1, and prints it zero-padded to <width> (default 4).
|
||||||
|
# If <parent_dir> does not exist or contains no matching subdir, prints the
|
||||||
|
# first number (0001 at default width).
|
||||||
|
|
||||||
|
next_numbered_dir() {
|
||||||
|
local parent_dir="${1:-}"
|
||||||
|
local width="${2:-4}"
|
||||||
|
|
||||||
|
if [[ -z "$parent_dir" ]]; then
|
||||||
|
echo "usage: next_numbered_dir <parent_dir> [width]" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local max=0
|
||||||
|
local entry base num
|
||||||
|
|
||||||
|
if [[ -d "$parent_dir" ]]; then
|
||||||
|
# Iterate only over direct subdirectories. The trailing slash in the
|
||||||
|
# glob ensures files (e.g. .gitkeep) are skipped — only dirs match.
|
||||||
|
for entry in "$parent_dir"/*/; do
|
||||||
|
# If the glob matched nothing it stays literal; guard with -d.
|
||||||
|
[[ -d "$entry" ]] || continue
|
||||||
|
base="$(basename "$entry")"
|
||||||
|
# Require a prefix of 4+ digits followed by a hyphen.
|
||||||
|
if [[ "$base" =~ ^([0-9]{4,})- ]]; then
|
||||||
|
num="${BASH_REMATCH[1]}"
|
||||||
|
# Force base 10 so leading zeros (08, 09) are not read as octal.
|
||||||
|
num=$((10#$num))
|
||||||
|
if (( num > max )); then
|
||||||
|
max=$num
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "%0*d\n" "$width" $(( max + 1 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
next_numbered_dir "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: init_paper
|
||||||
|
kind: pipeline
|
||||||
|
lang: bash
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "init_paper(slug: string, [--title <t>] [--domain <d>] [--tags <csv>]) -> void"
|
||||||
|
description: "Scaffold de un paper académico reproducible en papers/<NNNN-slug>/. Calcula el siguiente número incremental escaneando papers/, crea las subcarpetas (experiments data figures reviews out), copia las plantillas paper.md (IMRaD) + preregistration.md (anti-HARKing) rellenando el frontmatter (title, slug, date de hoy, phase=question, status=draft) y crea references.md. NO hace git init: el paper arranca en fase interna local (papers/ gitignored). Grupo de capacidad papers."
|
||||||
|
tags: [papers, scaffold, paper, pipeline, bash, launcher]
|
||||||
|
uses_functions:
|
||||||
|
- next_numbered_dir_bash_io
|
||||||
|
- slugify_ascii_py_core
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: slug
|
||||||
|
desc: "identificador legible del paper; se slugifica a ASCII (espacios/acentos se normalizan) y se prefija con el siguiente NNNN incremental"
|
||||||
|
- name: "--title"
|
||||||
|
desc: "título del paper (string); si se omite, usa el slug limpio. No debe contener el carácter '|'"
|
||||||
|
- name: "--domain"
|
||||||
|
desc: "dominio del paper escrito en el frontmatter (default datascience)"
|
||||||
|
- name: "--tags"
|
||||||
|
desc: "tags CSV que se escriben en el frontmatter de paper.md (opcional)"
|
||||||
|
output: "sin salida directa; crea papers/<NNNN-slug>/ con paper.md, preregistration.md, references.md y las subcarpetas experiments/ data/ figures/ reviews/ out/. Imprime el resumen y los pasos siguientes a stdout."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/pipelines/init_paper.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scaffold de un paper nuevo (numera 0001, 0002, ... automáticamente)
|
||||||
|
fn run init_paper mi-primer-paper --title "Mi primer paper"
|
||||||
|
fn run init_paper reactive-loop-calls --domain datascience --tags registry,telemetria
|
||||||
|
|
||||||
|
# El slug se slugifica: "Áreas de Mejora" -> papers/0003-areas-de-mejora/
|
||||||
|
fn run init_paper "Áreas de Mejora"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando empiezas un paper académico nuevo dentro de `fn_registry` y necesitas el esqueleto del artefacto (`papers/<NNNN-slug>/`) con las plantillas IMRaD y de pre-registro listas para rellenar. Es el paso 1 del grupo de capacidad `papers` (ver `docs/capabilities/papers.md`), antes de la revisión de literatura y del pre-registro de la hipótesis.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. Parsea `<slug>` (posicional) + flags `--title` / `--domain` / `--tags`. Falla con exit ≠ 0 si falta el slug.
|
||||||
|
2. `slugify_ascii` — normaliza el slug a ASCII lowercase sin diacríticos (reutiliza la función del registry, solo stdlib).
|
||||||
|
3. `next_numbered_dir papers/` — calcula el siguiente NNNN de 4 dígitos sin colisión.
|
||||||
|
4. Crea `papers/<NNNN-slug>/` con las subcarpetas `experiments/ data/ figures/ reviews/ out/`.
|
||||||
|
5. Copia `docs/templates/paper.md` + `docs/templates/preregistration.md` y rellena el frontmatter por clave de línea (title, slug, date de hoy, domain, tags; phase=question y status=draft vienen de la plantilla).
|
||||||
|
6. Crea `references.md` vacío.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **NO hace `git init`.** El paper arranca en fase interna local; `papers/` está gitignored en el repo padre (solo `papers/.gitkeep` se versiona). Promocionar a sub-repo Gitea (fase publishable) es manual.
|
||||||
|
- **El `--title` no debe contener el carácter `|`** (se usa como delimitador de sed al rellenar el frontmatter; los `&` y `\` sí se escapan).
|
||||||
|
- **No indexa el paper en `registry.db`** — los artefactos `papers/<slug>/` no se indexan en esta fase (KISS); sí se indexa este pipeline.
|
||||||
|
- Requiere `python3` (del venv del registry o del sistema) para slugificar; `slugify_ascii` solo usa stdlib, así que el venv no es obligatorio.
|
||||||
|
- Idempotencia: si el directorio destino ya existiera, aborta con exit ≠ 0 en vez de sobrescribir.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Cada paper es un artefacto independiente (mismo patrón que `apps/` y `analysis/`, pero para investigación). El pipeline usa `set -euo pipefail`: cualquier fallo detiene la ejecución. Parte del grupo de capacidad `papers` — diseño completo en `reports/0001-2026-06-30-papers-system-design.md`.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# init_paper
|
||||||
|
# ----------
|
||||||
|
# Scaffold de un paper académico reproducible en papers/<NNNN-slug>/.
|
||||||
|
#
|
||||||
|
# Calcula el siguiente número incremental escaneando papers/, crea el
|
||||||
|
# directorio con todas las subcarpetas (experiments data figures reviews out),
|
||||||
|
# copia las plantillas paper.md + preregistration.md rellenando el frontmatter
|
||||||
|
# (title, slug, date de hoy, phase=question, status=draft) y crea references.md.
|
||||||
|
#
|
||||||
|
# NO hace `git init`: el paper arranca en fase interna local (papers/ está
|
||||||
|
# gitignored en el repo padre, solo .gitkeep se versiona). La promoción a
|
||||||
|
# sub-repo Gitea (fase publishable) es un paso posterior MANUAL.
|
||||||
|
#
|
||||||
|
# Compone: next_numbered_dir (helper de numeración del registry) +
|
||||||
|
# slugify_ascii (slug ASCII del registry).
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# ./init_paper.sh <slug> [--title "..."] [--domain <d>] [--tags a,b,c]
|
||||||
|
#
|
||||||
|
# EJEMPLOS:
|
||||||
|
# ./init_paper.sh mi-primer-paper --title "Mi primer paper"
|
||||||
|
# ./init_paper.sh reactive-loop-calls --domain datascience --tags registry,telemetria
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
|
||||||
|
# Funciones atómicas del registry
|
||||||
|
source "$REGISTRY_ROOT/bash/functions/io/next_numbered_dir.sh"
|
||||||
|
|
||||||
|
# ── Parsing de argumentos ────────────────────────────────────
|
||||||
|
|
||||||
|
SLUG_RAW=""
|
||||||
|
TITLE=""
|
||||||
|
DOMAIN="datascience"
|
||||||
|
TAGS=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--title)
|
||||||
|
TITLE="$2"; shift 2 ;;
|
||||||
|
--domain)
|
||||||
|
DOMAIN="$2"; shift 2 ;;
|
||||||
|
--tags)
|
||||||
|
TAGS="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
|
||||||
|
-*)
|
||||||
|
echo "Flag desconocido: $1" >&2 ; exit 1 ;;
|
||||||
|
*)
|
||||||
|
if [ -z "$SLUG_RAW" ]; then
|
||||||
|
SLUG_RAW="$1"
|
||||||
|
else
|
||||||
|
echo "ERROR: argumento posicional inesperado: '$1' (solo se admite un <slug>)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SLUG_RAW" ]; then
|
||||||
|
echo "ERROR: falta el argumento <slug>." >&2
|
||||||
|
echo "Uso: $0 <slug> [--title \"...\"] [--domain <d>] [--tags a,b,c]" >&2
|
||||||
|
echo " Ejemplo: $0 mi-primer-paper --title \"Mi primer paper\"" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Slugificar (reutiliza slugify_ascii del registry; solo stdlib) ──
|
||||||
|
|
||||||
|
PYBIN="$REGISTRY_ROOT/python/.venv/bin/python3"
|
||||||
|
[ -x "$PYBIN" ] || PYBIN="$(command -v python3 || true)"
|
||||||
|
if [ -z "$PYBIN" ]; then
|
||||||
|
echo "ERROR: no se encontró python3 para slugificar el slug." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SLUG_CLEAN=$("$PYBIN" -c '
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(sys.argv[2], "python", "functions"))
|
||||||
|
from core.slugify_ascii import slugify_ascii
|
||||||
|
print(slugify_ascii(sys.argv[1], default="paper"))
|
||||||
|
' "$SLUG_RAW" "$REGISTRY_ROOT")
|
||||||
|
|
||||||
|
# ── Resolver número incremental y directorio destino ─────────
|
||||||
|
|
||||||
|
PAPERS_DIR="$REGISTRY_ROOT/papers"
|
||||||
|
mkdir -p "$PAPERS_DIR"
|
||||||
|
|
||||||
|
NUM=$(next_numbered_dir "$PAPERS_DIR")
|
||||||
|
SLUG_FULL="${NUM}-${SLUG_CLEAN}"
|
||||||
|
PAPER_DIR="$PAPERS_DIR/$SLUG_FULL"
|
||||||
|
|
||||||
|
if [ -d "$PAPER_DIR" ]; then
|
||||||
|
echo "ERROR: el directorio del paper ya existe: $PAPER_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
[ -n "$TITLE" ] || TITLE="$SLUG_CLEAN"
|
||||||
|
|
||||||
|
TAGS_YAML="[]"
|
||||||
|
if [ -n "$TAGS" ]; then
|
||||||
|
TAGS_YAML="[$(echo "$TAGS" | sed 's/,/, /g')]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo " INIT PAPER: ${SLUG_FULL}"
|
||||||
|
echo " Título: ${TITLE}"
|
||||||
|
echo " Directorio: ${PAPER_DIR}"
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Crear estructura ─────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "[1/3] Creando estructura..."
|
||||||
|
mkdir -p "$PAPER_DIR"/experiments "$PAPER_DIR"/data "$PAPER_DIR"/figures \
|
||||||
|
"$PAPER_DIR"/reviews "$PAPER_DIR"/out
|
||||||
|
echo " experiments/ data/ figures/ reviews/ out/"
|
||||||
|
|
||||||
|
# ── Copiar plantillas + rellenar frontmatter ─────────────────
|
||||||
|
|
||||||
|
echo "[2/3] Escribiendo paper.md + preregistration.md..."
|
||||||
|
|
||||||
|
# Escapa caracteres especiales del RHS de sed (delimitador |)
|
||||||
|
sed_escape() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; }
|
||||||
|
TITLE_ESC="$(sed_escape "$TITLE")"
|
||||||
|
DOMAIN_ESC="$(sed_escape "$DOMAIN")"
|
||||||
|
|
||||||
|
PAPER_MD="$PAPER_DIR/paper.md"
|
||||||
|
PREREG_MD="$PAPER_DIR/preregistration.md"
|
||||||
|
|
||||||
|
cp "$REGISTRY_ROOT/docs/templates/paper.md" "$PAPER_MD"
|
||||||
|
cp "$REGISTRY_ROOT/docs/templates/preregistration.md" "$PREREG_MD"
|
||||||
|
|
||||||
|
sed -i \
|
||||||
|
-e "s|^title:.*|title: \"${TITLE_ESC}\"|" \
|
||||||
|
-e "s|^slug:.*|slug: ${SLUG_FULL}|" \
|
||||||
|
-e "s|^date:.*|date: ${TODAY}|" \
|
||||||
|
-e "s|^domain:.*|domain: ${DOMAIN_ESC}|" \
|
||||||
|
-e "s|^tags:.*|tags: ${TAGS_YAML}|" \
|
||||||
|
"$PAPER_MD"
|
||||||
|
|
||||||
|
sed -i \
|
||||||
|
-e "s|^paper_slug:.*|paper_slug: ${SLUG_FULL}|" \
|
||||||
|
"$PREREG_MD"
|
||||||
|
|
||||||
|
echo " $PAPER_MD"
|
||||||
|
echo " $PREREG_MD"
|
||||||
|
|
||||||
|
# ── references.md ────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "[3/3] Escribiendo references.md..."
|
||||||
|
cat > "$PAPER_DIR/references.md" << EOF
|
||||||
|
# References — ${TITLE}
|
||||||
|
|
||||||
|
<!-- Una entrada por referencia. Formato libre (o BibTeX) hasta promocionar a publishable. -->
|
||||||
|
EOF
|
||||||
|
echo " $PAPER_DIR/references.md"
|
||||||
|
|
||||||
|
# ── Resumen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo " PAPER '${SLUG_FULL}' LISTO (fase: question, status: draft)"
|
||||||
|
echo "════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo " Pasos siguientes:"
|
||||||
|
echo " 1. Revisión de literatura (skill /deep-research) → Related work."
|
||||||
|
echo " 2. Pre-registro: congela H0/H1 + plan en preregistration.md (preregister_hypothesis)."
|
||||||
|
echo " 3. Experimentos en experiments/ → análisis (grupo eda) → escritura IMRaD en paper.md."
|
||||||
|
echo " 4. render_paper_pdf → out/paper.pdf. Peer review adversarial → reviews/."
|
||||||
|
echo ""
|
||||||
|
echo " papers/ está gitignored: este paper vive local hasta promocionar a publishable."
|
||||||
|
echo ""
|
||||||
@@ -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 |
|
| [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 |
|
| [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`) |
|
| [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` |
|
| [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` |
|
| [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 |
|
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# papers — papers académicos reproducibles
|
||||||
|
|
||||||
|
Grupo de capacidad para producir **papers académicos** dentro de `fn_registry`: investigación con hipótesis falsables, experimentos reproducibles, análisis estadístico honesto y escritura en formato IMRaD. Cada paper es un artefacto nuevo en `papers/<NNNN-slug>/` que reutiliza infraestructura existente (skill `deep-research` para la revisión de literatura, grupo `eda` para el análisis, motor md→PDF de `datascience`, patrón de verificación adversarial del orquestador) y añade lo que falta como funciones del registry.
|
||||||
|
|
||||||
|
Diseño completo y decisiones: `reports/0001-2026-06-30-papers-system-design.md`.
|
||||||
|
|
||||||
|
> **Regla de oro anti paper-mill:** una hipótesis que **podía** fallar + un experimento con riesgo real de refutación + estadística que no es teatro. Si no hay riesgo de refutación, no es un paper. Los claims nunca superan a la evidencia. El antídoto al HARKing es el **pre-registro**: el plan de análisis se congela *antes* de mirar los datos.
|
||||||
|
|
||||||
|
## Estructura del artefacto
|
||||||
|
|
||||||
|
```
|
||||||
|
papers/0001-mi-paper/
|
||||||
|
paper.md # frontmatter (title, slug, authors, date, status, phase, tags, domain, hypothesis_id) + cuerpo IMRaD
|
||||||
|
preregistration.md # H0/H1 + plan de análisis CONGELADO (frozen_at + content_hash) antes de correr
|
||||||
|
references.md # bibliografía
|
||||||
|
experiments/ # código / notebooks por experimento (exp01_*, exp02_*)
|
||||||
|
data/ # crudos + procesados (gitignored si pesa)
|
||||||
|
figures/ # gráficos generados
|
||||||
|
reviews/ # outputs del peer-review adversarial
|
||||||
|
out/ # paper.pdf — entregable final
|
||||||
|
.git/ # SOLO cuando promociona a fase publishable (sub-repo Gitea)
|
||||||
|
```
|
||||||
|
|
||||||
|
`papers/` está gitignored en el repo padre (solo `papers/.gitkeep` se versiona): un paper en fase interna no contamina el repo. Al promocionar a `status: publishable` se vuelve sub-repo Gitea `dataforge/<slug>` (como apps y analyses).
|
||||||
|
|
||||||
|
### Fases (campo `phase` de `paper.md`)
|
||||||
|
|
||||||
|
```
|
||||||
|
question → review → hypothesis → design → running → analysis → writing → internal-review
|
||||||
|
→ [DONE interno] → polish → submitted [solo en fase publishable]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Pureza | Estado | Qué hace |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `init_paper_bash_pipelines` | impure | ✅ disponible | Scaffold de `papers/<NNNN-slug>/`: calcula el siguiente NNNN, crea las subcarpetas, copia `paper.md` + `preregistration.md` con el frontmatter relleno (slug, title, date de hoy, `phase: question`, `status: draft`) y `references.md` vacío. NO hace `git init` (el paper arranca en fase interna local). |
|
||||||
|
| `next_numbered_dir_bash_io` | impure | ✅ disponible | Dado un directorio, devuelve el siguiente número incremental de 4 dígitos (`0001`, `0002`, …) escaneando los subdirs con prefijo `NNNN-`. Helper de numeración de `init_paper` (reutilizable por reports/issues). |
|
||||||
|
| `preregister_hypothesis` | impure | 🚧 en construcción (flota) | Congela el `preregistration.md` (H0/H1 + plan de análisis) con `frozen_at` + `content_hash`, pasa `status` a `frozen` y escribe `hypothesis_id` en `paper.md`. Mata el HARKing: tras congelar, el plan no se edita. |
|
||||||
|
| `cohens_d` (effect size) | pure | 🚧 en construcción (flota) | Tamaño del efecto (Cohen's d) entre dos grupos. Reporta magnitud, no solo significancia. |
|
||||||
|
| `confidence_interval` | pure | 🚧 en construcción (flota) | Intervalo de confianza de una métrica (media/diferencia). |
|
||||||
|
| `holm_bonferroni` | pure | 🚧 en construcción (flota) | Corrección de comparaciones múltiples (Holm-Bonferroni / FWER) para el plan de análisis. |
|
||||||
|
| `render_paper_pdf` | impure | 🚧 en construcción (flota) | Markdown IMRaD (`paper.md` + figuras) → `out/paper.pdf`, reutilizando el motor md→PDF del grupo `eda`/`datascience`. |
|
||||||
|
|
||||||
|
> Las funciones estadísticas reutilizan lo que ya exista en `datascience` (p.ej. `fdr_correction_py_datascience` cubre la corrección de comparaciones múltiples por FDR; el agente del rigor experimental decide si añade Holm-Bonferroni o reusa lo existente). Buscar antes de duplicar: `mcp__registry__fn_search query="effect size" domain="datascience"`.
|
||||||
|
|
||||||
|
### Peer review (no es función del registry)
|
||||||
|
|
||||||
|
El agente adversarial `.claude/agents/paper-reviewer.md` (🚧 en construcción por la flota) puntúa novedad, rigor, reproducibilidad y validez, e intenta **refutar** cada claim. Default a "failed" si la evidencia no soporta. Escribe su veredicto en `reviews/`. Es el equivalente al verificador adversarial del orquestador aplicado al paper.
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Scaffold del paper (fase question, local). Crea papers/0001-mi-paper/.
|
||||||
|
./fn run init_paper mi-paper --title "¿El bucle reactivo reduce las calls inline?" --domain datascience --tags registry,telemetria
|
||||||
|
|
||||||
|
# 2. Revisión de literatura → llena Related work (skill deep-research, fase review).
|
||||||
|
# /deep-research "..."
|
||||||
|
|
||||||
|
# 3. Pre-registro: congela H0/H1 + plan de análisis ANTES de mirar datos (fase hypothesis).
|
||||||
|
./fn run preregister_hypothesis papers/0001-mi-paper # 🚧 en construcción
|
||||||
|
|
||||||
|
# 4. Experimentos en papers/0001-mi-paper/experiments/ (fase running) →
|
||||||
|
# análisis con el grupo `eda` + funciones de effect size / CI / corrección múltiple (fase analysis).
|
||||||
|
|
||||||
|
# 5. Escritura IMRaD en paper.md (fase writing) → render del entregable PDF.
|
||||||
|
./fn run render_paper_pdf papers/0001-mi-paper # 🚧 en construcción → out/paper.pdf
|
||||||
|
|
||||||
|
# 6. Peer review adversarial (fase internal-review).
|
||||||
|
# Agent(subagent_type="paper-reviewer", prompt="Revisa papers/0001-mi-paper ...") # 🚧 en construcción
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **NO es para reports de trabajo.** Un report (`reports/`) es el entregable escrito de una tarea (resumen + evidencia + gaps); un paper es investigación con hipótesis falsable y experimento. Ver `.claude/rules/reports.md`.
|
||||||
|
- **NO se indexa en `registry.db` en esta fase.** No hay tabla `papers` ni `entity_type` `paper` (KISS); se añadiría con migración propia si se decide. Las *funciones* del grupo sí se indexan (viven en `bash/functions/`, `python/functions/`), pero los artefactos `papers/<slug>/` no.
|
||||||
|
- **NO hace `git init` en el scaffold.** El paper arranca en fase interna local y gitignored. La promoción a sub-repo Gitea (fase publishable) es un paso manual posterior.
|
||||||
|
- **NO soporta LaTeX/arXiv todavía.** Formato elegido: Markdown como fuente + PDF como entregable. El soporte LaTeX se añadiría al promocionar un paper a fase publishable.
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Fase de scaffolding. Disponible: estructura del artefacto, plantillas (`docs/templates/paper.md`, `docs/templates/preregistration.md`), pipeline `init_paper` + helper `next_numbered_dir`, esta página y el bloque gitignore de `papers/`. En construcción por la flota: `preregister_hypothesis`, funciones estadísticas (effect size / CI / corrección múltiple), `render_paper_pdf` y el agente `paper-reviewer`. Validación end-to-end con un paper piloto real: pendiente.
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
title: "TITULO DEL PAPER"
|
||||||
|
slug: NNNN-slug
|
||||||
|
authors: [Enmanuel]
|
||||||
|
date: 2026-01-01
|
||||||
|
status: draft # draft | internal | publishable
|
||||||
|
phase: question # question -> review -> hypothesis -> design -> running -> analysis -> writing -> internal-review -> polish -> submitted
|
||||||
|
tags: []
|
||||||
|
domain: datascience
|
||||||
|
hypothesis_id: "" # lo rellena preregister_hypothesis al congelar el preregistro
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Paper académico reproducible (formato IMRaD). Esta es la FUENTE editable en Markdown;
|
||||||
|
el entregable PDF se genera con render_paper_pdf (grupo `papers`).
|
||||||
|
|
||||||
|
Regla de oro anti paper-mill: una hipótesis que PODÍA fallar + un experimento con
|
||||||
|
riesgo real de refutación + estadística que no es teatro. Si no hay riesgo de
|
||||||
|
refutación, no es un paper. Los claims nunca superan a la evidencia.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# {{título del paper}}
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Resumen estructurado en 4-6 frases: contexto -> gap -> método -> resultados -> conclusión.
|
||||||
|
Sin citas, sin abreviaturas sin definir. Es lo único que mucha gente leerá: que se sostenga solo.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 1. Introduction
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Embudo en cuatro movimientos:
|
||||||
|
1. Contexto — el área y por qué importa.
|
||||||
|
2. Gap — qué NO se sabe todavía (el hueco que este paper llena).
|
||||||
|
3. Pregunta / hipótesis — formulada de forma falsable (ver preregistration.md).
|
||||||
|
4. Contribución — lista explícita de lo que aporta este trabajo ("Contributions:").
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 2. Related work
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Qué existe ya y por qué no basta. Agrupa por enfoque, no por autor. Cada cita debe
|
||||||
|
justificar por qué el gap sigue abierto. Output de la fase de revisión (skill deep-research).
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 3. Methods
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Diseño REPRODUCIBLE: otra persona lo corre y obtiene lo mismo.
|
||||||
|
- Variables: independiente(s), dependiente(s), control.
|
||||||
|
- Diseño: N, condiciones, muestreo, aleatorización.
|
||||||
|
- Métricas y cómo se miden.
|
||||||
|
- Protocolo paso a paso + dónde vive el código (experiments/) y los datos (data/).
|
||||||
|
Debe ser coherente con el preregistration.md congelado (no se cambia el plan tras ver datos).
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 4. Results
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Datos SIN interpretar. Tablas y figuras (figures/) con su lectura literal.
|
||||||
|
Reporta effect size + intervalos de confianza, no solo p-valores.
|
||||||
|
Incluye también los resultados negativos / no significativos (anti cherry-picking).
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 5. Discussion
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Interpretación de los resultados a la luz de la pregunta. Claims <= evidencia.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### 5.1 Limitaciones
|
||||||
|
|
||||||
|
<!-- Qué no cubre el estudio, supuestos, datos faltantes. Honestidad explícita. -->
|
||||||
|
|
||||||
|
### 5.2 Amenazas a la validez
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- Validez interna — ¿la causa es lo que decimos o hay confusores?
|
||||||
|
- Validez externa — ¿generaliza fuera de esta muestra/condiciones?
|
||||||
|
- Validez de constructo — ¿la métrica mide lo que dice medir?
|
||||||
|
- Validez estadística — ¿N suficiente, supuestos del test cumplidos, comparaciones múltiples corregidas?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 6. Conclusion + Future work
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Cierre en 2-4 frases: qué se aprendió (sin overclaiming) + las siguientes preguntas que abre.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
<!-- Ver references.md. -->
|
||||||
Vendored
+59
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
paper_slug: NNNN-slug
|
||||||
|
frozen_at: "" # timestamp ISO — lo rellena preregister_hypothesis al congelar
|
||||||
|
content_hash: "" # hash del contenido congelado — lo rellena preregister_hypothesis
|
||||||
|
status: draft # draft -> frozen (preregister_hypothesis lo pasa a frozen; tras congelar NO se edita)
|
||||||
|
---
|
||||||
|
|
||||||
|
> **⚠️ ESTE DOCUMENTO SE CONGELA ANTES DE MIRAR LOS DATOS (anti-HARKing).**
|
||||||
|
> El plan de análisis se fija aquí *antes* de ejecutar el experimento. Una vez congelado
|
||||||
|
> (`status: frozen`, con `frozen_at` + `content_hash`), **no se edita**. Inventar o ajustar
|
||||||
|
> la hipótesis después de ver los resultados (HARKing) invalida el paper. Si el plan cambia
|
||||||
|
> tras ver datos, eso es análisis exploratorio y se reporta como tal, no como confirmatorio.
|
||||||
|
|
||||||
|
# Pre-registro — {{título del paper}}
|
||||||
|
|
||||||
|
## 1. Pregunta de investigación
|
||||||
|
|
||||||
|
<!-- La pregunta concreta, en una frase. Debe poder responderse con un experimento. -->
|
||||||
|
|
||||||
|
## 2. Hipótesis
|
||||||
|
|
||||||
|
<!-- Falsable (Popper): una predicción que PODRÍA fallar. -->
|
||||||
|
|
||||||
|
- **H0 (nula):** <!-- no hay efecto / no hay diferencia. Es lo que el test intenta rechazar. -->
|
||||||
|
- **H1 (alternativa):** <!-- el efecto esperado, con dirección si la hay. -->
|
||||||
|
|
||||||
|
## 3. Variables
|
||||||
|
|
||||||
|
- **Independiente(s):** <!-- lo que se manipula. -->
|
||||||
|
- **Dependiente(s):** <!-- lo que se mide (la métrica de resultado). -->
|
||||||
|
- **Control:** <!-- lo que se mantiene fijo / se cubre estadísticamente. -->
|
||||||
|
|
||||||
|
## 4. Diseño
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- N: tamaño de muestra (y justificación / power analysis si aplica).
|
||||||
|
- Condiciones / grupos.
|
||||||
|
- Muestreo y aleatorización.
|
||||||
|
- Criterios de inclusión / exclusión de datos (definidos AHORA, no después).
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 5. Plan de análisis
|
||||||
|
|
||||||
|
<!--
|
||||||
|
El plan estadístico EXACTO, decidido antes de ver los datos:
|
||||||
|
- Test estadístico concreto (p.ej. t-test de Welch, Mann-Whitney U, regresión...).
|
||||||
|
- Métrica de effect size (p.ej. Cohen's d, diferencia de medias, odds ratio).
|
||||||
|
- Criterio de decisión (umbral alpha, qué resultado confirma/refuta H1).
|
||||||
|
- Corrección por comparaciones múltiples (p.ej. Holm-Bonferroni) si hay >1 contraste.
|
||||||
|
- Manejo de supuestos (normalidad, varianzas) y qué se hace si no se cumplen.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 6. Predicción cuantitativa
|
||||||
|
|
||||||
|
<!--
|
||||||
|
La predicción numérica concreta que el experimento pondrá a prueba.
|
||||||
|
P.ej. "esperamos d >= 0.5 con IC95% que no cruza 0" o "una reducción >= 15% en la métrica X".
|
||||||
|
Cuanto más específica, más falsable.
|
||||||
|
-->
|
||||||
@@ -34,6 +34,7 @@ from .theils_u import theils_u
|
|||||||
from .correlation_ratio import correlation_ratio
|
from .correlation_ratio import correlation_ratio
|
||||||
from .mutual_info_columns import mutual_info_columns
|
from .mutual_info_columns import mutual_info_columns
|
||||||
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
|
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
|
||||||
|
from .detect_declared_keys_duckdb import detect_declared_keys_duckdb
|
||||||
from .build_join_graph import build_join_graph
|
from .build_join_graph import build_join_graph
|
||||||
from .association_matrix import association_matrix
|
from .association_matrix import association_matrix
|
||||||
from .correlation_matrix_duckdb import correlation_matrix_duckdb
|
from .correlation_matrix_duckdb import correlation_matrix_duckdb
|
||||||
@@ -58,19 +59,29 @@ from .acf_pacf import acf_pacf
|
|||||||
from .stl_decompose import stl_decompose
|
from .stl_decompose import stl_decompose
|
||||||
from .to_returns import to_returns
|
from .to_returns import to_returns
|
||||||
from .fdr_correction import fdr_correction
|
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 .suggest_reexpression import suggest_reexpression
|
||||||
from .exploratory_caveats import exploratory_caveats
|
from .exploratory_caveats import exploratory_caveats
|
||||||
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
||||||
from .render_automatic_eda_pdf import render_automatic_eda_pdf
|
from .render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||||
from .render_automatic_eda_pptx import render_automatic_eda_pptx
|
from .render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
|
from .render_automatic_eda_markdown import render_automatic_eda_markdown
|
||||||
from .detect_time_column import detect_time_column
|
from .detect_time_column import detect_time_column
|
||||||
from .extract_timeseries_raw import extract_timeseries_raw
|
from .extract_timeseries_raw import extract_timeseries_raw
|
||||||
from .build_eda_render_ctx import build_eda_render_ctx
|
from .build_eda_render_ctx import build_eda_render_ctx
|
||||||
from .profile_datetime import profile_datetime
|
from .profile_datetime import profile_datetime
|
||||||
from .resample_timeseries import resample_timeseries
|
from .resample_timeseries import resample_timeseries
|
||||||
from .add_pdf_internal_links import add_pdf_internal_links
|
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__ = [
|
__all__ = [
|
||||||
|
"render_paper_pdf",
|
||||||
|
"draw_join_graph_figure",
|
||||||
|
"suggest_intratable_fk_candidates",
|
||||||
"detect_time_column",
|
"detect_time_column",
|
||||||
"extract_timeseries_raw",
|
"extract_timeseries_raw",
|
||||||
"build_eda_render_ctx",
|
"build_eda_render_ctx",
|
||||||
@@ -79,12 +90,16 @@ __all__ = [
|
|||||||
"resample_timeseries",
|
"resample_timeseries",
|
||||||
"render_automatic_eda_pdf",
|
"render_automatic_eda_pdf",
|
||||||
"render_automatic_eda_pptx",
|
"render_automatic_eda_pptx",
|
||||||
|
"render_automatic_eda_markdown",
|
||||||
"decode_qr_image",
|
"decode_qr_image",
|
||||||
"adf_kpss_stationarity",
|
"adf_kpss_stationarity",
|
||||||
"acf_pacf",
|
"acf_pacf",
|
||||||
"stl_decompose",
|
"stl_decompose",
|
||||||
"to_returns",
|
"to_returns",
|
||||||
"fdr_correction",
|
"fdr_correction",
|
||||||
|
"effect_size_cohens_d",
|
||||||
|
"confidence_interval_mean",
|
||||||
|
"preregister_hypothesis",
|
||||||
"suggest_reexpression",
|
"suggest_reexpression",
|
||||||
"exploratory_caveats",
|
"exploratory_caveats",
|
||||||
"render_eda_pdf",
|
"render_eda_pdf",
|
||||||
@@ -97,6 +112,7 @@ __all__ = [
|
|||||||
"correlation_ratio",
|
"correlation_ratio",
|
||||||
"mutual_info_columns",
|
"mutual_info_columns",
|
||||||
"infer_fk_containment_duckdb",
|
"infer_fk_containment_duckdb",
|
||||||
|
"detect_declared_keys_duckdb",
|
||||||
"build_join_graph",
|
"build_join_graph",
|
||||||
"association_matrix",
|
"association_matrix",
|
||||||
"correlation_matrix_duckdb",
|
"correlation_matrix_duckdb",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .model import ( # noqa: F401
|
|||||||
from .chapters_registry import CHAPTER_ORDER, build_chapter, build_document # noqa: F401
|
from .chapters_registry import CHAPTER_ORDER, build_chapter, build_document # noqa: F401
|
||||||
from .render_pdf_impl import render_pdf # noqa: F401
|
from .render_pdf_impl import render_pdf # noqa: F401
|
||||||
from .render_pptx_impl import render_pptx # noqa: F401
|
from .render_pptx_impl import render_pptx # noqa: F401
|
||||||
|
from .render_md_impl import render_md # noqa: F401
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ENGINE_NAME",
|
"ENGINE_NAME",
|
||||||
@@ -60,4 +61,5 @@ __all__ = [
|
|||||||
"build_document",
|
"build_document",
|
||||||
"render_pdf",
|
"render_pdf",
|
||||||
"render_pptx",
|
"render_pptx",
|
||||||
|
"render_md",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -89,6 +89,35 @@ _DEF_MAX_CARD = 20
|
|||||||
_DEF_MAX_MEASURES = 4
|
_DEF_MAX_MEASURES = 4
|
||||||
_DEF_TOP_N = 12
|
_DEF_TOP_N = 12
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains. Both appear in the always-rendered intro,
|
||||||
|
# so they are registered and marked clickable whenever a collector is in ctx —
|
||||||
|
# the canonical two-step pattern (see ``cat_distr``): ``glossary.add(key, label,
|
||||||
|
# definition)`` + the inline span ``[[term:KEY]]texto[[/term]]`` in a Markdown
|
||||||
|
# block. Mapping key -> (label, definition).
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"groupby": (
|
||||||
|
"Agrupación (split-apply-combine)",
|
||||||
|
"Operación de agrupación (group by): parte la tabla en grupos según los "
|
||||||
|
"valores de una columna categórica, aplica un cálculo (conteo, media, "
|
||||||
|
"mediana…) dentro de cada grupo y combina los resultados en una tabla "
|
||||||
|
"resumen. Es el patrón split-apply-combine."),
|
||||||
|
"pivot_table": (
|
||||||
|
"Tabla dinámica (pivot)",
|
||||||
|
"Tabla dinámica que cruza dos variables categóricas — una en las filas y "
|
||||||
|
"otra en las columnas — y rellena cada celda con un agregado (media, "
|
||||||
|
"suma…) de una medida numérica. Resume de un vistazo cómo interactúan las "
|
||||||
|
"dos categóricas sobre esa medida."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
|
||||||
|
|
||||||
|
The visible text is identical with or without the marker (the renderers strip
|
||||||
|
it), so wrapping never changes line layout — it only adds the link.
|
||||||
|
"""
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Formatting helpers (mirror the other chapters' defensive style).
|
# Formatting helpers (mirror the other chapters' defensive style).
|
||||||
@@ -525,15 +554,18 @@ def _sections_live(profile: dict, ctx: dict, candidates: dict) -> list:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Entry point.
|
# Entry point.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _intro_blocks() -> list:
|
def _intro_blocks(gloss=None, mark_term: bool = False) -> list:
|
||||||
|
if gloss is not None:
|
||||||
|
for key, (label, definition) in _TERM_DEFS.items():
|
||||||
|
gloss.add(key, label, definition)
|
||||||
|
t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)")
|
||||||
|
t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)")
|
||||||
text = (
|
text = (
|
||||||
"Este capítulo analiza la tabla **por grupos** (split-apply-combine): "
|
f"Este capítulo analiza la tabla {t_groupby}: elige las columnas "
|
||||||
"elige las columnas categóricas más informativas — por su cardinalidad "
|
"categóricas más informativas (por cardinalidad y relevancia, no todas "
|
||||||
"y relevancia, no todas contra todas, para no inflar comparaciones "
|
"contra todas) y resume las variables numéricas dentro de cada grupo "
|
||||||
"espurias — y resume las variables numéricas dentro de cada grupo "
|
f"(conteo, media, mediana, desviación). Se añaden {t_pivot} y "
|
||||||
"(conteo, media, mediana, desviación). Las **tablas dinámicas** (pivot) "
|
"**gráficos de barras** (siempre desde cero) para comparar los grupos."
|
||||||
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
|
|
||||||
"(siempre desde cero) comparan los grupos de un vistazo."
|
|
||||||
)
|
)
|
||||||
return [model.Heading(text=CHAPTER_TITLE, level=1),
|
return [model.Heading(text=CHAPTER_TITLE, level=1),
|
||||||
model.Markdown(text=text)]
|
model.Markdown(text=text)]
|
||||||
@@ -556,13 +588,21 @@ def build_agregacion(profile: dict, ctx: dict):
|
|||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Shared glossary collector: groupby + pivot_table live in the always-present
|
||||||
|
# intro, so they are registered + marked there. Degrades silently (mark_term
|
||||||
|
# False) when no collector is in ctx (standalone render).
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
|
||||||
|
mark_term = gloss is not None
|
||||||
|
|
||||||
# Pre-computed results take precedence (offline / tests / forward-compat).
|
# Pre-computed results take precedence (offline / tests / forward-compat).
|
||||||
pre = ctx.get("aggregations")
|
pre = ctx.get("aggregations")
|
||||||
if _is_dict(pre) and (pre.get("groupby") or pre.get("pivots")):
|
if _is_dict(pre) and (pre.get("groupby") or pre.get("pivots")):
|
||||||
sections = _sections_from_precomputed(pre)
|
sections = _sections_from_precomputed(pre)
|
||||||
if not sections:
|
if not sections:
|
||||||
return None
|
return None
|
||||||
blocks = _intro_blocks() + sections + _insights_section(ctx)
|
blocks = (_intro_blocks(gloss, mark_term) + sections
|
||||||
|
+ _insights_section(ctx))
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|
||||||
@@ -583,10 +623,11 @@ def build_agregacion(profile: dict, ctx: dict):
|
|||||||
"crudos. Pasa ctx['db_path'] + ctx['table'] (para el cálculo "
|
"crudos. Pasa ctx['db_path'] + ctx['table'] (para el cálculo "
|
||||||
"push-down en DuckDB) o ctx['aggregations'] ya precalculado. "
|
"push-down en DuckDB) o ctx['aggregations'] ya precalculado. "
|
||||||
f"Columnas categóricas candidatas: {keys or '—'}.")
|
f"Columnas categóricas candidatas: {keys or '—'}.")
|
||||||
blocks = _intro_blocks() + [note] + _insights_section(ctx)
|
blocks = (_intro_blocks(gloss, mark_term) + [note]
|
||||||
|
+ _insights_section(ctx))
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|
||||||
blocks = _intro_blocks() + sections + _insights_section(ctx)
|
blocks = _intro_blocks(gloss, mark_term) + sections + _insights_section(ctx)
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|||||||
@@ -254,3 +254,25 @@ def test_anti_corte_muchos_grupos_y_texto_largo():
|
|||||||
# First, middle and last words of the long paragraph all present.
|
# First, middle and last words of the long paragraph all present.
|
||||||
for i in (0, 60, 119):
|
for i in (0, 60, 119):
|
||||||
assert f"palabra{i}" in txt
|
assert f"palabra{i}" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_glosario_engancha_groupby_y_pivot():
|
||||||
|
"""Mejora 4b: la agrupación (split-apply-combine) y la tabla dinámica (pivot)
|
||||||
|
se registran en el colector compartido y se marcan clicables en el cuerpo.
|
||||||
|
Sin colector en ctx, el capítulo degrada y no marca nada."""
|
||||||
|
from datascience.automatic_eda.model import GlossaryCollector
|
||||||
|
|
||||||
|
g = GlossaryCollector()
|
||||||
|
ctx = dict(_ctx_precomputed())
|
||||||
|
ctx["glossary"] = g
|
||||||
|
ch = build_agregacion(_profile(), ctx)
|
||||||
|
assert ch is not None
|
||||||
|
keys = {t["key"] for t in g.terms()}
|
||||||
|
assert {"groupby", "pivot_table"} <= keys
|
||||||
|
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:groupby]]" in body and "[[term:pivot_table]]" in body
|
||||||
|
|
||||||
|
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||||
|
ch2 = build_agregacion(_profile(), _ctx_precomputed())
|
||||||
|
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:" not in body2
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.0.0"
|
# 1.1.0: drop the duplicated section labels — the dictionary and PII DataTables
|
||||||
|
# no longer carry a ``title`` (the section Heading labels them once, per the
|
||||||
|
# OVERVIEW pattern in the contract). The data-dictionary column already reads
|
||||||
|
# "Significado de negocio".
|
||||||
|
CHAPTER_VERSION = "1.1.0"
|
||||||
CHAPTER_ID = "analisis_llm"
|
CHAPTER_ID = "analisis_llm"
|
||||||
CHAPTER_TITLE = "Análisis LLM"
|
CHAPTER_TITLE = "Análisis LLM"
|
||||||
|
|
||||||
@@ -118,6 +122,11 @@ def _dictionary_block(llm: dict):
|
|||||||
Columns: Columna / Descripción / Significado de negocio / Unidad. The
|
Columns: Columna / Descripción / Significado de negocio / Unidad. The
|
||||||
paginator splits this by rows repeating the header and wraps long cells, so a
|
paginator splits this by rows repeating the header and wraps long cells, so a
|
||||||
long dictionary (many columns) never gets cut.
|
long dictionary (many columns) never gets cut.
|
||||||
|
|
||||||
|
The block carries **no** ``title``: the section is labelled once by the
|
||||||
|
``Heading`` that ``build_analisis_llm`` appends right before it (the canonical
|
||||||
|
OVERVIEW pattern, contract §8). Giving the table its own ``title`` too would
|
||||||
|
print "Diccionario de datos" twice in a row.
|
||||||
"""
|
"""
|
||||||
entries = llm.get("dictionary")
|
entries = llm.get("dictionary")
|
||||||
if not isinstance(entries, (list, tuple)) or not entries:
|
if not isinstance(entries, (list, tuple)) or not entries:
|
||||||
@@ -137,7 +146,7 @@ def _dictionary_block(llm: dict):
|
|||||||
])
|
])
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
return model.DataTable(header=header, rows=rows, title="Diccionario de datos")
|
return model.DataTable(header=header, rows=rows)
|
||||||
|
|
||||||
|
|
||||||
def _analyses_blocks(llm: dict) -> list:
|
def _analyses_blocks(llm: dict) -> list:
|
||||||
@@ -159,7 +168,12 @@ def _cleaning_blocks(llm: dict) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _pii_block(llm: dict):
|
def _pii_block(llm: dict):
|
||||||
"""DataTable for PII/GDPR findings, or None if absent/empty."""
|
"""DataTable for PII/GDPR findings, or None if absent/empty.
|
||||||
|
|
||||||
|
Like the dictionary block, it carries **no** ``title`` (the ``Heading`` in
|
||||||
|
``build_analisis_llm`` labels the section once); it keeps its ``note`` with
|
||||||
|
the orientative-detection caveat, which the renderers print under the table.
|
||||||
|
"""
|
||||||
entries = llm.get("pii")
|
entries = llm.get("pii")
|
||||||
if not isinstance(entries, (list, tuple)) or not entries:
|
if not isinstance(entries, (list, tuple)) or not entries:
|
||||||
return None
|
return None
|
||||||
@@ -176,7 +190,7 @@ def _pii_block(llm: dict):
|
|||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
return model.DataTable(
|
return model.DataTable(
|
||||||
header=header, rows=rows, title="Datos personales (PII / RGPD)",
|
header=header, rows=rows,
|
||||||
note="detección automática orientativa — revisar antes de tratar los datos")
|
note="detección automática orientativa — revisar antes de tratar los datos")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pptx import Presentation
|
|||||||
from datascience.automatic_eda.chapters.analisis_llm import (
|
from datascience.automatic_eda.chapters.analisis_llm import (
|
||||||
build_analisis_llm, CHAPTER_VERSION)
|
build_analisis_llm, CHAPTER_VERSION)
|
||||||
from datascience.automatic_eda.chapters_registry import build_document
|
from datascience.automatic_eda.chapters_registry import build_document
|
||||||
from datascience.automatic_eda.model import Chapter, DataTable
|
from datascience.automatic_eda.model import Chapter, DataTable, Heading
|
||||||
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||||
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
|
|
||||||
@@ -117,6 +117,45 @@ def test_golden_build_y_render_pdf_pptx():
|
|||||||
assert "DESCTOKEN" in ptx
|
assert "DESCTOKEN" in ptx
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_rotulos_duplicados_y_significado_de_negocio():
|
||||||
|
"""The dictionary / PII sections must be labelled ONCE.
|
||||||
|
|
||||||
|
Regression for the duplicated 'Diccionario de datos' and 'Datos personales
|
||||||
|
(PII / RGPD)' headings (each section used to print its label twice: a Heading
|
||||||
|
plus the DataTable's own title). The fix drops the DataTable title and keeps
|
||||||
|
a single Heading — the OVERVIEW pattern. The data-dictionary column header is
|
||||||
|
also pinned to the exact text 'Significado de negocio'.
|
||||||
|
"""
|
||||||
|
ch = build_analisis_llm(_profile(), {})
|
||||||
|
assert ch is not None
|
||||||
|
|
||||||
|
# Structure: section labels come from Headings; tables carry no title.
|
||||||
|
headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
|
||||||
|
assert headings.count("Diccionario de datos") == 1
|
||||||
|
assert headings.count("Datos personales (PII / RGPD)") == 1
|
||||||
|
for b in ch.blocks:
|
||||||
|
if isinstance(b, DataTable):
|
||||||
|
assert not b.title, f"DataTable should not duplicate the label: {b.title!r}"
|
||||||
|
|
||||||
|
# The data dictionary's third column reads exactly 'Significado de negocio'.
|
||||||
|
dicts = [b for b in ch.blocks if isinstance(b, DataTable) and "Descripción" in b.header]
|
||||||
|
assert dicts, "expected the data-dictionary DataTable"
|
||||||
|
assert dicts[0].header == ["Columna", "Descripción", "Significado de negocio", "Unidad"]
|
||||||
|
|
||||||
|
# The PII table keeps its orientative-detection note.
|
||||||
|
pii = [b for b in ch.blocks if isinstance(b, DataTable) and b.header == ["Columna", "Tipo", "Severidad"]]
|
||||||
|
assert pii and pii[0].note and "orientativa" in pii[0].note
|
||||||
|
|
||||||
|
# Render: each label appears exactly once across the whole document (the only
|
||||||
|
# 'Diccionario de datos' / 'Datos personales' producer is this chapter).
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out_pdf = os.path.join(d, "eda.pdf")
|
||||||
|
render_automatic_eda_pdf(_profile(), out_pdf, {"title": "EDA — ventas"})
|
||||||
|
txt = _pdf_text(out_pdf)
|
||||||
|
assert txt.count("Diccionario de datos") == 1
|
||||||
|
assert txt.count("Datos personales") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_orden_capitulo_junto_a_overview():
|
def test_orden_capitulo_junto_a_overview():
|
||||||
chapters = build_document(_profile(), {})
|
chapters = build_document(_profile(), {})
|
||||||
ids = [c.id for c in chapters]
|
ids = [c.id for c in chapters]
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
"""Data-quality chapter (CALIDAD) for AutomaticEDA.
|
"""Data-quality chapter (CALIDAD) for AutomaticEDA.
|
||||||
|
|
||||||
Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The
|
Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The
|
||||||
chapter answers, in Spanish and as tables, the three things the user asked for:
|
chapter implements the quality model of report 2046:
|
||||||
|
|
||||||
1. **En qué se basa la calidad** — an intro paragraph explaining the criteria and
|
1. **En qué se basa la calidad** — a concise intro naming the two scored
|
||||||
their weights (completeness, validity, consistency) before any number, plus a
|
dimensions and their weights (completitud 60%, validez 40%) plus the
|
||||||
table-level summary (global score and aggregates).
|
table-level row uniqueness, BEFORE any number, and stating that outliers are
|
||||||
|
reported as observations and do **not** lower the score. The criteria terms
|
||||||
|
(calidad de datos, completitud, validez, unicidad de registro) are hooked
|
||||||
|
into the shared glossary as clickable jumps; their full definitions live in
|
||||||
|
the GLOSARIO chapter, not inline here.
|
||||||
2. **Scores por columna** — a table with, per column, the total quality score and
|
2. **Scores por columna** — a table with, per column, the total quality score and
|
||||||
its breakdown into completeness / validity / consistency.
|
its breakdown into completeness / validity (no consistency dimension).
|
||||||
3. **Problemas en español** — a second table listing, per column, the readable
|
3. **Problemas de calidad** — a table listing ONLY real quality defects
|
||||||
issues in Spanish (kept separate from the type ``flags``).
|
(nulls, empty cells, values not conforming to their type/semantics).
|
||||||
|
4. **Observaciones analíticas** — a SEPARATE table for outliers, constant
|
||||||
|
columns, high-cardinality ids and strong skew, with an explicit note that
|
||||||
|
these do not affect the score.
|
||||||
|
|
||||||
The breakdown and the issues are NOT recomputed here: they come from the registry
|
The breakdown, issues and observations are NOT recomputed here: they come from
|
||||||
function ``column_quality_score`` (group ``eda``), which already derives
|
the registry function ``column_quality_score`` (group ``eda``), which derives
|
||||||
``{score, completeness, validity, consistency, issues}`` from the ColumnProfile.
|
``{score, completeness, validity, dimensions, applicable, issues,
|
||||||
This chapter is render-only — it consumes that function and lays the result out
|
observations}`` from the ColumnProfile. This chapter is render-only.
|
||||||
as model blocks; the renderers paginate tables (splitting by rows, repeating the
|
|
||||||
header) and wrap long cells so nothing is ever cut.
|
|
||||||
|
|
||||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||||
"""
|
"""
|
||||||
@@ -33,28 +38,47 @@ try: # pragma: no cover - import wiring
|
|||||||
except Exception: # noqa: BLE001 - never let an import error abort the document.
|
except Exception: # noqa: BLE001 - never let an import error abort the document.
|
||||||
_column_quality_score = None
|
_column_quality_score = None
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.0.0"
|
CHAPTER_VERSION = "2.0.0"
|
||||||
CHAPTER_ID = "calidad"
|
CHAPTER_ID = "calidad"
|
||||||
CHAPTER_TITLE = "Calidad"
|
CHAPTER_TITLE = "Calidad"
|
||||||
|
|
||||||
# Weights mirror column_quality_score: completeness 0.5, validity 0.3,
|
# Glossary terms this chapter explains (report 2046 §6). Registered in the shared
|
||||||
# consistency 0.2. Kept here only to render the human explanation; the actual
|
# collector and marked clickable on their first appearance (contract §11.1).
|
||||||
# numbers always come from the function so the two never drift in computation.
|
_TERMS = {
|
||||||
_CRITERIA_INTRO = (
|
"calidad_datos": (
|
||||||
"La calidad de cada columna es un score de 0 a 100 que combina tres "
|
"Calidad de datos (score 0-100)",
|
||||||
"criterios, cada uno con un peso:\n\n"
|
"Mide hasta qué punto los datos están presentes y son utilizables tal "
|
||||||
"- **Completitud (peso 50%)**: proporción de valores presentes (sin nulos "
|
"cual, no si son «buenos para el análisis». Se compone solo de "
|
||||||
"ni vacíos). Una columna con muchos nulos baja de score.\n"
|
"dimensiones medibles automáticamente desde el perfil de la tabla, sin "
|
||||||
"- **Validez (peso 30%)**: los valores son coherentes con su tipo y rango "
|
"fuente externa de verdad: completitud (60%), validez (40%, cuando es "
|
||||||
"esperado (penaliza outliers y semánticas declaradas que no coinciden).\n"
|
"medible) y, a nivel de tabla, unicidad de registro. Los valores "
|
||||||
"- **Consistencia (peso 20%)**: la columna aporta información útil (penaliza "
|
"atípicos NO bajan la calidad: se listan aparte como observaciones.",
|
||||||
"columnas constantes o identificadores de cardinalidad muy alta).\n\n"
|
),
|
||||||
"Score = 100 × (0,5·completitud + 0,3·validez + 0,2·consistencia). "
|
"completitud": (
|
||||||
"Los problemas detectados por columna se listan en español más abajo."
|
"Completitud",
|
||||||
)
|
"Proporción de valores realmente presentes en una columna (1 − % de "
|
||||||
|
"nulos; en texto, las celdas vacías también cuentan como faltantes). Los "
|
||||||
|
"nulos y vacíos bajan el score porque falta información que debería "
|
||||||
|
"estar. Pesa el 60% del score de columna.",
|
||||||
|
),
|
||||||
|
"validez": (
|
||||||
|
"Validez",
|
||||||
|
"Proporción de valores que encajan con su tipo o formato esperado: un "
|
||||||
|
"número que parsea, una fecha legible, un email con forma de email. Los "
|
||||||
|
"valores que no parsean a su tipo bajan el score. Si la columna es texto "
|
||||||
|
"libre sin formato esperado, la validez no se puede medir y el score se "
|
||||||
|
"basa solo en la completitud. Pesa el 40% del score cuando es medible.",
|
||||||
|
),
|
||||||
|
"unicidad_registro": (
|
||||||
|
"Unicidad de registro",
|
||||||
|
"A nivel de tabla, las filas duplicadas restan calidad al conjunto "
|
||||||
|
"(1 − % de filas duplicadas). Es distinta de que una columna no-clave "
|
||||||
|
"repita valores, que no es un defecto de calidad.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Cap for the joined issues cell so a single row never grows taller than a page;
|
# Cap for the joined cell so a single row never grows taller than a page; the
|
||||||
# the remainder is summarized as "(+N más)" instead of being silently dropped.
|
# remainder is summarized as "(+N más)" instead of being silently dropped.
|
||||||
_ISSUES_MAXLEN = 160
|
_ISSUES_MAXLEN = 160
|
||||||
|
|
||||||
|
|
||||||
@@ -82,12 +106,19 @@ def _fmt_unit_pct(value) -> str:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_validity(value) -> str:
|
||||||
|
"""Validity is ``None`` when not applicable: show ``n/a`` not a fake 0%."""
|
||||||
|
if value is None:
|
||||||
|
return "n/a"
|
||||||
|
return _fmt_unit_pct(value)
|
||||||
|
|
||||||
|
|
||||||
def _quality_of(col: dict) -> dict:
|
def _quality_of(col: dict) -> dict:
|
||||||
"""Return ``{score, completeness, validity, consistency, issues}`` for a column.
|
"""Return the quality dict for a column.
|
||||||
|
|
||||||
Uses the registry ``column_quality_score`` when available; otherwise falls
|
Uses the registry ``column_quality_score`` when available; otherwise falls
|
||||||
back to the per-column ``quality_score`` already in the profile (number only,
|
back to the per-column ``quality_score`` already in the profile (number only,
|
||||||
empty breakdown/issues). Never raises.
|
empty breakdown/issues/observations). Never raises.
|
||||||
"""
|
"""
|
||||||
if not isinstance(col, dict):
|
if not isinstance(col, dict):
|
||||||
col = {}
|
col = {}
|
||||||
@@ -98,26 +129,25 @@ def _quality_of(col: dict) -> dict:
|
|||||||
return res
|
return res
|
||||||
except Exception: # noqa: BLE001 - degrade instead of aborting.
|
except Exception: # noqa: BLE001 - degrade instead of aborting.
|
||||||
pass
|
pass
|
||||||
# Fallback: only the final score is available pre-computed in the profile.
|
|
||||||
return {
|
return {
|
||||||
"score": col.get("quality_score"),
|
"score": col.get("quality_score"),
|
||||||
"completeness": None,
|
"completeness": None,
|
||||||
"validity": None,
|
"validity": None,
|
||||||
"consistency": None,
|
|
||||||
"issues": [],
|
"issues": [],
|
||||||
|
"observations": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _join_issues(issues) -> str:
|
def _join_cells(items) -> str:
|
||||||
"""Join Spanish issue strings into one cell, truncating overly long lists.
|
"""Join Spanish strings into one cell, truncating overly long lists.
|
||||||
|
|
||||||
The renderer wraps cell text, but a column with many long issues could make a
|
The renderer wraps cell text, but a column with many long entries could make
|
||||||
single row taller than a whole page; cap the length and append ``(+N más)``
|
a single row taller than a whole page; cap the length and append ``(+N más)``
|
||||||
so the count of hidden issues is honest rather than silently lost.
|
so the count of hidden entries is honest rather than silently lost.
|
||||||
"""
|
"""
|
||||||
if not isinstance(issues, (list, tuple)) or not issues:
|
if not isinstance(items, (list, tuple)) or not items:
|
||||||
return ""
|
return ""
|
||||||
parts = [model._safe_str(i).strip() for i in issues]
|
parts = [model._safe_str(i).strip() for i in items]
|
||||||
parts = [p for p in parts if p]
|
parts = [p for p in parts if p]
|
||||||
if not parts:
|
if not parts:
|
||||||
return ""
|
return ""
|
||||||
@@ -142,6 +172,33 @@ def _columns_with_quality(profile: dict):
|
|||||||
yield c, _quality_of(c)
|
yield c, _quality_of(c)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_unit_pct_or_pct(value) -> str:
|
||||||
|
"""Format a value that may be a 0-1 fraction or an already-0-100 percentage."""
|
||||||
|
try:
|
||||||
|
num = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return model._safe_str(value)
|
||||||
|
if num != num: # NaN
|
||||||
|
return "—"
|
||||||
|
pct = num * 100 if num <= 1.0 else num
|
||||||
|
text = f"{pct:.1f}".rstrip("0").rstrip(".")
|
||||||
|
return f"{text}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _row_uniqueness(profile: dict):
|
||||||
|
"""Return row uniqueness (1 - duplicate_pct) in [0,1], or None if unknown."""
|
||||||
|
dup = profile.get("duplicate_pct")
|
||||||
|
if dup is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
d = float(dup)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if d > 1.0: # tolerate a 0-100 scale
|
||||||
|
d = d / 100.0
|
||||||
|
return max(0.0, min(1.0, 1.0 - d))
|
||||||
|
|
||||||
|
|
||||||
def _summary_block(profile: dict, evaluated: list):
|
def _summary_block(profile: dict, evaluated: list):
|
||||||
"""Table-level KVTable: global score and quality aggregates."""
|
"""Table-level KVTable: global score and quality aggregates."""
|
||||||
rows = []
|
rows = []
|
||||||
@@ -153,14 +210,15 @@ def _summary_block(profile: dict, evaluated: list):
|
|||||||
if isinstance(q.get("completeness"), (int, float))]
|
if isinstance(q.get("completeness"), (int, float))]
|
||||||
vals = [q.get("validity") for _, q in evaluated
|
vals = [q.get("validity") for _, q in evaluated
|
||||||
if isinstance(q.get("validity"), (int, float))]
|
if isinstance(q.get("validity"), (int, float))]
|
||||||
cons = [q.get("consistency") for _, q in evaluated
|
|
||||||
if isinstance(q.get("consistency"), (int, float))]
|
|
||||||
if comps:
|
if comps:
|
||||||
rows.append(("Completitud media", _fmt_unit_pct(sum(comps) / len(comps))))
|
rows.append(("Completitud media", _fmt_unit_pct(sum(comps) / len(comps))))
|
||||||
if vals:
|
if vals:
|
||||||
rows.append(("Validez media", _fmt_unit_pct(sum(vals) / len(vals))))
|
rows.append(("Validez media (donde aplica)",
|
||||||
if cons:
|
_fmt_unit_pct(sum(vals) / len(vals))))
|
||||||
rows.append(("Consistencia media", _fmt_unit_pct(sum(cons) / len(cons))))
|
|
||||||
|
ru = _row_uniqueness(profile)
|
||||||
|
if ru is not None:
|
||||||
|
rows.append(("Unicidad de registro", _fmt_unit_pct(ru)))
|
||||||
|
|
||||||
n_problem = sum(1 for _, q in evaluated if q.get("issues"))
|
n_problem = sum(1 for _, q in evaluated if q.get("issues"))
|
||||||
rows.append(("Columnas con problemas", str(n_problem)))
|
rows.append(("Columnas con problemas", str(n_problem)))
|
||||||
@@ -182,22 +240,9 @@ def _summary_block(profile: dict, evaluated: list):
|
|||||||
return model.KVTable(rows=rows, title="Resumen de calidad")
|
return model.KVTable(rows=rows, title="Resumen de calidad")
|
||||||
|
|
||||||
|
|
||||||
def _fmt_unit_pct_or_pct(value) -> str:
|
|
||||||
"""Format a value that may be a 0-1 fraction or an already-0-100 percentage."""
|
|
||||||
try:
|
|
||||||
num = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return model._safe_str(value)
|
|
||||||
if num != num: # NaN
|
|
||||||
return "—"
|
|
||||||
pct = num * 100 if num <= 1.0 else num
|
|
||||||
text = f"{pct:.1f}".rstrip("0").rstrip(".")
|
|
||||||
return f"{text}%"
|
|
||||||
|
|
||||||
|
|
||||||
def _scores_block(evaluated: list):
|
def _scores_block(evaluated: list):
|
||||||
"""DataTable with per-column score and its three-criteria breakdown."""
|
"""DataTable with per-column score and its completeness/validity breakdown."""
|
||||||
header = ["Columna", "Calidad", "Completitud", "Validez", "Consistencia"]
|
header = ["Columna", "Calidad", "Completitud", "Validez"]
|
||||||
rows = []
|
rows = []
|
||||||
# Worst columns first so the reader sees the problems at the top.
|
# Worst columns first so the reader sees the problems at the top.
|
||||||
ordered = sorted(
|
ordered = sorted(
|
||||||
@@ -210,22 +255,22 @@ def _scores_block(evaluated: list):
|
|||||||
col.get("name") or "(col)",
|
col.get("name") or "(col)",
|
||||||
_fmt_score(q.get("score")),
|
_fmt_score(q.get("score")),
|
||||||
_fmt_unit_pct(q.get("completeness")),
|
_fmt_unit_pct(q.get("completeness")),
|
||||||
_fmt_unit_pct(q.get("validity")),
|
_fmt_validity(q.get("validity")),
|
||||||
_fmt_unit_pct(q.get("consistency")),
|
|
||||||
])
|
])
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
return model.DataTable(header=header, rows=rows,
|
return model.DataTable(header=header, rows=rows,
|
||||||
title="Scores de calidad por columna",
|
title="Scores de calidad por columna",
|
||||||
note="0 = peor, 100 = mejor; ordenado de peor a mejor")
|
note="0 = peor, 100 = mejor; «n/a» = dimensión no "
|
||||||
|
"medible; ordenado de peor a mejor")
|
||||||
|
|
||||||
|
|
||||||
def _issues_block(evaluated: list):
|
def _issues_block(evaluated: list):
|
||||||
"""DataTable listing Spanish issues per column, or a Note when there are none."""
|
"""DataTable listing ONLY real quality defects per column, or a Note."""
|
||||||
header = ["Columna", "Problemas detectados (español)"]
|
header = ["Columna", "Problemas de calidad (español)"]
|
||||||
rows = []
|
rows = []
|
||||||
for col, q in evaluated:
|
for col, q in evaluated:
|
||||||
joined = _join_issues(q.get("issues"))
|
joined = _join_cells(q.get("issues"))
|
||||||
if joined:
|
if joined:
|
||||||
rows.append([col.get("name") or "(col)", joined])
|
rows.append([col.get("name") or "(col)", joined])
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -235,6 +280,55 @@ def _issues_block(evaluated: list):
|
|||||||
title="Problemas de calidad por columna")
|
title="Problemas de calidad por columna")
|
||||||
|
|
||||||
|
|
||||||
|
def _observations_block(evaluated: list):
|
||||||
|
"""DataTable listing analytical observations per column, or None.
|
||||||
|
|
||||||
|
Observations (outliers, constant columns, ids, strong skew) are NOT quality
|
||||||
|
defects: they do not affect the score. Returned as a separate table from the
|
||||||
|
issues so the report never presents a legitimate outlier as a problem.
|
||||||
|
"""
|
||||||
|
header = ["Columna", "Observaciones analíticas"]
|
||||||
|
rows = []
|
||||||
|
for col, q in evaluated:
|
||||||
|
joined = _join_cells(q.get("observations"))
|
||||||
|
if joined:
|
||||||
|
rows.append([col.get("name") or "(col)", joined])
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return model.DataTable(
|
||||||
|
header=header, rows=rows,
|
||||||
|
title="Observaciones analíticas por columna",
|
||||||
|
note="No son defectos de calidad y NO afectan al score; orientan el "
|
||||||
|
"análisis (atípicos, columnas constantes, identificadores).")
|
||||||
|
|
||||||
|
|
||||||
|
def _term(key: str, label: str, mark: bool) -> str:
|
||||||
|
"""Render a term as a clickable glossary span when marking is enabled."""
|
||||||
|
if mark:
|
||||||
|
return f"[[term:{key}]]**{label}**[[/term]]"
|
||||||
|
return f"**{label}**"
|
||||||
|
|
||||||
|
|
||||||
|
def _criteria_intro(mark: bool) -> str:
|
||||||
|
"""Intro: how the score is composed, with every term marked clickable.
|
||||||
|
|
||||||
|
Concise on purpose: the definitions of each term (calidad de datos,
|
||||||
|
completitud, validez, unicidad de registro) now live in the GLOSARIO
|
||||||
|
chapter, so the body no longer repeats them — it only states how the score
|
||||||
|
is composed and keeps each term marked so it stays a clickable jump.
|
||||||
|
"""
|
||||||
|
calidad = _term("calidad_datos", "calidad de datos", mark)
|
||||||
|
completitud = _term("completitud", "completitud", mark)
|
||||||
|
validez = _term("validez", "validez", mark)
|
||||||
|
unicidad = _term("unicidad_registro", "unicidad de registro", mark)
|
||||||
|
return (
|
||||||
|
f"La {calidad} de cada columna es un score de 0 a 100 que combina "
|
||||||
|
f"{completitud} (peso 60%) y {validez} (peso 40%, cuando es medible); "
|
||||||
|
f"a nivel de tabla se añade la {unicidad}. Los valores atípicos no "
|
||||||
|
"bajan el score: se listan aparte como **observaciones analíticas**."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_calidad(profile: dict, ctx: dict):
|
def build_calidad(profile: dict, ctx: dict):
|
||||||
"""Build the data-quality Chapter, or None if the profile has no columns.
|
"""Build the data-quality Chapter, or None if the profile has no columns.
|
||||||
|
|
||||||
@@ -250,17 +344,35 @@ def build_calidad(profile: dict, ctx: dict):
|
|||||||
if not evaluated:
|
if not evaluated:
|
||||||
return None # no columns to score -> chapter does not apply.
|
return None # no columns to score -> chapter does not apply.
|
||||||
|
|
||||||
|
# Register the criteria terms in the shared glossary (if present) and mark
|
||||||
|
# their first appearance clickable. Contract §11.1.
|
||||||
|
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
|
||||||
|
|
||||||
blocks = [
|
blocks = [
|
||||||
model.Heading(text="Cómo se calcula la calidad", level=2),
|
model.Heading(text="Cómo se calcula la calidad", level=2),
|
||||||
model.Markdown(text=_CRITERIA_INTRO),
|
model.Markdown(text=_criteria_intro(mark)),
|
||||||
_summary_block(profile, evaluated),
|
_summary_block(profile, evaluated),
|
||||||
model.Heading(text="Scores por columna", level=2),
|
model.Heading(text="Scores por columna", level=2),
|
||||||
]
|
]
|
||||||
scores = _scores_block(evaluated)
|
scores = _scores_block(evaluated)
|
||||||
if scores is not None:
|
if scores is not None:
|
||||||
blocks.append(scores)
|
blocks.append(scores)
|
||||||
blocks.append(model.Heading(text="Problemas detectados", level=2))
|
|
||||||
|
blocks.append(model.Heading(text="Problemas de calidad", level=2))
|
||||||
blocks.append(_issues_block(evaluated))
|
blocks.append(_issues_block(evaluated))
|
||||||
|
|
||||||
|
observations = _observations_block(evaluated)
|
||||||
|
if observations is not None:
|
||||||
|
blocks.append(model.Heading(text="Observaciones analíticas", level=2))
|
||||||
|
blocks.append(model.Note(
|
||||||
|
"Las observaciones siguientes NO son defectos de calidad y no "
|
||||||
|
"afectan al score: son señales para orientar el análisis."))
|
||||||
|
blocks.append(observations)
|
||||||
|
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Tests for the CALIDAD chapter — DoD: golden + edges + anti-cut.
|
"""Tests for the CALIDAD chapter — DoD: golden + edges + anti-cut + glossary.
|
||||||
|
|
||||||
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
||||||
and deterministic. Verifies that the chapter explains the quality criteria, shows
|
and deterministic. Verifies the report-2046 quality model: the chapter explains
|
||||||
per-column scores with the completeness/validity/consistency breakdown, lists the
|
the two scored dimensions (completitud 60% / validez 40%), shows per-column
|
||||||
issues in Spanish (separate from the type flags), returns None when it does not
|
scores without a consistency column, keeps quality DEFECTS (issues) separate
|
||||||
apply, and that a wide profile with long names renders to PDF and PPTX without
|
from analytical OBSERVATIONS (outliers, constant, ids), hooks the criteria terms
|
||||||
cutting any cell text (long content wraps, it is never truncated).
|
into the glossary, returns None when it does not apply, and renders a wide
|
||||||
|
profile to PDF and PPTX without cutting any cell text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -20,28 +21,30 @@ from datascience.automatic_eda.chapters.calidad import (
|
|||||||
CHAPTER_VERSION,
|
CHAPTER_VERSION,
|
||||||
)
|
)
|
||||||
from datascience.automatic_eda import build_document, render_pdf, render_pptx
|
from datascience.automatic_eda import build_document, render_pdf, render_pptx
|
||||||
|
from datascience.automatic_eda import model
|
||||||
|
|
||||||
|
|
||||||
def _profile() -> dict:
|
def _profile() -> dict:
|
||||||
"""A small profile with one column per quality problem (nulls, outliers,
|
"""A small profile with one column per quality problem (nulls, outliers,
|
||||||
constant, high-cardinality id) plus one clean column."""
|
constant, high-cardinality id) plus one clean column. ``outlier_pct`` is in
|
||||||
|
the 0-100 scale that describe_numeric actually emits."""
|
||||||
return {
|
return {
|
||||||
"table": "demo",
|
"table": "demo",
|
||||||
"quality_score": 72.5,
|
"quality_score": 82.0,
|
||||||
"duplicate_pct": 0.04,
|
"duplicate_pct": 0.04,
|
||||||
"null_cell_pct": 0.11,
|
"null_cell_pct": 0.11,
|
||||||
"constant_cols": ["flag_const"],
|
"constant_cols": ["flag_const"],
|
||||||
"all_null_cols": [],
|
"all_null_cols": [],
|
||||||
"columns": [
|
"columns": [
|
||||||
{"name": "edad", "inferred_type": "integer", "null_pct": 0.2,
|
{"name": "edad", "inferred_type": "numeric", "null_pct": 0.2,
|
||||||
"numeric": {"outlier_pct": 0.15, "min": 0, "max": 99},
|
"n_rows": 100, "unique_pct": 0.5,
|
||||||
"quality_score": 60},
|
"numeric": {"outlier_pct": 15.0, "min": 0, "max": 99}},
|
||||||
{"name": "nombre", "inferred_type": "text", "null_pct": 0.0,
|
{"name": "nombre", "inferred_type": "text", "null_pct": 0.0,
|
||||||
"unique_pct": 0.98, "quality_score": 80},
|
"unique_pct": 0.98, "flags": ["possible_id"]},
|
||||||
{"name": "flag_const", "inferred_type": "text", "null_pct": 0.0,
|
{"name": "flag_const", "inferred_type": "text", "null_pct": 0.0,
|
||||||
"flags": ["constant"], "quality_score": 50},
|
"unique_pct": 0.01, "flags": ["constant"]},
|
||||||
{"name": "limpia", "inferred_type": "float", "null_pct": 0.0,
|
{"name": "limpia", "inferred_type": "numeric", "null_pct": 0.0,
|
||||||
"numeric": {"outlier_pct": 0.0}, "quality_score": 100},
|
"unique_pct": 0.5, "numeric": {"outlier_pct": 0.0}},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,16 +53,9 @@ def _tables(chapter):
|
|||||||
return [b for b in chapter.blocks if getattr(b, "kind", None) == "data_table"]
|
return [b for b in chapter.blocks if getattr(b, "kind", None) == "data_table"]
|
||||||
|
|
||||||
|
|
||||||
def _scores_table(chapter):
|
def _table_by_title(chapter, needle):
|
||||||
for t in _tables(chapter):
|
for t in _tables(chapter):
|
||||||
if "Scores" in (t.title or ""):
|
if needle in (t.title or ""):
|
||||||
return t
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _issues_table(chapter):
|
|
||||||
for t in _tables(chapter):
|
|
||||||
if "Problemas" in (t.title or ""):
|
|
||||||
return t
|
return t
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -73,41 +69,86 @@ def test_golden_chapter_estructura_y_version():
|
|||||||
assert ch.id == "calidad"
|
assert ch.id == "calidad"
|
||||||
assert ch.version == CHAPTER_VERSION
|
assert ch.version == CHAPTER_VERSION
|
||||||
kinds = [b.kind for b in ch.blocks]
|
kinds = [b.kind for b in ch.blocks]
|
||||||
# intro heading + markdown criteria + summary kv + scores table + issues table
|
|
||||||
assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds
|
assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds
|
||||||
|
|
||||||
|
|
||||||
def test_golden_intro_explica_criterios_y_pesos():
|
def test_golden_intro_nombra_dos_dimensiones_y_pesos():
|
||||||
|
# La intro nombra las dos dimensiones, sus pesos y la unicidad, pero ya NO
|
||||||
|
# repite sus definiciones largas: estas viven ahora en el capítulo GLOSARIO.
|
||||||
ch = build_calidad(_profile(), {})
|
ch = build_calidad(_profile(), {})
|
||||||
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
|
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
|
||||||
for needle in ("Completitud", "Validez", "Consistencia",
|
for needle in ("completitud", "validez", "60%", "40%",
|
||||||
"50%", "30%", "20%"):
|
"unicidad de registro"):
|
||||||
assert needle in intro, f"falta {needle!r} en la intro de criterios"
|
assert needle in intro, f"falta {needle!r} en la intro de criterios"
|
||||||
|
# El principio: los outliers NO bajan la calidad.
|
||||||
|
assert "atípicos" in intro and "no bajan" in intro
|
||||||
|
# Ya no se menciona la dimensión consistencia eliminada.
|
||||||
|
assert "20%" not in intro
|
||||||
|
|
||||||
|
|
||||||
def test_golden_scores_incluyen_desglose_por_criterio():
|
def test_golden_scores_sin_columna_consistencia():
|
||||||
ch = build_calidad(_profile(), {})
|
ch = build_calidad(_profile(), {})
|
||||||
scores = _scores_table(ch)
|
scores = _table_by_title(ch, "Scores")
|
||||||
assert scores is not None
|
assert scores is not None
|
||||||
assert scores.header == ["Columna", "Calidad", "Completitud",
|
assert scores.header == ["Columna", "Calidad", "Completitud", "Validez"]
|
||||||
"Validez", "Consistencia"]
|
assert "Consistencia" not in scores.header
|
||||||
# 4 columns scored, none dropped.
|
|
||||||
assert len(scores.rows) == 4
|
assert len(scores.rows) == 4
|
||||||
names = {r[0] for r in scores.rows}
|
names = {r[0] for r in scores.rows}
|
||||||
assert names == {"edad", "nombre", "flag_const", "limpia"}
|
assert names == {"edad", "nombre", "flag_const", "limpia"}
|
||||||
|
|
||||||
|
|
||||||
def test_golden_issues_en_espanol_separados_de_flags():
|
def test_golden_outliers_en_observaciones_no_en_problemas():
|
||||||
ch = build_calidad(_profile(), {})
|
ch = build_calidad(_profile(), {})
|
||||||
issues = _issues_table(ch)
|
problemas = _table_by_title(ch, "Problemas de calidad")
|
||||||
assert issues is not None
|
observaciones = _table_by_title(ch, "Observaciones")
|
||||||
flat = " | ".join(" ".join(r) for r in issues.rows)
|
assert problemas is not None
|
||||||
assert "nulos" in flat # completeness issue (ES)
|
assert observaciones is not None
|
||||||
assert "outliers" in flat # validity issue (ES)
|
|
||||||
assert "columna constante" in flat
|
problemas_txt = " | ".join(" ".join(r) for r in problemas.rows)
|
||||||
assert "posible id de alta cardinalidad" in flat
|
observaciones_txt = " | ".join(" ".join(r) for r in observaciones.rows)
|
||||||
# The raw type flag string must NOT leak as a "problem".
|
|
||||||
assert "constant" not in flat or "columna constante" in flat
|
# Los nulos SÍ son problema de calidad.
|
||||||
|
assert "nulos" in problemas_txt
|
||||||
|
# Los outliers NO aparecen como problema...
|
||||||
|
assert "atípic" not in problemas_txt and "outlier" not in problemas_txt
|
||||||
|
# ...sino como observación analítica.
|
||||||
|
assert "atípic" in observaciones_txt
|
||||||
|
# Constante e id: observaciones, no problemas.
|
||||||
|
assert "constante" in observaciones_txt
|
||||||
|
assert "identificador" in observaciones_txt
|
||||||
|
assert "constante" not in problemas_txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_score_columna_limpia_es_100():
|
||||||
|
"""Columna sin nulos, numérica nativa: score 100 aunque tenga (o no) outliers."""
|
||||||
|
ch = build_calidad(_profile(), {})
|
||||||
|
scores = _table_by_title(ch, "Scores")
|
||||||
|
by_name = {r[0]: r for r in scores.rows}
|
||||||
|
assert by_name["limpia"][1] == "100 / 100"
|
||||||
|
# edad: 20% nulos -> 100*(0.6*0.8 + 0.4*1.0) = 88; los outliers no bajan nada.
|
||||||
|
assert by_name["edad"][1] == "88 / 100"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Glosario (contrato §11.1)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_glosario_registra_los_cuatro_terminos_y_marca_clicable():
|
||||||
|
glossary = model.GlossaryCollector()
|
||||||
|
ch = build_calidad(_profile(), {"glossary": glossary})
|
||||||
|
for key in ("calidad_datos", "completitud", "validez", "unicidad_registro"):
|
||||||
|
assert glossary.has(key), f"término {key!r} no registrado en el glosario"
|
||||||
|
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
|
||||||
|
# Con colector presente, la primera aparición se marca clicable.
|
||||||
|
assert "[[term:completitud]]" in intro
|
||||||
|
assert "[[term:validez]]" in intro
|
||||||
|
assert "[[term:calidad_datos]]" in intro
|
||||||
|
assert "[[term:unicidad_registro]]" in intro
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_glosario_no_marca_terminos():
|
||||||
|
ch = build_calidad(_profile(), {}) # ctx sin glossary
|
||||||
|
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
|
||||||
|
assert "[[term:" not in intro
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -124,17 +165,17 @@ def test_edge_perfil_limpio_sin_problemas_usa_nota():
|
|||||||
prof = {
|
prof = {
|
||||||
"quality_score": 100,
|
"quality_score": 100,
|
||||||
"columns": [
|
"columns": [
|
||||||
{"name": "a", "inferred_type": "float", "null_pct": 0.0,
|
{"name": "a", "inferred_type": "numeric", "null_pct": 0.0,
|
||||||
"numeric": {"outlier_pct": 0.0}},
|
"unique_pct": 0.5, "numeric": {"outlier_pct": 0.0}},
|
||||||
{"name": "b", "inferred_type": "float", "null_pct": 0.0,
|
{"name": "b", "inferred_type": "numeric", "null_pct": 0.0,
|
||||||
"numeric": {"outlier_pct": 0.0}},
|
"unique_pct": 0.5, "numeric": {"outlier_pct": 0.0}},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
ch = build_calidad(prof, {})
|
ch = build_calidad(prof, {})
|
||||||
assert ch is not None
|
assert ch is not None
|
||||||
assert _issues_table(ch) is None # no issues table
|
assert _table_by_title(ch, "Problemas de calidad") is None # no issues table
|
||||||
notes = [b for b in ch.blocks if b.kind == "note"]
|
notes = [b for b in ch.blocks if b.kind == "note"]
|
||||||
assert notes and "No se detectaron problemas" in notes[0].text
|
assert any("No se detectaron problemas" in n.text for n in notes)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -143,44 +184,42 @@ def test_edge_perfil_limpio_sin_problemas_usa_nota():
|
|||||||
def _wide_profile(ncols: int = 22) -> dict:
|
def _wide_profile(ncols: int = 22) -> dict:
|
||||||
cols = [
|
cols = [
|
||||||
{"name": "identificador_unico_de_transaccion_con_nombre_muy_largo",
|
{"name": "identificador_unico_de_transaccion_con_nombre_muy_largo",
|
||||||
"inferred_type": "text", "null_pct": 0.0, "unique_pct": 0.99},
|
"inferred_type": "text", "null_pct": 0.0, "unique_pct": 0.99,
|
||||||
|
"flags": ["possible_id"]},
|
||||||
{"name": "columna_constante_sin_ninguna_variacion_de_valor",
|
{"name": "columna_constante_sin_ninguna_variacion_de_valor",
|
||||||
"inferred_type": "text", "null_pct": 0.0, "flags": ["constant"]},
|
"inferred_type": "text", "null_pct": 0.0, "unique_pct": 0.01,
|
||||||
|
"flags": ["constant"]},
|
||||||
]
|
]
|
||||||
for k in range(ncols - 2):
|
for k in range(ncols - 2):
|
||||||
cols.append({
|
cols.append({
|
||||||
"name": f"metrica_numerica_de_negocio_{k:02d}_con_nombre_largo",
|
"name": f"metrica_numerica_de_negocio_{k:02d}_con_nombre_largo",
|
||||||
"inferred_type": "float", "null_pct": 0.1 + (k % 3) * 0.05,
|
"inferred_type": "numeric", "null_pct": 0.1 + (k % 3) * 0.05,
|
||||||
"numeric": {"outlier_pct": 0.08, "min": 0, "max": 1000},
|
"unique_pct": 0.5,
|
||||||
|
"numeric": {"outlier_pct": 8.0, "min": 0, "max": 1000},
|
||||||
})
|
})
|
||||||
return {"table": "ancha", "quality_score": 70.0, "columns": cols}
|
return {"table": "ancha", "quality_score": 70.0, "duplicate_pct": 0.0,
|
||||||
|
"columns": cols}
|
||||||
|
|
||||||
|
|
||||||
def test_anticut_pdf_y_pptx_no_truncan_nombres_largos():
|
def test_anticut_pdf_y_pptx_no_truncan_nombres_largos():
|
||||||
prof = _wide_profile(22)
|
prof = _wide_profile(22)
|
||||||
full = build_document(prof, {"dataset_name": "ancha"})
|
full = build_document(prof, {"dataset_name": "ancha"})
|
||||||
assert any(c.id == "calidad" for c in full)
|
assert any(c.id == "calidad" for c in full)
|
||||||
# Render ONLY the calidad chapter so the anti-cut assertions are scoped to
|
|
||||||
# this chapter (other chapters, e.g. portada, legitimately contain '…').
|
|
||||||
chapters = [c for c in full if c.id == "calidad"]
|
chapters = [c for c in full if c.id == "calidad"]
|
||||||
long_name = "metrica_numerica_de_negocio_00_con_nombre_largo"
|
long_name = "metrica_numerica_de_negocio_00_con_nombre_largo"
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
pdf = os.path.join(d, "q.pdf")
|
pdf = os.path.join(d, "q.pdf")
|
||||||
pptx = os.path.join(d, "q.pptx")
|
pptx = os.path.join(d, "q.pptx")
|
||||||
rp = render_pdf(chapters, pdf, {"title": "EDA"})
|
rp = render_pdf(chapters, pdf, {"title": "EDA"})
|
||||||
rx = render_pptx(chapters, pptx, {"title": "EDA"})
|
render_pptx(chapters, pptx, {"title": "EDA"})
|
||||||
assert os.path.exists(pdf) and os.path.exists(pptx)
|
assert os.path.exists(pdf) and os.path.exists(pptx)
|
||||||
# The wide table forces pagination across several pages/slides.
|
|
||||||
assert (rp or {}).get("n_pages", 0) >= 2
|
assert (rp or {}).get("n_pages", 0) >= 2
|
||||||
|
|
||||||
# PDF: the long name survives whole once wraps (spaces/newlines) removed,
|
|
||||||
# and there is no truncation marker.
|
|
||||||
pdf_txt = "".join((pg.extract_text() or "") for pg in PdfReader(pdf).pages)
|
pdf_txt = "".join((pg.extract_text() or "") for pg in PdfReader(pdf).pages)
|
||||||
assert "…" not in pdf_txt and "..." not in pdf_txt
|
assert "…" not in pdf_txt and "..." not in pdf_txt
|
||||||
norm = re.sub(r"\s+", "", pdf_txt)
|
norm = re.sub(r"\s+", "", pdf_txt)
|
||||||
assert long_name in norm, "el nombre largo se cortó en el PDF"
|
assert long_name in norm, "el nombre largo se cortó en el PDF"
|
||||||
|
|
||||||
# PPTX: long name present in some cell, untruncated.
|
|
||||||
allt = []
|
allt = []
|
||||||
for s in Presentation(pptx).slides:
|
for s in Presentation(pptx).slides:
|
||||||
for sh in s.shapes:
|
for sh in s.shapes:
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
"""Categorical distributions chapter (CAT DISTR).
|
"""Categorical distributions chapter (CAT DISTR).
|
||||||
|
|
||||||
Third reference chapter for AutomaticEDA. For every categorical column it shows,
|
Third reference chapter for AutomaticEDA. Each categorical column gets **its own
|
||||||
fulfilling the user's request:
|
page (PDF) / slide (PPTX)**: every column is wrapped in a keep-together
|
||||||
|
``model.Group`` with ``page_break_before=True`` (except the first, which may share
|
||||||
|
the intro's page), so its chart sits next to its tables and no column is split.
|
||||||
|
|
||||||
1. A short opening explanation of **Shannon entropy** (what it measures, its 0
|
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** term —
|
||||||
and log2(k) bounds, the normalized 0–1 version) and the dataset row total used
|
the full definition lives in the GLOSARIO chapter, so it is NOT repeated inline
|
||||||
as a comparison baseline.
|
here (one click jumps to the glossary entry). The intro also carries the dataset
|
||||||
2. Per column, a cardinality key/value table: distinct values, ``% distinct``
|
row total used as a comparison baseline.
|
||||||
(distinct / total rows), total dataset rows, singleton values (frequency 1),
|
|
||||||
entropy with its theoretical maximum and the normalized ratio, mode, imbalance
|
Per column the Group contains, in order:
|
||||||
and string-length stats.
|
|
||||||
3. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
1. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
|
||||||
|
total rows), total dataset rows, singleton values (frequency 1), entropy with
|
||||||
|
its theoretical maximum and the normalized ratio, mode, imbalance and
|
||||||
|
string-length stats.
|
||||||
|
2. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
||||||
single dominating category).
|
single dominating category).
|
||||||
4. A ``top-k`` table (value / count / %).
|
3. A ``top-k`` table (value / count / %).
|
||||||
5. A **donut pie chart** of the most common categories (top-k + an "Otros"
|
4. A **donut pie chart** of the most common categories (top-k + an "Otros"
|
||||||
bucket), drawn lazily so the renderers scale it to fit entirely.
|
bucket), drawn lazily so the renderers scale it to fit entirely.
|
||||||
|
|
||||||
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
|
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
|
||||||
@@ -33,7 +39,7 @@ import math
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.1.0"
|
CHAPTER_VERSION = "1.2.0"
|
||||||
CHAPTER_ID = "cat_distr"
|
CHAPTER_ID = "cat_distr"
|
||||||
CHAPTER_TITLE = "Distribuciones categóricas"
|
CHAPTER_TITLE = "Distribuciones categóricas"
|
||||||
|
|
||||||
@@ -53,11 +59,17 @@ _TERM_ENTROPIA_DEF = (
|
|||||||
# Cap the number of categorical columns rendered to keep the document bounded;
|
# Cap the number of categorical columns rendered to keep the document bounded;
|
||||||
# the rest are summarized in a closing note (no silent truncation).
|
# the rest are summarized in a closing note (no silent truncation).
|
||||||
MAX_COLS = 40
|
MAX_COLS = 40
|
||||||
# Rows shown in each top-k table and explicit slices in the pie.
|
# Rows shown in each top-k table and explicit slices in the pie. Kept moderate so
|
||||||
TOP_TABLE_ROWS = 15
|
# the whole column — cardinality table + top-k table + donut — fits on ONE
|
||||||
|
# page/slide with the chart next to its tables; the table note still reports
|
||||||
|
# "top N of M" so nothing is silently hidden. For id-like columns (≈100%
|
||||||
|
# distinct) the top-k table is dropped entirely (it would be a list of unique
|
||||||
|
# values — pure noise), which also frees the room the donut needs (see build).
|
||||||
|
TOP_TABLE_ROWS = 8
|
||||||
PIE_TOP_K = 6
|
PIE_TOP_K = 6
|
||||||
# Truncate very long category labels in tables (the renderer also wraps).
|
# Truncate very long category labels in tables (the renderer also wraps). Kept
|
||||||
LABEL_MAX = 48
|
# tight so a column with long id-like values (names, tickets) still fits its page.
|
||||||
|
LABEL_MAX = 28
|
||||||
|
|
||||||
|
|
||||||
def _fmt_int(value) -> str:
|
def _fmt_int(value) -> str:
|
||||||
@@ -267,45 +279,55 @@ def _normalize_card(card: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _cardinality_block(card: dict):
|
def _cardinality_block(card: dict):
|
||||||
"""KVTable with the cardinality / entropy metrics for one column."""
|
"""KVTable with the cardinality / entropy metrics for one column.
|
||||||
|
|
||||||
|
Related metrics are grouped onto a single row each (distinct/%/unique;
|
||||||
|
entropy bits/max/normalized; length min/mean/max) so the whole column —
|
||||||
|
table + chart — fits one page/slide without dropping any datum; the short
|
||||||
|
16:9 PPTX slide does not fit one metric per row plus a chart otherwise."""
|
||||||
n_singletons = card.get("n_singletons")
|
n_singletons = card.get("n_singletons")
|
||||||
if n_singletons is not None and card.get("n_singletons_partial"):
|
if n_singletons is not None and card.get("n_singletons_partial"):
|
||||||
singletons = f"≥{_fmt_int(n_singletons)} (en top mostrado)"
|
singletons = f"≥{_fmt_int(n_singletons)}"
|
||||||
elif n_singletons is not None:
|
elif n_singletons is not None:
|
||||||
singletons = _fmt_int(n_singletons)
|
singletons = _fmt_int(n_singletons)
|
||||||
else:
|
else:
|
||||||
singletons = "—"
|
singletons = "—"
|
||||||
|
|
||||||
entropy_ref = _fmt_num(card.get("entropy"))
|
# Distinct count · % distinct · unique (frequency 1) on one row.
|
||||||
emax = card.get("entropy_max")
|
distinct_combo = (f"{_fmt_int(card.get('n_distinct'))} · "
|
||||||
if emax is not None:
|
f"{_fmt_pct_value(card.get('pct_distinct'))} · "
|
||||||
entropy_ref = f"{entropy_ref} (máx {_fmt_num(emax)})"
|
f"{singletons} únicos")
|
||||||
|
|
||||||
|
# Entropy bits · theoretical max · normalized 0–1 on one row.
|
||||||
|
entropy_combo = (f"{_fmt_num(card.get('entropy'))} bits · "
|
||||||
|
f"máx {_fmt_num(card.get('entropy_max'))} · "
|
||||||
|
f"norm {_fmt_num(card.get('entropy_norm'))}")
|
||||||
|
|
||||||
mode = card.get("mode")
|
mode = card.get("mode")
|
||||||
mode_pct = card.get("mode_pct")
|
mode_pct = card.get("mode_pct")
|
||||||
mode_str = "—" if mode is None else model._safe_str(mode)
|
mode_str = "—" if mode is None else _truncate(mode, 32)
|
||||||
if mode is not None and mode_pct is not None:
|
if mode is not None and mode_pct is not None:
|
||||||
mode_str = f"{mode_str} ({_fmt_pct_value(mode_pct)})"
|
mode_str = f"{mode_str} ({_fmt_pct_value(mode_pct)})"
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
("Valores distintos", _fmt_int(card.get("n_distinct"))),
|
("Distintos · % · únicos", distinct_combo),
|
||||||
("% distintos", _fmt_pct_value(card.get("pct_distinct"))),
|
|
||||||
("Total filas (dataset)", _fmt_int(card.get("n_rows"))),
|
("Total filas (dataset)", _fmt_int(card.get("n_rows"))),
|
||||||
("Valores únicos (frecuencia 1)", singletons),
|
("Entropía (bits · máx · norm)", entropy_combo),
|
||||||
("Entropía (bits)", entropy_ref),
|
|
||||||
("Entropía normalizada (0–1)", _fmt_num(card.get("entropy_norm"))),
|
|
||||||
("Moda", mode_str),
|
("Moda", mode_str),
|
||||||
]
|
]
|
||||||
imbalance = card.get("imbalance")
|
imbalance = card.get("imbalance")
|
||||||
if imbalance is not None:
|
|
||||||
rows.append(("Desbalance", _fmt_num(imbalance)))
|
|
||||||
lm = card.get("len_min")
|
lm = card.get("len_min")
|
||||||
lmean = card.get("len_mean")
|
lmean = card.get("len_mean")
|
||||||
lmax = card.get("len_max")
|
lmax = card.get("len_max")
|
||||||
|
# Imbalance and string length (both secondary) share one closing row.
|
||||||
|
extras = []
|
||||||
|
if imbalance is not None:
|
||||||
|
extras.append(f"desbalance {_fmt_num(imbalance)}")
|
||||||
if any(v is not None for v in (lm, lmean, lmax)):
|
if any(v is not None for v in (lm, lmean, lmax)):
|
||||||
rows.append((
|
extras.append(
|
||||||
"Longitud (mín/media/máx)",
|
f"long. {_fmt_num(lm)}/{_fmt_num(lmean)}/{_fmt_num(lmax)}")
|
||||||
f"{_fmt_num(lm)} / {_fmt_num(lmean)} / {_fmt_num(lmax)}"))
|
if extras:
|
||||||
|
rows.append(("Desbalance · longitud", " · ".join(extras)))
|
||||||
return model.KVTable(rows=rows, title="Cardinalidad")
|
return model.KVTable(rows=rows, title="Cardinalidad")
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +337,8 @@ def _flag_note(card: dict):
|
|||||||
return model.Note(
|
return model.Note(
|
||||||
"Casi todos los valores son distintos (≈100% distintos): la columna "
|
"Casi todos los valores son distintos (≈100% distintos): la columna "
|
||||||
"se comporta como un identificador y aporta poco para agrupar o "
|
"se comporta como un identificador y aporta poco para agrupar o "
|
||||||
"comparar categorías.")
|
"comparar categorías. No se lista el top de categorías (serían "
|
||||||
|
"valores casi todos únicos).")
|
||||||
if card.get("dominated"):
|
if card.get("dominated"):
|
||||||
mp = card.get("mode_pct")
|
mp = card.get("mode_pct")
|
||||||
mp_str = _fmt_pct_value(mp) if mp is not None else "muy alta"
|
mp_str = _fmt_pct_value(mp) if mp is not None else "muy alta"
|
||||||
@@ -335,7 +358,7 @@ def _topk_table(cat: dict):
|
|||||||
if not isinstance(t, dict):
|
if not isinstance(t, dict):
|
||||||
continue
|
continue
|
||||||
rows.append([
|
rows.append([
|
||||||
model._safe_str(t.get("value")),
|
_truncate(t.get("value")),
|
||||||
_fmt_int(t.get("count")),
|
_fmt_int(t.get("count")),
|
||||||
_pct_from_maybe_fraction(t.get("pct")),
|
_pct_from_maybe_fraction(t.get("pct")),
|
||||||
])
|
])
|
||||||
@@ -353,20 +376,16 @@ def _topk_table(cat: dict):
|
|||||||
def _intro_blocks(n_rows, mark_term: bool = False):
|
def _intro_blocks(n_rows, mark_term: bool = False):
|
||||||
total = _fmt_int(n_rows)
|
total = _fmt_int(n_rows)
|
||||||
# Mark the first appearance of the term as a clickable glossary jump when the
|
# Mark the first appearance of the term as a clickable glossary jump when the
|
||||||
# term was registered (mark_term). The visible text is identical either way.
|
# term was registered (mark_term). The full definition of entropy lives in the
|
||||||
entropia = ("[[term:entropia]]**entropía de Shannon**[[/term]]" if mark_term
|
# GLOSARIO chapter, so the intro only names the clickable term here instead of
|
||||||
else "**entropía de Shannon**")
|
# repeating the long explanation (avoids the redundancy with the glossary).
|
||||||
|
entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
|
||||||
|
else "entropía")
|
||||||
text = (
|
text = (
|
||||||
f"La {entropia} mide cómo de repartidos están los valores de "
|
f"Cada columna categórica ocupa su propia página: sus métricas de "
|
||||||
"una columna categórica, en bits. Vale 0 cuando una sola categoría "
|
f"cardinalidad —incluida la {entropia}—, una nota que señala cardinalidad "
|
||||||
"concentra todas las filas (máxima previsibilidad) y alcanza su máximo, "
|
"problemática, la tabla de las categorías más frecuentes y un gráfico de "
|
||||||
"log2(k) para k categorías distintas, cuando todas aparecen por igual "
|
"tarta (donut) de las más comunes, todo junto."
|
||||||
"(máxima diversidad). La **entropía normalizada** (entropía dividida por "
|
|
||||||
"su máximo) la lleva al rango 0–1 para comparar columnas con distinto "
|
|
||||||
"número de categorías. Para cada columna se muestran los valores "
|
|
||||||
"distintos, el porcentaje que representan sobre el total de filas, los "
|
|
||||||
"valores únicos (que aparecen una sola vez), la tabla de las categorías "
|
|
||||||
"más frecuentes y un gráfico de tarta (donut) de las más comunes."
|
|
||||||
)
|
)
|
||||||
if n_rows is not None:
|
if n_rows is not None:
|
||||||
text += f" El dataset tiene {total} filas en total como referencia."
|
text += f" El dataset tiene {total} filas en total como referencia."
|
||||||
@@ -398,24 +417,37 @@ def build_cat_distr(profile: dict, ctx: dict):
|
|||||||
blocks = list(_intro_blocks(n_rows, mark_term=mark_term))
|
blocks = list(_intro_blocks(n_rows, mark_term=mark_term))
|
||||||
|
|
||||||
rendered = cat_cols[:MAX_COLS]
|
rendered = cat_cols[:MAX_COLS]
|
||||||
for col in rendered:
|
for idx, col in enumerate(rendered):
|
||||||
name = col.get("name") or "(columna)"
|
name = col.get("name") or "(columna)"
|
||||||
cat = col.get("categorical") or {}
|
cat = col.get("categorical") or {}
|
||||||
card = _normalize_card(_cardinality(cat, n_rows))
|
card = _normalize_card(_cardinality(cat, n_rows))
|
||||||
|
|
||||||
blocks.append(model.Heading(text=str(name), level=2))
|
# One Group per categorical column: heading + cardinality table + flag
|
||||||
blocks.append(_cardinality_block(card))
|
# note + top-k table + donut figure are kept together and the renderer
|
||||||
|
# starts each on a fresh page/slide (page_break_before) so every column
|
||||||
|
# gets its own page with its chart next to its tables. The first column
|
||||||
|
# may share the intro's page (no forced break) to avoid a near-empty page.
|
||||||
|
col_blocks = [
|
||||||
|
model.Heading(text=str(name), level=2),
|
||||||
|
_cardinality_block(card),
|
||||||
|
]
|
||||||
note = _flag_note(card)
|
note = _flag_note(card)
|
||||||
if note is not None:
|
if note is not None:
|
||||||
blocks.append(note)
|
col_blocks.append(note)
|
||||||
topk = _topk_table(cat)
|
# For id-like columns (≈100% distinct) the top-k is a list of unique
|
||||||
if topk is not None:
|
# values — pure noise; skip it (the flag note already explains why) and
|
||||||
blocks.append(topk)
|
# let the donut take that room so the whole column fits one page/slide.
|
||||||
blocks.append(model.Figure(
|
if not card.get("id_like"):
|
||||||
|
topk = _topk_table(cat)
|
||||||
|
if topk is not None:
|
||||||
|
col_blocks.append(topk)
|
||||||
|
col_blocks.append(model.Figure(
|
||||||
make=_pie_make(cat.get("top") or [], card.get("n_distinct"),
|
make=_pie_make(cat.get("top") or [], card.get("n_distinct"),
|
||||||
str(name), n_rows),
|
str(name), n_rows),
|
||||||
caption=(f"Categorías más comunes de «{_truncate(name, 32)}» "
|
caption=(f"Categorías más comunes de «{_truncate(name, 32)}» "
|
||||||
"(donut: top-k + «Otros»)")))
|
"(donut: top-k + «Otros»)")))
|
||||||
|
blocks.append(model.Group(blocks=col_blocks,
|
||||||
|
page_break_before=(idx > 0)))
|
||||||
|
|
||||||
if len(cat_cols) > len(rendered):
|
if len(cat_cols) > len(rendered):
|
||||||
omitted = len(cat_cols) - len(rendered)
|
omitted = len(cat_cols) - len(rendered)
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
||||||
and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user
|
and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user
|
||||||
asked for (entropy intro, distinct/total/%-distinct/unique metrics, top-k table
|
asked for (distinct/total/%-distinct/unique metrics, top-k table and a donut
|
||||||
and a donut figure), that the chapter renders inside the full document to both
|
figure), that EACH categorical column is wrapped in its own keep-together
|
||||||
PDF and PPTX showing that content, that a profile with no categorical columns
|
``Group`` that starts on a fresh page/slide (one column per page, chart next to
|
||||||
yields ``None`` without raising, and that long labels / many columns are never
|
its tables), that the long entropy explanation is NOT repeated inline (it lives
|
||||||
cut in either output.
|
in the glossary — only the clickable term is kept), that the chapter renders
|
||||||
|
inside the full document to both PDF and PPTX showing that content, that a
|
||||||
|
profile with no categorical columns yields ``None`` without raising, and that
|
||||||
|
long labels / many columns are never cut in either output.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -17,7 +20,8 @@ from pypdf import PdfReader
|
|||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
|
|
||||||
from datascience.automatic_eda.model import (
|
from datascience.automatic_eda.model import (
|
||||||
DataTable, Figure, Heading, KVTable, Note,
|
DataTable, Figure, GlossaryCollector, Group, Heading, KVTable, Markdown,
|
||||||
|
Note,
|
||||||
)
|
)
|
||||||
from datascience.automatic_eda.chapters.cat_distr import (
|
from datascience.automatic_eda.chapters.cat_distr import (
|
||||||
CHAPTER_ID, CHAPTER_VERSION, build_cat_distr,
|
CHAPTER_ID, CHAPTER_VERSION, build_cat_distr,
|
||||||
@@ -81,8 +85,20 @@ def _pptx_text(path: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", " ".join(parts))
|
return re.sub(r"\s+", " ", " ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
def _kinds(chapter):
|
def _flatten(blocks):
|
||||||
return [b.kind for b in chapter.blocks]
|
"""Expand keep-together Groups so the per-column heading/table/figure are
|
||||||
|
inspectable as a flat block list (the chapter wraps each column in a Group)."""
|
||||||
|
out = []
|
||||||
|
for b in blocks:
|
||||||
|
if getattr(b, "kind", "") == "group":
|
||||||
|
out.extend(_flatten(getattr(b, "blocks", []) or []))
|
||||||
|
else:
|
||||||
|
out.append(b)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _column_groups(chapter):
|
||||||
|
return [b for b in chapter.blocks if isinstance(b, Group)]
|
||||||
|
|
||||||
|
|
||||||
def test_golden_build_cat_distr_emite_bloques_pedidos():
|
def test_golden_build_cat_distr_emite_bloques_pedidos():
|
||||||
@@ -90,36 +106,101 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
|||||||
assert ch is not None
|
assert ch is not None
|
||||||
assert ch.id == CHAPTER_ID
|
assert ch.id == CHAPTER_ID
|
||||||
assert ch.version == CHAPTER_VERSION
|
assert ch.version == CHAPTER_VERSION
|
||||||
kinds = _kinds(ch)
|
|
||||||
# Entropy intro present.
|
# Entropy intro present, but the long explanation is gone (it lives in the
|
||||||
|
# glossary now): only the term is named, no log2/normalizada walkthrough.
|
||||||
headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
|
headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
|
||||||
assert any("Entrop" in h for h in headings)
|
assert any("Entrop" in h for h in headings)
|
||||||
md = next(b for b in ch.blocks if b.kind == "markdown")
|
md = next(b for b in ch.blocks if isinstance(b, Markdown))
|
||||||
assert "entropía" in md.text.lower() and "log2" in md.text
|
assert "entropía" in md.text.lower()
|
||||||
# Cardinality metrics: distinct, total rows, %-distinct, unique values.
|
assert "log2" not in md.text # redundant explanation removed.
|
||||||
kv = next(b for b in ch.blocks if isinstance(b, KVTable))
|
assert "máxima diversidad" not in md.text
|
||||||
|
|
||||||
|
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
|
||||||
|
flat = _flatten(ch.blocks)
|
||||||
|
kv = next(b for b in flat if isinstance(b, KVTable))
|
||||||
labels = [r[0] for r in kv.rows]
|
labels = [r[0] for r in kv.rows]
|
||||||
assert "Valores distintos" in labels
|
values = " ".join(str(r[1]) for r in kv.rows)
|
||||||
assert "% distintos" in labels
|
# Cardinality metrics: distinct count, %-distinct, unique values and total
|
||||||
|
# rows are present (grouped onto compact rows so the chart fits the page).
|
||||||
|
assert "Distintos · % · únicos" in labels
|
||||||
assert "Total filas (dataset)" in labels
|
assert "Total filas (dataset)" in labels
|
||||||
assert "Valores únicos (frecuencia 1)" in labels
|
|
||||||
assert any("Entropía" in lbl for lbl in labels)
|
assert any("Entropía" in lbl for lbl in labels)
|
||||||
|
assert "únicos" in values and "%" in values
|
||||||
|
assert "bits" in values and "norm" in values # entropy + max + normalized.
|
||||||
# Top-k table + pie figure.
|
# Top-k table + pie figure.
|
||||||
dt = next(b for b in ch.blocks if isinstance(b, DataTable))
|
dt = next(b for b in flat if isinstance(b, DataTable))
|
||||||
assert dt.header == ["Valor", "Conteo", "%"]
|
assert dt.header == ["Valor", "Conteo", "%"]
|
||||||
assert any("neumaticos" in str(cell) for row in dt.rows for cell in row)
|
assert any("neumaticos" in str(cell) for row in dt.rows for cell in row)
|
||||||
assert any(isinstance(b, Figure) for b in ch.blocks)
|
assert any(isinstance(b, Figure) for b in flat)
|
||||||
# id-like column flagged with a Note.
|
# id-like column flagged with a Note that also explains the top-k is dropped.
|
||||||
assert any(isinstance(b, Note) and "identificador" in b.text
|
idnote = next((b for b in flat
|
||||||
for b in ch.blocks)
|
if isinstance(b, Note) and "identificador" in b.text), None)
|
||||||
|
assert idnote is not None
|
||||||
|
assert "No se lista el top" in idnote.text
|
||||||
|
|
||||||
|
|
||||||
def test_golden_render_pdf_muestra_categoricas():
|
def test_golden_idlike_omite_topk_y_conserva_donut():
|
||||||
|
# The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
|
||||||
|
# (it would be a list of unique values), but must still keep its donut Figure
|
||||||
|
# and its cardinality table so it stays a full per-column page.
|
||||||
|
ch = build_cat_distr(_profile(), {})
|
||||||
|
groups = _column_groups(ch)
|
||||||
|
uuid_group = next(g for g in groups
|
||||||
|
if any(getattr(b, "text", "") == "uuid" for b in g.blocks))
|
||||||
|
kinds = [b.kind for b in uuid_group.blocks]
|
||||||
|
assert "data_table" not in kinds # top-k of unique values dropped.
|
||||||
|
assert "kv_table" in kinds # cardinality kept.
|
||||||
|
assert "figure" in kinds # donut kept (chart per column).
|
||||||
|
# A non-id-like column keeps its top-k table.
|
||||||
|
cat_group = next(g for g in groups
|
||||||
|
if any(getattr(b, "text", "") == "categoria"
|
||||||
|
for b in g.blocks))
|
||||||
|
assert "data_table" in [b.kind for b in cat_group.blocks]
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_una_pagina_por_columna_groups():
|
||||||
|
ch = build_cat_distr(_profile(), {})
|
||||||
|
groups = _column_groups(ch)
|
||||||
|
# Two categorical columns -> two column Groups (numeric column excluded).
|
||||||
|
assert len(groups) == 2
|
||||||
|
# Each Group carries one column: a heading + its cardinality table + figure.
|
||||||
|
for g in groups:
|
||||||
|
kinds = [b.kind for b in g.blocks]
|
||||||
|
assert kinds[0] == "heading"
|
||||||
|
assert "kv_table" in kinds
|
||||||
|
assert "figure" in kinds
|
||||||
|
# The first column may share the intro page (no forced break); every later
|
||||||
|
# column starts on a fresh page/slide so each column gets its own page.
|
||||||
|
assert groups[0].page_break_before is False
|
||||||
|
assert all(g.page_break_before is True for g in groups[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_entropia_clicable_y_definicion_en_glosario():
|
||||||
|
# With a glossary collector the intro marks the clickable term and the FULL
|
||||||
|
# definition (the long explanation removed from the intro) lands in the
|
||||||
|
# glossary, not inline — no data lost, just relocated.
|
||||||
|
gc = GlossaryCollector()
|
||||||
|
ch = build_cat_distr(_profile(), {"glossary": gc})
|
||||||
|
md = next(b for b in ch.blocks if isinstance(b, Markdown))
|
||||||
|
assert "[[term:entropia]]entropía[[/term]]" in md.text
|
||||||
|
assert gc.has("entropia")
|
||||||
|
entry = gc.get("entropia")
|
||||||
|
assert entry is not None
|
||||||
|
# The definition kept in the glossary still carries the detail removed inline.
|
||||||
|
assert "log2" in entry["definition"]
|
||||||
|
assert "normalizada" in entry["definition"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_render_pdf_una_pagina_por_columna():
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
out = os.path.join(d, "eda.pdf")
|
out = os.path.join(d, "eda.pdf")
|
||||||
res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
|
res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
|
||||||
assert res["path"] == out and os.path.exists(out)
|
assert res["path"] == out and os.path.exists(out)
|
||||||
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
|
cat_meta = next(c for c in res["chapters"] if c["id"] == CHAPTER_ID)
|
||||||
|
# Two categorical columns, each on its own page -> >= 2 pages for the
|
||||||
|
# chapter (intro shares the first column's page).
|
||||||
|
assert cat_meta["n_pages"] >= 2
|
||||||
txt = _pdf_text(out)
|
txt = _pdf_text(out)
|
||||||
assert "Entrop" in txt
|
assert "Entrop" in txt
|
||||||
assert "distintos" in txt
|
assert "distintos" in txt
|
||||||
@@ -133,13 +214,91 @@ def test_golden_render_pptx_muestra_categoricas():
|
|||||||
out = os.path.join(d, "eda.pptx")
|
out = os.path.join(d, "eda.pptx")
|
||||||
res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
|
res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
|
||||||
assert res["path"] == out and os.path.exists(out)
|
assert res["path"] == out and os.path.exists(out)
|
||||||
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
|
cat_meta = next(c for c in res["chapters"] if c["id"] == CHAPTER_ID)
|
||||||
|
assert cat_meta["n_slides"] >= 2 # one slide per categorical column.
|
||||||
txt = _pptx_text(out)
|
txt = _pptx_text(out)
|
||||||
assert "Entrop" in txt
|
assert "Entrop" in txt
|
||||||
assert "categoria" in txt and "neumaticos" in txt
|
assert "categoria" in txt and "neumaticos" in txt
|
||||||
assert "distintos" in txt
|
assert "distintos" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_high_card() -> dict:
|
||||||
|
"""Profile with a high-cardinality NON-id-like categorical column whose top-k
|
||||||
|
of long values would split from its donut on a short 16:9 slide unless the
|
||||||
|
renderer trims the table — the exact case the adversarial check flagged
|
||||||
|
(Ticket / Cabin)."""
|
||||||
|
long_vals = [f"Valor largo de categoria numero {i:02d} con texto extra"
|
||||||
|
for i in range(40)]
|
||||||
|
top = [{"value": v, "count": 60 - i, "pct": (60 - i) / 5000.0}
|
||||||
|
for i, v in enumerate(long_vals)]
|
||||||
|
return {
|
||||||
|
"table": "t", "source": "t.csv", "n_rows": 5000, "n_cols": 3,
|
||||||
|
"quality_score": 80.0,
|
||||||
|
"columns": [
|
||||||
|
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.0,
|
||||||
|
"numeric": {"mean": 1.0, "median": 1.0, "min": 0.0, "max": 2.0,
|
||||||
|
"std": 0.5}},
|
||||||
|
# 40 distinct over 5000 rows = 0.8% distinct -> NOT id-like, keeps
|
||||||
|
# its (long) top-k table; the tall table must not push the donut off.
|
||||||
|
{"name": "alta_card_col", "inferred_type": "categorical",
|
||||||
|
"null_pct": 0.0, "distinct_count": 40,
|
||||||
|
"categorical": {"top": top, "mode": long_vals[0], "n_distinct": 40,
|
||||||
|
"entropy": 5.2, "imbalance": 1.2, "len_min": 40,
|
||||||
|
"len_mean": 45, "len_max": 50}},
|
||||||
|
{"name": "baja_card_col", "inferred_type": "categorical",
|
||||||
|
"null_pct": 0.0, "distinct_count": 4,
|
||||||
|
"categorical": {
|
||||||
|
"top": [{"value": "norte", "count": 2000, "pct": 0.4},
|
||||||
|
{"value": "sur", "count": 1500, "pct": 0.3},
|
||||||
|
{"value": "este", "count": 1000, "pct": 0.2},
|
||||||
|
{"value": "oeste", "count": 500, "pct": 0.1}],
|
||||||
|
"mode": "norte", "n_distinct": 4, "entropy": 1.8}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
||||||
|
"""Each categorical column occupies EXACTLY ONE cat_distr slide that carries
|
||||||
|
BOTH its cardinality table and its donut figure (picture) — i.e. the chart is
|
||||||
|
never separated from its table, even for a high-cardinality column."""
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
|
||||||
|
prof = _profile_high_card()
|
||||||
|
cat_names = ["alta_card_col", "baja_card_col"]
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = os.path.join(d, "eda.pptx")
|
||||||
|
res = render_automatic_eda_pptx(prof, out, {"title": "EDA"})
|
||||||
|
assert res["path"] == out and os.path.exists(out)
|
||||||
|
prs = Presentation(out)
|
||||||
|
|
||||||
|
# Per column: the cat_distr slides whose text mentions it, and whether the
|
||||||
|
# owning slide also has the donut caption + an actual picture shape.
|
||||||
|
slides_with_col = {n: [] for n in cat_names}
|
||||||
|
owner_has_chart = {n: False for n in cat_names}
|
||||||
|
for i, sl in enumerate(prs.slides):
|
||||||
|
texts, has_pic = [], False
|
||||||
|
for sh in sl.shapes:
|
||||||
|
if sh.has_text_frame:
|
||||||
|
texts.append(sh.text_frame.text)
|
||||||
|
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||||||
|
has_pic = True
|
||||||
|
txt = re.sub(r"\s+", " ", " ".join(texts))
|
||||||
|
if "Distribuciones categ" not in txt: # footer stamp of the chapter.
|
||||||
|
continue
|
||||||
|
for n in cat_names:
|
||||||
|
if n in txt:
|
||||||
|
slides_with_col[n].append(i)
|
||||||
|
has_table = "Cardinalidad" in txt or "distintos" in txt
|
||||||
|
if has_pic and "donut" in txt and has_table:
|
||||||
|
owner_has_chart[n] = True
|
||||||
|
|
||||||
|
for n in cat_names:
|
||||||
|
# Exactly one slide carries the column (not split across slides).
|
||||||
|
assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
|
||||||
|
# That single slide also holds its table AND its donut picture.
|
||||||
|
assert owner_has_chart[n], (n, "tabla y donut no están en el mismo slide")
|
||||||
|
|
||||||
|
|
||||||
def test_edge_sin_categoricas_devuelve_none():
|
def test_edge_sin_categoricas_devuelve_none():
|
||||||
only_numeric = {
|
only_numeric = {
|
||||||
"n_rows": 10, "columns": [
|
"n_rows": 10, "columns": [
|
||||||
@@ -170,11 +329,15 @@ def test_anti_corte_label_largo_y_muchas_columnas():
|
|||||||
|
|
||||||
ch = build_cat_distr(profile, {})
|
ch = build_cat_distr(profile, {})
|
||||||
assert ch is not None
|
assert ch is not None
|
||||||
|
# One Group per column, each forcing its own page (except the first).
|
||||||
|
groups = _column_groups(ch)
|
||||||
|
assert len(groups) == 30
|
||||||
|
assert sum(1 for g in groups if g.page_break_before) == 29
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
pdf = os.path.join(d, "anti.pdf")
|
pdf = os.path.join(d, "anti.pdf")
|
||||||
res = render_automatic_eda_pdf(profile, pdf, {"write_manifest": False})
|
res = render_automatic_eda_pdf(profile, pdf, {"write_manifest": False})
|
||||||
assert res["path"] == pdf
|
assert res["path"] == pdf
|
||||||
assert res["n_pages"] > 1 # many columns spilled across pages, OK.
|
assert res["n_pages"] > 1 # one page per column, OK.
|
||||||
txt = _pdf_text(pdf)
|
txt = _pdf_text(pdf)
|
||||||
# Long label wrapped (not truncated): every word survives.
|
# Long label wrapped (not truncated): every word survives.
|
||||||
for word in ("Lorem", "incididunt", "reprehenderit", "voluptate"):
|
for word in ("Lorem", "incididunt", "reprehenderit", "voluptate"):
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import math
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.0.0"
|
CHAPTER_VERSION = "1.1.0"
|
||||||
CHAPTER_ID = "correlacion"
|
CHAPTER_ID = "correlacion"
|
||||||
CHAPTER_TITLE = "Correlación"
|
CHAPTER_TITLE = "Correlación"
|
||||||
|
|
||||||
@@ -47,6 +47,60 @@ _MAX_MATRIX_LABELS = 16
|
|||||||
# How many pairs to show in each of the top-positive / top-negative tables.
|
# How many pairs to show in each of the top-positive / top-negative tables.
|
||||||
_TOP_N = 10
|
_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
|
||||||
|
# implementation): ``glossary.add(key, label, definition)`` + the inline span
|
||||||
|
# ``[[term:KEY]]texto visible[[/term]]`` in a Markdown block. Mapping key ->
|
||||||
|
# (label, definition). ``fdr`` is only registered when the FDR summary is present.
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"pearson": (
|
||||||
|
"Pearson (coeficiente r)",
|
||||||
|
"Coeficiente de correlación lineal de Pearson (r) entre dos variables "
|
||||||
|
"numéricas. Va de −1 (relación lineal inversa perfecta) a +1 (directa "
|
||||||
|
"perfecta); 0 indica ausencia de relación lineal. Sólo capta relaciones "
|
||||||
|
"lineales, por eso lleva signo."),
|
||||||
|
"spearman": (
|
||||||
|
"Spearman (correlación de rangos)",
|
||||||
|
"Correlación de rangos de Spearman: el coeficiente de Pearson calculado "
|
||||||
|
"sobre los puestos (rangos) de los valores en vez de sus magnitudes. Mide "
|
||||||
|
"relaciones monótonas (no necesariamente lineales), va de −1 a +1 y es "
|
||||||
|
"robusta frente a valores atípicos."),
|
||||||
|
"cramers_v": (
|
||||||
|
"Cramér's V",
|
||||||
|
"Medida de asociación entre dos variables categóricas, derivada del "
|
||||||
|
"estadístico chi-cuadrado y normalizada al rango 0–1 (0 = independientes, "
|
||||||
|
"1 = asociación total). No tiene signo: sólo mide la intensidad."),
|
||||||
|
"correlation_ratio": (
|
||||||
|
"Razón de correlación (η)",
|
||||||
|
"Razón de correlación (eta) entre una variable numérica y una "
|
||||||
|
"categórica: la fracción de la varianza de la numérica explicada por los "
|
||||||
|
"grupos de la categórica. Va de 0 (los grupos no explican nada) a 1 (la "
|
||||||
|
"explican toda); no tiene signo."),
|
||||||
|
"fdr": (
|
||||||
|
"Comparaciones múltiples (FDR)",
|
||||||
|
"Al evaluar muchos pares a la vez, algunos parecen significativos por "
|
||||||
|
"puro azar. La corrección por tasa de falsos descubrimientos (FDR, "
|
||||||
|
"Benjamini-Hochberg) ajusta los p-valores para controlar la proporción "
|
||||||
|
"esperada de falsos positivos entre los pares declarados significativos."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
|
||||||
|
|
||||||
|
The visible text is identical with or without the marker (the renderers strip
|
||||||
|
the marker), so wrapping never changes line layout — it only adds the link.
|
||||||
|
"""
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
def _is_num(v) -> bool:
|
def _is_num(v) -> bool:
|
||||||
"""True for a real, finite int/float (not bool, not NaN/inf)."""
|
"""True for a real, finite int/float (not bool, not NaN/inf)."""
|
||||||
@@ -245,7 +299,7 @@ def _methods_block(corr: dict):
|
|||||||
return model.KVTable(rows=rows, title="Métodos de asociación")
|
return model.KVTable(rows=rows, title="Métodos de asociación")
|
||||||
|
|
||||||
|
|
||||||
def _fdr_text(corr: dict) -> str | None:
|
def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
|
||||||
"""One-line summary of the multiple-testing (FDR) correction, or None."""
|
"""One-line summary of the multiple-testing (FDR) correction, or None."""
|
||||||
mt = corr.get("multiple_testing")
|
mt = corr.get("multiple_testing")
|
||||||
if not isinstance(mt, dict) or not mt:
|
if not isinstance(mt, dict) or not mt:
|
||||||
@@ -254,7 +308,8 @@ def _fdr_text(corr: dict) -> str | None:
|
|||||||
alpha = mt.get("alpha")
|
alpha = mt.get("alpha")
|
||||||
n_tests = mt.get("n_tests")
|
n_tests = mt.get("n_tests")
|
||||||
n_rej = mt.get("n_rejected")
|
n_rej = mt.get("n_rejected")
|
||||||
parts = [f"Corrección por comparaciones múltiples ({method}"]
|
multi = _term(mark_term, "fdr", "comparaciones múltiples")
|
||||||
|
parts = [f"Corrección por {multi} ({method}"]
|
||||||
if _is_num(alpha):
|
if _is_num(alpha):
|
||||||
parts[0] += f", α={float(alpha):g}"
|
parts[0] += f", α={float(alpha):g}"
|
||||||
parts[0] += ")."
|
parts[0] += ")."
|
||||||
@@ -266,6 +321,139 @@ def _fdr_text(corr: dict) -> str | None:
|
|||||||
return " ".join(parts)
|
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):
|
def build_correlacion(profile: dict, ctx: dict):
|
||||||
"""Build the Correlation Chapter, or None if there are no pairs to show.
|
"""Build the Correlation Chapter, or None if there are no pairs to show.
|
||||||
|
|
||||||
@@ -289,13 +477,30 @@ def build_correlacion(profile: dict, ctx: dict):
|
|||||||
|
|
||||||
blocks: list = []
|
blocks: list = []
|
||||||
|
|
||||||
# Intro: what this chapter shows and how to read the sign.
|
# Register the always-present method terms in the shared glossary and mark
|
||||||
|
# their first appearance clickable (the FDR term is registered lazily below,
|
||||||
|
# only when the FDR summary is actually emitted). Degrades silently when no
|
||||||
|
# collector is in ctx (standalone render) — mark_term stays False.
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
|
||||||
|
mark_term = gloss is not None
|
||||||
|
if gloss is not None:
|
||||||
|
for key in ("pearson", "spearman", "cramers_v", "correlation_ratio"):
|
||||||
|
label, definition = _TERM_DEFS[key]
|
||||||
|
gloss.add(key, label, definition)
|
||||||
|
|
||||||
|
# Intro: what this chapter shows and how to read the sign. Build the marked
|
||||||
|
# method names as locals first (avoids backslash-in-f-string for "Cramér's V").
|
||||||
|
t_pearson = _term(mark_term, "pearson", "Pearson")
|
||||||
|
t_spearman = _term(mark_term, "spearman", "Spearman")
|
||||||
|
t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
|
||||||
|
t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
|
||||||
blocks.append(model.Markdown(text=(
|
blocks.append(model.Markdown(text=(
|
||||||
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
|
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada "
|
||||||
"sus tipos (Pearson/Spearman entre numéricas — con **signo**; Cramér's V "
|
f"a sus tipos: {t_pearson}/{t_spearman} (numéricas), {t_cramers} "
|
||||||
"entre categóricas; razón de correlación num-categórica; información mutua "
|
f"(categóricas), {t_corr_ratio} (num-categórica) e información mutua. "
|
||||||
"como medida común no lineal). Sólo las correlaciones **num-num** tienen "
|
"Sólo las correlaciones **num-num** llevan **signo** (dirección): por "
|
||||||
"dirección: por eso los pares **negativos** son siempre num-num.")))
|
"eso los pares **negativos** son siempre num-num.")))
|
||||||
|
|
||||||
# 1) Association matrix (heatmap).
|
# 1) Association matrix (heatmap).
|
||||||
labels, trimmed = _ordered_labels(pairs)
|
labels, trimmed = _ordered_labels(pairs)
|
||||||
@@ -327,6 +532,18 @@ def build_correlacion(profile: dict, ctx: dict):
|
|||||||
"No se han hallado correlaciones negativas significativas entre "
|
"No se han hallado correlaciones negativas significativas entre "
|
||||||
"columnas numéricas.")))
|
"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 (Granger–Newbold).
|
# 3) Spuriousness caveat for level-based correlations (Granger–Newbold).
|
||||||
caveat = corr.get("levels_caveat")
|
caveat = corr.get("levels_caveat")
|
||||||
if isinstance(caveat, str) and caveat.strip():
|
if isinstance(caveat, str) and caveat.strip():
|
||||||
@@ -337,9 +554,13 @@ def build_correlacion(profile: dict, ctx: dict):
|
|||||||
"no estacionarias y pueden ser espurias (Granger–Newbold). Compáralas "
|
"no estacionarias y pueden ser espurias (Granger–Newbold). Compáralas "
|
||||||
"sobre los retornos/diferencias antes de interpretarlas.")))
|
"sobre los retornos/diferencias antes de interpretarlas.")))
|
||||||
|
|
||||||
# 4) FDR summary + methods legend.
|
# 4) FDR summary + methods legend. Register the FDR term only when its
|
||||||
fdr_text = _fdr_text(corr)
|
# summary is emitted, so the glossary never lists an unreferenced entry.
|
||||||
|
fdr_text = _fdr_text(corr, mark_term=mark_term)
|
||||||
if fdr_text:
|
if fdr_text:
|
||||||
|
if gloss is not None:
|
||||||
|
label, definition = _TERM_DEFS["fdr"]
|
||||||
|
gloss.add("fdr", label, definition)
|
||||||
blocks.append(model.Markdown(text=fdr_text))
|
blocks.append(model.Markdown(text=fdr_text))
|
||||||
methods = _methods_block(corr)
|
methods = _methods_block(corr)
|
||||||
if methods is not None:
|
if methods is not None:
|
||||||
|
|||||||
@@ -173,3 +173,124 @@ def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
|
|||||||
assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
|
assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
|
||||||
# A short, unbreakable fragment of the long label survives the wrap.
|
# A short, unbreakable fragment of the long label survives the wrap.
|
||||||
assert "azufre" in _pdf_text(pdf)
|
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
|
||||||
|
registran en el colector compartido y se marcan clicables en el cuerpo. Sin
|
||||||
|
colector en ctx, el capítulo degrada y no marca nada."""
|
||||||
|
from datascience.automatic_eda.model import GlossaryCollector
|
||||||
|
|
||||||
|
g = GlossaryCollector()
|
||||||
|
ch = build_correlacion(_profile(), {"glossary": g})
|
||||||
|
assert ch is not None
|
||||||
|
keys = {t["key"] for t in g.terms()}
|
||||||
|
assert {"pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"} <= keys
|
||||||
|
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||||
|
for k in ("pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"):
|
||||||
|
assert f"[[term:{k}]]" in body, k
|
||||||
|
|
||||||
|
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||||
|
ch2 = build_correlacion(_profile(), {})
|
||||||
|
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:" not in body2
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -6,15 +6,16 @@ normality}``). It renders, as structured markdown/tables/figures that the core
|
|||||||
paginator never cuts:
|
paginator never cuts:
|
||||||
|
|
||||||
1. **Normalization note** — every multivariate model below standardizes the
|
1. **Normalization note** — every multivariate model below standardizes the
|
||||||
columns with z-score first; the chapter explains why (different scales would
|
columns with z-score first (the term is marked clickable; its definition
|
||||||
otherwise dominate distance/variance).
|
lives in the GLOSARIO chapter, not inline).
|
||||||
2. **PCA** — a scree plot (explained + cumulative variance, single Y axis) plus
|
2. **PCA** — a scree plot (explained + cumulative variance, single Y axis) plus
|
||||||
variance and top-loadings tables.
|
variance and top-loadings tables.
|
||||||
3. **KMeans segments** — a PCA scatter **coloured by cluster** (its own
|
3. **KMeans segments** — a PCA scatter **coloured by cluster** (its own
|
||||||
page/slide), the cluster-size table, and a per-cluster LLM micro-analysis
|
page/slide), the cluster-size table, and a per-cluster LLM micro-analysis
|
||||||
with a title for each segment.
|
with a title for each segment.
|
||||||
4. **Isolation Forest outliers** — a short explanation of how anomalous rows are
|
4. **Isolation Forest outliers** — the multivariate anomaly counts and decision
|
||||||
isolated multivariately and how the threshold is chosen, plus the counts.
|
threshold (the method is marked clickable; its definition lives in the
|
||||||
|
GLOSARIO chapter, not inline).
|
||||||
5. **Normality** — per-column Jarque-Bera / D'Agostino / Shapiro verdicts.
|
5. **Normality** — per-column Jarque-Bera / D'Agostino / Shapiro verdicts.
|
||||||
|
|
||||||
The raw numeric data needed to colour the cluster scatter is **not** in the
|
The raw numeric data needed to colour the cluster scatter is **not** in the
|
||||||
@@ -55,6 +56,62 @@ _CLUSTER_COLORS = [
|
|||||||
"#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac",
|
"#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains. Each is registered in the shared
|
||||||
|
# collector (ctx['glossary']) and marked clickable on its first appearance — the
|
||||||
|
# canonical two-step pattern (see ``cat_distr``): ``glossary.add(key, label,
|
||||||
|
# definition)`` + the inline span ``[[term:KEY]]texto[[/term]]`` in a Markdown
|
||||||
|
# block. A term is registered only when its section is actually rendered, so the
|
||||||
|
# glossary never lists an entry no in-text appearance points to.
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"zscore": (
|
||||||
|
"Estandarización z-score",
|
||||||
|
"Transformación que lleva cada columna numérica a media 0 y desviación "
|
||||||
|
"típica 1: a cada valor le resta la media de su columna y lo divide por "
|
||||||
|
"la desviación típica. Así variables con escalas muy distintas (euros "
|
||||||
|
"frente a un ratio 0–1) pesan por igual en las distancias y la varianza."),
|
||||||
|
"pca": (
|
||||||
|
"PCA (componentes principales)",
|
||||||
|
"El análisis de componentes principales resume muchas variables "
|
||||||
|
"numéricas correlacionadas en pocos ejes nuevos (componentes), "
|
||||||
|
"ortogonales entre sí y ordenados por la cantidad de varianza que "
|
||||||
|
"capturan. Permite ver la estructura de los datos en 2D y saber cuántas "
|
||||||
|
"dimensiones bastan para explicarlos."),
|
||||||
|
"kmeans": (
|
||||||
|
"KMeans (segmentación)",
|
||||||
|
"Algoritmo de agrupamiento no supervisado que reparte las filas en k "
|
||||||
|
"segmentos: asigna cada fila al centro (centroide) más cercano y recoloca "
|
||||||
|
"los centroides de forma iterativa hasta minimizar la distancia interna "
|
||||||
|
"de cada grupo. Aquí k se elige automáticamente."),
|
||||||
|
"silhouette": (
|
||||||
|
"Coeficiente de silueta (silhouette)",
|
||||||
|
"Métrica de calidad de un agrupamiento, en el rango −1 a 1: para cada "
|
||||||
|
"fila compara cómo de cerca está de su propio segmento frente al segmento "
|
||||||
|
"vecino más próximo. Cuanto más alto el promedio, más compactos y "
|
||||||
|
"separados están los segmentos."),
|
||||||
|
"isolation_forest": (
|
||||||
|
"Isolation Forest (anomalías)",
|
||||||
|
"Algoritmo de detección de anomalías multivariante: 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 outliers según un umbral de contaminación."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
|
||||||
|
|
||||||
|
The visible text is identical with or without the marker (the renderers strip
|
||||||
|
it), so wrapping never changes line layout — it only adds the link.
|
||||||
|
"""
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
|
def _register(gloss, key: str) -> None:
|
||||||
|
"""Register term ``key`` in the collector (idempotent); no-op if gloss None."""
|
||||||
|
if gloss is not None:
|
||||||
|
label, definition = _TERM_DEFS[key]
|
||||||
|
gloss.add(key, label, definition)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Formatting helpers (mirror the overview chapter's defensive style).
|
# Formatting helpers (mirror the overview chapter's defensive style).
|
||||||
@@ -252,34 +309,33 @@ def _make_cluster_scatter(projection: dict):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Section builders. Each returns a list of blocks (possibly empty).
|
# Section builders. Each returns a list of blocks (possibly empty).
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _normalization_intro() -> list:
|
def _normalization_intro(gloss=None, mark_term: bool = False) -> list:
|
||||||
|
_register(gloss, "zscore")
|
||||||
|
zscore = _term(mark_term, "zscore", "**estandarizan con z-score**")
|
||||||
text = (
|
text = (
|
||||||
"Estos modelos son **no supervisados**: buscan estructura latente sin "
|
"Estos modelos son **no supervisados**: buscan estructura latente sin "
|
||||||
"una variable objetivo. Antes de aplicarlos, todas las columnas "
|
"una variable objetivo. Antes de aplicarlos, todas las columnas "
|
||||||
"numéricas se **estandarizan con z-score** (cada valor menos la media, "
|
f"numéricas se {zscore}, para que todas pesen por igual con "
|
||||||
"dividido por la desviación típica). Sin esta normalización, una "
|
"independencia de su escala."
|
||||||
"variable con escala grande (p.ej. ingresos en euros) dominaría las "
|
|
||||||
"distancias y la varianza frente a otra de escala pequeña (p.ej. un "
|
|
||||||
"ratio entre 0 y 1), sesgando tanto el PCA como el KMeans. Tras la "
|
|
||||||
"estandarización todas las variables pesan por igual."
|
|
||||||
)
|
)
|
||||||
return [model.Heading(text="Modelos no supervisados", level=1),
|
return [model.Heading(text="Modelos no supervisados", level=1),
|
||||||
model.Markdown(text=text)]
|
model.Markdown(text=text)]
|
||||||
|
|
||||||
|
|
||||||
def _pca_section(pca: dict) -> list:
|
def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
|
||||||
if not _is_dict(pca) or not pca.get("explained_variance_ratio"):
|
if not _is_dict(pca) or not pca.get("explained_variance_ratio"):
|
||||||
return []
|
return []
|
||||||
|
_register(gloss, "pca")
|
||||||
blocks = [model.Heading(text="PCA — varianza explicada", level=2)]
|
blocks = [model.Heading(text="PCA — varianza explicada", level=2)]
|
||||||
|
|
||||||
n_used = pca.get("n_rows_used")
|
n_used = pca.get("n_rows_used")
|
||||||
n_feat = pca.get("n_features")
|
n_feat = pca.get("n_features")
|
||||||
intro = (
|
intro = (
|
||||||
f"El PCA resume {_fmt_num(n_feat)} variables numéricas en componentes "
|
f"El {_term(mark_term, 'pca', 'PCA')} se aplica sobre "
|
||||||
f"ortogonales ordenados por la varianza que capturan "
|
f"{_fmt_num(n_feat)} variables numéricas ({_fmt_num(n_used)} filas "
|
||||||
f"({_fmt_num(n_used)} filas usadas tras eliminar nulos). El gráfico de "
|
"usadas tras eliminar nulos). El gráfico de sedimentación (scree) "
|
||||||
"sedimentación (scree) muestra cuánta varianza aporta cada componente y "
|
"muestra cuánta varianza aporta cada componente y su acumulado: un "
|
||||||
"su acumulado: un codo marca cuántos componentes bastan."
|
"codo marca cuántos componentes bastan."
|
||||||
)
|
)
|
||||||
blocks.append(model.Markdown(text=intro))
|
blocks.append(model.Markdown(text=intro))
|
||||||
|
|
||||||
@@ -325,11 +381,14 @@ def _pca_section(pca: dict) -> list:
|
|||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
|
def _kmeans_section(kmeans: dict, projection: dict, titles,
|
||||||
|
gloss=None, mark_term: bool = False) -> list:
|
||||||
has_km = _is_dict(kmeans) and kmeans.get("best_k")
|
has_km = _is_dict(kmeans) and kmeans.get("best_k")
|
||||||
has_proj = _is_dict(projection) and projection.get("points")
|
has_proj = _is_dict(projection) and projection.get("points")
|
||||||
if not has_km and not has_proj:
|
if not has_km and not has_proj:
|
||||||
return []
|
return []
|
||||||
|
_register(gloss, "kmeans")
|
||||||
|
_register(gloss, "silhouette")
|
||||||
|
|
||||||
blocks = [model.Heading(text="Segmentación (KMeans)", level=2)]
|
blocks = [model.Heading(text="Segmentación (KMeans)", level=2)]
|
||||||
|
|
||||||
@@ -337,11 +396,12 @@ def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
|
|||||||
sil = (projection or {}).get("silhouette")
|
sil = (projection or {}).get("silhouette")
|
||||||
if sil is None:
|
if sil is None:
|
||||||
sil = (kmeans or {}).get("silhouette")
|
sil = (kmeans or {}).get("silhouette")
|
||||||
|
t_kmeans = _term(mark_term, "kmeans", "KMeans")
|
||||||
|
t_sil = _term(mark_term, "silhouette", "*silhouette*")
|
||||||
intro = (
|
intro = (
|
||||||
f"KMeans agrupa las filas en **{_fmt_num(best_k)} segmentos** elegidos "
|
f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** "
|
||||||
"automáticamente maximizando el coeficiente de *silhouette* "
|
f"elegidos automáticamente por el coeficiente de {t_sil} "
|
||||||
f"(**{_fmt_num(sil)}**, rango −1 a 1: cuanto más alto, segmentos más "
|
f"(**{_fmt_num(sil)}**). Los segmentos se proyectan sobre el plano de "
|
||||||
"compactos y separados). Los segmentos se proyectan sobre el plano de "
|
|
||||||
"los dos primeros componentes principales para visualizarlos."
|
"los dos primeros componentes principales para visualizarlos."
|
||||||
)
|
)
|
||||||
blocks.append(model.Markdown(text=intro))
|
blocks.append(model.Markdown(text=intro))
|
||||||
@@ -394,23 +454,21 @@ def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
|
|||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def _outliers_section(outliers: dict) -> list:
|
def _outliers_section(outliers: dict, gloss=None, mark_term: bool = False) -> list:
|
||||||
if not _is_dict(outliers) or outliers.get("n_outliers") is None:
|
if not _is_dict(outliers) or outliers.get("n_outliers") is None:
|
||||||
return []
|
return []
|
||||||
if outliers.get("note") and not outliers.get("n_rows_used"):
|
if outliers.get("note") and not outliers.get("n_rows_used"):
|
||||||
# insufficient data — nothing meaningful to show.
|
# insufficient data — nothing meaningful to show.
|
||||||
return []
|
return []
|
||||||
|
_register(gloss, "isolation_forest")
|
||||||
blocks = [model.Heading(text="Detección de anomalías (Isolation Forest)",
|
blocks = [model.Heading(text="Detección de anomalías (Isolation Forest)",
|
||||||
level=2)]
|
level=2)]
|
||||||
|
isof = _term(mark_term, "isolation_forest", "**Isolation Forest**")
|
||||||
explain = (
|
explain = (
|
||||||
"**Isolation Forest** detecta filas anómalas de forma *multivariante*: "
|
f"{isof} marca filas anómalas de forma *multivariante*: combinaciones "
|
||||||
"construye árboles que parten el espacio con cortes aleatorios y mide "
|
"de valores poco frecuentes considerando **todas las columnas a la "
|
||||||
"cuántos cortes hacen falta para aislar cada fila. Las filas raras "
|
"vez**, no una sola. La tabla resume cuántas se detectaron y el umbral "
|
||||||
"(combinaciones de valores poco frecuentes considerando **todas las "
|
"de decisión empleado."
|
||||||
"columnas a la vez**, no una sola) se aíslan con muy pocos cortes y "
|
|
||||||
"obtienen un score bajo. El **umbral** de decisión separa las filas "
|
|
||||||
"normales de las anómalas según la contaminación esperada del modelo: "
|
|
||||||
"una fila es outlier cuando su score queda por debajo de ese umbral."
|
|
||||||
)
|
)
|
||||||
blocks.append(model.Markdown(text=explain))
|
blocks.append(model.Markdown(text=explain))
|
||||||
blocks.append(model.KVTable(rows=[
|
blocks.append(model.KVTable(rows=[
|
||||||
@@ -484,15 +542,21 @@ def build_modelos(profile: dict, ctx: dict):
|
|||||||
(kmeans and kmeans.get("best_k")) or (projection and projection.get("points"))
|
(kmeans and kmeans.get("best_k")) or (projection and projection.get("points"))
|
||||||
) else None
|
) else None
|
||||||
|
|
||||||
|
# Shared glossary collector: terms are registered + marked clickable inside
|
||||||
|
# each section, only when that section actually renders (no orphan entries).
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
|
||||||
|
mark_term = gloss is not None
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
sections += _pca_section(pca) if pca else []
|
sections += _pca_section(pca, gloss, mark_term) if pca else []
|
||||||
sections += _kmeans_section(kmeans, projection, titles)
|
sections += _kmeans_section(kmeans, projection, titles, gloss, mark_term)
|
||||||
sections += _outliers_section(outliers) if outliers else []
|
sections += _outliers_section(outliers, gloss, mark_term) if outliers else []
|
||||||
sections += _normality_section(normality) if normality else []
|
sections += _normality_section(normality) if normality else []
|
||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
return None # models block present but nothing renderable.
|
return None # models block present but nothing renderable.
|
||||||
|
|
||||||
blocks = _normalization_intro() + sections
|
blocks = _normalization_intro(gloss, mark_term) + sections
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|||||||
@@ -257,3 +257,26 @@ def test_anticortes_tabla_normalidad_larga_no_corta():
|
|||||||
# Every column name survives (wrapped/split, never truncated).
|
# Every column name survives (wrapped/split, never truncated).
|
||||||
for i in (0, 19, 39):
|
for i in (0, 19, 39):
|
||||||
assert f"col_{i}" in txt
|
assert f"col_{i}" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_glosario_engancha_terminos_modelos():
|
||||||
|
"""Mejora 4b: PCA, KMeans, silhouette, Isolation Forest y la estandarización
|
||||||
|
z-score se registran en el colector compartido y se marcan clicables en el
|
||||||
|
cuerpo. Sin colector en ctx, el capítulo degrada y no marca nada."""
|
||||||
|
from datascience.automatic_eda.model import GlossaryCollector
|
||||||
|
|
||||||
|
g = GlossaryCollector()
|
||||||
|
ctx = dict(_ctx_full())
|
||||||
|
ctx["glossary"] = g
|
||||||
|
ch = build_modelos(_profile(), ctx)
|
||||||
|
assert ch is not None
|
||||||
|
keys = {t["key"] for t in g.terms()}
|
||||||
|
assert {"zscore", "pca", "kmeans", "silhouette", "isolation_forest"} <= keys
|
||||||
|
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||||
|
for k in ("zscore", "pca", "kmeans", "silhouette", "isolation_forest"):
|
||||||
|
assert f"[[term:{k}]]" in body, k
|
||||||
|
|
||||||
|
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||||
|
ch2 = build_modelos(_profile(), _ctx_full())
|
||||||
|
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:" not in body2
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Numeric distributions chapter (NUM DISTR) for AutomaticEDA.
|
"""Numeric distributions chapter (NUM DISTR) for AutomaticEDA.
|
||||||
|
|
||||||
For every numeric column the chapter draws, as a single indivisible figure, a
|
For every numeric column the chapter draws, as a single indivisible figure, a
|
||||||
histogram with the **mean, median and ±1σ band drawn as reference lines** and a
|
histogram with the **mean, median and ±1σ band drawn as reference lines** (the
|
||||||
**Tukey boxplot right below it** sharing the same X axis — exactly the user
|
legend reports the numeric value of the mean, the median **and the standard
|
||||||
requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
|
deviation σ**) and a **Tukey boxplot right below it** sharing the same X axis —
|
||||||
|
exactly the user requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
|
||||||
so the renderers rasterize and scale it to fit a whole page/slide and nothing is
|
so the renderers rasterize and scale it to fit a whole page/slide and nothing is
|
||||||
ever cut; columns with many numerics simply flow across pages as small
|
ever cut; columns with many numerics simply flow across pages as small
|
||||||
multiples.
|
multiples.
|
||||||
@@ -34,7 +35,7 @@ try:
|
|||||||
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
|
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
|
||||||
build_boxplot_stats = None # type: ignore[assignment]
|
build_boxplot_stats = None # type: ignore[assignment]
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.1.0"
|
CHAPTER_VERSION = "1.2.0"
|
||||||
CHAPTER_ID = "num_distr"
|
CHAPTER_ID = "num_distr"
|
||||||
CHAPTER_TITLE = "Distribuciones numéricas"
|
CHAPTER_TITLE = "Distribuciones numéricas"
|
||||||
|
|
||||||
@@ -140,9 +141,11 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
|
|||||||
std = numeric.get("std")
|
std = numeric.get("std")
|
||||||
|
|
||||||
# ±1σ band first (behind the lines), then median (solid) and mean (dashed).
|
# ±1σ band first (behind the lines), then median (solid) and mean (dashed).
|
||||||
|
# The band's legend entry also reports the numeric value of the standard
|
||||||
|
# deviation, so the reader sees mean, median AND σ at a glance.
|
||||||
if mean is not None and std is not None and std > 0:
|
if mean is not None and std is not None and std > 0:
|
||||||
ax_h.axvspan(mean - std, mean + std, color="#f0c27b", alpha=0.22,
|
ax_h.axvspan(mean - std, mean + std, color="#f0c27b", alpha=0.22,
|
||||||
zorder=1, label="±1σ")
|
zorder=1, label=f"±1σ (σ = {_fmt_num(std)})")
|
||||||
if median is not None:
|
if median is not None:
|
||||||
ax_h.axvline(median, color="#2e8b57", linestyle="-", linewidth=1.6,
|
ax_h.axvline(median, color="#2e8b57", linestyle="-", linewidth=1.6,
|
||||||
zorder=4, label=f"mediana = {_fmt_num(median)}")
|
zorder=4, label=f"mediana = {_fmt_num(median)}")
|
||||||
@@ -152,7 +155,19 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
|
|||||||
|
|
||||||
ax_h.set_ylabel("frecuencia", fontsize=8)
|
ax_h.set_ylabel("frecuencia", fontsize=8)
|
||||||
ax_h.tick_params(labelsize=7)
|
ax_h.tick_params(labelsize=7)
|
||||||
ax_h.legend(fontsize=6.5, loc="upper right", framealpha=0.85)
|
# Always surface σ in the legend: if the ±1σ band could not be drawn (no mean
|
||||||
|
# or std<=0) but σ is still known, add a label-only proxy handle so the value
|
||||||
|
# of the standard deviation is reported regardless of the band.
|
||||||
|
handles, labels = ax_h.get_legend_handles_labels()
|
||||||
|
if std is not None and not any("σ =" in lbl for lbl in labels):
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
proxy = Line2D([], [], linestyle="none", marker="",
|
||||||
|
label=f"σ = {_fmt_num(std)}")
|
||||||
|
handles.append(proxy)
|
||||||
|
labels.append(f"σ = {_fmt_num(std)}")
|
||||||
|
if handles:
|
||||||
|
ax_h.legend(handles, labels, fontsize=6.5, loc="upper right",
|
||||||
|
framealpha=0.85)
|
||||||
for spine in ("top", "right"):
|
for spine in ("top", "right"):
|
||||||
ax_h.spines[spine].set_visible(False)
|
ax_h.spines[spine].set_visible(False)
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,50 @@ def test_anti_corte_muchas_columnas_pdf_y_pptx():
|
|||||||
assert res_pptx["n_slides"] >= 8 # at least one slide per column figure.
|
assert res_pptx["n_slides"] >= 8 # at least one slide per column figure.
|
||||||
|
|
||||||
|
|
||||||
|
def _hist_legend_texts(numeric, box=None):
|
||||||
|
"""Build the per-column figure and return its histogram-legend label texts."""
|
||||||
|
from datascience.automatic_eda.chapters.num_distr import _make_hist_box
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
fig = _make_hist_box("col", numeric, box or {})
|
||||||
|
ax_h = fig.axes[0] # the histogram is the top axis.
|
||||||
|
leg = ax_h.get_legend()
|
||||||
|
texts = [t.get_text() for t in leg.get_texts()] if leg else []
|
||||||
|
plt.close(fig)
|
||||||
|
return texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_leyenda_histograma_reporta_valor_std():
|
||||||
|
# The histogram legend must report the numeric value of the standard
|
||||||
|
# deviation σ next to mean and median.
|
||||||
|
numeric = _numeric_block(42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5)
|
||||||
|
texts = _hist_legend_texts(numeric)
|
||||||
|
joined = " ".join(texts)
|
||||||
|
assert any("σ =" in t for t in texts), f"σ value missing in legend: {texts}"
|
||||||
|
assert "12.3" in joined, f"std value 12.3 not in legend: {texts}"
|
||||||
|
assert any("media =" in t for t in texts)
|
||||||
|
assert any("mediana =" in t for t in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_std_en_leyenda_aunque_no_haya_banda():
|
||||||
|
# When the ±1σ band cannot be drawn (no mean) but σ is known, the legend
|
||||||
|
# still surfaces the σ value via a label-only proxy handle.
|
||||||
|
numeric = _numeric_block(42.5, 40.0, 7.5, 1.0, 100.0, "right-skewed", 0)
|
||||||
|
numeric["mean"] = None # forces the band off; σ must still appear.
|
||||||
|
texts = _hist_legend_texts(numeric)
|
||||||
|
assert any("σ = 7.5" in t for t in texts), f"σ proxy missing: {texts}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_sin_std_no_revienta_la_figura():
|
||||||
|
# A numeric block without σ must not raise and simply omits the σ entry.
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
numeric = _numeric_block(42.5, 40.0, 0.0, 1.0, 100.0, "discrete", 0)
|
||||||
|
numeric["std"] = None
|
||||||
|
texts = _hist_legend_texts(numeric)
|
||||||
|
assert not any("σ =" in t for t in texts)
|
||||||
|
# mean/median lines still produce their own legend entries.
|
||||||
|
assert any("media =" in t for t in texts)
|
||||||
|
|
||||||
|
|
||||||
def test_distribution_gloss_cubre_todas_las_etiquetas():
|
def test_distribution_gloss_cubre_todas_las_etiquetas():
|
||||||
# Every label detect_distribution_type can emit has a Spanish gloss.
|
# Every label detect_distribution_type can emit has a Spanish gloss.
|
||||||
for label in ("normal-ish", "right-skewed", "left-skewed", "heavy-tail",
|
for label in ("normal-ish", "right-skewed", "left-skewed", "heavy-tail",
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
Builds the document cover from a TableProfile plus an optional ``ctx`` of
|
Builds the document cover from a TableProfile plus an optional ``ctx`` of
|
||||||
presentation metadata. Reads everything defensively (``.get``) and degrades
|
presentation metadata. Reads everything defensively (``.get``) and degrades
|
||||||
honestly: a field that is neither in the profile nor in ``ctx`` is shown as a
|
honestly.
|
||||||
placeholder rather than invented, leaving a hook for the LLM layer to fill it.
|
|
||||||
|
The dataset size (N rows x M columns) is always shown big, as a heading right
|
||||||
|
under the dataset name (kept together in a ``Group``), not buried in the
|
||||||
|
metadata table. The Description and Granularity are resolved through a cascade
|
||||||
|
so they are never empty: an explicit ``ctx`` value wins; otherwise the LLM block
|
||||||
|
(``profile['llm']`` from ``eda_llm_insights``) provides ``summary`` /
|
||||||
|
``row_meaning``; otherwise a short summary is derived from the profile itself
|
||||||
|
(shape, column-type mix, quality score) and a "Cada fila es…" sentence from the
|
||||||
|
key-candidate columns or the table shape. Nothing is invented: the derived
|
||||||
|
fallbacks state that they come from the profile.
|
||||||
|
|
||||||
Contract for chapter authors (see ``docs/capabilities/automatic_eda.md``):
|
Contract for chapter authors (see ``docs/capabilities/automatic_eda.md``):
|
||||||
build_<id>(profile: dict, ctx: dict) -> Chapter | None
|
build_<id>(profile: dict, ctx: dict) -> Chapter | None
|
||||||
@@ -17,10 +26,15 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.1.0"
|
CHAPTER_VERSION = "1.2.0"
|
||||||
CHAPTER_ID = "portada"
|
CHAPTER_ID = "portada"
|
||||||
CHAPTER_TITLE = "Portada"
|
CHAPTER_TITLE = "Portada"
|
||||||
|
|
||||||
|
# Key under which eda_llm_insights stores its interpretive block in the profile.
|
||||||
|
# The cover reads ``summary`` (what the table is) and ``row_meaning`` (what one
|
||||||
|
# row represents) from it when the LLM layer ran (``run_llm``).
|
||||||
|
_LLM_KEY = "llm"
|
||||||
|
|
||||||
# Default human description of what the table quality score measures. Chapters
|
# Default human description of what the table quality score measures. Chapters
|
||||||
# can override it via ctx["quality_criteria"].
|
# can override it via ctx["quality_criteria"].
|
||||||
_DEFAULT_QUALITY_CRITERIA = (
|
_DEFAULT_QUALITY_CRITERIA = (
|
||||||
@@ -142,6 +156,88 @@ def _fmt_date_eu(value) -> str:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_block(profile: dict, ctx: dict) -> dict:
|
||||||
|
"""Return the interpretive LLM block (``eda_llm_insights`` output), or {}.
|
||||||
|
|
||||||
|
It is stored under ``profile['llm']`` by ``profile_table(run_llm=True)`` and
|
||||||
|
may also be forwarded in ``ctx['llm']``. Read defensively: anything that is
|
||||||
|
not a dict degrades to an empty dict so the cover never raises.
|
||||||
|
"""
|
||||||
|
block = profile.get(_LLM_KEY)
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
block = ctx.get(_LLM_KEY)
|
||||||
|
return block if isinstance(block, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_column_types(profile: dict, ctx: dict):
|
||||||
|
"""Best-effort (n_numeric, n_categorical) for the dataset.
|
||||||
|
|
||||||
|
Prefers the aggregated ``ctx['document_summary']`` (computed by the engine
|
||||||
|
over the whole body); falls back to counting the profile columns directly so
|
||||||
|
the cover still has the numbers when no summary was passed.
|
||||||
|
"""
|
||||||
|
summary = ctx.get("document_summary")
|
||||||
|
if isinstance(summary, dict):
|
||||||
|
n_num = summary.get("n_numeric")
|
||||||
|
n_cat = summary.get("n_categorical")
|
||||||
|
if n_num is not None or n_cat is not None:
|
||||||
|
return n_num, n_cat
|
||||||
|
cols = profile.get("columns") or []
|
||||||
|
n_num = sum(1 for c in cols if isinstance(c, dict)
|
||||||
|
and c.get("inferred_type") == "numeric")
|
||||||
|
n_cat = sum(1 for c in cols if isinstance(c, dict)
|
||||||
|
and isinstance(c.get("categorical"), dict)
|
||||||
|
and c.get("categorical", {}).get("top")
|
||||||
|
and c.get("inferred_type") != "numeric")
|
||||||
|
return n_num, n_cat
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_description(profile: dict, ctx: dict) -> str:
|
||||||
|
"""A short, honest description of the dataset from the profile.
|
||||||
|
|
||||||
|
Used only when no explicit ``ctx['description']`` and no LLM ``summary`` are
|
||||||
|
available. Summarizes shape, column-type mix and quality score; never empty,
|
||||||
|
never invents business meaning (it states the description was derived)."""
|
||||||
|
n_rows = profile.get("n_rows")
|
||||||
|
n_cols = profile.get("n_cols")
|
||||||
|
n_num, n_cat = _count_column_types(profile, ctx)
|
||||||
|
head = f"Conjunto de datos con {_fmt_int(n_rows)} filas y {_fmt_int(n_cols)} columnas"
|
||||||
|
type_bits = []
|
||||||
|
if n_num:
|
||||||
|
type_bits.append(f"{_fmt_int(n_num)} numéricas")
|
||||||
|
if n_cat:
|
||||||
|
type_bits.append(f"{_fmt_int(n_cat)} categóricas")
|
||||||
|
if type_bits:
|
||||||
|
head += " (" + ", ".join(type_bits) + ")"
|
||||||
|
parts = [head + "."]
|
||||||
|
score = profile.get("quality_score")
|
||||||
|
if score is not None:
|
||||||
|
parts.append(f"Calidad media estimada: {score}/100.")
|
||||||
|
parts.append(
|
||||||
|
"Resumen derivado del perfil; active la interpretación LLM (`run_llm`) "
|
||||||
|
"para una descripción de negocio más rica.")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_granularity(profile: dict, dataset_name: str) -> str:
|
||||||
|
"""A ``Cada fila es…`` granularity sentence from the profile.
|
||||||
|
|
||||||
|
Prefers the key-candidate columns (a row is identified by them); when no key
|
||||||
|
is detected, falls back to the table shape so the line is always meaningful
|
||||||
|
and starts with ``Cada fila es`` as the user requested."""
|
||||||
|
keys = profile.get("key_candidates") or []
|
||||||
|
if keys:
|
||||||
|
shown = ", ".join(str(k) for k in keys[:3])
|
||||||
|
more = "" if len(keys) <= 3 else f" (y {len(keys) - 3} más)"
|
||||||
|
return (f"Cada fila es un registro identificado por {shown}{more}, "
|
||||||
|
"candidata(s) a clave por ser únicas y sin nulos.")
|
||||||
|
n_rows = profile.get("n_rows")
|
||||||
|
tail = f" El dataset tiene {_fmt_int(n_rows)} filas en total." if n_rows else ""
|
||||||
|
return (f"Cada fila es un registro de «{dataset_name}». No se detectó una "
|
||||||
|
"columna identificadora única, así que la granularidad se infiere "
|
||||||
|
"de la forma de la tabla." + tail)
|
||||||
|
|
||||||
|
|
||||||
def build_portada(profile: dict, ctx: dict):
|
def build_portada(profile: dict, ctx: dict):
|
||||||
"""Build the cover Chapter, or None if there is truly nothing to show."""
|
"""Build the cover Chapter, or None if there is truly nothing to show."""
|
||||||
profile = profile or {}
|
profile = profile or {}
|
||||||
@@ -166,30 +262,38 @@ def build_portada(profile: dict, ctx: dict):
|
|||||||
quality_criteria = ctx.get("quality_criteria") or _DEFAULT_QUALITY_CRITERIA
|
quality_criteria = ctx.get("quality_criteria") or _DEFAULT_QUALITY_CRITERIA
|
||||||
quality_value = "—" if score is None else f"{score} / 100"
|
quality_value = "—" if score is None else f"{score} / 100"
|
||||||
|
|
||||||
# Granularity: ctx wins; else derive from key candidates; else be honest.
|
llm = _llm_block(profile, ctx)
|
||||||
|
|
||||||
|
# Granularity: explicit ctx wins; then the LLM "row_meaning"; then the key
|
||||||
|
# candidates; finally a shape-based fallback. Always a real "Cada fila es…".
|
||||||
granularity = ctx.get("granularity")
|
granularity = ctx.get("granularity")
|
||||||
if not granularity:
|
if not granularity:
|
||||||
keys = profile.get("key_candidates") or []
|
granularity = (llm.get("row_meaning") or "").strip() or None
|
||||||
if keys:
|
if not granularity:
|
||||||
granularity = ("Cada fila parece identificada por "
|
granularity = _derive_granularity(profile, str(dataset_name))
|
||||||
+ ", ".join(str(k) for k in keys[:3]) + ".")
|
|
||||||
else:
|
|
||||||
granularity = ("Cada fila es… (granularidad no determinada — "
|
|
||||||
"pendiente de la capa de cálculo/LLM).")
|
|
||||||
|
|
||||||
|
# Description: explicit ctx wins; then the LLM "summary"; finally a short
|
||||||
|
# profile-derived summary. Never the old empty placeholder.
|
||||||
description = ctx.get("description")
|
description = ctx.get("description")
|
||||||
if not description:
|
if not description:
|
||||||
description = ("Descripción no provista — pendiente de la capa LLM "
|
description = (llm.get("summary") or "").strip() or None
|
||||||
"(`run_llm`) o de `ctx['description']`.")
|
if not description:
|
||||||
|
description = _derive_description(profile, ctx)
|
||||||
|
|
||||||
blocks = [
|
# Title + dataset size shown together and BIG (Heading) at the top, kept on
|
||||||
|
# the same page (Group). The size is no longer buried in the metadata table.
|
||||||
|
cover = [
|
||||||
model.Heading(text=str(dataset_name), level=1),
|
model.Heading(text=str(dataset_name), level=1),
|
||||||
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
|
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
|
||||||
|
model.Heading(text=shape, level=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
model.Group(blocks=cover),
|
||||||
model.KVTable(rows=[
|
model.KVTable(rows=[
|
||||||
("Fuente", source_origin),
|
("Fuente", source_origin),
|
||||||
("Almacenamiento", storage),
|
("Almacenamiento", storage),
|
||||||
("Generado", when),
|
("Generado", when),
|
||||||
("Tamaño", shape),
|
|
||||||
("Calidad", quality_value),
|
("Calidad", quality_value),
|
||||||
("Criterios de calidad", quality_criteria),
|
("Criterios de calidad", quality_criteria),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Tests for the PORTADA (cover) chapter — DoD: golden + edges + render.
|
||||||
|
|
||||||
|
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
||||||
|
and deterministic. Verifies the Fase 4b improvements:
|
||||||
|
|
||||||
|
1. The dataset size (N rows x M columns) is always shown BIG — as a level-2
|
||||||
|
heading kept together with the dataset name in a ``Group`` — and is no longer
|
||||||
|
a row of the metadata table.
|
||||||
|
2. Description and Granularity are resolved through a real cascade and are never
|
||||||
|
the old empty placeholders: an explicit ``ctx`` value wins; otherwise the LLM
|
||||||
|
block (``profile['llm']``) provides ``summary`` / ``row_meaning``; otherwise a
|
||||||
|
short summary is derived from the profile and a "Cada fila es…" sentence from
|
||||||
|
the key-candidate columns or the table shape.
|
||||||
|
3. The chapter degrades without raising on empty/None input.
|
||||||
|
4. It renders inside the full document to both PDF and PPTX showing that content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from pptx import Presentation
|
||||||
|
|
||||||
|
from datascience.automatic_eda.model import Group, Heading, KVTable, Markdown
|
||||||
|
from datascience.automatic_eda.chapters.portada import (
|
||||||
|
CHAPTER_ID, CHAPTER_VERSION, build_portada,
|
||||||
|
)
|
||||||
|
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||||
|
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(with_llm: bool = True, with_keys: bool = True) -> dict:
|
||||||
|
prof = {
|
||||||
|
"table": "titanic",
|
||||||
|
"source": "/data/titanic.csv",
|
||||||
|
"profiled_at": "2026-06-30T10:00:00+00:00",
|
||||||
|
"n_rows": 891,
|
||||||
|
"n_cols": 12,
|
||||||
|
"quality_score": 78.0,
|
||||||
|
"columns": [
|
||||||
|
{"name": "PassengerId", "inferred_type": "numeric",
|
||||||
|
"null_pct": 0.0, "numeric": {"mean": 446.0, "min": 1.0,
|
||||||
|
"max": 891.0, "std": 257.0}},
|
||||||
|
{"name": "Survived", "inferred_type": "numeric",
|
||||||
|
"null_pct": 0.0, "numeric": {"mean": 0.38, "min": 0.0,
|
||||||
|
"max": 1.0, "std": 0.49}},
|
||||||
|
{"name": "Sex", "inferred_type": "categorical", "null_pct": 0.0,
|
||||||
|
"categorical": {"top": [{"value": "male", "count": 577, "pct": 0.65},
|
||||||
|
{"value": "female", "count": 314,
|
||||||
|
"pct": 0.35}],
|
||||||
|
"mode": "male", "n_distinct": 2, "entropy": 0.93}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if with_keys:
|
||||||
|
prof["key_candidates"] = ["PassengerId"]
|
||||||
|
if with_llm:
|
||||||
|
prof["llm"] = {
|
||||||
|
"summary": "Pasajeros del Titanic con su supervivencia y datos de viaje.",
|
||||||
|
"row_meaning": "Cada fila es un pasajero del Titanic.",
|
||||||
|
"dictionary": [], "pii": [], "cleaning": [], "analyses": [],
|
||||||
|
}
|
||||||
|
return prof
|
||||||
|
|
||||||
|
|
||||||
|
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 _pptx_text(path: str) -> str:
|
||||||
|
prs = Presentation(path)
|
||||||
|
parts = []
|
||||||
|
for sl in prs.slides:
|
||||||
|
for sh in sl.shapes:
|
||||||
|
if sh.has_text_frame:
|
||||||
|
parts.append(sh.text_frame.text)
|
||||||
|
if sh.has_table:
|
||||||
|
tb = sh.table
|
||||||
|
for r in range(len(tb.rows)):
|
||||||
|
for c in range(len(tb.columns)):
|
||||||
|
parts.append(tb.cell(r, c).text)
|
||||||
|
return re.sub(r"\s+", " ", " ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_after(blocks, heading_text):
|
||||||
|
"""Return the Markdown block that follows a Heading whose text matches."""
|
||||||
|
for i, b in enumerate(blocks):
|
||||||
|
if isinstance(b, Heading) and heading_text.lower() in b.text.lower():
|
||||||
|
for nb in blocks[i + 1:]:
|
||||||
|
if isinstance(nb, Markdown):
|
||||||
|
return nb
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_tamano_grande_y_textos_llm():
|
||||||
|
ch = build_portada(_profile(), {})
|
||||||
|
assert ch is not None
|
||||||
|
assert ch.id == CHAPTER_ID
|
||||||
|
assert ch.version == CHAPTER_VERSION
|
||||||
|
|
||||||
|
# 1) Title + size kept together in a Group; size is a BIG level-2 heading.
|
||||||
|
group = next(b for b in ch.blocks if isinstance(b, Group))
|
||||||
|
inner = group.blocks
|
||||||
|
assert isinstance(inner[0], Heading) and inner[0].level == 1
|
||||||
|
assert inner[0].text == "titanic"
|
||||||
|
size_h = next(b for b in inner if isinstance(b, Heading) and b.level == 2)
|
||||||
|
assert "891" in size_h.text and "12" in size_h.text
|
||||||
|
assert "filas" in size_h.text and "columnas" in size_h.text
|
||||||
|
|
||||||
|
# 2) Size is no longer a row of the metadata table.
|
||||||
|
kv = next(b for b in ch.blocks if isinstance(b, KVTable))
|
||||||
|
labels = [r[0] for r in kv.rows]
|
||||||
|
assert "Tamaño" not in labels
|
||||||
|
assert "Fuente" in labels and "Calidad" in labels
|
||||||
|
|
||||||
|
# 3) Description and Granularity come from the LLM block.
|
||||||
|
desc = _markdown_after(ch.blocks, "Descripción")
|
||||||
|
gran = _markdown_after(ch.blocks, "Granularidad")
|
||||||
|
assert desc is not None and "Titanic" in desc.text
|
||||||
|
assert gran is not None and gran.text.startswith("Cada fila es")
|
||||||
|
assert "pasajero" in gran.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_sin_llm_usa_keys_y_perfil():
|
||||||
|
# No LLM block: description derived from the profile, granularity from keys.
|
||||||
|
ch = build_portada(_profile(with_llm=False, with_keys=True), {})
|
||||||
|
desc = _markdown_after(ch.blocks, "Descripción")
|
||||||
|
gran = _markdown_after(ch.blocks, "Granularidad")
|
||||||
|
# Description is the derived summary, never the old "pendiente" placeholder.
|
||||||
|
assert "pendiente" not in desc.text.lower()
|
||||||
|
assert "891" in desc.text and "columnas" in desc.text
|
||||||
|
assert "numéricas" in desc.text or "categóricas" in desc.text
|
||||||
|
# Granularity mentions the key candidate and starts with "Cada fila es".
|
||||||
|
assert gran.text.startswith("Cada fila es")
|
||||||
|
assert "PassengerId" in gran.text
|
||||||
|
assert "…" not in gran.text # the old ellipsis placeholder is gone.
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_sin_llm_sin_keys_usa_forma():
|
||||||
|
ch = build_portada(_profile(with_llm=False, with_keys=False), {})
|
||||||
|
gran = _markdown_after(ch.blocks, "Granularidad")
|
||||||
|
assert gran.text.startswith("Cada fila es")
|
||||||
|
assert "titanic" in gran.text.lower()
|
||||||
|
assert "pendiente" not in gran.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ctx_explicito_gana_sobre_llm():
|
||||||
|
ctx = {"description": "Descripción manual.",
|
||||||
|
"granularity": "Cada fila es una unidad manual."}
|
||||||
|
ch = build_portada(_profile(), ctx)
|
||||||
|
desc = _markdown_after(ch.blocks, "Descripción")
|
||||||
|
gran = _markdown_after(ch.blocks, "Granularidad")
|
||||||
|
assert desc.text == "Descripción manual."
|
||||||
|
assert gran.text == "Cada fila es una unidad manual."
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_perfil_vacio_no_lanza():
|
||||||
|
# Empty / None never raise; the cover still shows a size and real texts.
|
||||||
|
for prof, ctx in (({}, {}), (None, None)):
|
||||||
|
ch = build_portada(prof, ctx)
|
||||||
|
assert ch is not None
|
||||||
|
group = next(b for b in ch.blocks if isinstance(b, Group))
|
||||||
|
size_h = next(b for b in group.blocks
|
||||||
|
if isinstance(b, Heading) and b.level == 2)
|
||||||
|
assert "filas" in size_h.text and "columnas" in size_h.text
|
||||||
|
desc = _markdown_after(ch.blocks, "Descripción")
|
||||||
|
gran = _markdown_after(ch.blocks, "Granularidad")
|
||||||
|
assert desc.text and "pendiente" not in desc.text.lower()
|
||||||
|
assert gran.text.startswith("Cada fila es")
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_render_pdf_muestra_portada():
|
||||||
|
prof = _profile()
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = os.path.join(d, "eda.pdf")
|
||||||
|
res = render_automatic_eda_pdf(prof, out, {"title": "EDA"})
|
||||||
|
assert res["path"] == out and os.path.exists(out)
|
||||||
|
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
|
||||||
|
txt = _pdf_text(out)
|
||||||
|
assert "titanic" in txt.lower()
|
||||||
|
assert "891" in txt and "filas" in txt and "columnas" in txt
|
||||||
|
assert "Titanic" in txt # LLM summary in the Description.
|
||||||
|
assert "Cada fila es" in txt # granularity sentence.
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_render_pptx_muestra_portada():
|
||||||
|
prof = _profile()
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = os.path.join(d, "eda.pptx")
|
||||||
|
res = render_automatic_eda_pptx(prof, out, {"title": "EDA"})
|
||||||
|
assert res["path"] == out and os.path.exists(out)
|
||||||
|
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
|
||||||
|
txt = _pptx_text(out)
|
||||||
|
assert "titanic" in txt.lower()
|
||||||
|
assert "891" in txt and "columnas" in txt
|
||||||
|
assert "Cada fila es" in txt
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"""Key-relations chapter (RELACIONES) — the keys / join structure of the data.
|
||||||
|
|
||||||
|
This chapter is the *relational* section of an AutomaticEDA report. It answers a
|
||||||
|
single question for the table (or the whole DuckDB source it lives in): **how do
|
||||||
|
the keys relate?** It composes, without reimplementing them, the registry's
|
||||||
|
relation primitives and degrades honestly when a layer does not apply.
|
||||||
|
|
||||||
|
It renders, in order, only the layers that have something to say:
|
||||||
|
|
||||||
|
1. **Declared keys** (real schema constraints) — when the DuckDB source declares
|
||||||
|
PRIMARY KEY / FOREIGN KEY / UNIQUE constraints, they are read verbatim via
|
||||||
|
``detect_declared_keys_duckdb`` and shown as ground truth: which column is the
|
||||||
|
PK, which columns are FKs and the table/column they point to.
|
||||||
|
2. **Primary-key candidates** — the ``key_candidates`` the TableProfile already
|
||||||
|
carries (columns whose cardinality equals the row count, with no nulls). These
|
||||||
|
are *candidates*: a column that could serve as the row identifier.
|
||||||
|
3. **Foreign-key candidates** when none are declared:
|
||||||
|
- **Inter-table** (the DuckDB source has several tables): real FK candidates by
|
||||||
|
name signal + value containment via ``infer_fk_containment_duckdb``, plus the
|
||||||
|
join graph (roles + a pasteable Mermaid diagram) via ``build_join_graph``.
|
||||||
|
- **Intra-table** (a single table): columns that *look* like a foreign key by a
|
||||||
|
name+cardinality heuristic (``suggest_intratable_fk_candidates``). This is a
|
||||||
|
**suggestion**, explicitly flagged as a heuristic, never an assertion.
|
||||||
|
|
||||||
|
``build_relaciones(profile, ctx) -> Chapter | None``: returns ``None`` when there
|
||||||
|
is nothing to say (no declared key, no key candidates, and no FK candidate —
|
||||||
|
inter- or intra-table). Reads everything defensively (``.get``) and never raises:
|
||||||
|
anything missing degrades to a note or is omitted; a failing registry call drops
|
||||||
|
its layer instead of aborting the chapter.
|
||||||
|
|
||||||
|
ctx keys this chapter consumes (all optional):
|
||||||
|
db_path, table : str — the DuckDB file and table being profiled (set by
|
||||||
|
``build_eda_render_ctx``). ``db_path`` is needed to read declared
|
||||||
|
constraints, to list the sibling tables, and to run the containment-based
|
||||||
|
FK inference. Without it, only the profile-derived layers (PK candidates,
|
||||||
|
intra-table FK heuristic) are available.
|
||||||
|
glossary : model.GlossaryCollector — shared glossary; the chapter registers
|
||||||
|
the relational terms (PK, FK, containment, cardinality) and marks their
|
||||||
|
first appearance clickable.
|
||||||
|
|
||||||
|
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import model
|
||||||
|
|
||||||
|
# Pure/impure registry functions (group ``eda``) this chapter composes. Imported
|
||||||
|
# defensively (module-leaf imports, like the AGREGACION chapter) so the chapter
|
||||||
|
# still builds — degrading the affected layer to nothing — if a function is
|
||||||
|
# somehow unavailable / not indexed yet.
|
||||||
|
try:
|
||||||
|
from datascience.detect_declared_keys_duckdb import detect_declared_keys_duckdb
|
||||||
|
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
|
||||||
|
detect_declared_keys_duckdb = None # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
from datascience.infer_fk_containment_duckdb import infer_fk_containment_duckdb
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
infer_fk_containment_duckdb = None # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
from datascience.build_join_graph import build_join_graph
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
build_join_graph = None # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
from datascience.suggest_intratable_fk_candidates import (
|
||||||
|
suggest_intratable_fk_candidates,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
suggest_intratable_fk_candidates = None # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
from infra import duckdb_list_tables
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
duckdb_list_tables = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
CHAPTER_VERSION = "1.0.0"
|
||||||
|
CHAPTER_ID = "relaciones"
|
||||||
|
CHAPTER_TITLE = "Relaciones de clave"
|
||||||
|
|
||||||
|
# Cap the inter-table FK table so a wide schema does not blow up the page; the
|
||||||
|
# rest is summarized in a closing note (no silent truncation).
|
||||||
|
MAX_FK_ROWS = 40
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Glossary terms this chapter explains. Registered in the shared collector and
|
||||||
|
# marked clickable on their first appearance (contract §11.1).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_TERMS = {
|
||||||
|
"pk": (
|
||||||
|
"Clave primaria (PK)",
|
||||||
|
"Columna (o conjunto de columnas) que identifica de forma única cada fila "
|
||||||
|
"de una tabla: sus valores no se repiten y no son nulos. Una tabla tiene "
|
||||||
|
"como mucho una clave primaria; es el ancla por la que otras tablas la "
|
||||||
|
"referencian.",
|
||||||
|
),
|
||||||
|
"fk": (
|
||||||
|
"Clave foránea (FK)",
|
||||||
|
"Columna de una tabla cuyos valores apuntan a la clave primaria de otra "
|
||||||
|
"tabla (o de la misma), creando una relación entre ambas. Una FK suele ser "
|
||||||
|
"N:1: muchas filas de la tabla origen comparten el mismo valor de la tabla "
|
||||||
|
"destino.",
|
||||||
|
),
|
||||||
|
"containment": (
|
||||||
|
"Containment / inclusión",
|
||||||
|
"Señal con la que se infiere una clave foránea sin que la base la declare: "
|
||||||
|
"la fracción de valores distintos de una columna A que también aparecen "
|
||||||
|
"como valores de otra columna B. Si casi todos los valores de A están "
|
||||||
|
"contenidos en B (inclusión ≈ 1) y B parece una clave, A → B es una FK "
|
||||||
|
"candidata.",
|
||||||
|
),
|
||||||
|
"cardinalidad": (
|
||||||
|
"Cardinalidad",
|
||||||
|
"Número de valores distintos de una columna. Cardinalidad igual al número "
|
||||||
|
"de filas (y sin nulos) señala un identificador (candidato a clave "
|
||||||
|
"primaria); cardinalidad alta pero menor que el número de filas, con "
|
||||||
|
"valores repetidos, es típica de una clave foránea.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_terms(ctx: dict) -> bool:
|
||||||
|
"""Register the relational terms in the shared glossary. Returns whether the
|
||||||
|
in-text appearances should be marked clickable."""
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
if not isinstance(glossary, model.GlossaryCollector):
|
||||||
|
return False
|
||||||
|
for key, (label, definition) in _TERMS.items():
|
||||||
|
glossary.add(key, label, definition)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Formatting helpers (mirror the other chapters' defensive style).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _fmt_int(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{int(value):,}".replace(",", ".")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return model._safe_str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct_fraction(value, decimals: int = 1) -> str:
|
||||||
|
"""Format a 0–1 fraction as a percentage. None -> placeholder."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
v = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return model._safe_str(value)
|
||||||
|
if v <= 1.0:
|
||||||
|
v *= 100.0
|
||||||
|
return f"{v:.{decimals}f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ratio(value, decimals: int = 3) -> str:
|
||||||
|
"""Format an already-0–1 ratio (inclusion) as a plain number."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{float(value):.{decimals}f}".rstrip("0").rstrip(".")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return model._safe_str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dict(v) -> bool:
|
||||||
|
return isinstance(v, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _columns_by_name(profile: dict) -> dict:
|
||||||
|
"""Index the profile columns by name for quick metric lookup."""
|
||||||
|
out = {}
|
||||||
|
for col in (profile.get("columns") or []):
|
||||||
|
if _is_dict(col) and col.get("name") is not None:
|
||||||
|
out[col.get("name")] = col
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Layer 1 — declared keys (real schema constraints).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _declared_keys(db_path: str, table: str):
|
||||||
|
"""Read declared PK/FK/UNIQUE for the source, or None if unavailable."""
|
||||||
|
if not db_path or detect_declared_keys_duckdb is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
out = detect_declared_keys_duckdb(db_path, table)
|
||||||
|
except Exception: # noqa: BLE001 — dict-no-throw: treat as unavailable.
|
||||||
|
return None
|
||||||
|
if not _is_dict(out) or out.get("status") != "ok":
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_section(declared: dict) -> list:
|
||||||
|
"""Blocks for the declared-keys layer, or [] if there is nothing declared."""
|
||||||
|
pks = [p for p in (declared.get("primary_keys") or []) if _is_dict(p)]
|
||||||
|
fks = [f for f in (declared.get("foreign_keys") or []) if _is_dict(f)]
|
||||||
|
uqs = [u for u in (declared.get("unique") or []) if _is_dict(u)]
|
||||||
|
if not (pks or fks or uqs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
model.Heading(text="Claves declaradas en el esquema", level=2),
|
||||||
|
model.Markdown(text=(
|
||||||
|
"La base **declara** estas relaciones de clave como restricciones "
|
||||||
|
"reales del esquema (constraints). Son la verdad de referencia: no se "
|
||||||
|
"infieren, se leen tal cual de la definición de las tablas.")),
|
||||||
|
]
|
||||||
|
|
||||||
|
if pks:
|
||||||
|
rows = [[model._safe_str(p.get("table")),
|
||||||
|
", ".join(model._safe_str(c) for c in (p.get("columns") or []))]
|
||||||
|
for p in pks]
|
||||||
|
blocks.append(model.DataTable(
|
||||||
|
header=["Tabla", "Columna(s) PK"], rows=rows,
|
||||||
|
title="Claves primarias declaradas",
|
||||||
|
note="Cada fila: la clave primaria declarada de una tabla."))
|
||||||
|
|
||||||
|
if fks:
|
||||||
|
rows = []
|
||||||
|
for f in fks:
|
||||||
|
src = ", ".join(model._safe_str(c) for c in (f.get("columns") or []))
|
||||||
|
dst = ", ".join(
|
||||||
|
model._safe_str(c) for c in (f.get("referenced_columns") or []))
|
||||||
|
rows.append([
|
||||||
|
model._safe_str(f.get("table")), src,
|
||||||
|
model._safe_str(f.get("referenced_table")), dst])
|
||||||
|
blocks.append(model.DataTable(
|
||||||
|
header=["Tabla origen", "Columna(s) FK", "→ Tabla destino",
|
||||||
|
"Columna(s) destino"],
|
||||||
|
rows=rows, title="Claves foráneas declaradas",
|
||||||
|
note="Cada fila: una FK declarada — origen → destino."))
|
||||||
|
|
||||||
|
if uqs:
|
||||||
|
rows = [[model._safe_str(u.get("table")),
|
||||||
|
", ".join(model._safe_str(c) for c in (u.get("columns") or []))]
|
||||||
|
for u in uqs]
|
||||||
|
blocks.append(model.DataTable(
|
||||||
|
header=["Tabla", "Columna(s) UNIQUE"], rows=rows,
|
||||||
|
title="Restricciones UNIQUE declaradas"))
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Layer 2 — primary-key candidates (from the profile).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _pk_candidates_section(profile: dict, mark: bool) -> list:
|
||||||
|
"""Blocks for the PK-candidates layer, or [] if there are none."""
|
||||||
|
keys = [k for k in (profile.get("key_candidates") or []) if k is not None]
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
by_name = _columns_by_name(profile)
|
||||||
|
|
||||||
|
pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark
|
||||||
|
else "**clave primaria**")
|
||||||
|
intro = (
|
||||||
|
f"Columnas **candidatas a {pk}**: su "
|
||||||
|
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y "
|
||||||
|
"no tienen nulos. Son candidatas, no una clave declarada: la base no "
|
||||||
|
"las marca como tal."
|
||||||
|
if mark else
|
||||||
|
"Columnas **candidatas a clave primaria**: su cardinalidad iguala al "
|
||||||
|
"número de filas y no tienen nulos. Son candidatas, no una clave "
|
||||||
|
"declarada.")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name in keys:
|
||||||
|
col = by_name.get(name) or {}
|
||||||
|
rows.append([
|
||||||
|
model._safe_str(name),
|
||||||
|
_fmt_int(col.get("distinct_count")),
|
||||||
|
_fmt_pct_fraction(col.get("unique_pct")),
|
||||||
|
model._safe_str(col.get("inferred_type") or col.get("physical_type") or "—"),
|
||||||
|
])
|
||||||
|
return [
|
||||||
|
model.Heading(text="Candidatos a clave primaria", level=2),
|
||||||
|
model.Markdown(text=intro),
|
||||||
|
model.DataTable(
|
||||||
|
header=["Columna", "Valores distintos", "% único", "Tipo"],
|
||||||
|
rows=rows, title="Candidatas a clave primaria",
|
||||||
|
note=f"{_fmt_int(profile.get('n_rows'))} filas en total como referencia."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Layer 3a — inter-table FK candidates (containment) + join graph.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _list_source_tables(db_path: str) -> list:
|
||||||
|
"""List the tables in the DuckDB source, or [] if it can't be listed."""
|
||||||
|
if not db_path or duckdb_list_tables is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
out = duckdb_list_tables(db_path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
if not _is_dict(out) or out.get("status") != "ok":
|
||||||
|
return []
|
||||||
|
return [t for t in (out.get("tables") or []) if isinstance(t, str)]
|
||||||
|
|
||||||
|
|
||||||
|
def _inter_table_section(db_path: str, tables: list, mark: bool) -> list:
|
||||||
|
"""Blocks for the inter-table FK layer (containment + join graph), or []."""
|
||||||
|
if infer_fk_containment_duckdb is None or len(tables) < 2:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
fk = infer_fk_containment_duckdb(db_path, tables=tables)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
if not _is_dict(fk) or fk.get("status") != "ok":
|
||||||
|
return []
|
||||||
|
candidates = [c for c in (fk.get("fk_candidates") or []) if _is_dict(c)]
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
containment = ("[[term:containment]]containment (inclusión de valores)[[/term]]"
|
||||||
|
if mark else "containment (inclusión de valores)")
|
||||||
|
fk_term = "[[term:fk]]**claves foráneas**[[/term]]" if mark else "**claves foráneas**"
|
||||||
|
blocks = [
|
||||||
|
model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2),
|
||||||
|
model.Markdown(text=(
|
||||||
|
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se "
|
||||||
|
f"infieren por señal de nombre y por {containment}. No están "
|
||||||
|
"declaradas por la base; son la relación más probable según los "
|
||||||
|
"datos.")),
|
||||||
|
]
|
||||||
|
|
||||||
|
shown = candidates[:MAX_FK_ROWS]
|
||||||
|
rows = []
|
||||||
|
for c in shown:
|
||||||
|
rows.append([
|
||||||
|
f"{model._safe_str(c.get('from_table'))}.{model._safe_str(c.get('from_col'))}",
|
||||||
|
f"{model._safe_str(c.get('to_table'))}.{model._safe_str(c.get('to_col'))}",
|
||||||
|
_fmt_ratio(c.get("inclusion")),
|
||||||
|
model._safe_str(c.get("cardinality") or "—"),
|
||||||
|
"sí" if c.get("name_match") else "no",
|
||||||
|
])
|
||||||
|
note = "Ordenadas por señal de nombre e inclusión."
|
||||||
|
if len(candidates) > len(shown):
|
||||||
|
note += f" Se muestran {len(shown)} de {len(candidates)} candidatas."
|
||||||
|
blocks.append(model.DataTable(
|
||||||
|
header=["Origen", "→ Destino", "Inclusión", "Cardinalidad", "Coincide nombre"],
|
||||||
|
rows=rows, title="FK candidatas por containment", note=note))
|
||||||
|
|
||||||
|
# Join graph: node roles + a pasteable Mermaid diagram, kept together.
|
||||||
|
if build_join_graph is not None:
|
||||||
|
try:
|
||||||
|
graph = build_join_graph(candidates, tables=tables)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
graph = None
|
||||||
|
if _is_dict(graph):
|
||||||
|
graph_blocks = [model.Heading(text="Grafo de relaciones", level=3)]
|
||||||
|
nodes = [n for n in (graph.get("nodes") or []) if _is_dict(n)]
|
||||||
|
if nodes:
|
||||||
|
node_rows = [[
|
||||||
|
model._safe_str(n.get("table")),
|
||||||
|
model._safe_str(n.get("role") or "—"),
|
||||||
|
_fmt_int(n.get("out_degree")),
|
||||||
|
_fmt_int(n.get("in_degree")),
|
||||||
|
] for n in nodes]
|
||||||
|
graph_blocks.append(model.DataTable(
|
||||||
|
header=["Tabla", "Rol", "FK salientes", "FK entrantes"],
|
||||||
|
rows=node_rows, title="Tablas y su rol en el grafo",
|
||||||
|
note="Rol: fact (apunta a otras), dimension (referenciada), "
|
||||||
|
"bridge (ambas), standalone (aislada)."))
|
||||||
|
hubs = [h for h in (graph.get("hubs") or []) if h]
|
||||||
|
if hubs:
|
||||||
|
graph_blocks.append(model.Markdown(text=(
|
||||||
|
"Tablas con más relaciones salientes (candidatas a tabla de "
|
||||||
|
"hechos): " + ", ".join(model._safe_str(h) for h in hubs) + ".")))
|
||||||
|
mermaid = model._safe_str(graph.get("mermaid")).strip()
|
||||||
|
if mermaid:
|
||||||
|
graph_blocks.append(model.Markdown(text=(
|
||||||
|
"Diagrama de las relaciones (pegable en un bloque Mermaid):")))
|
||||||
|
graph_blocks.append(model.Markdown(
|
||||||
|
text="```mermaid\n" + mermaid + "\n```"))
|
||||||
|
if len(graph_blocks) > 1:
|
||||||
|
blocks.append(model.Group(blocks=graph_blocks,
|
||||||
|
title="Grafo de relaciones"))
|
||||||
|
|
||||||
|
skipped = [s for s in (fk.get("skipped") or []) if s]
|
||||||
|
if skipped:
|
||||||
|
blocks.append(model.Note(
|
||||||
|
"Algunos pares se omitieron por tamaño: "
|
||||||
|
+ "; ".join(model._safe_str(s) for s in skipped) + "."))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Layer 3b — intra-table FK candidates (name+cardinality heuristic).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _intra_table_section(profile: dict, mark: bool) -> list:
|
||||||
|
"""Blocks for the intra-table FK heuristic layer, or [] if no candidates."""
|
||||||
|
if suggest_intratable_fk_candidates is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
cands = suggest_intratable_fk_candidates(profile)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
cands = [c for c in (cands or []) if _is_dict(c)]
|
||||||
|
if not cands:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fk_term = "[[term:fk]]**claves foráneas**[[/term]]" if mark else "**claves foráneas**"
|
||||||
|
blocks = [
|
||||||
|
model.Heading(text="Posibles claves foráneas (heurística de nombre)", level=2),
|
||||||
|
model.Markdown(text=(
|
||||||
|
f"No hay otras tablas que referenciar, pero algunas columnas **parecen** "
|
||||||
|
f"{fk_term} por su nombre (terminan en «id») y su cardinalidad (muchos "
|
||||||
|
"valores repetidos, N:1). Es una **sugerencia heurística**, no una "
|
||||||
|
"afirmación: el nombre de la tabla destino es una conjetura y no se "
|
||||||
|
"comprueba inclusión de valores contra ninguna tabla real.")),
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for c in cands:
|
||||||
|
rows.append([
|
||||||
|
model._safe_str(c.get("column")),
|
||||||
|
model._safe_str(c.get("ref_table_guess") or "—"),
|
||||||
|
_fmt_int(c.get("distinct_count")),
|
||||||
|
_fmt_pct_fraction(c.get("unique_pct")),
|
||||||
|
model._safe_str(c.get("inferred_type") or c.get("physical_type") or "—"),
|
||||||
|
model._safe_str(c.get("reason") or ""),
|
||||||
|
])
|
||||||
|
blocks.append(model.DataTable(
|
||||||
|
header=["Columna", "Posible tabla", "Valores distintos", "% único",
|
||||||
|
"Tipo", "Motivo"],
|
||||||
|
rows=rows, title="Posibles FK por nombre y cardinalidad",
|
||||||
|
note="Heurística: posibles falsos positivos/negativos. No confirma containment."))
|
||||||
|
blocks.append(model.Note(
|
||||||
|
"Estas sugerencias se basan solo en el nombre y la cardinalidad. Para "
|
||||||
|
"confirmarlas haría falta la tabla destino y comprobar la inclusión de "
|
||||||
|
"valores (containment)."))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Entry point.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _intro_blocks(mark: bool) -> list:
|
||||||
|
pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria"
|
||||||
|
fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea"
|
||||||
|
text = (
|
||||||
|
f"Este capítulo analiza las **relaciones de clave** de la tabla: cuál es "
|
||||||
|
f"la {pk} y cuáles son las {fk}. Cuando la base las **declara** como "
|
||||||
|
"restricciones del esquema, se muestran tal cual; cuando no, se proponen "
|
||||||
|
"las más probables a partir de los datos —por containment entre tablas o, "
|
||||||
|
"en una sola tabla, por una heurística de nombre y cardinalidad— siempre "
|
||||||
|
"marcadas como candidatas, nunca como hechos.")
|
||||||
|
return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_relaciones(profile: dict, ctx: dict):
|
||||||
|
"""Build the RELACIONES Chapter, or None if there is nothing to say.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: the ``eda`` group TableProfile dict (may be None/empty).
|
||||||
|
ctx: presentation context. Consumes ``db_path`` + ``table`` (to read
|
||||||
|
declared constraints, list sibling tables and run the containment FK
|
||||||
|
inference) and ``glossary`` (to register the relational terms).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``model.Chapter`` with the applicable relation layers; or ``None`` when
|
||||||
|
the dataset has no declared key, no key candidates and no FK candidate
|
||||||
|
(neither inter- nor intra-table).
|
||||||
|
"""
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
profile = {}
|
||||||
|
ctx = ctx if isinstance(ctx, dict) else {}
|
||||||
|
db_path = ctx.get("db_path")
|
||||||
|
table = ctx.get("table")
|
||||||
|
|
||||||
|
mark = _register_terms(ctx)
|
||||||
|
|
||||||
|
# Build each layer; the chapter is the concatenation of the non-empty ones.
|
||||||
|
declared = _declared_keys(db_path, table)
|
||||||
|
declared_blocks = _declared_section(declared) if declared else []
|
||||||
|
declared_has_fk = bool(declared and declared.get("foreign_keys"))
|
||||||
|
|
||||||
|
pk_blocks = _pk_candidates_section(profile, mark)
|
||||||
|
|
||||||
|
tables = _list_source_tables(db_path)
|
||||||
|
inter_blocks = _inter_table_section(db_path, tables, mark)
|
||||||
|
|
||||||
|
# The intra-table heuristic only makes sense when no real FK is available for
|
||||||
|
# this table — neither declared nor inferred inter-table. Otherwise the real
|
||||||
|
# relations already answer the question and the heuristic is just noise.
|
||||||
|
if declared_has_fk or inter_blocks:
|
||||||
|
intra_blocks = []
|
||||||
|
else:
|
||||||
|
intra_blocks = _intra_table_section(profile, mark)
|
||||||
|
|
||||||
|
body = declared_blocks + pk_blocks + inter_blocks + intra_blocks
|
||||||
|
if not body:
|
||||||
|
return None # chapter does not apply: nothing to say about relations.
|
||||||
|
|
||||||
|
blocks = _intro_blocks(mark) + body
|
||||||
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Tests for the RELACIONES chapter — DoD: golden(s) + edges + no-cut render.
|
||||||
|
|
||||||
|
Two goldens covering the two real paths of the chapter:
|
||||||
|
|
||||||
|
- **Intra-table** (a single table, no db source for relations): the chapter shows
|
||||||
|
the primary-key candidates from the profile and the heuristic foreign-key
|
||||||
|
suggestions (name + cardinality), explicitly flagged as a heuristic. Renders to
|
||||||
|
PDF and PPTX with nothing cut.
|
||||||
|
- **Inter-table** (a real DuckDB file with two related tables, customers/orders,
|
||||||
|
with a declared FK): the chapter shows the declared keys, the containment-based
|
||||||
|
FK candidates and the join graph (roles + a pasteable Mermaid diagram).
|
||||||
|
|
||||||
|
Edges: a profile with no key candidate and no FK-looking column returns None;
|
||||||
|
``None`` / ``{}`` profiles do not raise. The chapter registers its glossary terms.
|
||||||
|
|
||||||
|
Layers that depend on the sibling registry functions delegated alongside this
|
||||||
|
chapter (``detect_declared_keys_duckdb``, ``suggest_intratable_fk_candidates``)
|
||||||
|
are asserted **conditionally on the function being importable**, so the chapter's
|
||||||
|
honest-degradation contract is what is tested, never a hard dependency on import
|
||||||
|
timing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
from pptx import Presentation
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
from datascience.automatic_eda.chapters.relaciones import build_relaciones
|
||||||
|
from datascience.automatic_eda.model import Chapter, Group, GlossaryCollector
|
||||||
|
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||||
|
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
|
|
||||||
|
# The optional sibling functions: their layers are asserted only when present.
|
||||||
|
try:
|
||||||
|
from datascience.detect_declared_keys_duckdb import detect_declared_keys_duckdb
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
detect_declared_keys_duckdb = None
|
||||||
|
try:
|
||||||
|
from datascience.suggest_intratable_fk_candidates import (
|
||||||
|
suggest_intratable_fk_candidates,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
suggest_intratable_fk_candidates = None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _flatten(blocks) -> list:
|
||||||
|
"""Flatten Group blocks so a test can inspect every leaf block."""
|
||||||
|
out = []
|
||||||
|
for b in blocks:
|
||||||
|
if isinstance(b, Group):
|
||||||
|
out.extend(_flatten(b.blocks))
|
||||||
|
else:
|
||||||
|
out.append(b)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _text_of(chapter: Chapter) -> str:
|
||||||
|
"""Collect all visible text of a chapter's blocks into one string."""
|
||||||
|
parts = []
|
||||||
|
for b in _flatten(chapter.blocks):
|
||||||
|
for attr in ("text", "title", "note"):
|
||||||
|
v = getattr(b, attr, None)
|
||||||
|
if isinstance(v, str):
|
||||||
|
parts.append(v)
|
||||||
|
header = getattr(b, "header", None)
|
||||||
|
if isinstance(header, list):
|
||||||
|
parts.extend(str(c) for c in header)
|
||||||
|
rows = getattr(b, "rows", None)
|
||||||
|
if isinstance(rows, list):
|
||||||
|
for r in rows:
|
||||||
|
if isinstance(r, (list, tuple)):
|
||||||
|
parts.extend(str(c) for c in r)
|
||||||
|
else:
|
||||||
|
parts.append(str(r))
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_both(chapter: Chapter, tag: str):
|
||||||
|
"""Render the chapter to PDF and PPTX; return (pdf_text, n_slides)."""
|
||||||
|
tmp = tempfile.mkdtemp(prefix=f"relaciones_{tag}_")
|
||||||
|
pdf_path = os.path.join(tmp, "out.pdf")
|
||||||
|
pptx_path = os.path.join(tmp, "out.pptx")
|
||||||
|
meta = {"title": f"EDA — {tag}"}
|
||||||
|
render_automatic_eda_pdf([chapter], pdf_path, meta)
|
||||||
|
render_automatic_eda_pptx([chapter], pptx_path, meta)
|
||||||
|
assert os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0
|
||||||
|
assert os.path.exists(pptx_path) and os.path.getsize(pptx_path) > 0
|
||||||
|
text = "".join(p.extract_text() or "" for p in PdfReader(pdf_path).pages)
|
||||||
|
n_slides = len(Presentation(pptx_path).slides)
|
||||||
|
return text, n_slides
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _titanic_profile() -> dict:
|
||||||
|
"""A single-table profile: a PK candidate + a column that looks like a FK."""
|
||||||
|
return {
|
||||||
|
"table": "titanic",
|
||||||
|
"source": "/data/titanic.csv",
|
||||||
|
"n_rows": 891,
|
||||||
|
"n_cols": 4,
|
||||||
|
"key_candidates": ["PassengerId"],
|
||||||
|
"columns": [
|
||||||
|
{"name": "PassengerId", "inferred_type": "numeric",
|
||||||
|
"physical_type": "BIGINT", "distinct_count": 891,
|
||||||
|
"unique_pct": 1.0, "flags": ["possible_id"]},
|
||||||
|
{"name": "ticket_id", "inferred_type": "numeric",
|
||||||
|
"physical_type": "BIGINT", "distinct_count": 681,
|
||||||
|
"unique_pct": 0.76, "flags": []},
|
||||||
|
{"name": "fare", "inferred_type": "numeric",
|
||||||
|
"physical_type": "DOUBLE", "distinct_count": 248,
|
||||||
|
"unique_pct": 0.28, "flags": []},
|
||||||
|
{"name": "sex", "inferred_type": "categorical",
|
||||||
|
"physical_type": "VARCHAR", "distinct_count": 2,
|
||||||
|
"unique_pct": 0.002, "flags": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_relational_db(path: str) -> None:
|
||||||
|
"""Create a small DuckDB with customers(id) <- orders(customer_id), real FK."""
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE orders(id INTEGER PRIMARY KEY, "
|
||||||
|
"customer_id INTEGER REFERENCES customers(id), amount DOUBLE)")
|
||||||
|
con.execute("INSERT INTO customers VALUES "
|
||||||
|
"(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e')")
|
||||||
|
con.execute("INSERT INTO orders VALUES "
|
||||||
|
"(1,1,10.0),(2,1,20.0),(3,2,30.0),(4,3,40.0),"
|
||||||
|
"(5,3,50.0),(6,4,60.0),(7,5,70.0),(8,2,80.0)")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_profile() -> dict:
|
||||||
|
"""A profile for the `orders` table of the relational DB."""
|
||||||
|
return {
|
||||||
|
"table": "orders",
|
||||||
|
"source": "orders",
|
||||||
|
"n_rows": 8,
|
||||||
|
"n_cols": 3,
|
||||||
|
"key_candidates": ["id"],
|
||||||
|
"columns": [
|
||||||
|
{"name": "id", "inferred_type": "numeric", "physical_type": "INTEGER",
|
||||||
|
"distinct_count": 8, "unique_pct": 1.0, "flags": ["possible_id"]},
|
||||||
|
{"name": "customer_id", "inferred_type": "numeric",
|
||||||
|
"physical_type": "INTEGER", "distinct_count": 5, "unique_pct": 0.625,
|
||||||
|
"flags": []},
|
||||||
|
{"name": "amount", "inferred_type": "numeric", "physical_type": "DOUBLE",
|
||||||
|
"distinct_count": 8, "unique_pct": 1.0, "flags": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Golden 1 — intra-table.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_golden_intra_table_pk_and_fk_heuristic():
|
||||||
|
"""Single table: PK candidate shown; FK heuristic shown (if fn available);
|
||||||
|
renders to PDF + PPTX with nothing cut."""
|
||||||
|
prof = _titanic_profile()
|
||||||
|
glossary = GlossaryCollector()
|
||||||
|
# No db_path: only the profile-derived layers apply (no declared, no inter).
|
||||||
|
chapter = build_relaciones(prof, {"glossary": glossary})
|
||||||
|
|
||||||
|
assert isinstance(chapter, Chapter)
|
||||||
|
assert chapter.id == "relaciones"
|
||||||
|
text = _text_of(chapter)
|
||||||
|
|
||||||
|
# PK candidate is always present (comes from the profile).
|
||||||
|
assert "Candidatos a clave primaria" in text
|
||||||
|
assert "PassengerId" in text
|
||||||
|
|
||||||
|
# Glossary terms got registered.
|
||||||
|
for key in ("pk", "fk", "cardinalidad"):
|
||||||
|
assert glossary.has(key)
|
||||||
|
|
||||||
|
# FK heuristic layer: present iff the delegated function is importable.
|
||||||
|
if suggest_intratable_fk_candidates is not None:
|
||||||
|
assert "Posibles claves foráneas" in text
|
||||||
|
assert "ticket_id" in text
|
||||||
|
# The float measure and the PK itself are NOT suggested as FKs.
|
||||||
|
assert "Posibles FK por nombre" in text
|
||||||
|
|
||||||
|
pdf_text, n_slides = _render_both(chapter, "intra")
|
||||||
|
assert "PassengerId" in pdf_text
|
||||||
|
assert n_slides >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Golden 2 — inter-table (real DuckDB).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_golden_inter_table_containment_and_join_graph():
|
||||||
|
"""Two related tables: declared FK (if fn available) + containment FK
|
||||||
|
candidate + Mermaid join graph."""
|
||||||
|
tmp = tempfile.mkdtemp(prefix="relaciones_db_")
|
||||||
|
db_path = os.path.join(tmp, "shop.duckdb")
|
||||||
|
_make_relational_db(db_path)
|
||||||
|
|
||||||
|
prof = _orders_profile()
|
||||||
|
glossary = GlossaryCollector()
|
||||||
|
chapter = build_relaciones(
|
||||||
|
prof, {"db_path": db_path, "table": "orders", "glossary": glossary})
|
||||||
|
|
||||||
|
assert isinstance(chapter, Chapter)
|
||||||
|
text = _text_of(chapter)
|
||||||
|
|
||||||
|
# Inter-table containment FK candidate: customer_id -> customers.id. This path
|
||||||
|
# uses infer_fk_containment_duckdb + build_join_graph, both already in the
|
||||||
|
# registry, so it must be present.
|
||||||
|
assert "Claves foráneas candidatas (inter-tabla)" in text
|
||||||
|
assert "orders.customer_id" in text
|
||||||
|
assert "customers.id" in text
|
||||||
|
# Join graph with a pasteable Mermaid diagram.
|
||||||
|
assert "Grafo de relaciones" in text
|
||||||
|
assert "mermaid" in text
|
||||||
|
assert "graph LR" in text
|
||||||
|
assert "containment" in text.lower()
|
||||||
|
|
||||||
|
# Declared-keys layer: present iff the delegated function is importable.
|
||||||
|
if detect_declared_keys_duckdb is not None:
|
||||||
|
assert "Claves declaradas en el esquema" in text
|
||||||
|
assert "Claves foráneas declaradas" in text
|
||||||
|
|
||||||
|
pdf_text, n_slides = _render_both(chapter, "inter")
|
||||||
|
assert "customer_id" in pdf_text
|
||||||
|
assert n_slides >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Edges.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_none_when_no_relations():
|
||||||
|
"""No key candidates, no FK-looking columns, no db source -> None."""
|
||||||
|
prof = {
|
||||||
|
"table": "flat", "n_rows": 100, "n_cols": 2, "key_candidates": [],
|
||||||
|
"columns": [
|
||||||
|
{"name": "value", "inferred_type": "numeric", "physical_type": "DOUBLE",
|
||||||
|
"distinct_count": 50, "unique_pct": 0.5, "flags": []},
|
||||||
|
{"name": "label", "inferred_type": "categorical",
|
||||||
|
"physical_type": "VARCHAR", "distinct_count": 3, "unique_pct": 0.03,
|
||||||
|
"flags": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert build_relaciones(prof, {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_none_profile_do_not_raise():
|
||||||
|
"""None / {} profile and missing ctx degrade to None without raising."""
|
||||||
|
assert build_relaciones(None, None) is None
|
||||||
|
assert build_relaciones({}, {}) is None
|
||||||
|
assert build_relaciones({}, {"glossary": GlossaryCollector()}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pk_candidate_only_builds_chapter():
|
||||||
|
"""A profile with only a key candidate (no FK anything, no db) still builds:
|
||||||
|
the relations chapter applies because there is a PK candidate to report."""
|
||||||
|
prof = {
|
||||||
|
"table": "t", "n_rows": 10, "n_cols": 1, "key_candidates": ["row_id"],
|
||||||
|
"columns": [
|
||||||
|
{"name": "row_id", "inferred_type": "numeric", "physical_type": "BIGINT",
|
||||||
|
"distinct_count": 10, "unique_pct": 1.0, "flags": ["possible_id"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
chapter = build_relaciones(prof, {})
|
||||||
|
assert isinstance(chapter, Chapter)
|
||||||
|
assert "Candidatos a clave primaria" in _text_of(chapter)
|
||||||
@@ -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,8 +31,11 @@ CHAPTER_ORDER = [
|
|||||||
"analisis_llm", # LLM interpretation — sits next to overview (user request)
|
"analisis_llm", # LLM interpretation — sits next to overview (user request)
|
||||||
"num_distr", # numeric distributions
|
"num_distr", # numeric distributions
|
||||||
"cat_distr", # categorical distributions
|
"cat_distr", # categorical distributions
|
||||||
|
"text_distr", # free-text / NLP distributions (non-tabular content)
|
||||||
"calidad", # data quality
|
"calidad", # data quality
|
||||||
|
"missingness", # missing-data patterns (co-occurrence of absences; MCAR/MAR)
|
||||||
"correlacion", # correlations / associations
|
"correlacion", # correlations / associations
|
||||||
|
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
|
||||||
"modelos", # cheap models (PCA/KMeans/outliers)
|
"modelos", # cheap models (PCA/KMeans/outliers)
|
||||||
"timeseries", # time-series analysis
|
"timeseries", # time-series analysis
|
||||||
"geospatial", # geospatial
|
"geospatial", # geospatial
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -139,10 +139,17 @@ class Group:
|
|||||||
it starts on a fresh page and flows (honest degradation, never cut). Use it to
|
it starts on a fresh page and flows (honest degradation, never cut). Use it to
|
||||||
bind ``Heading`` + ``Markdown`` + ``Figure`` of one idea together (see the
|
bind ``Heading`` + ``Markdown`` + ``Figure`` of one idea together (see the
|
||||||
DISTR NUM / AGREGACION chapters).
|
DISTR NUM / AGREGACION chapters).
|
||||||
|
|
||||||
|
When ``page_break_before`` is True the renderer additionally forces the group
|
||||||
|
to *start* on a fresh page/slide (unless the current one is already empty), so
|
||||||
|
a chapter can give each unit its own page — e.g. one categorical column per
|
||||||
|
page (see CAT DISTR). It is purely additive: the default False keeps the plain
|
||||||
|
keep-together behaviour for every existing chapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
blocks: list = field(default_factory=list)
|
blocks: list = field(default_factory=list)
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
page_break_before: bool = False
|
||||||
kind: str = field(default="group", init=False)
|
kind: str = field(default="group", init=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -228,7 +235,9 @@ def as_block(obj: Any):
|
|||||||
return Note(text=_safe_str(obj.get("text")))
|
return Note(text=_safe_str(obj.get("text")))
|
||||||
if cls is Group:
|
if cls is Group:
|
||||||
return Group(blocks=as_blocks(obj.get("blocks")),
|
return Group(blocks=as_blocks(obj.get("blocks")),
|
||||||
title=obj.get("title"))
|
title=obj.get("title"),
|
||||||
|
page_break_before=bool(
|
||||||
|
obj.get("page_break_before", False)))
|
||||||
if cls is GlossaryEntry:
|
if cls is GlossaryEntry:
|
||||||
return GlossaryEntry(key=_safe_str(obj.get("key")),
|
return GlossaryEntry(key=_safe_str(obj.get("key")),
|
||||||
label=_safe_str(obj.get("label")),
|
label=_safe_str(obj.get("label")),
|
||||||
|
|||||||
@@ -0,0 +1,748 @@
|
|||||||
|
"""AutomaticEDA Markdown serializer — one self-contained file to paste to an LLM.
|
||||||
|
|
||||||
|
Same document model as the PDF/PPTX renderers (an ordered list of
|
||||||
|
:class:`Chapter`, each a list of format-independent blocks) but emitted as plain
|
||||||
|
**Markdown** instead of a binary. The goal is different from the other two
|
||||||
|
renderers: a Markdown EDA is meant to be *pasted into an LLM*, so it prioritises
|
||||||
|
TEXT and DATA over visuals. Tables become Markdown tables (every row dumped, no
|
||||||
|
pagination — nothing is cut because there are no pages); a ``Figure`` becomes its
|
||||||
|
caption plus, when possible, the underlying bar/histogram data as a Markdown
|
||||||
|
table (an LLM cannot see the image); glossary term markers are stripped while
|
||||||
|
``**bold**`` is kept (it is valid Markdown).
|
||||||
|
|
||||||
|
dict-no-throw (the ``eda`` group style): :func:`render_md` never raises. On a
|
||||||
|
fatal error it returns ``{path: None, ...}`` with a ``note`` explaining why; a
|
||||||
|
malformed block degrades to a readable note rather than crashing the document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from . import model
|
||||||
|
|
||||||
|
# Glossary span markers (kept text, dropped markers). We intentionally do NOT use
|
||||||
|
# ``text_layout.strip_inline_md`` for Markdown blocks because that also removes
|
||||||
|
# ``**bold**`` — valid Markdown we want to preserve when pasting to an LLM.
|
||||||
|
_TERM_OPEN_RE = re.compile(r"\[\[term:[A-Za-z0-9_]+\]\]")
|
||||||
|
_MAX_BAR_ROWS = 100
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Small helpers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _clean_terms(s) -> str:
|
||||||
|
"""Drop glossary term markers, keeping the visible text (and any **bold**)."""
|
||||||
|
s = model._safe_str(s)
|
||||||
|
s = _TERM_OPEN_RE.sub("", s)
|
||||||
|
return s.replace("[[/term]]", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(v) -> str:
|
||||||
|
"""Render a value as a safe Markdown table cell.
|
||||||
|
|
||||||
|
Escapes pipes (``|`` -> ``\\|``) so they do not break the column layout and
|
||||||
|
folds newlines to ``<br>`` so a multi-line value stays inside one cell. None
|
||||||
|
becomes an empty string.
|
||||||
|
"""
|
||||||
|
s = model._safe_str(v)
|
||||||
|
s = s.replace("|", "\\|")
|
||||||
|
s = s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(text: str) -> str:
|
||||||
|
"""GitHub-style heading anchor: lowercase, spaces->'-', drop other symbols."""
|
||||||
|
s = model._safe_str(text).strip().lower()
|
||||||
|
out = []
|
||||||
|
for ch in s:
|
||||||
|
if ch.isalnum():
|
||||||
|
out.append(ch)
|
||||||
|
elif ch in " -":
|
||||||
|
out.append("-")
|
||||||
|
# any other symbol is dropped.
|
||||||
|
slug = "".join(out)
|
||||||
|
while "--" in slug:
|
||||||
|
slug = slug.replace("--", "-")
|
||||||
|
return slug.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_num(v) -> str:
|
||||||
|
"""Compact number for the figure data tables (ints as ints, else 4 sig figs)."""
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return model._safe_str(v)
|
||||||
|
if f != f: # NaN
|
||||||
|
return "NaN"
|
||||||
|
if f == int(f) and abs(f) < 1e15:
|
||||||
|
return str(int(f))
|
||||||
|
return f"{f:.4g}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_int(v) -> str:
|
||||||
|
try:
|
||||||
|
return str(int(v))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return model._safe_str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Document header (title + metadata blockquote + numbered index).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _meta_block(meta: dict) -> list:
|
||||||
|
"""Build the metadata lines for the header blockquote (omitting absentees)."""
|
||||||
|
ctx = meta.get("ctx") if isinstance(meta.get("ctx"), dict) else {}
|
||||||
|
lines: list = []
|
||||||
|
|
||||||
|
def add(label, value) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
s = model._safe_str(value).strip()
|
||||||
|
if s and s.lower() != "none":
|
||||||
|
lines.append(f"**{label}:** {s}")
|
||||||
|
|
||||||
|
add("Dataset", ctx.get("dataset_name") or meta.get("dataset_name"))
|
||||||
|
add("Fuente", ctx.get("source_origin") or meta.get("source_origin"))
|
||||||
|
add("Almacenamiento", ctx.get("storage") or meta.get("storage"))
|
||||||
|
n_rows = ctx.get("n_rows", meta.get("n_rows"))
|
||||||
|
n_cols = ctx.get("n_cols", meta.get("n_cols"))
|
||||||
|
if n_rows is not None and n_cols is not None:
|
||||||
|
lines.append(
|
||||||
|
f"**Dimensiones:** {_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas")
|
||||||
|
add("Generado", meta.get("generated_at") or _now_iso())
|
||||||
|
lines.append(f"**Motor:** {model.ENGINE_NAME} v{model.ENGINE_VERSION}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Per-block serializers. Each returns a Markdown string (no surrounding blanks;
|
||||||
|
# the caller separates blocks with a blank line).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _md_heading(block) -> str:
|
||||||
|
level = int(getattr(block, "level", 1) or 1)
|
||||||
|
hashes = "#" * min(level + 2, 6) # level1 -> ###; '#'/'##' reserved for doc/chapter.
|
||||||
|
text = _clean_terms(getattr(block, "text", "")).strip()
|
||||||
|
return f"{hashes} {text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _md_markdown(block) -> str:
|
||||||
|
# Keep the text verbatim, dropping only glossary markers (keep **bold**).
|
||||||
|
return _clean_terms(getattr(block, "text", "")).rstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _md_kv_table(block) -> str:
|
||||||
|
lines: list = []
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
lines.append(f"**{_clean_terms(title).strip()}**")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Campo | Valor |")
|
||||||
|
lines.append("| --- | --- |")
|
||||||
|
for row in (getattr(block, "rows", []) or []):
|
||||||
|
try:
|
||||||
|
label, value = row[0], row[1]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
label, value = row, ""
|
||||||
|
lines.append(f"| {_cell(label)} | {_cell(value)} |")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _md_data_table(block) -> str:
|
||||||
|
lines: list = []
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
lines.append(f"**{_clean_terms(title).strip()}**")
|
||||||
|
lines.append("")
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
if not header:
|
||||||
|
ncol = max((len(r) for r in rows), default=1)
|
||||||
|
header = [f"col{i + 1}" for i in range(ncol)]
|
||||||
|
ncol = len(header)
|
||||||
|
lines.append("| " + " | ".join(_cell(h) for h in header) + " |")
|
||||||
|
lines.append("| " + " | ".join(["---"] * ncol) + " |")
|
||||||
|
for r in rows: # dump every row — no pagination, nothing cut.
|
||||||
|
cells = [_cell(r[c]) if c < len(r) else "" for c in range(ncol)]
|
||||||
|
lines.append("| " + " | ".join(cells) + " |")
|
||||||
|
note = getattr(block, "note", None)
|
||||||
|
if note:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"*{_clean_terms(note).strip()}*")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
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)} |")
|
||||||
|
out = "\n".join(lines)
|
||||||
|
extra = len(bars) - len(shown)
|
||||||
|
if extra > 0:
|
||||||
|
out += f"\n\n*… ({extra} filas más)*"
|
||||||
|
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.
|
||||||
|
|
||||||
|
Histogram / bar-chart bars are ``matplotlib.patches.Rectangle`` with positive
|
||||||
|
width and height; spines, legends and zero-area artists are skipped. Never
|
||||||
|
raises — returns ``[]`` on any problem.
|
||||||
|
"""
|
||||||
|
bars: list = []
|
||||||
|
try:
|
||||||
|
for ax in fig.get_axes():
|
||||||
|
# Collect this axes' positive-area rectangles, then keep only the ones
|
||||||
|
# that look like actual histogram/bar bins. Reference shapes that
|
||||||
|
# matplotlib also stores in ``ax.patches`` — most notably the ``±1σ``
|
||||||
|
# band drawn by ``axvspan`` (a single rectangle far wider than a bin)
|
||||||
|
# and a lone Tukey boxplot box — would otherwise show up as fake
|
||||||
|
# "bins". A histogram axes has several near-equal-width bars, so we
|
||||||
|
# drop any rectangle whose width is more than twice the median width
|
||||||
|
# of that axes' rectangles (the σ-band spans many bins; uniform bins
|
||||||
|
# all sit at the median width and stay).
|
||||||
|
ax_bars: list = []
|
||||||
|
for patch in list(getattr(ax, "patches", []) or []):
|
||||||
|
try:
|
||||||
|
w = patch.get_width()
|
||||||
|
h = patch.get_height()
|
||||||
|
x = patch.get_x()
|
||||||
|
except Exception: # noqa: BLE001 — not a Rectangle-like patch.
|
||||||
|
continue
|
||||||
|
if w and w > 0 and h and h > 0:
|
||||||
|
ax_bars.append((x, x + w, h))
|
||||||
|
if len(ax_bars) >= 3:
|
||||||
|
widths = sorted(b[1] - b[0] for b in ax_bars)
|
||||||
|
median_w = widths[len(widths) // 2]
|
||||||
|
if median_w > 0:
|
||||||
|
ax_bars = [b for b in ax_bars
|
||||||
|
if (b[1] - b[0]) <= 2.0 * median_w]
|
||||||
|
bars.extend(ax_bars)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def _md_figure(block, meta: dict, out_path: str, counter: list) -> str:
|
||||||
|
"""Serialize a Figure prioritising TEXT + DATA (an LLM cannot see the image).
|
||||||
|
|
||||||
|
Emits the caption, then — if the matplotlib figure has bars — a Markdown table
|
||||||
|
of the underlying (Desde, Hasta, Frecuencia) values. Optionally (when
|
||||||
|
``meta['embed_figures']`` is True) also exports a PNG beside the .md and adds
|
||||||
|
an image link; off by default so the Markdown stays self-contained.
|
||||||
|
"""
|
||||||
|
caption = model._safe_str(getattr(block, "caption", "")).strip()
|
||||||
|
parts = [f"*Figura: {caption}*" if caption else "*Figura*"]
|
||||||
|
fig = None
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg") # defensive: headless rasterization backend.
|
||||||
|
fig = getattr(block, "fig", None)
|
||||||
|
make = getattr(block, "make", None)
|
||||||
|
if fig is None and callable(make):
|
||||||
|
fig = make()
|
||||||
|
if fig is not None:
|
||||||
|
bars = _extract_bars(fig)
|
||||||
|
if 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:
|
||||||
|
parts.append(f"")
|
||||||
|
except Exception: # noqa: BLE001 — a bad figure degrades to just its caption.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if fig is not None:
|
||||||
|
try:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
plt.close(fig)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_png(fig, out_path: str, counter: list) -> str:
|
||||||
|
"""Export the figure to ``<basename>_figN.png`` beside the .md; return its name."""
|
||||||
|
try:
|
||||||
|
counter[0] += 1
|
||||||
|
base = os.path.splitext(os.path.basename(out_path))[0] or "figura"
|
||||||
|
name = f"{base}_fig{counter[0]}.png"
|
||||||
|
path = os.path.join(os.path.dirname(os.path.abspath(out_path)), name)
|
||||||
|
fig.savefig(path, format="png", dpi=120, bbox_inches="tight")
|
||||||
|
return name
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _md_image(block) -> str:
|
||||||
|
path = model._safe_str(getattr(block, "path", ""))
|
||||||
|
caption = model._safe_str(getattr(block, "caption", "")).strip()
|
||||||
|
out = f""
|
||||||
|
if caption:
|
||||||
|
out += f"\n\n*{caption}*"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _md_caption(block) -> str:
|
||||||
|
return f"*{_clean_terms(getattr(block, 'text', '')).strip()}*"
|
||||||
|
|
||||||
|
|
||||||
|
def _md_note(block) -> str:
|
||||||
|
text = _clean_terms(getattr(block, "text", "")).strip()
|
||||||
|
lines = text.split("\n")
|
||||||
|
return "\n".join((f"> {ln}" if ln.strip() else ">") for ln in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _md_group(block, meta: dict, out_path: str, counter: list) -> str:
|
||||||
|
parts: list = []
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
parts.append(f"### {_clean_terms(title).strip()}")
|
||||||
|
for b in (getattr(block, "blocks", []) or []):
|
||||||
|
try:
|
||||||
|
seg = _serialize_block(b, meta, out_path, counter)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
seg = ""
|
||||||
|
if seg:
|
||||||
|
parts.append(seg)
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _md_glossary_entry(block) -> str:
|
||||||
|
label = (model._safe_str(getattr(block, "label", "")).strip()
|
||||||
|
or model._safe_str(getattr(block, "key", "")).strip())
|
||||||
|
definition = _clean_terms(getattr(block, "definition", "")).strip()
|
||||||
|
out = f"### {label}"
|
||||||
|
if definition:
|
||||||
|
out += f"\n\n{definition}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_block(block, meta: dict, out_path: str, counter: list) -> str:
|
||||||
|
"""Dispatch a single block to its Markdown serializer. Unknown -> note."""
|
||||||
|
kind = getattr(block, "kind", "")
|
||||||
|
if kind == "heading":
|
||||||
|
return _md_heading(block)
|
||||||
|
if kind == "markdown":
|
||||||
|
return _md_markdown(block)
|
||||||
|
if kind == "kv_table":
|
||||||
|
return _md_kv_table(block)
|
||||||
|
if kind == "data_table":
|
||||||
|
return _md_data_table(block)
|
||||||
|
if kind == "figure":
|
||||||
|
return _md_figure(block, meta, out_path, counter)
|
||||||
|
if kind == "image":
|
||||||
|
return _md_image(block)
|
||||||
|
if kind == "caption":
|
||||||
|
return _md_caption(block)
|
||||||
|
if kind == "note":
|
||||||
|
return _md_note(block)
|
||||||
|
if kind == "group":
|
||||||
|
return _md_group(block, meta, out_path, counter)
|
||||||
|
if kind == "glossary_entry":
|
||||||
|
return _md_glossary_entry(block)
|
||||||
|
# Unknown content -> readable note (mirrors the model's defensive coercion).
|
||||||
|
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 = "sí" 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 = "sí" 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.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def render_md(chapters: list, out_path: str, meta: dict = None) -> dict:
|
||||||
|
"""Serialize a list of Chapters into a single self-contained Markdown file.
|
||||||
|
|
||||||
|
The output leads with ``# <title>``, a metadata blockquote and a numbered
|
||||||
|
``## Índice`` linking each chapter, then one ``## N. <title>`` section per
|
||||||
|
chapter with its blocks. Tables become Markdown tables (every row dumped),
|
||||||
|
figures become caption + underlying data table, glossary markers are stripped
|
||||||
|
while ``**bold**`` is kept. Designed to be pasted into an LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chapters: a list of ``Chapter`` (dataclasses or dicts); normalized
|
||||||
|
defensively with ``model.as_chapters``.
|
||||||
|
out_path: filesystem path for the ``.md`` (parent dirs are created).
|
||||||
|
meta: optional dict. Recognised keys: ``title``, ``ctx`` (dict with
|
||||||
|
``dataset_name``/``source_origin``/``storage``/``n_rows``/``n_cols``),
|
||||||
|
``generated_at``, ``embed_figures`` (export PNGs beside the .md,
|
||||||
|
default False).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict (never raises): ``{path: str|None, n_chars: int,
|
||||||
|
chapters: list[{id, version}], note: str}``. On a fatal error ``path`` is
|
||||||
|
None and ``note`` explains why.
|
||||||
|
"""
|
||||||
|
meta = meta or {}
|
||||||
|
chapters = model.as_chapters(chapters)
|
||||||
|
title = model._safe_str(meta.get("title")) or model.ENGINE_NAME
|
||||||
|
|
||||||
|
# Edge: nothing to render -> a minimal but valid Markdown document.
|
||||||
|
if not chapters:
|
||||||
|
content = (f"# {title}\n\n"
|
||||||
|
"*(documento vacío — sin capítulos aplicables)*\n")
|
||||||
|
return _write(out_path, content, [], "documento vacío")
|
||||||
|
|
||||||
|
counter = [0] # document-wide figure counter for unique PNG names.
|
||||||
|
notes: list = []
|
||||||
|
segments: list = [f"# {title}"]
|
||||||
|
|
||||||
|
meta_lines = _meta_block(meta)
|
||||||
|
if meta_lines:
|
||||||
|
segments.append("\n".join(f"> {ln}" for ln in meta_lines))
|
||||||
|
|
||||||
|
# Numbered index. The anchor matches the chapter heading emitted below
|
||||||
|
# (``## N. <title>``) in GitHub slug style.
|
||||||
|
chap_heads = []
|
||||||
|
idx_lines = ["## Índice"]
|
||||||
|
for i, ch in enumerate(chapters, 1):
|
||||||
|
head_text = f"{i}. {model._safe_str(ch.title)}"
|
||||||
|
anchor = _slug(head_text)
|
||||||
|
chap_heads.append((head_text, anchor))
|
||||||
|
idx_lines.append(f"{i}. [{model._safe_str(ch.title)}](#{anchor})")
|
||||||
|
segments.append("\n".join(idx_lines))
|
||||||
|
|
||||||
|
chapters_meta = []
|
||||||
|
for i, ch in enumerate(chapters, 1):
|
||||||
|
segments.append("---")
|
||||||
|
head_text, _anchor = chap_heads[i - 1]
|
||||||
|
segments.append(f"## {head_text}")
|
||||||
|
|
||||||
|
blocks = list(ch.blocks or [])
|
||||||
|
# Omit a leading level-1 Heading that just repeats the chapter title.
|
||||||
|
if blocks:
|
||||||
|
b0 = blocks[0]
|
||||||
|
if (getattr(b0, "kind", "") == "heading"
|
||||||
|
and int(getattr(b0, "level", 1) or 1) == 1
|
||||||
|
and _clean_terms(getattr(b0, "text", "")).strip()
|
||||||
|
== model._safe_str(ch.title).strip()):
|
||||||
|
blocks = blocks[1:]
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
try:
|
||||||
|
seg = _serialize_block(block, meta, out_path, counter)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
seg = _md_note(model.Note(text=model._safe_str(block)))
|
||||||
|
notes.append(
|
||||||
|
f"bloque '{getattr(block, 'kind', '?')}' del capítulo "
|
||||||
|
f"'{ch.id}' degradado: {e}")
|
||||||
|
if seg:
|
||||||
|
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:
|
||||||
|
note += " · " + "; ".join(notes)
|
||||||
|
return _write(out_path, content, chapters_meta, note)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(out_path: str, content: str, chapters_meta: list, note: str) -> dict:
|
||||||
|
"""Write the Markdown to disk (creating parents). dict-no-throw."""
|
||||||
|
try:
|
||||||
|
parent = os.path.dirname(os.path.abspath(out_path))
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
with open(out_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
except Exception as e: # noqa: BLE001 — never raise from the writer.
|
||||||
|
return {"path": None, "n_chars": 0, "chapters": [],
|
||||||
|
"note": f"no se pudo escribir el Markdown: {e}"}
|
||||||
|
return {"path": out_path, "n_chars": len(content),
|
||||||
|
"chapters": chapters_meta, "note": note}
|
||||||
@@ -675,6 +675,61 @@ def _measure_figure_like(block) -> float:
|
|||||||
return target_h + 0.04 + cap_h + _GAP
|
return target_h + 0.04 + cap_h + _GAP
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_kv_table(block) -> float:
|
||||||
|
"""Faithful height of a KVTable — matches ``_place_kv_table``.
|
||||||
|
|
||||||
|
Counts the optional title heading and, per row, the wrapped VALUE column
|
||||||
|
(the label column never wraps in the placer). The previous estimate assumed
|
||||||
|
one line per row and ignored the title, so a column's keep-together Group
|
||||||
|
under-budgeted the figure and the chart spilled to the next page. Keep this in
|
||||||
|
sync with ``_place_kv_table``."""
|
||||||
|
h = 0.0
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
h += _measure_heading_text(title, 2)
|
||||||
|
rows = getattr(block, "rows", []) or []
|
||||||
|
key_w = 1.9
|
||||||
|
val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY)
|
||||||
|
lh = tl.line_height_in(_FS_BODY)
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
value = row[1]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
value = ""
|
||||||
|
v_lines = tl.wrap(model._safe_str(value), val_chars)
|
||||||
|
h += lh * len(v_lines) + _ROW_VPAD
|
||||||
|
return h + _GAP
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_data_table(block) -> float:
|
||||||
|
"""Faithful height of a DataTable — matches ``_place_data_table``.
|
||||||
|
|
||||||
|
Counts the optional title heading, the wrapped header row, every wrapped data
|
||||||
|
row (per-column wrap via the same ``_col_widths``/``_wrap_row`` the placer
|
||||||
|
uses) and the optional note. Keep this in sync with ``_place_data_table``."""
|
||||||
|
h = 0.0
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
h += _measure_heading_text(title, 2)
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
fs = _FS_CELL
|
||||||
|
widths = _col_widths(header, rows, fs)
|
||||||
|
lh = tl.line_height_in(fs)
|
||||||
|
if header:
|
||||||
|
header_lines = _wrap_row(header, widths, fs)
|
||||||
|
h += lh * max((len(c) for c in header_lines), default=1) + _ROW_VPAD * 2
|
||||||
|
for r in rows:
|
||||||
|
cells_lines = _wrap_row(r, widths, fs)
|
||||||
|
h += lh * max((len(c) for c in cells_lines), default=1) + _ROW_VPAD * 2
|
||||||
|
note = getattr(block, "note", None)
|
||||||
|
if note:
|
||||||
|
nlines = tl.wrap(model._safe_str(note),
|
||||||
|
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||||
|
h += tl.line_height_in(_FS_NOTE) * len(nlines)
|
||||||
|
return h + _GAP
|
||||||
|
|
||||||
|
|
||||||
def _measure_block(st: _PdfState, block) -> float:
|
def _measure_block(st: _PdfState, block) -> float:
|
||||||
kind = getattr(block, "kind", "")
|
kind = getattr(block, "kind", "")
|
||||||
try:
|
try:
|
||||||
@@ -690,13 +745,9 @@ def _measure_block(st: _PdfState, block) -> float:
|
|||||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||||
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
|
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
|
||||||
if kind == "kv_table":
|
if kind == "kv_table":
|
||||||
rows = getattr(block, "rows", []) or []
|
return _measure_kv_table(block)
|
||||||
return (tl.line_height_in(_FS_BODY) + _ROW_VPAD) * (len(rows) + 1) \
|
|
||||||
+ _GAP
|
|
||||||
if kind == "data_table":
|
if kind == "data_table":
|
||||||
rows = getattr(block, "rows", []) or []
|
return _measure_data_table(block)
|
||||||
return (tl.line_height_in(_FS_CELL) + _ROW_VPAD * 2) \
|
|
||||||
* (len(rows) + 1) + _GAP
|
|
||||||
if kind == "group":
|
if kind == "group":
|
||||||
return sum(_measure_block(st, b)
|
return sum(_measure_block(st, b)
|
||||||
for b in (getattr(block, "blocks", []) or []))
|
for b in (getattr(block, "blocks", []) or []))
|
||||||
@@ -735,6 +786,10 @@ def _place_group(st: _PdfState, block) -> None:
|
|||||||
blocks = getattr(block, "blocks", []) or []
|
blocks = getattr(block, "blocks", []) or []
|
||||||
if not blocks:
|
if not blocks:
|
||||||
return
|
return
|
||||||
|
# Opt-in page break: start this group on a fresh page unless the current one
|
||||||
|
# is still empty (so a chapter can give each unit its own page).
|
||||||
|
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
||||||
|
_new_page(st)
|
||||||
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||||
_shrink_group_figures(st, blocks, avail_full)
|
_shrink_group_figures(st, blocks, avail_full)
|
||||||
total = sum(_measure_block(st, b) for b in blocks)
|
total = sum(_measure_block(st, b) for b in blocks)
|
||||||
|
|||||||
@@ -625,6 +625,55 @@ def _measure_figure_like(block) -> float:
|
|||||||
return target_h + 0.05 + cap_h + _GAP
|
return target_h + 0.05 + cap_h + _GAP
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_kv_table(block) -> float:
|
||||||
|
"""Faithful KVTable height — matches ``_place_kv_table`` (rendered as a
|
||||||
|
Campo/Valor data table with wrapped cells). The previous estimate assumed one
|
||||||
|
line per row and ignored the title, so a keep-together Group under-budgeted
|
||||||
|
the figure and the chart spilled to the next slide. Keep in sync."""
|
||||||
|
h = 0.0
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
h += _measure_heading_text(title, 2)
|
||||||
|
rows = getattr(block, "rows", []) or []
|
||||||
|
data_rows = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
label, value = row[0], row[1]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
label, value = str(row), ""
|
||||||
|
data_rows.append([model._safe_str(label), model._safe_str(value)])
|
||||||
|
header = ["Campo", "Valor"]
|
||||||
|
widths = _col_widths(header, data_rows)
|
||||||
|
fs = _FS_CELL
|
||||||
|
h += _row_height_in(header, widths, fs)
|
||||||
|
for r in data_rows:
|
||||||
|
h += _row_height_in(r, widths, fs)
|
||||||
|
return h + _GAP
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_data_table(block) -> float:
|
||||||
|
"""Faithful DataTable height — matches ``_place_data_table`` (title heading +
|
||||||
|
wrapped header + every wrapped row + optional note). Keep in sync."""
|
||||||
|
h = 0.0
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
if title:
|
||||||
|
h += _measure_heading_text(title, 2)
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
fs = _FS_CELL
|
||||||
|
widths = _col_widths(header, rows)
|
||||||
|
if header:
|
||||||
|
h += _row_height_in(header, widths, fs)
|
||||||
|
for r in rows:
|
||||||
|
h += _row_height_in(r, widths, fs)
|
||||||
|
note = getattr(block, "note", None)
|
||||||
|
if note:
|
||||||
|
nlines = tl.wrap(model._safe_str(note),
|
||||||
|
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||||
|
h += tl.line_height_in(_FS_NOTE) * len(nlines) + 0.05
|
||||||
|
return h + _GAP
|
||||||
|
|
||||||
|
|
||||||
def _measure_block(st: _PptxState, block) -> float:
|
def _measure_block(st: _PptxState, block) -> float:
|
||||||
kind = getattr(block, "kind", "")
|
kind = getattr(block, "kind", "")
|
||||||
try:
|
try:
|
||||||
@@ -639,9 +688,10 @@ def _measure_block(st: _PptxState, block) -> float:
|
|||||||
lines = tl.wrap(getattr(block, "text", ""),
|
lines = tl.wrap(getattr(block, "text", ""),
|
||||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||||
return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
|
return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
|
||||||
if kind in ("kv_table", "data_table"):
|
if kind == "kv_table":
|
||||||
rows = getattr(block, "rows", []) or []
|
return _measure_kv_table(block)
|
||||||
return (tl.line_height_in(_FS_CELL) + 0.10) * (len(rows) + 1) + _GAP
|
if kind == "data_table":
|
||||||
|
return _measure_data_table(block)
|
||||||
if kind == "group":
|
if kind == "group":
|
||||||
return sum(_measure_block(st, b)
|
return sum(_measure_block(st, b)
|
||||||
for b in (getattr(block, "blocks", []) or []))
|
for b in (getattr(block, "blocks", []) or []))
|
||||||
@@ -664,10 +714,14 @@ def _shrink_group_figures(st: _PptxState, blocks: list, avail_full: float) -> No
|
|||||||
if getattr(b, "kind", "") not in ("figure", "image"))
|
if getattr(b, "kind", "") not in ("figure", "image"))
|
||||||
fig_overhead = tl.line_height_in(_FS_NOTE) + 0.05 + 0.05 + _GAP
|
fig_overhead = tl.line_height_in(_FS_NOTE) + 0.05 + 0.05 + _GAP
|
||||||
budget = avail_full - nonfig_h - 0.10 * len(fig_blocks)
|
budget = avail_full - nonfig_h - 0.10 * len(fig_blocks)
|
||||||
if budget <= 1.0:
|
# Low thresholds: a 16:9 slide is short, so a content-heavy column (cardinality
|
||||||
|
# table + top-k + chart) only fits if the chart is allowed to shrink small.
|
||||||
|
# Prefer a small-but-present chart on the SAME slide over splitting the column
|
||||||
|
# across slides (matches the PDF renderer's keep-together philosophy).
|
||||||
|
if budget <= 0.6:
|
||||||
return # not enough room to keep together; let it flow (degrade).
|
return # not enough room to keep together; let it flow (degrade).
|
||||||
per = budget / len(fig_blocks) - fig_overhead
|
per = budget / len(fig_blocks) - fig_overhead
|
||||||
if per <= 0.8:
|
if per <= 0.35:
|
||||||
return
|
return
|
||||||
for fb in fig_blocks:
|
for fb in fig_blocks:
|
||||||
cur = getattr(fb, "height_in", None)
|
cur = getattr(fb, "height_in", None)
|
||||||
@@ -675,12 +729,90 @@ def _shrink_group_figures(st: _PptxState, blocks: list, avail_full: float) -> No
|
|||||||
if isinstance(cur, (int, float)) and cur > 0 else per)
|
if isinstance(cur, (int, float)) and cur > 0 else per)
|
||||||
|
|
||||||
|
|
||||||
|
# Minimum height (inches) reserved for a figure inside a keep-together group on
|
||||||
|
# the short 16:9 slide. When a high-cardinality column's table(s) would otherwise
|
||||||
|
# leave no room, the data table is trimmed (with an honest note) so the chart
|
||||||
|
# stays on the SAME slide next to its table instead of spilling to the next one.
|
||||||
|
_GROUP_MIN_FIG_H = 1.3
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_data_table_to_budget(block, budget: float):
|
||||||
|
"""Return a copy of a DataTable whose rows fit within ``budget`` inches.
|
||||||
|
|
||||||
|
Keeps the title, header, as many leading rows as fit (at least one) and an
|
||||||
|
honest note reporting how many of the original rows are shown. NEVER mutates
|
||||||
|
the original block — the same Chapter blocks are rendered by the PDF renderer,
|
||||||
|
which keeps the full table (an A5 page fits it)."""
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
fs = _FS_CELL
|
||||||
|
widths = _col_widths(header, rows)
|
||||||
|
fixed = 0.0
|
||||||
|
if title:
|
||||||
|
fixed += _measure_heading_text(title, 2)
|
||||||
|
if header:
|
||||||
|
fixed += _row_height_in(header, widths, fs)
|
||||||
|
note_h = tl.line_height_in(_FS_NOTE) + 0.05
|
||||||
|
avail_rows = budget - fixed - note_h - _GAP
|
||||||
|
kept = []
|
||||||
|
used = 0.0
|
||||||
|
for r in rows:
|
||||||
|
rh = _row_height_in(r, widths, fs)
|
||||||
|
if used + rh > avail_rows and kept:
|
||||||
|
break
|
||||||
|
kept.append(r)
|
||||||
|
used += rh
|
||||||
|
if len(kept) >= len(rows):
|
||||||
|
return block # already fits; keep the original (with its own note).
|
||||||
|
note = (f"top {len(kept)} de {len(rows)} categorías mostradas "
|
||||||
|
"(recortado para caber en el slide; el PDF muestra más)")
|
||||||
|
return model.DataTable(header=header, rows=kept, title=title, note=note)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_group_blocks(st: _PptxState, blocks: list, avail_full: float) -> list:
|
||||||
|
"""Return a slide-fitting copy of a keep-together group's blocks.
|
||||||
|
|
||||||
|
On the short 16:9 slide a high-cardinality column's top-k table plus its
|
||||||
|
chart can overflow. Reserve ``_GROUP_MIN_FIG_H`` for the (later shrunk) figure
|
||||||
|
and trim the data table(s) to what is left, so every column keeps its chart
|
||||||
|
next to its table on ONE slide. No-op when the group has no figure+table pair
|
||||||
|
(e.g. id-like columns already drop the top-k upstream, or it already fits)."""
|
||||||
|
has_fig = any(getattr(b, "kind", "") in ("figure", "image") for b in blocks)
|
||||||
|
tbls = [b for b in blocks if getattr(b, "kind", "") == "data_table"]
|
||||||
|
if not (has_fig and tbls):
|
||||||
|
return blocks
|
||||||
|
fixed_h = sum(_measure_block(st, b) for b in blocks
|
||||||
|
if getattr(b, "kind", "") not in ("figure", "image",
|
||||||
|
"data_table"))
|
||||||
|
tables_h = sum(_measure_block(st, b) for b in tbls)
|
||||||
|
budget_tables = avail_full - fixed_h - _GROUP_MIN_FIG_H
|
||||||
|
if tables_h <= budget_tables:
|
||||||
|
return blocks # already fits next to a min-height figure; leave intact.
|
||||||
|
out = []
|
||||||
|
for b in blocks:
|
||||||
|
if getattr(b, "kind", "") != "data_table":
|
||||||
|
out.append(b)
|
||||||
|
continue
|
||||||
|
trimmed = _trim_data_table_to_budget(b, max(budget_tables, 0.8))
|
||||||
|
out.append(trimmed)
|
||||||
|
budget_tables -= _measure_data_table(trimmed)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _place_group(st: _PptxState, block) -> None:
|
def _place_group(st: _PptxState, block) -> None:
|
||||||
"""Render a keep-together Group: move it whole to the next slide if needed."""
|
"""Render a keep-together Group: move it whole to the next slide if needed."""
|
||||||
blocks = getattr(block, "blocks", []) or []
|
blocks = getattr(block, "blocks", []) or []
|
||||||
if not blocks:
|
if not blocks:
|
||||||
return
|
return
|
||||||
|
# Opt-in slide break: start this group on a fresh slide unless the current one
|
||||||
|
# is still empty (so a chapter can give each unit its own slide).
|
||||||
|
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
||||||
|
_new_slide(st, cont=True)
|
||||||
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||||
|
# Trim oversized tables first (keeps the chart on the same slide), then shrink
|
||||||
|
# the figure to share the remaining room.
|
||||||
|
blocks = _fit_group_blocks(st, blocks, avail_full)
|
||||||
_shrink_group_figures(st, blocks, avail_full)
|
_shrink_group_figures(st, blocks, avail_full)
|
||||||
total = sum(_measure_block(st, b) for b in blocks)
|
total = sum(_measure_block(st, b) for b in blocks)
|
||||||
if total <= avail_full:
|
if total <= avail_full:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,10 +4,10 @@ name: column_quality_score
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "2.0.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def column_quality_score(col: dict) -> dict"
|
signature: "def column_quality_score(col: dict) -> dict"
|
||||||
description: "Calcula un score de calidad de datos 0-100 para un ColumnProfile del grupo eda, con desglose completeness/validity/consistency y lista de issues legibles. Funcion pura, no muta el input."
|
description: "Calcula un score de calidad de datos 0-100 para un ColumnProfile del grupo eda. Combina completeness (0.6) y validity (0.4) con renormalizacion por aplicabilidad; los outliers, columnas constantes e ids NO bajan el score (van a observations). Devuelve desglose por dimension, issues (defectos) y observations (señales analiticas). Funcion pura, no muta el input."
|
||||||
tags: [eda, data-quality, profiling, scoring, datascience]
|
tags: [eda, data-quality, profiling, scoring, datascience]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -17,20 +17,26 @@ error_type: ""
|
|||||||
imports: []
|
imports: []
|
||||||
example: |
|
example: |
|
||||||
from datascience import column_quality_score
|
from datascience import column_quality_score
|
||||||
col = {"name": "precio", "inferred_type": "float", "null_pct": 0.2,
|
col = {"name": "precio", "inferred_type": "numeric", "null_pct": 0.2,
|
||||||
"unique_pct": 0.4, "flags": [], "numeric": {"outlier_pct": 0.08}}
|
"unique_pct": 0.4, "flags": [], "numeric": {"outlier_pct": 8.0}}
|
||||||
column_quality_score(col)
|
column_quality_score(col)
|
||||||
# {"score": 86.8, "completeness": 0.8, "validity": 0.92,
|
# {"score": 88.0, "completeness": 0.8, "validity": 1.0,
|
||||||
# "consistency": 1.0, "issues": ["20% nulos", "8% outliers"]}
|
# "applicable": ["completeness", "validity"], "issues": ["20% nulos"],
|
||||||
|
# "observations": ["8% de valores atípicos (z-score>3): ..."]}
|
||||||
tested: true
|
tested: true
|
||||||
tests:
|
tests:
|
||||||
- "test_clean_column_high_score"
|
- "test_clean_column_high_score"
|
||||||
- "test_half_null_lowers_completeness_and_score"
|
- "test_weights_60_40_native_type"
|
||||||
- "test_constant_column_flags_issue"
|
- "test_outliers_do_not_penalize_score"
|
||||||
|
- "test_nulls_lower_score_more_than_outliers"
|
||||||
|
- "test_validity_from_parse_rate_lowers_score"
|
||||||
|
- "test_validity_from_match_rate"
|
||||||
|
- "test_free_text_renormalizes_to_completeness_only"
|
||||||
|
- "test_all_null_column_scores_zero"
|
||||||
|
- "test_constant_column_scores_full_and_is_observation"
|
||||||
|
- "test_high_cardinality_id_scores_full_and_is_observation"
|
||||||
|
- "test_mostly_null_no_double_counts_validity"
|
||||||
- "test_empty_dict_does_not_crash"
|
- "test_empty_dict_does_not_crash"
|
||||||
- "test_outliers_penalize_validity"
|
|
||||||
- "test_mostly_null_flag_halves_validity"
|
|
||||||
- "test_high_cardinality_text_flagged_as_id"
|
|
||||||
- "test_none_values_treated_defensively"
|
- "test_none_values_treated_defensively"
|
||||||
- "test_does_not_mutate_input"
|
- "test_does_not_mutate_input"
|
||||||
test_file_path: "python/functions/datascience/column_quality_score_test.py"
|
test_file_path: "python/functions/datascience/column_quality_score_test.py"
|
||||||
@@ -38,16 +44,22 @@ file_path: "python/functions/datascience/column_quality_score.py"
|
|||||||
params:
|
params:
|
||||||
- name: col
|
- name: col
|
||||||
desc: >
|
desc: >
|
||||||
ColumnProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb).
|
ColumnProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb /
|
||||||
Se leen sus claves de forma defensiva con .get(...) y se toleran valores
|
profile_table). Se leen sus claves de forma defensiva con .get(...) y se
|
||||||
None. Claves usadas: null_pct (0-1), inferred_type, semantic_type,
|
toleran valores None. Claves usadas: null_pct (0-1), n_rows, empty_count
|
||||||
unique_pct (0-1), flags (list[str], reconoce "constant"/"mostly_null"),
|
(texto), inferred_type, semantic_type, validity_rate (0-1, lo expone
|
||||||
numeric ({outlier_pct: 0-1, ...}|None) y match_rate (opcional, 0-1).
|
profile_table al promocionar texto a numero/fecha), match_rate (0-1),
|
||||||
|
unique_pct (0-1), flags (list[str], reconoce
|
||||||
|
"constant"/"possible_id"/"high_cardinality") y numeric ({outlier_pct: 0-100,
|
||||||
|
skew, ...}|None).
|
||||||
output: >
|
output: >
|
||||||
dict con score (float 0-100, redondeado a 1 decimal), completeness (0-1),
|
dict con score (float 0-100, 1 decimal), completeness (0-1), validity (0-1 o
|
||||||
validity (0-1), consistency (0-1) e issues (list[str] de descripciones
|
None si no aplicable), dimensions ({completeness, validity}), applicable
|
||||||
legibles de los problemas detectados). score = round(100 * (0.5*completeness
|
(list[str] de dimensiones que entraron en el score), issues (list[str] SOLO de
|
||||||
+ 0.3*validity + 0.2*consistency), 1).
|
defectos de calidad: nulos, vacios, valores no conformes) y observations
|
||||||
|
(list[str] de señales analiticas que NO bajan el score: outliers, columna
|
||||||
|
constante, posible id, asimetria). score = round(100 * (0.6*completeness +
|
||||||
|
0.4*validity) / pesos_aplicables, 1), renormalizado cuando validity no aplica.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ejemplo
|
## Ejemplo
|
||||||
@@ -59,51 +71,71 @@ from datascience import column_quality_score
|
|||||||
col = {
|
col = {
|
||||||
"name": "precio",
|
"name": "precio",
|
||||||
"physical_type": "DOUBLE",
|
"physical_type": "DOUBLE",
|
||||||
"inferred_type": "float",
|
"inferred_type": "numeric",
|
||||||
"semantic_type": "",
|
"semantic_type": "",
|
||||||
"count": 800,
|
|
||||||
"n_rows": 1000,
|
"n_rows": 1000,
|
||||||
"null_count": 200,
|
"null_count": 200,
|
||||||
"null_pct": 0.20,
|
"null_pct": 0.20,
|
||||||
"distinct_count": 400,
|
"distinct_count": 400,
|
||||||
"unique_pct": 0.40,
|
"unique_pct": 0.40,
|
||||||
"flags": [],
|
"flags": [],
|
||||||
"numeric": {"outlier_pct": 0.08},
|
"numeric": {"outlier_pct": 8.0, "skew": 0.3},
|
||||||
"categorical": None,
|
"categorical": None,
|
||||||
"datetime": None,
|
"datetime": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
column_quality_score(col)
|
column_quality_score(col)
|
||||||
# {
|
# {
|
||||||
# "score": 86.8,
|
# "score": 88.0, # 100 * (0.6*0.8 + 0.4*1.0)
|
||||||
# "completeness": 0.8, # 1 - 0.20
|
# "completeness": 0.8, # 1 - 0.20
|
||||||
# "validity": 0.92, # 1 - min(0.08, 0.3)
|
# "validity": 1.0, # numerica nativa: el tipo es conforme
|
||||||
# "consistency": 1.0,
|
# "dimensions": {"completeness": 0.8, "validity": 1.0},
|
||||||
# "issues": ["20% nulos", "8% outliers"],
|
# "applicable": ["completeness", "validity"],
|
||||||
|
# "issues": ["20% nulos"], # SOLO defectos de calidad
|
||||||
|
# "observations": ["8% de valores atípicos (z-score>3): ..."], # NO bajan score
|
||||||
# }
|
# }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Cuando hayas perfilado una tabla con el grupo `eda` (p.ej.
|
Cuando hayas perfilado una tabla con el grupo `eda` (p.ej.
|
||||||
`summarize_table_duckdb`) y necesites un numero 0-100 por columna para
|
`summarize_table_duckdb` / `profile_table`) y necesites un numero 0-100 por
|
||||||
ordenar/priorizar limpieza de datos, pintar semaforos de calidad en un
|
columna para ordenar/priorizar limpieza de datos, pintar semaforos de calidad,
|
||||||
dashboard, o decidir que columnas descartar antes de modelar. Es la capa de
|
o decidir que columnas descartar antes de modelar. Separa los **defectos de
|
||||||
scoring sobre el ColumnProfile crudo: lee el perfil, no toca los datos.
|
calidad reales** (`issues`: nulos, vacios, valores que no parsean a su tipo) de
|
||||||
|
las **observaciones analiticas** (`observations`: outliers, columnas constantes,
|
||||||
|
ids), que se reportan pero no penalizan. Es la capa de scoring sobre el
|
||||||
|
ColumnProfile crudo: lee el perfil, no toca los datos.
|
||||||
|
|
||||||
## Notas
|
## Gotchas
|
||||||
|
|
||||||
Funcion pura, sin I/O ni dependencias externas, no muta `col`. Lee todas las
|
Funcion pura, sin I/O, no muta `col`. Aun asi conviene saber:
|
||||||
claves con `.get(...)` y tolera que vengan en `None` (un ColumnProfile recien
|
|
||||||
salido de `summarize_table_duckdb` trae muchas claves a `None`), por lo que
|
|
||||||
nunca falla por claves ausentes — un `{}` produce un resultado bien definido.
|
|
||||||
|
|
||||||
Pesos del score: completeness 0.5, validity 0.3, consistency 0.2.
|
- **Los outliers NO bajan el score.** Un valor extremo puede ser real y correcto
|
||||||
|
(un cliente que compra mucho); detectar atipicos es analisis de la
|
||||||
|
distribucion, no un juicio de correccion. Salen en `observations`, no en
|
||||||
|
`issues`. Mismo trato para columnas constantes e identificadores de alta
|
||||||
|
cardinalidad: son observaciones, no defectos.
|
||||||
|
- **`validity` puede ser `None`** (no aplicable): texto libre sin `semantic_type`
|
||||||
|
ni `validity_rate`, o columna 100% nula. En ese caso el score se renormaliza a
|
||||||
|
solo `completeness` (la columna no se premia ni castiga por algo no medible).
|
||||||
|
- **`outlier_pct` se interpreta en escala 0-100** (la que emite
|
||||||
|
`describe_numeric`, z-score>3). Pasar una fraccion 0-1 produce un texto de
|
||||||
|
observacion con el % equivocado, pero NUNCA afecta al score.
|
||||||
|
- **`validity_rate` lo puebla `profile_table`** al promocionar una columna de
|
||||||
|
texto a numero/fecha (fraccion que parsea). Si no esta presente y el tipo es
|
||||||
|
nativo numerico/fecha/bool, `validity = 1.0`.
|
||||||
|
- Sin doble conteo: la falta de datos cuenta solo en `completeness` (el antiguo
|
||||||
|
castigo de `mostly_null` sobre `validity` se elimino).
|
||||||
|
|
||||||
- **completeness** = `1 - null_pct` (None -> 0 nulls -> 1.0).
|
## Capability growth log
|
||||||
- **validity**: parte de 1.0 y penaliza `min(outlier_pct, 0.3)` en columnas
|
|
||||||
numericas, `0.5 * (1 - match_rate)` si hay `semantic_type` declarado con
|
- v2.0.0 (2026-06-30) — nueva formula de calidad (report 2046): pesos 60/40
|
||||||
`match_rate` bajo disponible, y multiplica por 0.5 si el flag `mostly_null`
|
(completeness/validity) con renormalizacion por aplicabilidad; se elimina la
|
||||||
esta presente.
|
dimension `consistency`-como-informatividad y el doble castigo de
|
||||||
- **consistency**: 1.0 salvo flag `constant` (-> 0.3, columna poco informativa)
|
`mostly_null`; los outliers/constantes/ids salen del score a `observations`;
|
||||||
o texto con `unique_pct > 0.9` (-> 0.6, posible id de alta cardinalidad).
|
validity mide conformidad real (parse rate / match rate / tipo nativo). Salida
|
||||||
|
ampliada con `dimensions`, `applicable` y `observations`.
|
||||||
|
- v1.0.0 — version inicial: pesos 50/30/20 (completeness/validity/consistency),
|
||||||
|
los outliers penalizaban validity (con bug de escala) y consistency penalizaba
|
||||||
|
informatividad.
|
||||||
|
|||||||
@@ -1,34 +1,78 @@
|
|||||||
"""Score de calidad de datos (0-100) para un ColumnProfile del grupo eda.
|
"""Score de calidad de datos (0-100) para un ColumnProfile del grupo eda.
|
||||||
|
|
||||||
Funcion pura: dado el perfil de una columna producido por el grupo de
|
Funcion pura: dado el perfil de una columna producido por el grupo de
|
||||||
capacidad `eda` (p.ej. summarize_table_duckdb), calcula un score agregado
|
capacidad `eda` (p.ej. summarize_table_duckdb / profile_table), calcula un
|
||||||
de calidad junto a su desglose en completeness / validity / consistency y
|
score agregado de calidad junto a su desglose por dimension y dos listas
|
||||||
una lista de issues legibles. No realiza I/O ni muta el input.
|
legibles separadas: `issues` (defectos de calidad reales que SI bajan el
|
||||||
|
score) y `observations` (señales analiticas que NO bajan el score). No
|
||||||
|
realiza I/O ni muta el input.
|
||||||
|
|
||||||
|
Modelo (DAMA-DMBOK / ISO 8000), ver report 2046:
|
||||||
|
|
||||||
|
- Solo entran en el score las dimensiones medibles automaticamente desde el
|
||||||
|
perfil, sin fuente externa de verdad: completeness y validity por columna.
|
||||||
|
- Renormalizacion por aplicabilidad: si una dimension no es medible en la
|
||||||
|
columna (texto libre sin semantica -> validity no aplica; columna 100% nula
|
||||||
|
-> validity no medible), se excluye y los pesos se renormalizan sobre las
|
||||||
|
aplicables. Una columna ni se premia ni se castiga por algo no medible.
|
||||||
|
- Sin doble conteo: la falta de datos cuenta solo en completeness (se elimino
|
||||||
|
el antiguo castigo extra de `mostly_null` sobre validity).
|
||||||
|
- Los OUTLIERS NO bajan la calidad. Un valor extremo puede ser real y
|
||||||
|
correcto; detectar atipicos es analisis de la distribucion, no un juicio de
|
||||||
|
coreccion. Outliers, columnas constantes e identificadores de alta
|
||||||
|
cardinalidad pasan a `observations`, nunca a `issues`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Pesos base de las dimensiones de columna (se renormalizan por aplicabilidad).
|
||||||
|
_W_COMPLETENESS = 0.6
|
||||||
|
_W_VALIDITY = 0.4
|
||||||
|
|
||||||
|
# Tipos inferidos cuyo almacen garantiza la conformidad de tipo (validity=1.0)
|
||||||
|
# cuando NO vienen de una promocion de texto (en cuyo caso manda validity_rate).
|
||||||
|
_NATIVE_TYPED = ("numeric", "integer", "float", "datetime", "date", "boolean", "bool")
|
||||||
|
|
||||||
|
|
||||||
def column_quality_score(col: dict) -> dict:
|
def column_quality_score(col: dict) -> dict:
|
||||||
"""Calcula un score de calidad de datos 0-100 para un ColumnProfile.
|
"""Calcula un score de calidad de datos 0-100 para un ColumnProfile.
|
||||||
|
|
||||||
El score pondera tres dimensiones:
|
El score combina solo dimensiones de calidad medibles desde el perfil, con
|
||||||
- completeness (0.5): proporcion de valores no nulos.
|
renormalizacion por aplicabilidad:
|
||||||
- validity (0.3): ausencia de outliers / heuristicas de validez.
|
|
||||||
- consistency (0.2): la columna aporta informacion (no constante, no ruido).
|
- completeness (peso base 0.6, siempre aplica): proporcion de valores
|
||||||
|
presentes = 1 - null_pct. En texto, las celdas vacias (`empty_count`)
|
||||||
|
tambien cuentan como faltantes.
|
||||||
|
- validity (peso base 0.4, cuando hay un criterio de validacion real):
|
||||||
|
fraccion de valores no nulos conformes a su tipo/semantica. Tipo nativo
|
||||||
|
numerico/fecha/bool = 1.0; texto promovido a numero/fecha = parse rate
|
||||||
|
(`validity_rate`); texto con `semantic_type` regexable = `match_rate`;
|
||||||
|
texto libre o columna 100% nula = NO aplicable (renormaliza a solo
|
||||||
|
completeness).
|
||||||
|
|
||||||
|
Los outliers, columnas constantes, identificadores y asimetria fuerte NO
|
||||||
|
bajan el score: se devuelven en `observations`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
col: ColumnProfile dict del grupo eda. Se leen las claves de forma
|
col: ColumnProfile dict del grupo eda. Se leen las claves de forma
|
||||||
defensiva con .get(...) y se tolera que muchas vengan en None.
|
defensiva con .get(...) y se tolera que muchas vengan en None.
|
||||||
Claves relevantes: null_pct, inferred_type, semantic_type,
|
Claves relevantes: null_pct (0-1), n_rows, empty_count,
|
||||||
unique_pct, flags (list[str]), numeric ({outlier_pct, ...}|None),
|
inferred_type, semantic_type, validity_rate (0-1, lo expone
|
||||||
match_rate (opcional).
|
profile_table al promocionar texto a numero/fecha), match_rate
|
||||||
|
(0-1), unique_pct (0-1), flags (list[str], reconoce
|
||||||
|
"constant"/"possible_id"/"high_cardinality"), numeric
|
||||||
|
({outlier_pct: 0-100, skew, ...}|None).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con:
|
dict con:
|
||||||
score (float, 0-100, redondeado a 1 decimal),
|
score (float 0-100, redondeado a 1 decimal),
|
||||||
completeness (float, 0-1),
|
completeness (float 0-1),
|
||||||
validity (float, 0-1),
|
validity (float 0-1 | None si no aplicable),
|
||||||
consistency (float, 0-1),
|
dimensions ({completeness, validity}),
|
||||||
issues (list[str]) descripciones legibles de los problemas.
|
applicable (list[str] de dimensiones que entraron en el score),
|
||||||
|
issues (list[str]) SOLO defectos de calidad (nulos, vacios,
|
||||||
|
valores no conformes a su tipo/semantica),
|
||||||
|
observations (list[str]) señales analiticas que NO bajan el score
|
||||||
|
(outliers, columna constante, posible id, asimetria).
|
||||||
"""
|
"""
|
||||||
if not isinstance(col, dict):
|
if not isinstance(col, dict):
|
||||||
col = {}
|
col = {}
|
||||||
@@ -39,103 +83,153 @@ def column_quality_score(col: dict) -> dict:
|
|||||||
flags = set(flags)
|
flags = set(flags)
|
||||||
|
|
||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
|
observations: list[str] = []
|
||||||
|
|
||||||
|
inferred_type = col.get("inferred_type") or ""
|
||||||
|
semantic_type = col.get("semantic_type") or ""
|
||||||
|
|
||||||
# --- completeness -------------------------------------------------
|
# --- completeness -------------------------------------------------
|
||||||
null_pct = col.get("null_pct")
|
# Falta de datos = nulos + (en texto) celdas vacias. Es el unico sitio
|
||||||
if null_pct is None:
|
# donde la falta de datos cuenta: nunca se duplica en validity.
|
||||||
null_pct = 0.0
|
null_pct = _clamp(_num(col.get("null_pct"), 0.0), 0.0, 1.0)
|
||||||
try:
|
|
||||||
null_pct = float(null_pct)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
null_pct = 0.0
|
|
||||||
null_pct = _clamp(null_pct, 0.0, 1.0)
|
|
||||||
completeness = 1.0 - null_pct
|
completeness = 1.0 - null_pct
|
||||||
if null_pct > 0:
|
if null_pct > 0:
|
||||||
issues.append(f"{round(null_pct * 100)}% nulos")
|
issues.append(f"{_pct(null_pct)} nulos")
|
||||||
|
|
||||||
# --- validity -----------------------------------------------------
|
empty_frac = 0.0
|
||||||
validity = 1.0
|
n_rows = col.get("n_rows")
|
||||||
inferred_type = col.get("inferred_type") or ""
|
empty_count = col.get("empty_count")
|
||||||
|
if (
|
||||||
|
isinstance(n_rows, (int, float)) and not isinstance(n_rows, bool) and n_rows > 0
|
||||||
|
and isinstance(empty_count, (int, float)) and not isinstance(empty_count, bool)
|
||||||
|
and empty_count > 0
|
||||||
|
):
|
||||||
|
empty_frac = _clamp(float(empty_count) / float(n_rows), 0.0, 1.0)
|
||||||
|
completeness = _clamp(completeness - empty_frac, 0.0, 1.0)
|
||||||
|
issues.append(f"{_pct(empty_frac)} vacíos")
|
||||||
|
|
||||||
numeric = col.get("numeric")
|
# --- validity (con renormalizacion por aplicabilidad) -------------
|
||||||
is_numeric = inferred_type in ("integer", "float", "numeric") or isinstance(numeric, dict)
|
# None = no medible -> se excluye del score (no penaliza ni premia).
|
||||||
if isinstance(numeric, dict):
|
validity = None
|
||||||
outlier_pct = numeric.get("outlier_pct")
|
if completeness <= 0.0:
|
||||||
if outlier_pct is not None:
|
# Columna 100% faltante: no hay valores no nulos sobre los que medir
|
||||||
try:
|
# conformidad. validity no aplica -> el score sale solo de completeness
|
||||||
outlier_pct = float(outlier_pct)
|
# (= 0). Es el peor defecto de calidad posible.
|
||||||
except (TypeError, ValueError):
|
validity = None
|
||||||
outlier_pct = 0.0
|
|
||||||
outlier_pct = _clamp(outlier_pct, 0.0, 1.0)
|
|
||||||
if outlier_pct > 0:
|
|
||||||
penalty = min(outlier_pct, 0.3)
|
|
||||||
validity -= penalty
|
|
||||||
issues.append(f"{round(outlier_pct * 100)}% outliers")
|
|
||||||
|
|
||||||
# semantic_type declarado pero con baja tasa de match (si la conocemos).
|
|
||||||
semantic_type = col.get("semantic_type") or ""
|
|
||||||
match_rate = col.get("match_rate")
|
|
||||||
if semantic_type and match_rate is not None:
|
|
||||||
try:
|
|
||||||
match_rate = float(match_rate)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
match_rate = None
|
|
||||||
if match_rate is not None:
|
|
||||||
match_rate = _clamp(match_rate, 0.0, 1.0)
|
|
||||||
if match_rate < 1.0:
|
|
||||||
shortfall = 1.0 - match_rate
|
|
||||||
validity -= 0.5 * shortfall
|
|
||||||
issues.append(
|
|
||||||
f"semantic_type '{semantic_type}' con baja coincidencia "
|
|
||||||
f"({round(match_rate * 100)}%)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if "mostly_null" in flags:
|
|
||||||
validity *= 0.5
|
|
||||||
issues.append("mayoritariamente nula")
|
|
||||||
|
|
||||||
validity = _clamp(validity, 0.0, 1.0)
|
|
||||||
|
|
||||||
# --- consistency --------------------------------------------------
|
|
||||||
consistency = 1.0
|
|
||||||
if "constant" in flags:
|
|
||||||
consistency = 0.3
|
|
||||||
issues.append("columna constante")
|
|
||||||
else:
|
else:
|
||||||
unique_pct = col.get("unique_pct")
|
validity_rate = col.get("validity_rate")
|
||||||
if unique_pct is not None:
|
match_rate = col.get("match_rate")
|
||||||
try:
|
if validity_rate is not None:
|
||||||
unique_pct = float(unique_pct)
|
# Texto promovido a numero/fecha: parse rate real de la muestra.
|
||||||
except (TypeError, ValueError):
|
v = _num(validity_rate, None)
|
||||||
unique_pct = None
|
if v is not None:
|
||||||
if (
|
validity = _clamp(v, 0.0, 1.0)
|
||||||
inferred_type == "text"
|
if validity < 1.0:
|
||||||
|
kind = (
|
||||||
|
"número" if inferred_type == "numeric"
|
||||||
|
else "fecha" if inferred_type == "datetime"
|
||||||
|
else inferred_type or "su tipo"
|
||||||
|
)
|
||||||
|
issues.append(
|
||||||
|
f"{_pct(1.0 - validity)} no parsea al tipo {kind}"
|
||||||
|
)
|
||||||
|
elif inferred_type in _NATIVE_TYPED:
|
||||||
|
# Tipo nativo garantizado por el almacen: no hay valores que no
|
||||||
|
# parseen. validity = 1.0 (no se confunde con tener outliers).
|
||||||
|
validity = 1.0
|
||||||
|
elif semantic_type and match_rate is not None:
|
||||||
|
v = _num(match_rate, None)
|
||||||
|
if v is not None:
|
||||||
|
validity = _clamp(v, 0.0, 1.0)
|
||||||
|
if validity < 1.0:
|
||||||
|
issues.append(
|
||||||
|
f"{_pct(1.0 - validity)} no casa con el "
|
||||||
|
f"formato «{semantic_type}»"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Texto libre / categorica sin semantica: no hay criterio honesto
|
||||||
|
# de validez. No aplica.
|
||||||
|
validity = None
|
||||||
|
|
||||||
|
# --- observations (NO bajan el score) -----------------------------
|
||||||
|
numeric = col.get("numeric")
|
||||||
|
if isinstance(numeric, dict):
|
||||||
|
# outlier_pct viene en escala 0-100 desde describe_numeric (z-score>3).
|
||||||
|
outlier_pct = _num(numeric.get("outlier_pct"), None)
|
||||||
|
if outlier_pct is not None and outlier_pct >= 0.05:
|
||||||
|
observations.append(
|
||||||
|
f"{_pct(outlier_pct / 100.0)} de valores atípicos (z-score>3): "
|
||||||
|
"revisar si son errores u observaciones legítimas"
|
||||||
|
)
|
||||||
|
skew = _num(numeric.get("skew"), None)
|
||||||
|
if skew is not None and abs(skew) >= 1.0:
|
||||||
|
observations.append(
|
||||||
|
f"asimetría fuerte (skew={round(skew, 2)}): considerar "
|
||||||
|
"re-expresión antes de modelar"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "constant" in flags:
|
||||||
|
observations.append(
|
||||||
|
"columna constante: aporta poca información para el análisis"
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_pct = _num(col.get("unique_pct"), None)
|
||||||
|
is_id = (
|
||||||
|
"possible_id" in flags
|
||||||
|
or "high_cardinality" in flags
|
||||||
|
or (
|
||||||
|
inferred_type in ("text", "categorical")
|
||||||
and unique_pct is not None
|
and unique_pct is not None
|
||||||
and _clamp(unique_pct, 0.0, 1.0) > 0.9
|
and _clamp(unique_pct, 0.0, 1.0) > 0.9
|
||||||
):
|
)
|
||||||
consistency = 0.6
|
|
||||||
issues.append("posible id de alta cardinalidad")
|
|
||||||
|
|
||||||
consistency = _clamp(consistency, 0.0, 1.0)
|
|
||||||
|
|
||||||
# --- score agregado ----------------------------------------------
|
|
||||||
score = round(
|
|
||||||
100.0 * (0.5 * completeness + 0.3 * validity + 0.2 * consistency),
|
|
||||||
1,
|
|
||||||
)
|
)
|
||||||
|
if is_id:
|
||||||
|
observations.append(
|
||||||
|
"valores casi únicos: posible identificador (no es un defecto de calidad)"
|
||||||
|
)
|
||||||
|
|
||||||
# Silencia warnings sobre la variable de tipo no usada.
|
# --- score agregado con renormalizacion ---------------------------
|
||||||
_ = is_numeric
|
applicable = ["completeness"]
|
||||||
|
num = _W_COMPLETENESS * completeness
|
||||||
|
den = _W_COMPLETENESS
|
||||||
|
if validity is not None:
|
||||||
|
applicable.append("validity")
|
||||||
|
num += _W_VALIDITY * validity
|
||||||
|
den += _W_VALIDITY
|
||||||
|
score = round(100.0 * num / den, 1) if den > 0 else 0.0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"score": score,
|
"score": score,
|
||||||
"completeness": completeness,
|
"completeness": completeness,
|
||||||
"validity": validity,
|
"validity": validity,
|
||||||
"consistency": consistency,
|
"dimensions": {"completeness": completeness, "validity": validity},
|
||||||
|
"applicable": applicable,
|
||||||
"issues": issues,
|
"issues": issues,
|
||||||
|
"observations": observations,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(frac: float) -> str:
|
||||||
|
"""Formatea una fraccion 0-1 como porcentaje honesto: «N%» si >=1%, «0.N%»
|
||||||
|
por debajo (para no mostrar «0%» cuando hay un defecto real pequeño)."""
|
||||||
|
p = frac * 100.0
|
||||||
|
if p >= 1.0:
|
||||||
|
return f"{round(p)}%"
|
||||||
|
return f"{p:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _num(x, default):
|
||||||
|
"""Convierte x a float; devuelve `default` si es None o no parseable."""
|
||||||
|
if x is None:
|
||||||
|
return default
|
||||||
|
if isinstance(x, bool):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(x)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||||
"""Recorta x al rango [lo, hi]."""
|
"""Recorta x al rango [lo, hi]."""
|
||||||
if x < lo:
|
if x < lo:
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
"""Tests para column_quality_score."""
|
"""Tests para column_quality_score (nueva fórmula, report 2046).
|
||||||
|
|
||||||
|
Verifica las invariantes de la fórmula de calidad:
|
||||||
|
- completeness (0.6) + validity (0.4) con renormalización por aplicabilidad.
|
||||||
|
- Los OUTLIERS no bajan el score (van a observations, no a issues).
|
||||||
|
- Columnas constantes e ids no bajan el score (observations).
|
||||||
|
- Sin doble conteo de la falta de datos.
|
||||||
|
- all-null -> score 0; función pura (no muta el input).
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -9,11 +17,11 @@ from column_quality_score import column_quality_score
|
|||||||
|
|
||||||
|
|
||||||
def _clean_numeric_col() -> dict:
|
def _clean_numeric_col() -> dict:
|
||||||
"""ColumnProfile de una columna numerica sana, sin problemas."""
|
"""ColumnProfile de una columna numérica nativa sana, sin problemas."""
|
||||||
return {
|
return {
|
||||||
"name": "edad",
|
"name": "edad",
|
||||||
"physical_type": "INTEGER",
|
"physical_type": "INTEGER",
|
||||||
"inferred_type": "integer",
|
"inferred_type": "numeric",
|
||||||
"semantic_type": "",
|
"semantic_type": "",
|
||||||
"count": 1000,
|
"count": 1000,
|
||||||
"n_rows": 1000,
|
"n_rows": 1000,
|
||||||
@@ -28,85 +36,163 @@ def _clean_numeric_col() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Golden
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
def test_clean_column_high_score():
|
def test_clean_column_high_score():
|
||||||
out = column_quality_score(_clean_numeric_col())
|
out = column_quality_score(_clean_numeric_col())
|
||||||
assert out["score"] > 90
|
assert out["score"] == 100.0
|
||||||
assert out["completeness"] == 1.0
|
assert out["completeness"] == 1.0
|
||||||
assert out["validity"] == 1.0
|
assert out["validity"] == 1.0
|
||||||
assert out["consistency"] == 1.0
|
assert out["applicable"] == ["completeness", "validity"]
|
||||||
assert out["issues"] == []
|
assert out["issues"] == []
|
||||||
|
assert out["observations"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_half_null_lowers_completeness_and_score():
|
def test_weights_60_40_native_type():
|
||||||
|
"""30% nulos en numérica nativa: score = 100*(0.6*0.7 + 0.4*1.0) = 82."""
|
||||||
col = _clean_numeric_col()
|
col = _clean_numeric_col()
|
||||||
col["null_count"] = 500
|
col["null_pct"] = 0.30
|
||||||
col["null_pct"] = 0.5
|
col["null_count"] = 300
|
||||||
clean_score = column_quality_score(_clean_numeric_col())["score"]
|
|
||||||
out = column_quality_score(col)
|
out = column_quality_score(col)
|
||||||
assert out["completeness"] == 0.5
|
assert out["completeness"] == 0.7
|
||||||
assert out["score"] < clean_score
|
assert out["validity"] == 1.0
|
||||||
assert any("nulos" in issue for issue in out["issues"])
|
assert out["score"] == 82.0
|
||||||
|
assert any("nulos" in i for i in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
def test_constant_column_flags_issue():
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Outliers FUERA del score
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_outliers_do_not_penalize_score():
|
||||||
|
"""Columna con outliers pero sin nulos -> score máximo; outliers en observations."""
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["numeric"] = {"outlier_pct": 18.0, "skew": 0.2} # 18% atípicos (escala 0-100)
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["score"] == 100.0 # los outliers NO bajan la calidad
|
||||||
|
assert out["validity"] == 1.0
|
||||||
|
# No aparecen como problema de calidad...
|
||||||
|
assert not any("atípic" in i or "outlier" in i for i in out["issues"])
|
||||||
|
# ...sino como observación analítica.
|
||||||
|
assert any("atípic" in o for o in out["observations"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_nulls_lower_score_more_than_outliers():
|
||||||
|
"""Vacíos sí penalizan; outliers no: comparar las dos columnas."""
|
||||||
|
con_nulos = _clean_numeric_col()
|
||||||
|
con_nulos["null_pct"] = 0.30
|
||||||
|
con_outliers = _clean_numeric_col()
|
||||||
|
con_outliers["numeric"] = {"outlier_pct": 30.0}
|
||||||
|
assert column_quality_score(con_nulos)["score"] < \
|
||||||
|
column_quality_score(con_outliers)["score"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Validity: aplicabilidad y renormalización
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_validity_from_parse_rate_lowers_score():
|
||||||
|
"""Numérica como texto con 20% basura: validity=0.8 -> score=92."""
|
||||||
|
col = {
|
||||||
|
"name": "precio_txt", "inferred_type": "numeric", "semantic_type": "decimal",
|
||||||
|
"null_pct": 0.0, "validity_rate": 0.80, "flags": [], "numeric": None,
|
||||||
|
}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["validity"] == 0.8
|
||||||
|
assert out["score"] == 92.0 # 100*(0.6 + 0.4*0.8)
|
||||||
|
assert any("no parsea" in i for i in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validity_from_match_rate():
|
||||||
|
"""Texto con semantic_type y 5% no conforme: validity=0.95."""
|
||||||
|
col = {
|
||||||
|
"name": "email", "inferred_type": "text", "semantic_type": "email",
|
||||||
|
"null_pct": 0.0, "match_rate": 0.95, "unique_pct": 0.5, "flags": [],
|
||||||
|
}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["validity"] == 0.95
|
||||||
|
assert out["score"] == 98.0 # 100*(0.6 + 0.4*0.95)
|
||||||
|
assert any("no casa" in i for i in out["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_text_renormalizes_to_completeness_only():
|
||||||
|
"""Texto libre sin semántica: validity no aplica -> score = 100*completeness."""
|
||||||
|
col = {
|
||||||
|
"name": "comentario", "inferred_type": "text", "semantic_type": "",
|
||||||
|
"null_pct": 0.30, "unique_pct": 0.5, "flags": [], "numeric": None,
|
||||||
|
}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["validity"] is None
|
||||||
|
assert out["applicable"] == ["completeness"]
|
||||||
|
assert out["completeness"] == 0.7
|
||||||
|
assert out["score"] == 70.0 # renormalizado a solo completeness
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Casos límite (report §4.6)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_all_null_column_scores_zero():
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["null_pct"] = 1.0
|
||||||
|
col["null_count"] = 1000
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["completeness"] == 0.0
|
||||||
|
assert out["validity"] is None # no medible sin valores no nulos
|
||||||
|
assert out["score"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_column_scores_full_and_is_observation():
|
||||||
|
"""Columna constante: dato válido y completo -> score 100; baja info = observación."""
|
||||||
col = _clean_numeric_col()
|
col = _clean_numeric_col()
|
||||||
col["flags"] = ["constant"]
|
col["flags"] = ["constant"]
|
||||||
col["distinct_count"] = 1
|
col["distinct_count"] = 1
|
||||||
col["unique_pct"] = 0.001
|
col["unique_pct"] = 0.001
|
||||||
out = column_quality_score(col)
|
out = column_quality_score(col)
|
||||||
assert out["consistency"] == 0.3
|
assert out["score"] == 100.0 # NO se castiga la baja informatividad
|
||||||
assert any("constante" in issue for issue in out["issues"])
|
assert not any("constante" in i for i in out["issues"])
|
||||||
|
assert any("constante" in o for o in out["observations"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_cardinality_id_scores_full_and_is_observation():
|
||||||
|
"""Id de alta cardinalidad: unicidad perfecta -> score 100; posible id = observación."""
|
||||||
|
col = {
|
||||||
|
"name": "uuid", "inferred_type": "text", "semantic_type": "",
|
||||||
|
"null_pct": 0.0, "unique_pct": 0.99, "flags": ["possible_id"],
|
||||||
|
"numeric": None,
|
||||||
|
}
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["score"] == 100.0
|
||||||
|
assert not any("identificador" in i for i in out["issues"])
|
||||||
|
assert any("identificador" in o for o in out["observations"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mostly_null_no_double_counts_validity():
|
||||||
|
"""85% nulos: solo completeness penaliza; validity nativa sigue 1.0 (sin doble castigo)."""
|
||||||
|
col = _clean_numeric_col()
|
||||||
|
col["null_pct"] = 0.85
|
||||||
|
col["flags"] = ["mostly_null"]
|
||||||
|
out = column_quality_score(col)
|
||||||
|
assert out["validity"] == 1.0 # ya no se multiplica por 0.5
|
||||||
|
# score = 100*(0.6*0.15 + 0.4*1.0) = 49
|
||||||
|
assert out["score"] == 49.0
|
||||||
|
assert not any("mayoritariamente" in o for o in out["observations"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Robustez
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
def test_empty_dict_does_not_crash():
|
def test_empty_dict_does_not_crash():
|
||||||
out = column_quality_score({})
|
out = column_quality_score({})
|
||||||
assert isinstance(out["score"], float)
|
assert isinstance(out["score"], float)
|
||||||
assert out["completeness"] == 1.0
|
assert out["completeness"] == 1.0
|
||||||
assert 0.0 <= out["score"] <= 100.0
|
assert 0.0 <= out["score"] <= 100.0
|
||||||
assert isinstance(out["issues"], list)
|
assert isinstance(out["issues"], list)
|
||||||
|
assert isinstance(out["observations"], list)
|
||||||
|
|
||||||
def test_outliers_penalize_validity():
|
|
||||||
col = _clean_numeric_col()
|
|
||||||
col["numeric"] = {"outlier_pct": 0.2}
|
|
||||||
out = column_quality_score(col)
|
|
||||||
assert out["validity"] < 1.0
|
|
||||||
assert any("outliers" in issue for issue in out["issues"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_mostly_null_flag_halves_validity():
|
|
||||||
col = _clean_numeric_col()
|
|
||||||
col["null_pct"] = 0.85
|
|
||||||
col["flags"] = ["mostly_null"]
|
|
||||||
out = column_quality_score(col)
|
|
||||||
assert out["validity"] == 0.5
|
|
||||||
assert any("mayoritariamente nula" in issue for issue in out["issues"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_high_cardinality_text_flagged_as_id():
|
|
||||||
col = {
|
|
||||||
"name": "uuid",
|
|
||||||
"inferred_type": "text",
|
|
||||||
"semantic_type": "",
|
|
||||||
"null_pct": 0.0,
|
|
||||||
"unique_pct": 0.99,
|
|
||||||
"flags": [],
|
|
||||||
"numeric": None,
|
|
||||||
}
|
|
||||||
out = column_quality_score(col)
|
|
||||||
assert out["consistency"] < 1.0
|
|
||||||
assert any("alta cardinalidad" in issue for issue in out["issues"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_none_values_treated_defensively():
|
def test_none_values_treated_defensively():
|
||||||
col = {
|
col = {
|
||||||
"name": "x",
|
"name": "x", "inferred_type": None, "semantic_type": None,
|
||||||
"inferred_type": None,
|
"null_pct": None, "unique_pct": None, "flags": None, "numeric": None,
|
||||||
"semantic_type": None,
|
|
||||||
"null_pct": None,
|
|
||||||
"unique_pct": None,
|
|
||||||
"flags": None,
|
|
||||||
"numeric": None,
|
|
||||||
}
|
}
|
||||||
out = column_quality_score(col)
|
out = column_quality_score(col)
|
||||||
assert out["completeness"] == 1.0
|
assert out["completeness"] == 1.0
|
||||||
|
|||||||
@@ -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 (0–1) 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 (≈90–100 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", "sí", "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", "mí", "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", "sí", "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,107 @@
|
|||||||
|
---
|
||||||
|
name: detect_declared_keys_duckdb
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict"
|
||||||
|
description: "Detecta las claves DECLARADAS (constraints reales) de un schema DuckDB leyendo la table function duckdb_constraints(): extrae PRIMARY KEY, FOREIGN KEY y UNIQUE (ignora NOT NULL y CHECK) y las devuelve normalizadas con sus columnas, y para las FK con su tabla y columnas referenciadas. Con table=None procesa todas las tablas; con table='X' filtra a PK/UNIQUE de X y a FK cuyo origen es X (case-sensitive). A diferencia de infer_fk_containment_duckdb (que INFIERE FKs candidatas por containment de valores cuando el schema no las declara), esta funcion devuelve las relaciones de clave REALES del schema. Estilo dict-no-throw: nunca lanza. Parte del grupo eda (relaciones de clave)."
|
||||||
|
tags: [eda, duckdb, datascience, relations, primary-key, foreign-key, schema, exploratory-data-analysis]
|
||||||
|
params:
|
||||||
|
- name: db_path
|
||||||
|
desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via duckdb_query_readonly; no se crea). Un path inexistente devuelve {status:'error', ...}."
|
||||||
|
- name: table
|
||||||
|
desc: "Si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea `table` (no la referenciada). None (default) devuelve los constraints de todas las tablas. La comparacion es case-sensitive (nombres tal cual los devuelve DuckDB)."
|
||||||
|
output: "dict dict-no-throw. En exito {status:'ok', primary_keys:[{table:str, columns:[str,...]}, ...], foreign_keys:[{table:str, columns:[str,...], referenced_table:str, referenced_columns:[str,...]}, ...], unique:[{table:str, columns:[str,...]}, ...], tables:[str,...]} donde tables es la lista ordenada de tablas (origen) que poseen al menos un constraint PK/FK/UNIQUE emitido. Solo se emiten constraints de clave: NOT NULL y CHECK se ignoran. En error {status:'error', error:str}."
|
||||||
|
uses_functions: [duckdb_query_readonly_py_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_detecta_pks_y_fk", "test_golden_ignora_not_null_y_check", "test_edge_filtra_por_tabla_orders", "test_edge_filtra_por_tabla_customers", "test_edge_unique_declarado", "test_edge_sin_constraints_listas_vacias", "test_error_db_inexistente_no_lanza", "test_shape_resultado"]
|
||||||
|
test_file_path: "python/functions/datascience/detect_declared_keys_duckdb_test.py"
|
||||||
|
file_path: "python/functions/datascience/detect_declared_keys_duckdb.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, duckdb
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience import detect_declared_keys_duckdb
|
||||||
|
|
||||||
|
# Base de ejemplo en /tmp: orders.customer_id -> customers.id (FK declarada)
|
||||||
|
path = "/tmp/declared_keys_demo.duckdb"
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE orders("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" customer_id INTEGER REFERENCES customers(id),"
|
||||||
|
" amt DOUBLE)"
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = detect_declared_keys_duckdb(path)
|
||||||
|
if res["status"] == "ok":
|
||||||
|
for pk in res["primary_keys"]:
|
||||||
|
print(f"PK {pk['table']}({', '.join(pk['columns'])})")
|
||||||
|
for fk in res["foreign_keys"]:
|
||||||
|
print(f"FK {fk['table']}({', '.join(fk['columns'])}) -> "
|
||||||
|
f"{fk['referenced_table']}({', '.join(fk['referenced_columns'])})")
|
||||||
|
# PK customers(id)
|
||||||
|
# PK orders(id)
|
||||||
|
# FK orders(customer_id) -> customers(id)
|
||||||
|
else:
|
||||||
|
print("error:", res["error"])
|
||||||
|
|
||||||
|
# Filtrar a una tabla concreta (PK/UNIQUE de orders + FK con origen orders):
|
||||||
|
solo_orders = detect_declared_keys_duckdb(path, table="orders")
|
||||||
|
print(solo_orders["tables"]) # ['orders']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando exploras un esquema DuckDB y quieres mostrar las relaciones de clave REALES (PK/FK/UNIQUE) que el schema ha declarado, sin inferir nada.
|
||||||
|
- Como paso del capitulo RELACIONES del grupo `eda`: primero mira las claves declaradas con esta funcion; si el schema no declara FKs, complementa con `infer_fk_containment_duckdb` (inferencia por containment).
|
||||||
|
- Antes de documentar o migrar un esquema, para listar el contrato de integridad referencial que el motor ya conoce.
|
||||||
|
- Para validar que las constraints que esperas (esa FK que creaste con `REFERENCES`) realmente estan declaradas en la base materializada.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: lee de disco via la primitiva read-only `duckdb_query_readonly` (no crea ni modifica la base). El `db_path` debe existir; un path inexistente devuelve `{status:'error'}` (read_only NO crea la base).
|
||||||
|
- **Requiere `duckdb_constraints()`**: usa la table function `duckdb_constraints()`, disponible en DuckDB modernos (verificado en 1.5.2). En versiones antiguas sin esa funcion, la query falla y se devuelve `{status:'error'}`.
|
||||||
|
- **Solo claves DECLARADAS**: devuelve lo que el schema declaro con `PRIMARY KEY` / `FOREIGN KEY (... REFERENCES ...)` / `UNIQUE`. Una tabla materializada con `CREATE TABLE AS SELECT` NO lleva constraints — para esos casos no habra claves que mostrar y hay que INFERIRLAS (`infer_fk_containment_duckdb`).
|
||||||
|
- **NOT NULL y CHECK se ignoran**: `duckdb_constraints()` tambien emite filas `NOT NULL` (DuckDB genera una por cada columna PK) y `CHECK`; esta funcion las descarta y solo conserva PK/FK/UNIQUE.
|
||||||
|
- **Nombres case-sensitive**: el filtro `table='Orders'` no casa con una tabla `orders`. Se comparan los nombres tal cual los devuelve DuckDB.
|
||||||
|
- **FK atribuida al origen**: una FOREIGN KEY se atribuye a su tabla ORIGEN (el `table` de la entrada), no a la referenciada. El filtro `table='X'` trae las FK cuyo origen es X, no las que apuntan a X.
|
||||||
|
- **`tables` = tablas dueñas de constraints emitidos**: la lista `tables` contiene solo las tablas que poseen al menos un PK/FK/UNIQUE en el resultado (su campo `table`), ordenadas. No incluye tablas referenciadas que no tengan constraint propio en la salida.
|
||||||
|
- **Columnas como listas**: `constraint_column_names` y `referenced_column_names` son columnas LIST de DuckDB; en 1.5.2 llegan como listas Python. La funcion las normaliza a listas de strings con una red de seguridad por si llegaran como string.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
`duckdb_constraints()` devuelve una fila por constraint con los campos
|
||||||
|
`table_name`, `constraint_type`, `constraint_column_names`, `referenced_table`,
|
||||||
|
`referenced_column_names`. Mapeo a la salida:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRIMARY KEY -> primary_keys[]: {table, columns}
|
||||||
|
UNIQUE -> unique[]: {table, columns}
|
||||||
|
FOREIGN KEY -> foreign_keys[]: {table, columns, referenced_table, referenced_columns}
|
||||||
|
NOT NULL -> ignorado
|
||||||
|
CHECK -> ignorado
|
||||||
|
```
|
||||||
|
|
||||||
|
Para una FK, `referenced_table` y `referenced_column_names` vienen poblados; para
|
||||||
|
PK/UNIQUE, `referenced_table` es NULL y `referenced_column_names` una lista vacia.
|
||||||
|
|
||||||
|
Complementa a `infer_fk_containment_duckdb`: esta funcion devuelve las relaciones
|
||||||
|
de clave REALES del schema (declaradas); la otra INFIERE FKs candidatas por
|
||||||
|
containment de valores cuando el schema no las declaro. En el capitulo RELACIONES
|
||||||
|
de AutomaticEDA se usan en orden: primero las declaradas, luego la inferencia como
|
||||||
|
respaldo.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""detect_declared_keys_duckdb — lee las claves DECLARADAS de un schema DuckDB.
|
||||||
|
|
||||||
|
Funcion impura: lee de disco a traves de la primitiva read-only del grupo
|
||||||
|
`duckdb` (duckdb_query_readonly). Pertenece al grupo de capacidad `eda`
|
||||||
|
(relaciones de clave): a diferencia de infer_fk_containment_duckdb, que INFIERE
|
||||||
|
FOREIGN KEYs candidatas por containment de valores, esta funcion devuelve las
|
||||||
|
constraints REALES que el schema ha declarado (PRIMARY KEY / FOREIGN KEY /
|
||||||
|
UNIQUE) leyendo la table function `duckdb_constraints()`.
|
||||||
|
|
||||||
|
Es la pieza del capitulo RELACIONES de AutomaticEDA que muestra las relaciones de
|
||||||
|
clave reales cuando existen — frente a la inferencia, que se usa cuando el schema
|
||||||
|
no las declaro.
|
||||||
|
|
||||||
|
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
|
||||||
|
devuelve {status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from infra import duckdb_query_readonly
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value) -> list:
|
||||||
|
"""Normaliza el valor de una columna LIST de DuckDB a una lista de strings.
|
||||||
|
|
||||||
|
En DuckDB 1.5.2, `constraint_column_names` y `referenced_column_names` llegan
|
||||||
|
ya como listas Python a traves de duckdb_query_readonly. Este helper es solo
|
||||||
|
una red de seguridad: si por cualquier motivo llegara como string (p.ej. la
|
||||||
|
representacion `[id, customer_id]`), la parsea de forma defensiva.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [str(v) for v in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
s = value.strip()
|
||||||
|
if s.startswith("[") and s.endswith("]"):
|
||||||
|
s = s[1:-1]
|
||||||
|
if not s.strip():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
part.strip().strip("'\"")
|
||||||
|
for part in s.split(",")
|
||||||
|
if part.strip().strip("'\"")
|
||||||
|
]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict:
|
||||||
|
"""Detecta las claves PRIMARY KEY / FOREIGN KEY / UNIQUE declaradas en DuckDB.
|
||||||
|
|
||||||
|
Lee la table function `duckdb_constraints()` y extrae solo las constraints de
|
||||||
|
clave (PRIMARY KEY, FOREIGN KEY, UNIQUE), ignorando NOT NULL y CHECK.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only; no se
|
||||||
|
crea). Un path inexistente devuelve {status:'error', ...} sin lanzar.
|
||||||
|
table: si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY
|
||||||
|
y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea
|
||||||
|
`table`. None (default) devuelve los constraints de todas las tablas.
|
||||||
|
La comparacion de nombres es case-sensitive (tal cual los devuelve
|
||||||
|
DuckDB).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict dict-no-throw. En exito:
|
||||||
|
{status:'ok',
|
||||||
|
primary_keys:[{table:str, columns:[str, ...]}, ...],
|
||||||
|
foreign_keys:[{table:str, columns:[str, ...],
|
||||||
|
referenced_table:str,
|
||||||
|
referenced_columns:[str, ...]}, ...],
|
||||||
|
unique:[{table:str, columns:[str, ...]}, ...],
|
||||||
|
tables:[str, ...]} # tablas (origen) con algun PK/FK/UNIQUE emitido
|
||||||
|
En error (sin lanzar): {status:'error', error:str}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sql = (
|
||||||
|
"SELECT table_name, constraint_type, constraint_column_names, "
|
||||||
|
"referenced_table, referenced_column_names FROM duckdb_constraints()"
|
||||||
|
)
|
||||||
|
res = duckdb_query_readonly(db_path, sql)
|
||||||
|
if res["status"] != "ok":
|
||||||
|
return {"status": "error", "error": res["error"]}
|
||||||
|
|
||||||
|
primary_keys = []
|
||||||
|
foreign_keys = []
|
||||||
|
unique = []
|
||||||
|
tables = set()
|
||||||
|
|
||||||
|
for row in res["rows"]:
|
||||||
|
ctype = row["constraint_type"]
|
||||||
|
tname = row["table_name"]
|
||||||
|
|
||||||
|
# Filtro por tabla origen: para PK/FK/UNIQUE el dueño del constraint es
|
||||||
|
# `table_name`. Una FK se atribuye a su tabla origen (no a la
|
||||||
|
# referenciada), igual que el filtro pide.
|
||||||
|
if table is not None and tname != table:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cols = _as_list(row["constraint_column_names"])
|
||||||
|
|
||||||
|
if ctype == "PRIMARY KEY":
|
||||||
|
primary_keys.append({"table": tname, "columns": cols})
|
||||||
|
tables.add(tname)
|
||||||
|
elif ctype == "UNIQUE":
|
||||||
|
unique.append({"table": tname, "columns": cols})
|
||||||
|
tables.add(tname)
|
||||||
|
elif ctype == "FOREIGN KEY":
|
||||||
|
foreign_keys.append(
|
||||||
|
{
|
||||||
|
"table": tname,
|
||||||
|
"columns": cols,
|
||||||
|
"referenced_table": row["referenced_table"],
|
||||||
|
"referenced_columns": _as_list(
|
||||||
|
row["referenced_column_names"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tables.add(tname)
|
||||||
|
# NOT NULL y CHECK se ignoran: no son relaciones de clave.
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"primary_keys": primary_keys,
|
||||||
|
"foreign_keys": foreign_keys,
|
||||||
|
"unique": unique,
|
||||||
|
"tables": sorted(tables),
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Tests para detect_declared_keys_duckdb."""
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .detect_declared_keys_duckdb import detect_declared_keys_duckdb
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path):
|
||||||
|
"""DuckDB temporal con claves declaradas.
|
||||||
|
|
||||||
|
- customers(id PRIMARY KEY, name)
|
||||||
|
- orders(id PRIMARY KEY, customer_id REFERENCES customers(id), amt)
|
||||||
|
|
||||||
|
Esto declara dos PRIMARY KEY (customers.id, orders.id) y una FOREIGN KEY
|
||||||
|
(orders.customer_id -> customers.id). DuckDB ademas genera constraints
|
||||||
|
NOT NULL para las columnas PK, que la funcion debe ignorar.
|
||||||
|
"""
|
||||||
|
path = str(tmp_path / "keys_test.duckdb")
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE orders("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" customer_id INTEGER REFERENCES customers(id),"
|
||||||
|
" amt DOUBLE"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _pk_for(res, table):
|
||||||
|
"""Devuelve la entrada primary_keys cuya tabla es `table`, o None."""
|
||||||
|
for pk in res["primary_keys"]:
|
||||||
|
if pk["table"] == table:
|
||||||
|
return pk
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_detecta_pks_y_fk(db):
|
||||||
|
"""Golden: detecta las dos PK y la FK declaradas, con valores concretos."""
|
||||||
|
res = detect_declared_keys_duckdb(db)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
|
||||||
|
# PRIMARY KEY de customers y de orders.
|
||||||
|
pk_customers = _pk_for(res, "customers")
|
||||||
|
pk_orders = _pk_for(res, "orders")
|
||||||
|
assert pk_customers is not None
|
||||||
|
assert pk_customers["columns"] == ["id"]
|
||||||
|
assert pk_orders is not None
|
||||||
|
assert pk_orders["columns"] == ["id"]
|
||||||
|
|
||||||
|
# FOREIGN KEY orders.customer_id -> customers.id.
|
||||||
|
assert len(res["foreign_keys"]) == 1
|
||||||
|
fk = res["foreign_keys"][0]
|
||||||
|
assert fk["table"] == "orders"
|
||||||
|
assert fk["columns"] == ["customer_id"]
|
||||||
|
assert fk["referenced_table"] == "customers"
|
||||||
|
assert fk["referenced_columns"] == ["id"]
|
||||||
|
|
||||||
|
# tables incluye ambas (origen de algun constraint).
|
||||||
|
assert res["tables"] == ["customers", "orders"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_ignora_not_null_y_check(db):
|
||||||
|
"""NOT NULL (auto-generado por las PK) no aparece como clave."""
|
||||||
|
res = detect_declared_keys_duckdb(db)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
# Solo 2 PK reales (no las NOT NULL que DuckDB genera por cada columna PK).
|
||||||
|
assert len(res["primary_keys"]) == 2
|
||||||
|
# No hay UNIQUE declarado en este schema.
|
||||||
|
assert res["unique"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_filtra_por_tabla_orders(db):
|
||||||
|
"""Edge table='orders': PK de orders + su FK; NO la PK de customers."""
|
||||||
|
res = detect_declared_keys_duckdb(db, table="orders")
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
|
||||||
|
# Solo la PK de orders.
|
||||||
|
assert len(res["primary_keys"]) == 1
|
||||||
|
assert res["primary_keys"][0]["table"] == "orders"
|
||||||
|
assert res["primary_keys"][0]["columns"] == ["id"]
|
||||||
|
# La PK de customers NO esta.
|
||||||
|
assert _pk_for(res, "customers") is None
|
||||||
|
|
||||||
|
# La FK de orders si esta (origen = orders).
|
||||||
|
assert len(res["foreign_keys"]) == 1
|
||||||
|
assert res["foreign_keys"][0]["table"] == "orders"
|
||||||
|
assert res["foreign_keys"][0]["referenced_table"] == "customers"
|
||||||
|
|
||||||
|
# tables solo contiene orders (la dueña de los constraints emitidos).
|
||||||
|
assert res["tables"] == ["orders"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_filtra_por_tabla_customers(db):
|
||||||
|
"""Edge table='customers': solo su PK; ninguna FK (orders queda fuera)."""
|
||||||
|
res = detect_declared_keys_duckdb(db, table="customers")
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert len(res["primary_keys"]) == 1
|
||||||
|
assert res["primary_keys"][0]["table"] == "customers"
|
||||||
|
assert res["foreign_keys"] == []
|
||||||
|
assert res["tables"] == ["customers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_unique_declarado(tmp_path):
|
||||||
|
"""Edge: una constraint UNIQUE declarada aparece en `unique`."""
|
||||||
|
path = str(tmp_path / "unique_test.duckdb")
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute("CREATE TABLE products(sku INTEGER UNIQUE, name TEXT)")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = detect_declared_keys_duckdb(path)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert len(res["unique"]) == 1
|
||||||
|
assert res["unique"][0]["table"] == "products"
|
||||||
|
assert res["unique"][0]["columns"] == ["sku"]
|
||||||
|
assert res["primary_keys"] == []
|
||||||
|
assert res["foreign_keys"] == []
|
||||||
|
assert res["tables"] == ["products"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_sin_constraints_listas_vacias(tmp_path):
|
||||||
|
"""Edge: tabla sin PK/FK/UNIQUE -> todas las listas vacias, status ok."""
|
||||||
|
path = str(tmp_path / "no_keys.duckdb")
|
||||||
|
con = duckdb.connect(path)
|
||||||
|
con.execute("CREATE TABLE log(a INTEGER, b INTEGER)")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = detect_declared_keys_duckdb(path)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["primary_keys"] == []
|
||||||
|
assert res["foreign_keys"] == []
|
||||||
|
assert res["unique"] == []
|
||||||
|
assert res["tables"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_db_inexistente_no_lanza(tmp_path):
|
||||||
|
"""Error: db_path inexistente -> status error, sin lanzar excepcion."""
|
||||||
|
path = str(tmp_path / "does_not_exist.duckdb")
|
||||||
|
res = detect_declared_keys_duckdb(path)
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert isinstance(res["error"], str)
|
||||||
|
assert res["error"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_resultado(db):
|
||||||
|
"""El retorno tiene exactamente las claves esperadas."""
|
||||||
|
res = detect_declared_keys_duckdb(db)
|
||||||
|
assert set(res.keys()) == {
|
||||||
|
"status",
|
||||||
|
"primary_keys",
|
||||||
|
"foreign_keys",
|
||||||
|
"unique",
|
||||||
|
"tables",
|
||||||
|
}
|
||||||
|
for pk in res["primary_keys"]:
|
||||||
|
assert set(pk.keys()) == {"table", "columns"}
|
||||||
|
for fk in res["foreign_keys"]:
|
||||||
|
assert set(fk.keys()) == {
|
||||||
|
"table",
|
||||||
|
"columns",
|
||||||
|
"referenced_table",
|
||||||
|
"referenced_columns",
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
id: draw_join_graph_figure_py_datascience
|
||||||
|
name: draw_join_graph_figure
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def draw_join_graph_figure(join_graph: dict, title: str = None) -> \"matplotlib.figure.Figure\""
|
||||||
|
description: "Rasteriza el join graph de una base (relaciones FK inter-tabla, salida de build_join_graph) a un matplotlib.figure.Figure: nodos circulares con el nombre de cada tabla (hubs en color de acento cálido, el resto neutro) y aristas dirigidas etiquetadas from_col→to_col (más la cardinalidad si viene). Es la contrapartida dibujada del string Mermaid para que el capítulo de relaciones del informe AutomaticEDA muestre un diagrama real. Layout networkx spring_layout determinista (seed=42), backend Agg sin abrir ventanas; defensivo: nunca lanza y nunca hace I/O."
|
||||||
|
tags: [eda, plot, relations, graph, matplotlib, figure, networkx, datascience, impure]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [matplotlib, networkx]
|
||||||
|
example: |
|
||||||
|
from draw_join_graph_figure import draw_join_graph_figure
|
||||||
|
join_graph = {
|
||||||
|
"nodes": [
|
||||||
|
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||||
|
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"from_table": "orders", "from_col": "customer_id",
|
||||||
|
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
|
||||||
|
],
|
||||||
|
"hubs": ["orders"],
|
||||||
|
}
|
||||||
|
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
|
||||||
|
fig.savefig("/tmp/join_graph.png")
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_returns_figure_with_axis"
|
||||||
|
- "test_savefig_produces_nonempty_png"
|
||||||
|
- "test_empty_dict_does_not_raise_and_savefig_png"
|
||||||
|
- "test_none_does_not_raise_and_savefig_png"
|
||||||
|
test_file_path: "python/functions/datascience/draw_join_graph_figure_test.py"
|
||||||
|
file_path: "python/functions/datascience/draw_join_graph_figure.py"
|
||||||
|
params:
|
||||||
|
- name: join_graph
|
||||||
|
desc: "Dict producido por build_join_graph. Claves: `nodes` (list[dict] con table, out_degree, in_degree, role), `edges` (list[dict] con from_table, from_col, to_table, to_col y opcional cardinality/inclusion) y `hubs` (list[str] de tablas hub a destacar en color cálido). Claves ausentes, items no-dict, None o {} se toleran (devuelve Figure con texto, sin lanzar). Los nombres de nodo se derivan también de las aristas, así que un grafo con edges pero sin nodes explícitos igual se dibuja."
|
||||||
|
- name: title
|
||||||
|
desc: "Título dibujado sobre el diagrama. Si se omite (None) se usa \"Join graph\". Default None."
|
||||||
|
output: "Un matplotlib.figure.Figure (figsize 7x5) con un único Axes que contiene el diagrama node-link dirigido: tablas como nodos circulares etiquetados (hubs en acento cálido #DD8452, resto en azul neutro #4C72B0) y FKs como flechas dirigidas con etiqueta from_col→to_col (+ cardinalidad). Si join_graph no tiene nodos ni aristas (o es None/{}), devuelve igualmente una Figure con el texto centrado \"Sin relaciones FK detectadas.\"; ante cualquier fallo interno devuelve una Figure con un mensaje genérico (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from draw_join_graph_figure import draw_join_graph_figure
|
||||||
|
|
||||||
|
# `join_graph` es la salida de build_join_graph (nodes + edges + hubs).
|
||||||
|
join_graph = {
|
||||||
|
"nodes": [
|
||||||
|
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||||
|
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
|
||||||
|
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"from_table": "orders", "from_col": "customer_id",
|
||||||
|
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
|
||||||
|
{"from_table": "orders", "from_col": "product_id",
|
||||||
|
"to_table": "products", "to_col": "id", "cardinality": "N:1"},
|
||||||
|
],
|
||||||
|
"hubs": ["orders"], # `orders` se pinta en color de acento (tabla de hechos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
|
||||||
|
|
||||||
|
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||||
|
fig.savefig("/tmp/join_graph.png")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala en el capítulo de relaciones de un informe AutomaticEDA cuando quieras un
|
||||||
|
diagrama **dibujado** del esquema relacional, no solo el bloque Mermaid pegable.
|
||||||
|
Pásale directamente la salida de `build_join_graph` (`nodes` + `edges` + `hubs`)
|
||||||
|
y obtienes una `matplotlib.figure.Figure` lista para que el renderer perezoso la
|
||||||
|
rasterice. Es la pareja visual del string Mermaid: Mermaid sirve para pegar en
|
||||||
|
Markdown/docs que lo soporten; esta función produce la imagen real (PNG/PDF) que
|
||||||
|
va embebida en informes que no renderizan Mermaid.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura por matplotlib.** Fija el backend `Agg` al importar — no abre
|
||||||
|
ventanas ni depende de un display. Segura de llamar en lotes desde el
|
||||||
|
renderer.
|
||||||
|
- **Layout determinista (`seed=42`).** Usa `nx.spring_layout(G, seed=42)`, así
|
||||||
|
que la misma entrada produce el mismo diagrama (test reproducible). Para
|
||||||
|
grafos de 0/1 nodos usa una posición fija centrada en vez del spring layout.
|
||||||
|
- **No hace I/O.** No llama `plt.show()` ni guarda a disco — solo devuelve la
|
||||||
|
`Figure`. Quien la consume la rasteriza y la libera (`plt.close(fig)`) para no
|
||||||
|
acumular memoria en informes con muchas tablas.
|
||||||
|
- **Devuelve una Figure, NO un dict.** A diferencia de `build_join_graph` (que
|
||||||
|
devuelve el dict del grafo), esta función devuelve el objeto de figura ya
|
||||||
|
dibujado.
|
||||||
|
- **Defensiva, nunca lanza.** `None`, `{}`, claves ausentes o items malformados
|
||||||
|
se manejan sin error: en el peor caso devuelve una `Figure` con
|
||||||
|
"Sin relaciones FK detectadas." (vacío) o un mensaje genérico (fallo interno).
|
||||||
|
No la envuelvas en try/except por miedo a un raise — no lo hay.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Impure EDA helper: rasterize a join graph to a matplotlib Figure (`eda` group).
|
||||||
|
|
||||||
|
Takes the join graph produced by ``build_join_graph`` (inter-table FK relations)
|
||||||
|
and draws it as a directed node-link diagram on a ready-to-rasterize
|
||||||
|
``matplotlib.figure.Figure``. Hub tables (the ones with the highest out-degree,
|
||||||
|
candidate fact tables of a star schema) are highlighted in a warm accent colour;
|
||||||
|
the rest use a neutral colour. Directed edges carry a ``from_col→to_col`` label
|
||||||
|
(plus the cardinality when present).
|
||||||
|
|
||||||
|
This is the *drawn* counterpart of the Mermaid string that ``build_join_graph``
|
||||||
|
also emits: the relations chapter of an AutomaticEDA report can show a real
|
||||||
|
picture instead of only the pasteable Mermaid block.
|
||||||
|
|
||||||
|
Impure because it touches matplotlib's rendering machinery. It pins the headless
|
||||||
|
Agg backend and a deterministic ``spring_layout`` seed so the output is
|
||||||
|
reproducible. It never raises: on any internal failure (or empty input) it
|
||||||
|
returns a ``Figure`` carrying a centered message, so the lazy render of the
|
||||||
|
document is never broken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
import networkx as nx # noqa: E402
|
||||||
|
|
||||||
|
# Warm accent reserved for hub tables (candidate fact tables / star-schema cores).
|
||||||
|
_HUB_COLOR = "#DD8452"
|
||||||
|
# Neutral blue for every other table.
|
||||||
|
_NODE_COLOR = "#4C72B0"
|
||||||
|
# Muted gray for the empty/error message text.
|
||||||
|
_MUTED_TEXT = "#5f6b7a"
|
||||||
|
# Edge colour and label colour.
|
||||||
|
_EDGE_COLOR = "#7a7a7a"
|
||||||
|
_EDGE_LABEL_COLOR = "#34495e"
|
||||||
|
# Constant node size; shared with the edge drawing so arrowheads stop at the
|
||||||
|
# node boundary instead of being hidden under the marker.
|
||||||
|
_NODE_SIZE = 2200
|
||||||
|
|
||||||
|
|
||||||
|
def _text_figure(message: str) -> "matplotlib.figure.Figure":
|
||||||
|
"""Return a blank Figure carrying a single centered message.
|
||||||
|
|
||||||
|
Used both for the "no relations" case and as the never-raise fallback.
|
||||||
|
"""
|
||||||
|
fig, ax = plt.subplots(figsize=(7, 5))
|
||||||
|
ax.axis("off")
|
||||||
|
ax.text(
|
||||||
|
0.5,
|
||||||
|
0.5,
|
||||||
|
message,
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
fontsize=12,
|
||||||
|
color=_MUTED_TEXT,
|
||||||
|
transform=ax.transAxes,
|
||||||
|
)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_label(edge: dict) -> str:
|
||||||
|
"""Build the ``from_col→to_col`` label of an edge, appending cardinality."""
|
||||||
|
fc = edge.get("from_col")
|
||||||
|
tc = edge.get("to_col")
|
||||||
|
if fc is not None and tc is not None:
|
||||||
|
label = f"{fc}→{tc}"
|
||||||
|
elif fc is not None:
|
||||||
|
label = str(fc)
|
||||||
|
elif tc is not None:
|
||||||
|
label = str(tc)
|
||||||
|
else:
|
||||||
|
label = ""
|
||||||
|
card = edge.get("cardinality")
|
||||||
|
if card:
|
||||||
|
label = f"{label} ({card})" if label else str(card)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def draw_join_graph_figure(join_graph: dict, title: str = None):
|
||||||
|
"""Rasterize a join graph to a matplotlib Figure.
|
||||||
|
|
||||||
|
Builds a ``networkx.DiGraph`` from the graph's nodes and edges, lays it out
|
||||||
|
with a deterministic ``spring_layout`` (``seed=42``) and draws it on a
|
||||||
|
``matplotlib.figure.Figure``: tables as labelled circular nodes (hubs in a
|
||||||
|
warm accent, the rest neutral) and FK relations as directed arrows labelled
|
||||||
|
``from_col→to_col`` (plus cardinality when available).
|
||||||
|
|
||||||
|
The function never raises. On empty/``None`` input it returns a Figure with
|
||||||
|
a centered "Sin relaciones FK detectadas." message; on any internal failure
|
||||||
|
it returns a Figure with a generic centered message. It never shows the
|
||||||
|
figure nor writes it to disk — the document renderer rasterizes it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
join_graph: Dict produced by ``build_join_graph`` with keys ``nodes``
|
||||||
|
(list of ``{table, out_degree, in_degree, role}``), ``edges`` (list
|
||||||
|
of ``{from_table, from_col, to_table, to_col, cardinality?,
|
||||||
|
inclusion?}``) and ``hubs`` (list of hub table names to highlight).
|
||||||
|
Missing keys, non-dict items, ``None`` or ``{}`` are all tolerated.
|
||||||
|
title: Optional title drawn above the diagram. When omitted, the title
|
||||||
|
defaults to "Join graph".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``matplotlib.figure.Figure`` (figsize 7x5) with a single Axes holding
|
||||||
|
the node-link diagram. The caller rasterizes/closes it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
jg = join_graph if isinstance(join_graph, dict) else {}
|
||||||
|
nodes = jg.get("nodes") or []
|
||||||
|
edges = jg.get("edges") or []
|
||||||
|
hubs = {h for h in (jg.get("hubs") or []) if h is not None}
|
||||||
|
|
||||||
|
# Collect node names from the declared nodes and, defensively, from the
|
||||||
|
# edges (so a graph with edges but no explicit nodes still draws).
|
||||||
|
node_names: list = []
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
def _register(name) -> None:
|
||||||
|
if name is not None and name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
node_names.append(name)
|
||||||
|
|
||||||
|
for n in nodes:
|
||||||
|
if isinstance(n, dict):
|
||||||
|
_register(n.get("table"))
|
||||||
|
for e in edges:
|
||||||
|
if isinstance(e, dict):
|
||||||
|
_register(e.get("from_table"))
|
||||||
|
_register(e.get("to_table"))
|
||||||
|
|
||||||
|
if not node_names:
|
||||||
|
return _text_figure("Sin relaciones FK detectadas.")
|
||||||
|
|
||||||
|
graph = nx.DiGraph()
|
||||||
|
for name in node_names:
|
||||||
|
graph.add_node(name)
|
||||||
|
|
||||||
|
edge_labels: dict = {}
|
||||||
|
for e in edges:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
continue
|
||||||
|
ft = e.get("from_table")
|
||||||
|
tt = e.get("to_table")
|
||||||
|
if ft is None or tt is None:
|
||||||
|
continue
|
||||||
|
graph.add_edge(ft, tt)
|
||||||
|
edge_labels[(ft, tt)] = _edge_label(e)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(7, 5))
|
||||||
|
|
||||||
|
# Deterministic layout. Fixed positions for trivial graphs so a single
|
||||||
|
# node sits centered instead of at an arbitrary spring-layout point.
|
||||||
|
if graph.number_of_nodes() <= 1:
|
||||||
|
pos = {name: (0.5, 0.5) for name in graph.nodes()}
|
||||||
|
else:
|
||||||
|
pos = nx.spring_layout(graph, seed=42)
|
||||||
|
|
||||||
|
node_colors = [
|
||||||
|
_HUB_COLOR if name in hubs else _NODE_COLOR for name in graph.nodes()
|
||||||
|
]
|
||||||
|
nx.draw_networkx_nodes(
|
||||||
|
graph,
|
||||||
|
pos,
|
||||||
|
ax=ax,
|
||||||
|
node_color=node_colors,
|
||||||
|
node_size=_NODE_SIZE,
|
||||||
|
node_shape="o",
|
||||||
|
edgecolors="white",
|
||||||
|
linewidths=1.5,
|
||||||
|
)
|
||||||
|
nx.draw_networkx_labels(
|
||||||
|
graph,
|
||||||
|
pos,
|
||||||
|
ax=ax,
|
||||||
|
font_size=9,
|
||||||
|
font_color="white",
|
||||||
|
font_weight="bold",
|
||||||
|
)
|
||||||
|
nx.draw_networkx_edges(
|
||||||
|
graph,
|
||||||
|
pos,
|
||||||
|
ax=ax,
|
||||||
|
arrows=True,
|
||||||
|
arrowstyle="-|>",
|
||||||
|
arrowsize=18,
|
||||||
|
edge_color=_EDGE_COLOR,
|
||||||
|
width=1.4,
|
||||||
|
connectionstyle="arc3,rad=0.06",
|
||||||
|
node_size=_NODE_SIZE,
|
||||||
|
)
|
||||||
|
if any(lbl for lbl in edge_labels.values()):
|
||||||
|
nx.draw_networkx_edge_labels(
|
||||||
|
graph,
|
||||||
|
pos,
|
||||||
|
edge_labels=edge_labels,
|
||||||
|
ax=ax,
|
||||||
|
font_size=7,
|
||||||
|
font_color=_EDGE_LABEL_COLOR,
|
||||||
|
bbox={
|
||||||
|
"boxstyle": "round,pad=0.2",
|
||||||
|
"fc": "white",
|
||||||
|
"ec": "none",
|
||||||
|
"alpha": 0.7,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ax.set_title(title if title else "Join graph", fontsize=13)
|
||||||
|
ax.axis("off")
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
except Exception:
|
||||||
|
# Never raise — the document render is lazy and must not be broken.
|
||||||
|
return _text_figure("No se pudo dibujar el join graph.")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Tests para draw_join_graph_figure (rasteriza el join graph, grupo eda).
|
||||||
|
|
||||||
|
Usa el backend Agg sin abrir ventanas; cada test cierra la Figure construida
|
||||||
|
(matplotlib.pyplot.close) para no acumular estado entre tests. Las aserciones de
|
||||||
|
guardado escriben a tmp_path (fixture de pytest) y comprueban que el PNG no está
|
||||||
|
vacío.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
from matplotlib.figure import Figure # noqa: E402
|
||||||
|
|
||||||
|
from draw_join_graph_figure import draw_join_graph_figure
|
||||||
|
|
||||||
|
|
||||||
|
def _make_join_graph():
|
||||||
|
"""Join graph mínimo: 3 nodos (customers/orders/products) y 2 aristas.
|
||||||
|
|
||||||
|
orders -> customers y orders -> products. `orders` es el hub (out_degree 2).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"nodes": [
|
||||||
|
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||||
|
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
|
||||||
|
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"from_table": "orders",
|
||||||
|
"from_col": "customer_id",
|
||||||
|
"to_table": "customers",
|
||||||
|
"to_col": "id",
|
||||||
|
"cardinality": "N:1",
|
||||||
|
"inclusion": 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from_table": "orders",
|
||||||
|
"from_col": "product_id",
|
||||||
|
"to_table": "products",
|
||||||
|
"to_col": "id",
|
||||||
|
"cardinality": "N:1",
|
||||||
|
"inclusion": 0.98,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"hubs": ["orders"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_figure_with_axis():
|
||||||
|
fig = draw_join_graph_figure(_make_join_graph(), title="Relaciones FK")
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
# Al menos un eje con el diagrama.
|
||||||
|
assert len(fig.axes) >= 1
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_savefig_produces_nonempty_png(tmp_path):
|
||||||
|
fig = draw_join_graph_figure(_make_join_graph())
|
||||||
|
out = tmp_path / "g.png"
|
||||||
|
fig.savefig(out)
|
||||||
|
assert out.exists()
|
||||||
|
assert out.stat().st_size > 0
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dict_does_not_raise_and_savefig_png(tmp_path):
|
||||||
|
fig = draw_join_graph_figure({})
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
out = tmp_path / "empty.png"
|
||||||
|
fig.savefig(out)
|
||||||
|
assert out.stat().st_size > 0
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_does_not_raise_and_savefig_png(tmp_path):
|
||||||
|
fig = draw_join_graph_figure(None)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
out = tmp_path / "none.png"
|
||||||
|
fig.savefig(out)
|
||||||
|
assert out.stat().st_size > 0
|
||||||
|
plt.close(fig)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: effect_size_cohens_d
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def effect_size_cohens_d(group_a: list, group_b: list) -> dict"
|
||||||
|
description: "Tamano del efecto (effect size) entre dos grupos numericos: Cohen's d (diferencia de medias estandarizada por la desviacion tipica combinada, varianzas muestrales ddof=1), Hedges' g (d corregido por el sesgo al alza con muestras pequenas via el factor J) e interpretacion cualitativa de la magnitud segun los umbrales clasicos de Cohen (negligible/small/medium/large). El p-valor dice si hay diferencia; el effect size dice como de grande, de forma adimensional e independiente del N. Pura, sin dependencias externas; nunca lanza: los casos degenerados (varianza cero, N<2, listas vacias) devuelven NaN + una clave note."
|
||||||
|
tags: [papers, statistics, effect-size, cohens-d, hedges-g, python]
|
||||||
|
params:
|
||||||
|
- name: group_a
|
||||||
|
desc: "primera muestra (lista de numeros). Necesita >=2 observaciones para que exista la varianza muestral (ddof=1)."
|
||||||
|
- name: group_b
|
||||||
|
desc: "segunda muestra (lista de numeros). Necesita >=2 observaciones. El signo de cohens_d es positivo cuando mean_a > mean_b."
|
||||||
|
output: "dict {cohens_d: float (diferencia de medias estandarizada, puede ser NaN), hedges_g: float (cohens_d * factor de correccion J, puede ser NaN), interpretation: str ('negligible'|'small'|'medium'|'large', o 'undefined' en casos degenerados), n_a: int, n_b: int, mean_a: float, mean_b: float, pooled_sd: float (desviacion tipica combinada)}. Casos degenerados (varianza cero en ambos grupos, N<2 en algun grupo, o listas vacias) anaden clave note. Nunca None ni excepcion."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_large_effect", "test_hedges_g_menor_en_magnitud_que_cohens_d", "test_interpretation_thresholds", "test_signo_positivo_cuando_a_mayor_que_b", "test_varianza_cero_no_lanza", "test_n_insuficiente_no_lanza", "test_listas_vacias_no_lanza", "test_un_grupo_vacio_no_lanza"]
|
||||||
|
test_file_path: "python/functions/datascience/effect_size_cohens_d_test.py"
|
||||||
|
file_path: "python/functions/datascience/effect_size_cohens_d.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import effect_size_cohens_d
|
||||||
|
|
||||||
|
# Dos grupos desplazados 2 unidades, misma dispersion.
|
||||||
|
a = [1, 2, 3, 4, 5] # media 3, varianza muestral 2.5
|
||||||
|
b = [3, 4, 5, 6, 7] # media 5, varianza muestral 2.5
|
||||||
|
|
||||||
|
out = effect_size_cohens_d(a, b)
|
||||||
|
print(out["cohens_d"]) # -> -1.264911... (a esta 1.26 SD por debajo de b)
|
||||||
|
print(out["hedges_g"]) # -> -1.142500... (|g| < |d|: correccion N pequeno)
|
||||||
|
print(out["interpretation"]) # -> "large" (|d| >= 0.8)
|
||||||
|
print(out["pooled_sd"]) # -> 1.581138...
|
||||||
|
|
||||||
|
# Caso degenerado: varianza cero -> no lanza, NaN + note.
|
||||||
|
deg = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
|
||||||
|
print(deg["interpretation"]) # -> "undefined"
|
||||||
|
print(deg["note"]) # -> "varianza cero, effect size indefinido"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando ya sepas que dos grupos difieren (o quieras cuantificar su diferencia)
|
||||||
|
y necesites una medida **de magnitud, no de significancia**: comparar el antes
|
||||||
|
y el despues de una intervencion, el grupo control frente al tratamiento, o dos
|
||||||
|
cohortes. Reportala junto al p-valor para responder "¿como de grande es la
|
||||||
|
diferencia?" — un p-valor minusculo con N enorme puede esconder un efecto
|
||||||
|
trivial. Es adimensional (en unidades de desviaciones tipicas), asi que hace
|
||||||
|
comparables resultados entre estudios y alimenta meta-analisis. Usa **Hedges' g**
|
||||||
|
en lugar de Cohen's d cuando los grupos sean pequenos (decenas o menos): d
|
||||||
|
sobreestima el efecto y g lo corrige.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Pura y sin dependencias externas (solo `math` de la stdlib).
|
||||||
|
- Usa **varianza muestral** (ddof=1), no poblacional. Por eso cada grupo
|
||||||
|
necesita al menos 2 observaciones; con N=1 la varianza muestral no existe y la
|
||||||
|
funcion devuelve NaN + `note`.
|
||||||
|
- **Nunca lanza excepcion**. Los casos degenerados devuelven `cohens_d` y
|
||||||
|
`hedges_g` a `float('nan')`, `interpretation="undefined"` y una clave `note`:
|
||||||
|
varianza cero en ambos grupos (`pooled_sd == 0`), N<2 en algun grupo, o listas
|
||||||
|
vacias. Comprueba con `math.isnan(out["cohens_d"])` o la presencia de `note`
|
||||||
|
antes de usar el resultado.
|
||||||
|
- El **signo** de `cohens_d` depende del orden de los argumentos: positivo si
|
||||||
|
`mean_a > mean_b`, negativo en caso contrario. La `interpretation` usa `|d|`,
|
||||||
|
asi que no depende del orden.
|
||||||
|
- `pooled_sd` asume varianzas comparables entre grupos (homogeneidad). Si las
|
||||||
|
dispersiones son muy distintas, Cohen's d clasico pierde precision; considera
|
||||||
|
variantes (Glass's delta) fuera del alcance de esta funcion.
|
||||||
|
- Los umbrales de Cohen (0.2 / 0.5 / 0.8) son convencion, no ley: interpretalos
|
||||||
|
segun el dominio.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Effect size de dos grupos: Cohen's d, Hedges' g e interpretacion cualitativa.
|
||||||
|
|
||||||
|
Funcion pura del grupo papers. El p-valor responde a "¿hay diferencia?" pero no
|
||||||
|
a "¿como de grande es?". El tamano del efecto (effect size) cuantifica la
|
||||||
|
magnitud de la diferencia entre dos grupos de forma adimensional, independiente
|
||||||
|
del N, y es lo que hace comparables resultados entre estudios (meta-analisis).
|
||||||
|
|
||||||
|
- Cohen's d: diferencia de medias estandarizada por la desviacion tipica
|
||||||
|
combinada (pooled SD), con varianzas muestrales (ddof=1).
|
||||||
|
- Hedges' g: Cohen's d corregido por el sesgo al alza que sufre d con muestras
|
||||||
|
pequenas, multiplicando por el factor de correccion J.
|
||||||
|
- interpretation: etiqueta cualitativa de |d| segun los umbrales clasicos de
|
||||||
|
Cohen (negligible / small / medium / large).
|
||||||
|
|
||||||
|
No usa dependencias externas: aritmetica de la libreria estandar (``math``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def _mean(xs: list) -> float:
|
||||||
|
"""Media aritmetica de una lista no vacia de numeros."""
|
||||||
|
return sum(float(x) for x in xs) / len(xs)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_variance(xs: list, mean: float) -> float:
|
||||||
|
"""Varianza muestral (ddof=1) de una lista con al menos 2 elementos."""
|
||||||
|
n = len(xs)
|
||||||
|
return sum((float(x) - mean) ** 2 for x in xs) / (n - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _interpret(abs_d: float) -> str:
|
||||||
|
"""Etiqueta cualitativa del tamano del efecto segun |d| (umbrales de Cohen)."""
|
||||||
|
if abs_d < 0.2:
|
||||||
|
return "negligible"
|
||||||
|
if abs_d < 0.5:
|
||||||
|
return "small"
|
||||||
|
if abs_d < 0.8:
|
||||||
|
return "medium"
|
||||||
|
return "large"
|
||||||
|
|
||||||
|
|
||||||
|
def effect_size_cohens_d(group_a: list, group_b: list) -> dict:
|
||||||
|
"""Calcula el tamano del efecto entre dos grupos numericos.
|
||||||
|
|
||||||
|
Devuelve Cohen's d (diferencia de medias estandarizada por la pooled SD),
|
||||||
|
Hedges' g (d corregido por sesgo de muestra pequena) y una etiqueta
|
||||||
|
cualitativa de la magnitud segun los umbrales de Cohen.
|
||||||
|
|
||||||
|
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
|
||||||
|
excepcion ante datos degenerados; en su lugar devuelve un dict con
|
||||||
|
``cohens_d`` / ``hedges_g`` a ``float('nan')``, ``interpretation`` a
|
||||||
|
``"undefined"`` y una clave ``note`` explicando el caso.
|
||||||
|
|
||||||
|
Definiciones:
|
||||||
|
s_pooled = sqrt(((n1-1)*s1^2 + (n2-1)*s2^2) / (n1+n2-2)), con s1^2, s2^2
|
||||||
|
varianzas muestrales (ddof=1).
|
||||||
|
cohens_d = (mean_a - mean_b) / s_pooled.
|
||||||
|
J = 1 - 3 / (4*(n1+n2) - 9) (factor de correccion de Hedges).
|
||||||
|
hedges_g = cohens_d * J.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_a: primera muestra (lista de numeros). Necesita >=2 elementos para
|
||||||
|
que exista la varianza muestral.
|
||||||
|
group_b: segunda muestra (lista de numeros). Necesita >=2 elementos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves:
|
||||||
|
cohens_d: float, diferencia de medias estandarizada (puede ser NaN).
|
||||||
|
hedges_g: float, Cohen's d corregido por sesgo (puede ser NaN).
|
||||||
|
interpretation: str, "negligible" | "small" | "medium" | "large", o
|
||||||
|
"undefined" en casos degenerados.
|
||||||
|
n_a: int, tamano de group_a.
|
||||||
|
n_b: int, tamano de group_b.
|
||||||
|
mean_a: float, media de group_a (NaN si vacio).
|
||||||
|
mean_b: float, media de group_b (NaN si vacio).
|
||||||
|
pooled_sd: float, desviacion tipica combinada (NaN si indefinida).
|
||||||
|
|
||||||
|
Casos degenerados (lista vacia, N<2 en algun grupo, o varianza cero en
|
||||||
|
ambos grupos -> pooled_sd == 0) anaden ademas una clave ``note``.
|
||||||
|
"""
|
||||||
|
nan = float("nan")
|
||||||
|
n_a = len(group_a)
|
||||||
|
n_b = len(group_b)
|
||||||
|
|
||||||
|
# Listas vacias: ni media ni varianza definidas.
|
||||||
|
if n_a == 0 or n_b == 0:
|
||||||
|
return {
|
||||||
|
"cohens_d": nan,
|
||||||
|
"hedges_g": nan,
|
||||||
|
"interpretation": "undefined",
|
||||||
|
"n_a": n_a,
|
||||||
|
"n_b": n_b,
|
||||||
|
"mean_a": _mean(group_a) if n_a else nan,
|
||||||
|
"mean_b": _mean(group_b) if n_b else nan,
|
||||||
|
"pooled_sd": nan,
|
||||||
|
"note": "grupo vacio: media y varianza indefinidas, effect size indefinido",
|
||||||
|
}
|
||||||
|
|
||||||
|
mean_a = _mean(group_a)
|
||||||
|
mean_b = _mean(group_b)
|
||||||
|
|
||||||
|
# N insuficiente: la varianza muestral (ddof=1) no existe con un solo dato,
|
||||||
|
# y la correccion de Hedges no es fiable.
|
||||||
|
if n_a < 2 or n_b < 2:
|
||||||
|
return {
|
||||||
|
"cohens_d": nan,
|
||||||
|
"hedges_g": nan,
|
||||||
|
"interpretation": "undefined",
|
||||||
|
"n_a": n_a,
|
||||||
|
"n_b": n_b,
|
||||||
|
"mean_a": mean_a,
|
||||||
|
"mean_b": mean_b,
|
||||||
|
"pooled_sd": nan,
|
||||||
|
"note": (
|
||||||
|
"N insuficiente: cada grupo necesita >=2 observaciones para la "
|
||||||
|
"varianza muestral; effect size indefinido"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
var_a = _sample_variance(group_a, mean_a)
|
||||||
|
var_b = _sample_variance(group_b, mean_b)
|
||||||
|
pooled_sd = math.sqrt(
|
||||||
|
((n_a - 1) * var_a + (n_b - 1) * var_b) / (n_a + n_b - 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Varianza cero en ambos grupos: no se puede estandarizar (division por 0).
|
||||||
|
if pooled_sd == 0.0:
|
||||||
|
return {
|
||||||
|
"cohens_d": nan,
|
||||||
|
"hedges_g": nan,
|
||||||
|
"interpretation": "undefined",
|
||||||
|
"n_a": n_a,
|
||||||
|
"n_b": n_b,
|
||||||
|
"mean_a": mean_a,
|
||||||
|
"mean_b": mean_b,
|
||||||
|
"pooled_sd": 0.0,
|
||||||
|
"note": "varianza cero, effect size indefinido",
|
||||||
|
}
|
||||||
|
|
||||||
|
cohens_d = (mean_a - mean_b) / pooled_sd
|
||||||
|
j = 1.0 - 3.0 / (4.0 * (n_a + n_b) - 9.0)
|
||||||
|
hedges_g = cohens_d * j
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cohens_d": cohens_d,
|
||||||
|
"hedges_g": hedges_g,
|
||||||
|
"interpretation": _interpret(abs(cohens_d)),
|
||||||
|
"n_a": n_a,
|
||||||
|
"n_b": n_b,
|
||||||
|
"mean_a": mean_a,
|
||||||
|
"mean_b": mean_b,
|
||||||
|
"pooled_sd": pooled_sd,
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests para effect_size_cohens_d (tamano del efecto de dos grupos).
|
||||||
|
|
||||||
|
Importa el modulo hoja directamente (`effect_size_cohens_d`) para no depender de
|
||||||
|
que el paquete reexporte la funcion en su __init__ (lo integra el orquestador al
|
||||||
|
cerrar el grupo papers). El pytest del repo tiene pythonpath=["functions", ...],
|
||||||
|
asi que el modulo hoja se resuelve por su nombre directo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from effect_size_cohens_d import effect_size_cohens_d
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_large_effect():
|
||||||
|
# group_a: mean 3, var muestral 2.5; group_b: mean 5, var 2.5.
|
||||||
|
# pooled_sd = sqrt(2.5) ~= 1.5811388.
|
||||||
|
# cohens_d = (3-5)/1.5811388 ~= -1.264911.
|
||||||
|
# J = 1 - 3/(4*10-9) = 1 - 3/31 = 0.9032258.
|
||||||
|
# hedges_g = d * J = -1.2649111 * 0.9032258 ~= -1.142500.
|
||||||
|
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
|
||||||
|
assert abs(out["cohens_d"] - (-1.26491)) < 1e-4
|
||||||
|
assert abs(out["hedges_g"] - (-1.14250)) < 1e-4
|
||||||
|
assert out["interpretation"] == "large"
|
||||||
|
assert out["n_a"] == 5
|
||||||
|
assert out["n_b"] == 5
|
||||||
|
assert abs(out["mean_a"] - 3.0) < 1e-12
|
||||||
|
assert abs(out["mean_b"] - 5.0) < 1e-12
|
||||||
|
assert abs(out["pooled_sd"] - math.sqrt(2.5)) < 1e-9
|
||||||
|
assert "note" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_hedges_g_menor_en_magnitud_que_cohens_d():
|
||||||
|
# La correccion J esta en (0, 1), asi que |g| < |d| siempre.
|
||||||
|
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
|
||||||
|
assert abs(out["hedges_g"]) < abs(out["cohens_d"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpretation_thresholds():
|
||||||
|
# negligible: |d| < 0.2. Medias casi iguales con varianza grande.
|
||||||
|
neg = effect_size_cohens_d([0, 10, 20, 30], [1, 11, 21, 31])
|
||||||
|
assert neg["interpretation"] == "negligible"
|
||||||
|
assert abs(neg["cohens_d"]) < 0.2
|
||||||
|
|
||||||
|
# small: 0.2 <= |d| < 0.5.
|
||||||
|
small = effect_size_cohens_d([0, 10, 20, 30], [4, 14, 24, 34])
|
||||||
|
assert small["interpretation"] == "small"
|
||||||
|
assert 0.2 <= abs(small["cohens_d"]) < 0.5
|
||||||
|
|
||||||
|
# medium: 0.5 <= |d| < 0.8.
|
||||||
|
medium = effect_size_cohens_d([0, 10, 20, 30], [9, 19, 29, 39])
|
||||||
|
assert medium["interpretation"] == "medium"
|
||||||
|
assert 0.5 <= abs(medium["cohens_d"]) < 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_signo_positivo_cuando_a_mayor_que_b():
|
||||||
|
out = effect_size_cohens_d([10, 12, 14, 16], [1, 2, 3, 4])
|
||||||
|
assert out["cohens_d"] > 0
|
||||||
|
assert out["interpretation"] == "large"
|
||||||
|
|
||||||
|
|
||||||
|
def test_varianza_cero_no_lanza():
|
||||||
|
out = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
|
||||||
|
assert math.isnan(out["cohens_d"])
|
||||||
|
assert math.isnan(out["hedges_g"])
|
||||||
|
assert out["interpretation"] == "undefined"
|
||||||
|
assert out["pooled_sd"] == 0.0
|
||||||
|
assert "note" in out
|
||||||
|
assert "varianza cero" in out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_n_insuficiente_no_lanza():
|
||||||
|
out = effect_size_cohens_d([3], [1, 2, 3])
|
||||||
|
assert math.isnan(out["cohens_d"])
|
||||||
|
assert math.isnan(out["hedges_g"])
|
||||||
|
assert out["interpretation"] == "undefined"
|
||||||
|
assert out["n_a"] == 1
|
||||||
|
assert out["n_b"] == 3
|
||||||
|
assert "note" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_listas_vacias_no_lanza():
|
||||||
|
out = effect_size_cohens_d([], [])
|
||||||
|
assert math.isnan(out["cohens_d"])
|
||||||
|
assert math.isnan(out["hedges_g"])
|
||||||
|
assert out["interpretation"] == "undefined"
|
||||||
|
assert out["n_a"] == 0
|
||||||
|
assert out["n_b"] == 0
|
||||||
|
assert "note" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_grupo_vacio_no_lanza():
|
||||||
|
out = effect_size_cohens_d([1, 2, 3], [])
|
||||||
|
assert math.isnan(out["cohens_d"])
|
||||||
|
assert out["interpretation"] == "undefined"
|
||||||
|
assert out["n_b"] == 0
|
||||||
|
assert "note" in out
|
||||||
@@ -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
|
||||||
@@ -3,19 +3,19 @@ name: fdr_correction
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict"
|
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)."
|
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, p-value, data-mining-bias, python]
|
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, holm, holm-bonferroni, fwer, p-value, data-mining-bias, python]
|
||||||
params:
|
params:
|
||||||
- name: pvalues
|
- 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)."
|
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
|
- 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)."
|
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
|
- 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."
|
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}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
|
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_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -23,7 +23,7 @@ returns_optional: false
|
|||||||
error_type: ""
|
error_type: ""
|
||||||
imports: [math]
|
imports: [math]
|
||||||
tested: true
|
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"
|
test_file_path: "python/functions/datascience/fdr_correction_test.py"
|
||||||
file_path: "python/functions/datascience/fdr_correction.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["reject"]) # -> [True, False, False]
|
||||||
print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0]
|
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
|
# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la
|
||||||
# lista completa de pares y recuperar el mapeo 1:1.
|
# lista completa de pares y recuperar el mapeo 1:1.
|
||||||
mix = fdr_correction([0.001, None, 0.9])
|
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
|
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
|
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"`
|
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
|
por defecto (mejor potencia); `"holm"` (Holm-Bonferroni, FWER step-down) cuando
|
||||||
costoso y prefieras maxima cautela.
|
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
|
## Gotchas
|
||||||
|
|
||||||
@@ -76,8 +86,16 @@ costoso y prefieras maxima cautela.
|
|||||||
eso puedes pasar la lista completa de pares aunque algunos no tengan test.
|
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
|
- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que
|
||||||
`len(pvalues)` si hay `None`.
|
`len(pvalues)` si hay `None`.
|
||||||
- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos
|
- BH controla cosa distinta que Bonferroni/Holm: BH la tasa de falsos
|
||||||
descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso
|
descubrimientos (FDR); Bonferroni y Holm la probabilidad de *cualquier* falso
|
||||||
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
|
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
|
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
|
||||||
con `note`.
|
con `note`. Los metodos validos son `"bh"`, `"bonferroni"` y `"holm"`.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-30) — añade method="holm" (Holm-Bonferroni step-down, FWER, más potente que Bonferroni simple).
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ todos los pares de una matriz de asociacion), la probabilidad de obtener al meno
|
|||||||
un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria
|
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
|
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
|
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
|
- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos
|
||||||
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
|
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
|
||||||
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia
|
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia
|
||||||
(Family-Wise Error Rate, FWER). Mas conservador.
|
(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.
|
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:
|
def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict:
|
||||||
"""Corrige una lista de p-valores por comparaciones multiples.
|
"""Corrige una lista de p-valores por comparaciones multiples.
|
||||||
|
|
||||||
Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y
|
Aplica Benjamini-Hochberg (FDR), Bonferroni (FWER) o Holm-Bonferroni
|
||||||
devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y
|
(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
|
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
|
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`` /
|
``[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
|
otros valores no validos en posiciones sin test disponible; se
|
||||||
propagan como ``None`` en la salida y no cuentan como prueba.
|
propagan como ``None`` en la salida y no cuentan como prueba.
|
||||||
alpha: nivel de significancia objetivo tras la correccion (default 0.05).
|
alpha: nivel de significancia objetivo tras la correccion (default 0.05).
|
||||||
Para BH es el umbral del FDR; para Bonferroni, del FWER.
|
Para BH es el umbral del FDR; para Bonferroni y Holm, del FWER.
|
||||||
method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER).
|
method: ``"bh"`` (Benjamini-Hochberg, FDR), ``"bonferroni"`` (FWER) o
|
||||||
|
``"holm"`` (Holm-Bonferroni, FWER step-down, mas potente que
|
||||||
|
Bonferroni simple).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con las claves:
|
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_tests: numero de p-valores validos usados en la correccion (m).
|
||||||
n_rejected: numero de hipotesis rechazadas (significativas).
|
n_rejected: numero de hipotesis rechazadas (significativas).
|
||||||
alpha: nivel de significancia aplicado (float).
|
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
|
Casos degenerados (lista vacia, sin p-valores validos o metodo
|
||||||
desconocido) anaden ademas una clave ``note`` y devuelven listas
|
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).
|
en las posiciones invalidas).
|
||||||
"""
|
"""
|
||||||
method_norm = (method or "").strip().lower()
|
method_norm = (method or "").strip().lower()
|
||||||
if method_norm not in {"bh", "bonferroni"}:
|
if method_norm not in {"bh", "bonferroni", "holm"}:
|
||||||
n = len(pvalues)
|
n = len(pvalues)
|
||||||
return {
|
return {
|
||||||
"p_values_adjusted": [None] * n,
|
"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),
|
"alpha": float(alpha),
|
||||||
"method": method,
|
"method": method,
|
||||||
"note": (
|
"note": (
|
||||||
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) "
|
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg), "
|
||||||
"o 'bonferroni'"
|
"'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)
|
padj = min(1.0, p * m)
|
||||||
adjusted[orig_idx] = padj
|
adjusted[orig_idx] = padj
|
||||||
reject[orig_idx] = padj <= a
|
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:
|
else:
|
||||||
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
|
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
|
||||||
# con la monotonicidad acumulada de derecha a izquierda.
|
# con la monotonicidad acumulada de derecha a izquierda.
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ def test_solo_none_devuelve_note():
|
|||||||
|
|
||||||
|
|
||||||
def test_metodo_desconocido_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 "note" in out
|
||||||
assert out["n_rejected"] == 0
|
assert out["n_rejected"] == 0
|
||||||
assert out["reject"] == [False, False]
|
assert out["reject"] == [False, False]
|
||||||
@@ -97,3 +98,66 @@ def test_todos_significativos():
|
|||||||
assert bon["n_rejected"] == 3
|
assert bon["n_rejected"] == 3
|
||||||
assert all(bh["reject"])
|
assert all(bh["reject"])
|
||||||
assert all(bon["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,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user