Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26569c7015 | |||
| 44622339fa | |||
| c0d44a6352 | |||
| cab0fbf0a3 | |||
| 7f304adc9c | |||
| a74a5a047f | |||
| 44be1d6b58 | |||
| 64306f3b1c | |||
| f2eb782a5f | |||
| 80d10010f5 | |||
| ecc22d6d57 | |||
| 7bdb8bffb5 | |||
| 4139394326 | |||
| 54a9ab70c7 | |||
| 4773781323 | |||
| ea6678ec23 | |||
| 50c05d126c | |||
| 6f88f184f1 | |||
| 792b890195 | |||
| 9886e2905d | |||
| bebbd05de5 | |||
| 6fb6ef6cfe | |||
| 857c3d8637 | |||
| e5abc18211 | |||
| 4f1530797e | |||
| 9da1ee6533 | |||
| 5d4a48ec5e | |||
| 7fa19d65db | |||
| 6e3c3cf2a2 | |||
| 105e56cf05 | |||
| eaca41a532 | |||
| 6a1520f458 | |||
| e815f5b3b9 | |||
| 7ec2bb1b45 |
@@ -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 ""
|
||||||
@@ -41,12 +41,13 @@ reconocido se degrada a `Note`, nunca lanza).
|
|||||||
| `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento |
|
| `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento |
|
||||||
| `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** |
|
| `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** |
|
||||||
| `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve |
|
| `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve |
|
||||||
| `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **se parte por filas repitiendo cabecera**; las celdas largas se envuelven dentro de su columna |
|
| `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **si cabe** como texto se parte por filas repitiendo cabecera; **si NO cabe** (demasiadas columnas) se rasteriza entera como imagen de alta resolución para hacer zoom. Ver §11.4 |
|
||||||
| `Figure(fig=None, make=None, caption=None, height_in=None)` | una `matplotlib.figure.Figure` ya construida (`fig`) o un callable `make()->Figure` (perezoso) | se rasteriza y escala para caber entera (nunca recortada) |
|
| `Figure(fig=None, make=None, caption=None, height_in=None)` | una `matplotlib.figure.Figure` ya construida (`fig`) o un callable `make()->Figure` (perezoso) | se rasteriza y escala para caber entera (nunca recortada) |
|
||||||
| `Image(path, caption=None, height_in=None)` | ruta a PNG/JPG | se escala para caber entera |
|
| `Image(path, caption=None, height_in=None)` | ruta a PNG/JPG | se escala para caber entera |
|
||||||
| `Caption(text)` / `Note(text)` | texto auxiliar pequeño | pie/nota en gris; `Note` es además el fallback de lo desconocido |
|
| `Caption(text)` / `Note(text)` | texto auxiliar pequeño | pie/nota en gris; `Note` es además el fallback de lo desconocido |
|
||||||
| `Group(blocks, title=None)` | unidad **keep-together**: sus bloques se mantienen juntos | el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. Ver §11 |
|
| `Group(blocks, title=None, page_break_before=False, layout="stack")` | unidad **keep-together**: sus bloques se mantienen juntos | el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. `layout="side_by_side"` coloca tabla+figura en dos columnas (solo PPTX). Ver §11 y §11.4 |
|
||||||
| `GlossaryEntry(key, label, definition)` | una entrada del glosario (destino clicable) | la genera el capítulo `glosario`; registra su posición como destino de los términos marcados. Ver §11 |
|
| `GlossaryEntry(key, label, definition)` | una entrada del glosario (destino clicable) | la genera el capítulo `glosario`; registra su posición como destino de los términos marcados. Ver §11 |
|
||||||
|
| `TocEntry(label, target_id)` | una entrada de **índice clicable** en la portada | la genera el capítulo `portada`; el renderer la cablea como salto al inicio del capítulo cuyo `id` o `title` coincide con `target_id`. Ver §11.4 |
|
||||||
|
|
||||||
`Figure`/`Image` aceptan `height_in` (hint): el renderer **clampa** la figura a esa altura máxima (lo usa `Group` para encoger la figura). Toda figura escala dejando sitio a su caption en la misma página/slide; en PPTX el caption es **siempre** visible (si no se da `caption`, cae al último heading o a "Figura").
|
`Figure`/`Image` aceptan `height_in` (hint): el renderer **clampa** la figura a esa altura máxima (lo usa `Group` para encoger la figura). Toda figura escala dejando sitio a su caption en la misma página/slide; en PPTX el caption es **siempre** visible (si no se da `caption`, cae al último heading o a "Figura").
|
||||||
|
|
||||||
@@ -397,6 +398,65 @@ cabecera con su fondo propio. Es automático en PDF y PPTX; el patrón se mantie
|
|||||||
cuando una tabla larga se parte y repite cabecera (el índice de fila es lógico, no por
|
cuando una tabla larga se parte y repite cabecera (el índice de fila es lógico, no por
|
||||||
página). No hay nada que hacer en los capítulos.
|
página). No hay nada que hacer en los capítulos.
|
||||||
|
|
||||||
|
### 11.4 Calidad de render global: DPI alto, tabla ancha → imagen, figura al lado, índice clicable
|
||||||
|
|
||||||
|
Cuatro capacidades transversales del motor, **todas automáticas salvo `layout`** (que un
|
||||||
|
capítulo activa explícitamente). Aplican a PDF y PPTX salvo donde se indique.
|
||||||
|
|
||||||
|
**(a) DPI alto (automático).** Toda figura/imagen embebida se rasteriza a **220 dpi**
|
||||||
|
(constante `_RASTER_DPI` en ambos renderers; en PDF se aplica también al `savefig` de la
|
||||||
|
página, porque matplotlib re-rasteriza cada `imshow` al escribir la página). Objetivo:
|
||||||
|
ampliar en el móvil y leer detalle (ejes, celdas) sin pixelar. El texto sigue siendo
|
||||||
|
vectorial y seleccionable. No hay nada que hacer en los capítulos.
|
||||||
|
|
||||||
|
**(b) Tabla ancha → imagen de alta resolución (automático).** Cuando un `DataTable` tiene
|
||||||
|
**demasiadas columnas para ser legible como texto** en el ancho útil (criterio
|
||||||
|
`_table_fits_as_text`: ancho mínimo legible por columna × nº de columnas > ancho útil; en
|
||||||
|
la práctica salta sobre tablas tipo `df.head` con muchas columnas), en vez de comprimir las
|
||||||
|
columnas hasta hacerlas ilegibles, la tabla se dibuja **entera como una imagen de alta
|
||||||
|
resolución** (función `render_table_as_figure_py_datascience`: cabecera sombreada + zebra)
|
||||||
|
escalada para caber completa, de modo que el lector hace **zoom** y la lee sin perder datos.
|
||||||
|
Si la tabla **sí cabe**, se mantiene como texto seleccionable (PDF) / tabla nativa (PPTX).
|
||||||
|
Las `KVTable` (2 columnas) caben siempre y se quedan como texto. No hay nada que hacer en
|
||||||
|
los capítulos.
|
||||||
|
|
||||||
|
**(c) Figura al lado de la tabla — `Group(layout="side_by_side")`.** Hint de layout que un
|
||||||
|
capítulo activa para que su **tabla quede a la izquierda y su figura a la derecha** en la
|
||||||
|
misma diapositiva, en lugar de apiladas:
|
||||||
|
|
||||||
|
```python
|
||||||
|
model.Group(
|
||||||
|
layout="side_by_side",
|
||||||
|
blocks=[
|
||||||
|
model.Heading(text=str(name), level=2), # va a ancho completo arriba
|
||||||
|
model.DataTable(header=..., rows=...), # columna IZQUIERDA (~55%)
|
||||||
|
model.Figure(make=_grafico_perezoso(...)), # columna DERECHA (~45%)
|
||||||
|
model.Markdown(text="explicación…"), # va a ancho completo abajo
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrato exacto del campo:
|
||||||
|
|
||||||
|
| Campo | Valor | Efecto |
|
||||||
|
|---|---|---|
|
||||||
|
| `layout` | `"stack"` (por defecto) | comportamiento histórico: apilado vertical (keep-together). |
|
||||||
|
| `layout` | `"side_by_side"` | **PPTX**: la tabla (rasterizada a imagen) ocupa la columna izquierda (~55% del ancho útil) y la figura la derecha (~45%); cualquier otro bloque (heading, markdown) va a ancho completo arriba/abajo. Si no hay un par tabla+figura, o no caben lado a lado en una slide, **cae automáticamente a apilado**. **PDF**: se trata **igual que `stack`** (el ancho A5 móvil no admite dos columnas legibles). Valores desconocidos degradan a `"stack"`. |
|
||||||
|
|
||||||
|
Es **retrocompatible**: un `Group` sin `layout` (o `layout="stack"`) se comporta exactamente
|
||||||
|
como antes. El capítulo `cat_distr` es el consumidor previsto (gráfico a la derecha de la
|
||||||
|
tabla de categorías en PPT); este motor solo provee el soporte.
|
||||||
|
|
||||||
|
**(d) Índice clicable en la portada — `TocEntry`.** La portada emite un `Heading("Índice")`
|
||||||
|
seguido de un `TocEntry(label, target_id)` por capítulo. El renderer registra la
|
||||||
|
página/slide de inicio de **cada** capítulo (indexado por `id` **y** por `title`) y cablea
|
||||||
|
cada `TocEntry` como un salto real a ese inicio: en **PDF** vía
|
||||||
|
`add_pdf_internal_links_py_datascience` (link GOTO de PyMuPDF), en **PPTX** vía
|
||||||
|
`pptx_link_run_to_slide_py_datascience` (salto a slide nativo). Como la portada solo conoce
|
||||||
|
los **títulos** de los capítulos, el `target_id` se hace coincidir contra el `title` (o el
|
||||||
|
`id`) de destino. Si un destino no resuelve, la entrada se muestra igualmente como texto
|
||||||
|
(en color de enlace), nunca se corta. Es el mismo mecanismo que los términos clicables del
|
||||||
|
glosario (§11.1), reutilizado en sentido portada → capítulo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Integración futura con `profile_table` (siguiente fase)
|
## 10. Integración futura con `profile_table` (siguiente fase)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [cpp-tables](tql.md) | 9 | Table Query Language C++ puro: filter, group, agg, sort, join, stats, formulas Lua, round-trip emit/apply |
|
| [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.
|
||||||
|
-->
|
||||||
File diff suppressed because one or more lines are too long
@@ -59,6 +59,9 @@ 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
|
||||||
@@ -73,9 +76,15 @@ 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 .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
|
||||||
from .render_paper_pdf import render_paper_pdf
|
from .render_paper_pdf import render_paper_pdf
|
||||||
|
from .draw_join_graph_figure import draw_join_graph_figure
|
||||||
|
from .generate_synthetic_eda_table import generate_synthetic_eda_table
|
||||||
|
from .generate_synthetic_eda_folder import generate_synthetic_eda_folder
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"generate_synthetic_eda_table",
|
||||||
|
"generate_synthetic_eda_folder",
|
||||||
"render_paper_pdf",
|
"render_paper_pdf",
|
||||||
|
"draw_join_graph_figure",
|
||||||
"suggest_intratable_fk_candidates",
|
"suggest_intratable_fk_candidates",
|
||||||
"detect_time_column",
|
"detect_time_column",
|
||||||
"extract_timeseries_raw",
|
"extract_timeseries_raw",
|
||||||
@@ -92,6 +101,9 @@ __all__ = [
|
|||||||
"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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .model import ( # noqa: F401
|
|||||||
KVTable,
|
KVTable,
|
||||||
Markdown,
|
Markdown,
|
||||||
Note,
|
Note,
|
||||||
|
TocEntry,
|
||||||
as_blocks,
|
as_blocks,
|
||||||
as_chapters,
|
as_chapters,
|
||||||
merge_manifest,
|
merge_manifest,
|
||||||
@@ -52,6 +53,7 @@ __all__ = [
|
|||||||
"Group",
|
"Group",
|
||||||
"GlossaryEntry",
|
"GlossaryEntry",
|
||||||
"GlossaryCollector",
|
"GlossaryCollector",
|
||||||
|
"TocEntry",
|
||||||
"Chapter",
|
"Chapter",
|
||||||
"as_blocks",
|
"as_blocks",
|
||||||
"as_chapters",
|
"as_chapters",
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests del filtro `only` de build_document (selección de capítulos).
|
||||||
|
|
||||||
|
Verifican que:
|
||||||
|
- only=None mantiene el comportamiento histórico (todos los capítulos).
|
||||||
|
- only=[ids] restringe el CUERPO a esos ids, pero portada (primera) y glosario
|
||||||
|
(última) están SIEMPRE presentes.
|
||||||
|
- only=[] produce el documento mínimo (solo portada + glosario).
|
||||||
|
- la selección también viaja por la clave reservada ctx['_only_chapters']
|
||||||
|
(el canal que usan los renderers, que llaman build_document sin `only`), y
|
||||||
|
esa clave nunca se filtra a los capítulos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||||
|
if _FUNCTIONS not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS)
|
||||||
|
|
||||||
|
from datascience.automatic_eda import build_document # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_with_cat_and_num():
|
||||||
|
"""Perfil mínimo que hace construir cat_distr y num_distr (cuerpo no vacío)."""
|
||||||
|
return {
|
||||||
|
"table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91,
|
||||||
|
"duplicate_pct": 1.5, "null_cell_pct": 0.8,
|
||||||
|
"columns": [
|
||||||
|
{"name": "region", "inferred_type": "categorical",
|
||||||
|
"categorical": {
|
||||||
|
"top": [{"value": "norte", "count": 50, "pct": 0.42},
|
||||||
|
{"value": "sur", "count": 40, "pct": 0.33},
|
||||||
|
{"value": "este", "count": 30, "pct": 0.25}],
|
||||||
|
"mode": "norte", "n_distinct": 3, "entropy": 1.55,
|
||||||
|
"imbalance": 0.1}},
|
||||||
|
{"name": "importe", "inferred_type": "numeric",
|
||||||
|
"numeric": {"mean": 50.0, "median": 48.0, "std": 10.0,
|
||||||
|
"min": 10, "max": 99, "iqr": 15,
|
||||||
|
"histogram": [{"lo": 0, "hi": 50, "count": 40},
|
||||||
|
{"lo": 50, "hi": 100, "count": 80}]}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_none_is_full_document():
|
||||||
|
"""Retro-compat: sin `only`, salen todos los capítulos aplicables."""
|
||||||
|
chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
|
||||||
|
ids = [c.id for c in chs]
|
||||||
|
assert ids[0] == "portada"
|
||||||
|
assert ids[-1] == "glosario"
|
||||||
|
# El cuerpo trae las distribuciones (cat/num), no solo portada+glosario.
|
||||||
|
assert "num_distr" in ids
|
||||||
|
assert "cat_distr" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_restricts_body_but_keeps_cover_and_glossary():
|
||||||
|
# cat_distr registra el término "entropía" en el glosario, así que el
|
||||||
|
# glosario (destino del término clicable) aparece — demuestra el contrato
|
||||||
|
# "portada primera + capítulo + glosario última".
|
||||||
|
chs = build_document(_profile_with_cat_and_num(),
|
||||||
|
ctx={"dataset_name": "v"}, only=["cat_distr"])
|
||||||
|
ids = [c.id for c in chs]
|
||||||
|
assert ids[0] == "portada", f"portada no es la primera: {ids}"
|
||||||
|
assert ids[-1] == "glosario", f"glosario no es la última: {ids}"
|
||||||
|
assert "cat_distr" in ids
|
||||||
|
# num_distr quedó fuera de la selección.
|
||||||
|
assert "num_distr" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_empty_yields_minimal_document():
|
||||||
|
# only=[] -> cuerpo vacío. La portada está siempre; el glosario solo aparece
|
||||||
|
# si algún capítulo registró términos (patrón preexistente: glosario vacío se
|
||||||
|
# omite). Sin cuerpo no hay términos → documento mínimo = solo portada.
|
||||||
|
chs = build_document(_profile_with_cat_and_num(),
|
||||||
|
ctx={"dataset_name": "v"}, only=[])
|
||||||
|
ids = [c.id for c in chs]
|
||||||
|
assert ids == ["portada"], \
|
||||||
|
f"only=[] debe dar el documento mínimo (solo portada), no {ids}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_via_reserved_ctx_key():
|
||||||
|
"""La selección viaja por ctx['_only_chapters'] cuando no se pasa `only`."""
|
||||||
|
chs = build_document(_profile_with_cat_and_num(),
|
||||||
|
ctx={"dataset_name": "v",
|
||||||
|
"_only_chapters": ["cat_distr"]})
|
||||||
|
ids = [c.id for c in chs]
|
||||||
|
assert "cat_distr" in ids
|
||||||
|
assert "num_distr" not in ids
|
||||||
|
assert ids[0] == "portada" and ids[-1] == "glosario"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_only_arg_wins_over_ctx_key():
|
||||||
|
"""Si se pasan ambos, el argumento `only` manda sobre la clave del ctx."""
|
||||||
|
chs = build_document(_profile_with_cat_and_num(),
|
||||||
|
ctx={"dataset_name": "v",
|
||||||
|
"_only_chapters": ["cat_distr"]},
|
||||||
|
only=["num_distr"])
|
||||||
|
ids = [c.id for c in chs]
|
||||||
|
assert "num_distr" in ids
|
||||||
|
assert "cat_distr" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_key_not_leaked_to_caller_ctx():
|
||||||
|
"""build_document no muta el ctx del caller (copia interna)."""
|
||||||
|
ctx = {"dataset_name": "v", "_only_chapters": ["num_distr"]}
|
||||||
|
build_document(_profile_with_cat_and_num(), ctx=ctx)
|
||||||
|
# La clave reservada sigue en el dict del caller (no se mutó su copia).
|
||||||
|
assert ctx["_only_chapters"] == ["num_distr"]
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA.
|
||||||
|
|
||||||
|
Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para
|
||||||
|
computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo
|
||||||
|
consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un
|
||||||
|
SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve
|
||||||
|
los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos
|
||||||
|
capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la
|
||||||
|
vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa.
|
||||||
|
|
||||||
|
Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se
|
||||||
|
evita tocar los ``chapters/<id>.py`` (cada agente es dueño de su capítulo) y se
|
||||||
|
elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del
|
||||||
|
``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira.
|
||||||
|
|
||||||
|
Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo:
|
||||||
|
|
||||||
|
- ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR
|
||||||
|
para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros:
|
||||||
|
* ``run_models`` -> ``profile['models']`` (KMeans/IsolationForest/PCA).
|
||||||
|
Lo leen ``outliers`` (fallback del multivariante) y ``modelos``.
|
||||||
|
* ``run_series`` -> ``profile['series']`` (análisis de serie temporal).
|
||||||
|
Lo lee ``timeseries``.
|
||||||
|
* ``run_llm`` -> ``profile['llm']`` (interpretación del modelo).
|
||||||
|
Lo lee ``analisis_llm``.
|
||||||
|
|
||||||
|
- ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye
|
||||||
|
``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está
|
||||||
|
vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse
|
||||||
|
``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide.
|
||||||
|
Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``):
|
||||||
|
* ``head_rows`` -> ``ctx['head_rows']`` (overview: df.head real).
|
||||||
|
* ``raw_numeric`` -> ``ctx['raw_numeric']`` (outliers/modelos/
|
||||||
|
correlacion/missingness/geospatial: muestra numérica alineada por fila).
|
||||||
|
* ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda).
|
||||||
|
* ``geo_points`` -> ``ctx['geo_points']`` (+ ``raw_numeric``)
|
||||||
|
(geospatial: lat/lon).
|
||||||
|
* ``db_path_table`` -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/
|
||||||
|
text_distr/missingness/relaciones: push-down de queries propias).
|
||||||
|
|
||||||
|
``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE
|
||||||
|
(la portada resume el documento y el glosario es el destino de los términos
|
||||||
|
clicables), así que aquí se declaran sin requisitos de cómputo.
|
||||||
|
|
||||||
|
Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan
|
||||||
|
a test unitario directo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los
|
||||||
|
# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las
|
||||||
|
# claves vacías significan "no necesita ese tipo de dependencia".
|
||||||
|
CHAPTER_DEPS = {
|
||||||
|
# Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee
|
||||||
|
# el document_summary que arma build_document; el glosario lee los términos
|
||||||
|
# que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER
|
||||||
|
# entero y la validación los reconozca.
|
||||||
|
"portada": {"profile_flags": [], "ctx": []},
|
||||||
|
"overview": {"profile_flags": [], "ctx": ["head_rows"]},
|
||||||
|
"analisis_llm": {"profile_flags": ["run_llm"], "ctx": []},
|
||||||
|
"num_distr": {"profile_flags": [], "ctx": []},
|
||||||
|
"cat_distr": {"profile_flags": [], "ctx": []},
|
||||||
|
# text_distr empuja su propia query de texto (no usa raw_numeric); necesita
|
||||||
|
# db_path/table en el ctx para hacerlo.
|
||||||
|
"text_distr": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||||
|
"calidad": {"profile_flags": [], "ctx": []},
|
||||||
|
# missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y
|
||||||
|
# puede empujar una query de patrón de nulos con db_path/table.
|
||||||
|
"missingness": {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]},
|
||||||
|
# outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models
|
||||||
|
# asegura además el fallback profile['models']['outliers'] si el ctx faltara.
|
||||||
|
"outliers": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
|
||||||
|
"correlacion": {"profile_flags": [], "ctx": ["raw_numeric"]},
|
||||||
|
"relaciones": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||||
|
"modelos": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
|
||||||
|
"timeseries": {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]},
|
||||||
|
"geospatial": {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]},
|
||||||
|
"agregacion": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||||
|
"glosario": {"profile_flags": [], "ctx": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters.
|
||||||
|
ALWAYS_PRESENT = ("portada", "glosario")
|
||||||
|
|
||||||
|
# Flags de coste reconocidos (el orden no importa; se devuelven como set).
|
||||||
|
KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm")
|
||||||
|
|
||||||
|
# Mapeo de cada etiqueta de ctx a las claves REALES que produce
|
||||||
|
# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se
|
||||||
|
# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no
|
||||||
|
# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/
|
||||||
|
# geo_points) son las piezas de datos podables.
|
||||||
|
CTX_LABEL_TO_KEYS = {
|
||||||
|
"head_rows": {"head_rows"},
|
||||||
|
"raw_numeric": {"raw_numeric"},
|
||||||
|
"timeseries_raw": {"timeseries_raw"},
|
||||||
|
"geo_points": {"geo_points", "raw_numeric"},
|
||||||
|
"db_path_table": set(), # db_path/table siempre presentes; nunca se podan.
|
||||||
|
}
|
||||||
|
|
||||||
|
# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo
|
||||||
|
# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí.
|
||||||
|
DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_id_list(chapter_ids):
|
||||||
|
"""Normaliza la entrada a una lista de ids string, defensiva. None -> []."""
|
||||||
|
if chapter_ids is None:
|
||||||
|
return []
|
||||||
|
if isinstance(chapter_ids, str):
|
||||||
|
return [chapter_ids]
|
||||||
|
return [c for c in chapter_ids if isinstance(c, str)]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_chapter_ids(chapter_ids, order):
|
||||||
|
"""Separa los ids pedidos en válidos y desconocidos respecto a ``order``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chapter_ids: lista (o str) de ids de capítulo pedidos.
|
||||||
|
order: lista canónica de ids válidos (CHAPTER_ORDER).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de
|
||||||
|
aparición de la entrada. Función pura.
|
||||||
|
"""
|
||||||
|
valid_set = set(order or [])
|
||||||
|
valid, unknown = [], []
|
||||||
|
for cid in _as_id_list(chapter_ids):
|
||||||
|
(valid if cid in valid_set else unknown).append(cid)
|
||||||
|
return {"valid": valid, "unknown": unknown}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_requirements(chapter_ids):
|
||||||
|
"""Une los requisitos de cómputo de los capítulos pedidos.
|
||||||
|
|
||||||
|
Es el corazón de la resolución de dependencias: dado el subconjunto de
|
||||||
|
capítulos a renderizar, devuelve TODO lo que hay que activar/construir para
|
||||||
|
que esos capítulos lleguen COMPLETOS, y solo eso.
|
||||||
|
|
||||||
|
Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente
|
||||||
|
porque el pipeline siempre los incluye; como no tienen requisitos, no alteran
|
||||||
|
el resultado, pero se contemplan para que el conjunto sea coherente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se
|
||||||
|
ignoran silenciosamente (la validación estricta es de quien llama).
|
||||||
|
None o lista vacía -> requisitos vacíos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde
|
||||||
|
``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función
|
||||||
|
pura.
|
||||||
|
"""
|
||||||
|
ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT)
|
||||||
|
profile_flags = set()
|
||||||
|
ctx_keys = set()
|
||||||
|
for cid in ids:
|
||||||
|
dep = CHAPTER_DEPS.get(cid)
|
||||||
|
if not isinstance(dep, dict):
|
||||||
|
continue
|
||||||
|
for f in dep.get("profile_flags", []) or []:
|
||||||
|
if f in KNOWN_PROFILE_FLAGS:
|
||||||
|
profile_flags.add(f)
|
||||||
|
for k in dep.get("ctx", []) or []:
|
||||||
|
ctx_keys.add(k)
|
||||||
|
return {"profile_flags": profile_flags, "ctx_keys": ctx_keys}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile_flags(chapter_ids):
|
||||||
|
"""Atajo: solo el set de profile_flags a activar para los capítulos pedidos.
|
||||||
|
|
||||||
|
Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS.
|
||||||
|
"""
|
||||||
|
return resolve_requirements(chapter_ids)["profile_flags"]
|
||||||
|
|
||||||
|
|
||||||
|
def needs_render_ctx(chapter_ids):
|
||||||
|
"""True si algún capítulo pedido necesita datos crudos del ctx.
|
||||||
|
|
||||||
|
Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero
|
||||||
|
(ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de
|
||||||
|
datos crudos. Función pura.
|
||||||
|
"""
|
||||||
|
return bool(resolve_requirements(chapter_ids)["ctx_keys"])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ctx_data_keys(chapter_ids):
|
||||||
|
"""Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos.
|
||||||
|
|
||||||
|
Traduce las etiquetas de ctx a las claves concretas que produce
|
||||||
|
``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points).
|
||||||
|
El pipeline poda del ctx las claves de datos que NO estén en este set, para
|
||||||
|
que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table
|
||||||
|
nunca se podan (no aparecen aquí). Función pura.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[str] subconjunto de DATA_CTX_KEYS.
|
||||||
|
"""
|
||||||
|
req = resolve_requirements(chapter_ids)
|
||||||
|
keep = set()
|
||||||
|
for label in req["ctx_keys"]:
|
||||||
|
keep |= CTX_LABEL_TO_KEYS.get(label, set())
|
||||||
|
# Solo claves de datos podables (db_path/table se gestionan aparte).
|
||||||
|
return {k for k in keep if k in DATA_CTX_KEYS}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Tests del mapa central de dependencias por capítulo (chapter_deps).
|
||||||
|
|
||||||
|
Todas las funciones bajo prueba son PURAS (sin I/O): se ejercitan directamente
|
||||||
|
sin DuckDB ni renderizado. Cubren la resolución de requisitos (golden + edges),
|
||||||
|
la validación de ids y los helpers de eficiencia (qué cómputo se salta).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||||
|
if _FUNCTIONS not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS)
|
||||||
|
|
||||||
|
from datascience.automatic_eda.chapter_deps import ( # noqa: E402
|
||||||
|
ALWAYS_PRESENT,
|
||||||
|
CHAPTER_DEPS,
|
||||||
|
DATA_CTX_KEYS,
|
||||||
|
needs_render_ctx,
|
||||||
|
resolve_ctx_data_keys,
|
||||||
|
resolve_profile_flags,
|
||||||
|
resolve_requirements,
|
||||||
|
validate_chapter_ids,
|
||||||
|
)
|
||||||
|
from datascience.automatic_eda.chapters_registry import CHAPTER_ORDER # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# El mapa cubre CHAPTER_ORDER entero (sin huecos ni claves de más).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_chapter_deps_covers_every_chapter_in_order():
|
||||||
|
assert set(CHAPTER_DEPS) == set(CHAPTER_ORDER), (
|
||||||
|
"CHAPTER_DEPS debe declarar exactamente los ids de CHAPTER_ORDER")
|
||||||
|
# Cada entrada tiene la forma esperada.
|
||||||
|
for cid, dep in CHAPTER_DEPS.items():
|
||||||
|
assert isinstance(dep.get("profile_flags"), list), cid
|
||||||
|
assert isinstance(dep.get("ctx"), list), cid
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# resolve_requirements — golden: outliers exige run_models + raw_numeric.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_resolve_outliers_requires_run_models_and_raw_numeric():
|
||||||
|
req = resolve_requirements(["outliers"])
|
||||||
|
assert "run_models" in req["profile_flags"]
|
||||||
|
assert "raw_numeric" in req["ctx_keys"]
|
||||||
|
assert "run_series" not in req["profile_flags"]
|
||||||
|
assert "run_llm" not in req["profile_flags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_timeseries_requires_run_series():
|
||||||
|
req = resolve_requirements(["timeseries"])
|
||||||
|
assert req["profile_flags"] == {"run_series"}
|
||||||
|
assert "timeseries_raw" in req["ctx_keys"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_analisis_llm_requires_run_llm():
|
||||||
|
assert resolve_requirements(["analisis_llm"])["profile_flags"] == {"run_llm"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_union_of_several_chapters():
|
||||||
|
req = resolve_requirements(["outliers", "timeseries", "analisis_llm"])
|
||||||
|
assert req["profile_flags"] == {"run_models", "run_series", "run_llm"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Eficiencia: capítulos que NO necesitan flags caros no los activan.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_resolve_geospatial_needs_no_cost_flags():
|
||||||
|
"""geospatial sale de geo_points/raw_numeric del ctx, NO de los modelos."""
|
||||||
|
req = resolve_requirements(["geospatial"])
|
||||||
|
assert req["profile_flags"] == set(), \
|
||||||
|
"geospatial no debe activar run_models/run_series/run_llm"
|
||||||
|
assert "geo_points" in req["ctx_keys"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_correlacion_needs_raw_numeric_but_no_models():
|
||||||
|
req = resolve_requirements(["correlacion"])
|
||||||
|
assert req["profile_flags"] == set()
|
||||||
|
assert "raw_numeric" in req["ctx_keys"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_present_chapters_add_no_requirements():
|
||||||
|
"""portada y glosario están siempre, pero no arrastran cómputo."""
|
||||||
|
for cid in ALWAYS_PRESENT:
|
||||||
|
req = resolve_requirements([cid])
|
||||||
|
assert req["profile_flags"] == set()
|
||||||
|
assert req["ctx_keys"] == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_profile_flags_shortcut():
|
||||||
|
assert resolve_profile_flags(["modelos"]) == {"run_models"}
|
||||||
|
assert resolve_profile_flags(["num_distr"]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# needs_render_ctx — cuándo se puede saltar build_eda_render_ctx por completo.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_needs_render_ctx_true_when_chapter_reads_raw_data():
|
||||||
|
assert needs_render_ctx(["outliers"]) is True
|
||||||
|
assert needs_render_ctx(["agregacion"]) is True # db_path/table push-down
|
||||||
|
assert needs_render_ctx(["timeseries"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_render_ctx_false_for_purely_aggregated_chapters():
|
||||||
|
"""num_distr / cat_distr / calidad solo leen el profile agregado."""
|
||||||
|
assert needs_render_ctx(["num_distr"]) is False
|
||||||
|
assert needs_render_ctx(["cat_distr", "calidad"]) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# resolve_ctx_data_keys — poda: qué claves de DATOS conservar (db_path/table no).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_resolve_ctx_data_keys_outliers_keeps_only_raw_numeric():
|
||||||
|
assert resolve_ctx_data_keys(["outliers"]) == {"raw_numeric"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ctx_data_keys_geospatial_keeps_geo_and_numeric():
|
||||||
|
assert resolve_ctx_data_keys(["geospatial"]) == {"geo_points", "raw_numeric"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ctx_data_keys_aggregation_keeps_nothing_prunable():
|
||||||
|
"""agregacion usa db_path/table (siempre presentes), 0 claves podables."""
|
||||||
|
assert resolve_ctx_data_keys(["agregacion"]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ctx_data_keys_subset_of_data_keys():
|
||||||
|
keep = resolve_ctx_data_keys(["overview", "timeseries", "geospatial"])
|
||||||
|
assert keep <= set(DATA_CTX_KEYS)
|
||||||
|
assert {"head_rows", "timeseries_raw", "geo_points", "raw_numeric"} == keep
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# validate_chapter_ids — separa válidos de desconocidos preservando orden.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_validate_separates_known_and_unknown():
|
||||||
|
out = validate_chapter_ids(["outliers", "nope", "timeseries", "ghost"],
|
||||||
|
CHAPTER_ORDER)
|
||||||
|
assert out["valid"] == ["outliers", "timeseries"]
|
||||||
|
assert out["unknown"] == ["nope", "ghost"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_all_known():
|
||||||
|
out = validate_chapter_ids(["portada", "glosario"], CHAPTER_ORDER)
|
||||||
|
assert out["unknown"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Robustez: entradas raras nunca lanzan.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_resolve_handles_none_and_empty():
|
||||||
|
assert resolve_requirements(None)["profile_flags"] == set()
|
||||||
|
assert resolve_requirements([])["profile_flags"] == set()
|
||||||
|
# ids desconocidos se ignoran silenciosamente en la resolución.
|
||||||
|
assert resolve_requirements(["no_existe"])["ctx_keys"] == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_accepts_single_string():
|
||||||
|
assert resolve_requirements("outliers")["profile_flags"] == {"run_models"}
|
||||||
@@ -5,28 +5,32 @@ page (PDF) / slide (PPTX)**: every column is wrapped in a keep-together
|
|||||||
``model.Group`` with ``page_break_before=True`` (except the first, which may share
|
``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.
|
the intro's page), so its chart sits next to its tables and no column is split.
|
||||||
|
|
||||||
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** term —
|
Per column the Group is laid out ``side_by_side`` (PPTX: cardinality table LEFT,
|
||||||
the full definition lives in the GLOSARIO chapter, so it is NOT repeated inline
|
chart RIGHT; PDF: stacked) and contains, in order:
|
||||||
here (one click jumps to the glossary entry). The intro also carries the dataset
|
|
||||||
row total used as a comparison baseline.
|
|
||||||
|
|
||||||
Per column the Group contains, in order:
|
1. The column name plus, when the LLM layer ran, its business **description** and
|
||||||
|
**unit** (read from ``profile['llm']['dictionary']``, matched by column name).
|
||||||
1. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
|
2. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
|
||||||
total rows), total dataset rows, singleton values (frequency 1), entropy with
|
total rows), total dataset rows, singleton values (frequency 1), entropy with
|
||||||
its theoretical maximum and the normalized ratio, mode, imbalance and
|
its theoretical maximum and the normalized ratio, mode, imbalance and
|
||||||
string-length stats.
|
string-length stats.
|
||||||
2. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
3. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
||||||
single dominating category).
|
single dominating category).
|
||||||
3. A ``top-k`` table (value / count / %).
|
4. A ``top-k`` table (value / count / %).
|
||||||
4. A **donut pie chart** of the most common categories (top-k + an "Otros"
|
5. A **horizontal bar chart** of the most common categories (top-k + an "Otros"
|
||||||
bucket), drawn lazily so the renderers scale it to fit entirely.
|
bucket), drawn lazily so the renderers scale it to fit entirely.
|
||||||
|
|
||||||
|
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** and
|
||||||
|
**[[term:pagina_categorica]]page-layout[[/term]]** terms — their full
|
||||||
|
definitions live in the GLOSARIO chapter, so they are NOT repeated inline here
|
||||||
|
(one click jumps to the glossary entry). The intro also carries the dataset row
|
||||||
|
total used as a comparison baseline.
|
||||||
|
|
||||||
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
|
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
|
||||||
output of ``summarize_categorical`` (``top[{value,count,pct}]``, ``mode``,
|
output of ``summarize_categorical`` (``top[{value,count,pct}]``, ``mode``,
|
||||||
``n_distinct``, ``entropy``, ``imbalance``, ``len_min/mean/max``). The derived
|
``n_distinct``, ``entropy``, ``imbalance``, ``len_min/mean/max``). The derived
|
||||||
cardinality metrics and the pie figure are delegated to two registry functions
|
cardinality metrics and the bar figure are delegated to two registry functions
|
||||||
(``categorical_cardinality_block`` and ``categorical_top_pie_figure``); both are
|
(``categorical_cardinality_block`` and ``categorical_top_bar_figure``); both are
|
||||||
imported lazily and degrade to a minimal inline fallback so this chapter never
|
imported lazily and degrade to a minimal inline fallback so this chapter never
|
||||||
raises even if they are unavailable.
|
raises even if they are unavailable.
|
||||||
|
|
||||||
@@ -39,10 +43,21 @@ import math
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.2.0"
|
CHAPTER_VERSION = "1.3.0"
|
||||||
CHAPTER_ID = "cat_distr"
|
CHAPTER_ID = "cat_distr"
|
||||||
CHAPTER_TITLE = "Distribuciones categóricas"
|
CHAPTER_TITLE = "Distribuciones categóricas"
|
||||||
|
|
||||||
|
# Key under which eda_llm_insights stores its interpretive block in the profile.
|
||||||
|
LLM_KEY = "llm"
|
||||||
|
|
||||||
|
# Second glossary term this chapter names: "how each categorical page is laid
|
||||||
|
# out". The long paragraph that used to describe it inline in the intro now lives
|
||||||
|
# in the GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``);
|
||||||
|
# the intro only names the clickable term, relocating the explanation, not losing
|
||||||
|
# it. The chapter only needs to register key+label here.
|
||||||
|
_TERM_PAGINA_KEY = "pagina_categorica"
|
||||||
|
_TERM_PAGINA_LABEL = "Cómo se organiza cada página categórica"
|
||||||
|
|
||||||
# Glossary term this chapter explains. Registered in the shared collector and
|
# Glossary term this chapter explains. Registered in the shared collector and
|
||||||
# marked clickable on its first appearance (end-to-end glossary example —
|
# marked clickable on its first appearance (end-to-end glossary example —
|
||||||
# mejora 6). Other chapters hook their own terms the same way (see the contract).
|
# mejora 6). Other chapters hook their own terms the same way (see the contract).
|
||||||
@@ -59,14 +74,14 @@ _TERM_ENTROPIA_DEF = (
|
|||||||
# Cap the number of categorical columns rendered to keep the document bounded;
|
# 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. Kept moderate so
|
# Rows shown in each top-k table and explicit bars in the chart. Kept moderate so
|
||||||
# the whole column — cardinality table + top-k table + donut — fits on ONE
|
# the whole column — cardinality table + top-k table + bar chart — fits on ONE
|
||||||
# page/slide with the chart next to its tables; the table note still reports
|
# 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%
|
# "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
|
# distinct) the top-k table is dropped entirely (it would be a list of unique
|
||||||
# values — pure noise), which also frees the room the donut needs (see build).
|
# values — pure noise), which also frees the room the chart needs (see build).
|
||||||
TOP_TABLE_ROWS = 8
|
TOP_TABLE_ROWS = 8
|
||||||
PIE_TOP_K = 6
|
CHART_TOP_K = 6
|
||||||
# Truncate very long category labels in tables (the renderer also wraps). Kept
|
# Truncate very long category labels in tables (the renderer also wraps). Kept
|
||||||
# tight so a column with long id-like values (names, tickets) still fits its page.
|
# tight so a column with long id-like values (names, tickets) still fits its page.
|
||||||
LABEL_MAX = 28
|
LABEL_MAX = 28
|
||||||
@@ -208,26 +223,74 @@ def _fallback_cardinality(cat: dict, n_rows) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pie_make(top, n_distinct, title, n_rows):
|
def _llm_index(profile: dict, ctx: dict) -> dict:
|
||||||
"""Return a zero-arg callable that builds the donut figure lazily."""
|
"""Map column name -> its LLM dictionary entry (description/unit/...).
|
||||||
|
|
||||||
|
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
|
||||||
|
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
|
||||||
|
dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
|
||||||
|
defensive: never raises on malformed input.
|
||||||
|
"""
|
||||||
|
llm = profile.get(LLM_KEY)
|
||||||
|
if not isinstance(llm, dict):
|
||||||
|
llm = ctx.get(LLM_KEY)
|
||||||
|
if not isinstance(llm, dict):
|
||||||
|
return {}
|
||||||
|
entries = llm.get("dictionary")
|
||||||
|
if not isinstance(entries, (list, tuple)):
|
||||||
|
return {}
|
||||||
|
index: dict = {}
|
||||||
|
for e in entries:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
continue
|
||||||
|
col = e.get("column")
|
||||||
|
if col is None:
|
||||||
|
continue
|
||||||
|
index[model._safe_str(col)] = e
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_desc_unit_block(name: str, llm_index: dict):
|
||||||
|
"""Markdown block with the LLM business description + unit of a column, or
|
||||||
|
None when no LLM entry matches the column (clean fallback without LLM)."""
|
||||||
|
entry = llm_index.get(model._safe_str(name))
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
raw_desc = entry.get("description") or entry.get("business_meaning")
|
||||||
|
desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
|
||||||
|
raw_unit = entry.get("unit")
|
||||||
|
unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
|
||||||
|
parts = []
|
||||||
|
if desc:
|
||||||
|
parts.append(f"**Descripción:** {desc}")
|
||||||
|
if unit:
|
||||||
|
parts.append(f"**Unidad:** {unit}")
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
return model.Markdown(text=" · ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_make(top, n_distinct, title, n_rows):
|
||||||
|
"""Return a zero-arg callable that builds the bar figure lazily."""
|
||||||
|
|
||||||
def make():
|
def make():
|
||||||
try:
|
try:
|
||||||
from datascience.categorical_top_pie_figure import (
|
from datascience.categorical_top_bar_figure import (
|
||||||
categorical_top_pie_figure,
|
categorical_top_bar_figure,
|
||||||
)
|
)
|
||||||
|
|
||||||
return categorical_top_pie_figure(
|
return categorical_top_bar_figure(
|
||||||
top=top, n_distinct=n_distinct or 0, title=title,
|
top=top, n_distinct=n_distinct or 0, title=title,
|
||||||
top_k=PIE_TOP_K, n_rows=n_rows)
|
top_k=CHART_TOP_K, n_rows=n_rows)
|
||||||
except Exception: # noqa: BLE001 — minimal local fallback figure.
|
except Exception: # noqa: BLE001 — minimal local fallback figure.
|
||||||
return _fallback_pie(top, title)
|
return _fallback_bar(top, title)
|
||||||
|
|
||||||
return make
|
return make
|
||||||
|
|
||||||
|
|
||||||
def _fallback_pie(top, title):
|
def _fallback_bar(top, title):
|
||||||
"""Minimal donut figure used only if the registry function is unavailable."""
|
"""Minimal horizontal-bar figure used only if the registry function is
|
||||||
|
unavailable. Largest category on top, the rest folded into "Otros"."""
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
||||||
matplotlib.use("Agg")
|
matplotlib.use("Agg")
|
||||||
@@ -238,8 +301,8 @@ def _fallback_pie(top, title):
|
|||||||
items = [t for t in (top or [])
|
items = [t for t in (top or [])
|
||||||
if isinstance(t, dict) and isinstance(t.get("count"), (int, float))]
|
if isinstance(t, dict) and isinstance(t.get("count"), (int, float))]
|
||||||
items = sorted(items, key=lambda t: t.get("count") or 0, reverse=True)
|
items = sorted(items, key=lambda t: t.get("count") or 0, reverse=True)
|
||||||
head = items[:PIE_TOP_K]
|
head = items[:CHART_TOP_K]
|
||||||
rest = items[PIE_TOP_K:]
|
rest = items[CHART_TOP_K:]
|
||||||
labels = [_truncate(t.get("value"), 20) for t in head]
|
labels = [_truncate(t.get("value"), 20) for t in head]
|
||||||
sizes = [float(t.get("count") or 0) for t in head]
|
sizes = [float(t.get("count") or 0) for t in head]
|
||||||
if rest:
|
if rest:
|
||||||
@@ -249,10 +312,13 @@ def _fallback_pie(top, title):
|
|||||||
ax.text(0.5, 0.5, "sin datos categóricos", ha="center", va="center")
|
ax.text(0.5, 0.5, "sin datos categóricos", ha="center", va="center")
|
||||||
ax.axis("off")
|
ax.axis("off")
|
||||||
return fig
|
return fig
|
||||||
ax.pie(sizes, labels=None, wedgeprops={"width": 0.42},
|
# barh draws bottom-up, so reverse to put the largest category on top.
|
||||||
autopct=lambda p: f"{p:.0f}%" if p >= 4 else "")
|
y_pos = range(len(labels))
|
||||||
ax.legend(labels, loc="center left", bbox_to_anchor=(1.0, 0.5),
|
ax.barh(list(y_pos), list(reversed(sizes)), color="#4C72B0",
|
||||||
fontsize=7, frameon=False)
|
edgecolor="white")
|
||||||
|
ax.set_yticks(list(y_pos))
|
||||||
|
ax.set_yticklabels(list(reversed(labels)), fontsize=7)
|
||||||
|
ax.set_xlabel("conteo", fontsize=8)
|
||||||
ax.set_title(_truncate(title, 40))
|
ax.set_title(_truncate(title, 40))
|
||||||
fig.tight_layout()
|
fig.tight_layout()
|
||||||
return fig
|
return fig
|
||||||
@@ -373,22 +439,17 @@ def _topk_table(cat: dict):
|
|||||||
note=note)
|
note=note)
|
||||||
|
|
||||||
|
|
||||||
def _intro_blocks(n_rows, mark_term: bool = False):
|
def _intro_blocks(mark_term: bool = False):
|
||||||
total = _fmt_int(n_rows)
|
# The full explanation of entropy AND of how each categorical page is laid out
|
||||||
# Mark the first appearance of the term as a clickable glossary jump when the
|
# lives in the GLOSARIO chapter; the chapter body keeps only the minimal
|
||||||
# term was registered (mark_term). The full definition of entropy lives in the
|
# clickable terms — no descriptive prose — to avoid duplicating the glossary.
|
||||||
# GLOSARIO chapter, so the intro only names the clickable term here instead of
|
# The dataset row total is not repeated here: each column's cardinality table
|
||||||
# repeating the long explanation (avoids the redundancy with the glossary).
|
# already carries "Total filas (dataset)".
|
||||||
entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
|
entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
|
||||||
else "entropía")
|
else "entropía")
|
||||||
text = (
|
pagina = ("[[term:pagina_categorica]]cómo se organiza cada página[[/term]]"
|
||||||
f"Cada columna categórica ocupa su propia página: sus métricas de "
|
if mark_term else "cómo se organiza cada página")
|
||||||
f"cardinalidad —incluida la {entropia}—, una nota que señala cardinalidad "
|
text = f"Términos: {entropia} · {pagina}."
|
||||||
"problemática, la tabla de las categorías más frecuentes y un gráfico de "
|
|
||||||
"tarta (donut) de las más comunes, todo junto."
|
|
||||||
)
|
|
||||||
if n_rows is not None:
|
|
||||||
text += f" El dataset tiene {total} filas en total como referencia."
|
|
||||||
return [
|
return [
|
||||||
model.Heading(text="Entropía y cardinalidad", level=2),
|
model.Heading(text="Entropía y cardinalidad", level=2),
|
||||||
model.Markdown(text=text),
|
model.Markdown(text=text),
|
||||||
@@ -406,15 +467,22 @@ def build_cat_distr(profile: dict, ctx: dict):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
n_rows = profile.get("n_rows")
|
n_rows = profile.get("n_rows")
|
||||||
# Register "entropía" in the shared glossary collector (if present) and mark
|
# Register "entropía" and the "how each categorical page is laid out" term in
|
||||||
# its first appearance clickable. End-to-end glossary example (mejora 6).
|
# the shared glossary collector (if present) and mark their first appearance
|
||||||
|
# clickable. End-to-end glossary example (mejora 6).
|
||||||
glossary = ctx.get("glossary")
|
glossary = ctx.get("glossary")
|
||||||
mark_term = False
|
mark_term = False
|
||||||
if isinstance(glossary, model.GlossaryCollector):
|
if isinstance(glossary, model.GlossaryCollector):
|
||||||
glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL,
|
glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL,
|
||||||
_TERM_ENTROPIA_DEF)
|
_TERM_ENTROPIA_DEF)
|
||||||
|
glossary.add(_TERM_PAGINA_KEY, _TERM_PAGINA_LABEL)
|
||||||
mark_term = True
|
mark_term = True
|
||||||
blocks = list(_intro_blocks(n_rows, mark_term=mark_term))
|
blocks = list(_intro_blocks(mark_term=mark_term))
|
||||||
|
|
||||||
|
# Business description + unit per column come from the LLM dictionary
|
||||||
|
# (profile['llm']['dictionary'], matched by column name); absent without
|
||||||
|
# run_llm, in which case the per-column description block is simply omitted.
|
||||||
|
llm_index = _llm_index(profile, ctx)
|
||||||
|
|
||||||
rendered = cat_cols[:MAX_COLS]
|
rendered = cat_cols[:MAX_COLS]
|
||||||
for idx, col in enumerate(rendered):
|
for idx, col in enumerate(rendered):
|
||||||
@@ -422,31 +490,36 @@ def build_cat_distr(profile: dict, ctx: dict):
|
|||||||
cat = col.get("categorical") or {}
|
cat = col.get("categorical") or {}
|
||||||
card = _normalize_card(_cardinality(cat, n_rows))
|
card = _normalize_card(_cardinality(cat, n_rows))
|
||||||
|
|
||||||
# One Group per categorical column: heading + cardinality table + flag
|
# One Group per categorical column: heading + (optional) LLM description +
|
||||||
# note + top-k table + donut figure are kept together and the renderer
|
# cardinality table + flag note + top-k table + bar figure are kept
|
||||||
# starts each on a fresh page/slide (page_break_before) so every column
|
# together and the renderer starts each on a fresh page/slide
|
||||||
# gets its own page with its chart next to its tables. The first column
|
# (page_break_before) so every column gets its own page with its chart next
|
||||||
# may share the intro's page (no forced break) to avoid a near-empty page.
|
# to its tables. The first column may share the intro's page (no forced
|
||||||
col_blocks = [
|
# break) to avoid a near-empty page.
|
||||||
model.Heading(text=str(name), level=2),
|
col_blocks = [model.Heading(text=str(name), level=2)]
|
||||||
_cardinality_block(card),
|
desc_block = _llm_desc_unit_block(name, llm_index)
|
||||||
]
|
if desc_block is not None:
|
||||||
|
col_blocks.append(desc_block)
|
||||||
|
col_blocks.append(_cardinality_block(card))
|
||||||
note = _flag_note(card)
|
note = _flag_note(card)
|
||||||
if note is not None:
|
if note is not None:
|
||||||
col_blocks.append(note)
|
col_blocks.append(note)
|
||||||
# For id-like columns (≈100% distinct) the top-k is a list of unique
|
# For id-like columns (≈100% distinct) the top-k is a list of unique
|
||||||
# values — pure noise; skip it (the flag note already explains why) and
|
# values — pure noise; skip it (the flag note already explains why) and
|
||||||
# let the donut take that room so the whole column fits one page/slide.
|
# let the bar chart take that room so the whole column fits one page/slide.
|
||||||
if not card.get("id_like"):
|
if not card.get("id_like"):
|
||||||
topk = _topk_table(cat)
|
topk = _topk_table(cat)
|
||||||
if topk is not None:
|
if topk is not None:
|
||||||
col_blocks.append(topk)
|
col_blocks.append(topk)
|
||||||
col_blocks.append(model.Figure(
|
col_blocks.append(model.Figure(
|
||||||
make=_pie_make(cat.get("top") or [], card.get("n_distinct"),
|
make=_bar_make(cat.get("top") or [], card.get("n_distinct"),
|
||||||
str(name), n_rows),
|
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»)")))
|
"(barras: top-k + «Otros»)")))
|
||||||
blocks.append(model.Group(blocks=col_blocks,
|
# layout="side_by_side": in PPTX the cardinality table goes to the LEFT and
|
||||||
|
# the bar chart to the RIGHT of the same slide; the PDF renderer stacks it
|
||||||
|
# (the A5 mobile page is too narrow for two readable columns).
|
||||||
|
blocks.append(model.Group(blocks=col_blocks, layout="side_by_side",
|
||||||
page_break_before=(idx > 0)))
|
page_break_before=(idx > 0)))
|
||||||
|
|
||||||
if len(cat_cols) > len(rendered):
|
if len(cat_cols) > len(rendered):
|
||||||
|
|||||||
@@ -2,12 +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 (distinct/total/%-distinct/unique metrics, top-k table and a donut
|
asked for (distinct/total/%-distinct/unique metrics, top-k table and a bar
|
||||||
figure), that EACH categorical column is wrapped in its own keep-together
|
figure), that EACH categorical column is wrapped in its own keep-together
|
||||||
``Group`` that starts on a fresh page/slide (one column per page, chart next to
|
``Group`` laid out ``side_by_side`` (PPTX: table left / bars right) that starts on
|
||||||
its tables), that the long entropy explanation is NOT repeated inline (it lives
|
a fresh page/slide (one column per page, chart next to its tables), that the LLM
|
||||||
in the glossary — only the clickable term is kept), that the chapter renders
|
business description + unit are shown per column when the profile carries an LLM
|
||||||
inside the full document to both PDF and PPTX showing that content, that a
|
block, that the long entropy / page-layout explanations are NOT repeated inline
|
||||||
|
(they live in the glossary — only the clickable terms are kept), that the chapter
|
||||||
|
renders inside the full document to both PDF and PPTX showing that content, that a
|
||||||
profile with no categorical columns yields ``None`` without raising, and that
|
profile with no categorical columns yields ``None`` without raising, and that
|
||||||
long labels / many columns are never cut in either output.
|
long labels / many columns are never cut in either output.
|
||||||
"""
|
"""
|
||||||
@@ -116,6 +118,10 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
|||||||
assert "log2" not in md.text # redundant explanation removed.
|
assert "log2" not in md.text # redundant explanation removed.
|
||||||
assert "máxima diversidad" not in md.text
|
assert "máxima diversidad" not in md.text
|
||||||
|
|
||||||
|
# The donut/pie is gone: the intro no longer mentions tarta/donut (the chart
|
||||||
|
# is now a bar chart; the long page-layout explanation moved to the glossary).
|
||||||
|
assert "donut" not in md.text and "tarta" not in md.text
|
||||||
|
|
||||||
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
|
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
|
||||||
flat = _flatten(ch.blocks)
|
flat = _flatten(ch.blocks)
|
||||||
kv = next(b for b in flat if isinstance(b, KVTable))
|
kv = next(b for b in flat if isinstance(b, KVTable))
|
||||||
@@ -128,11 +134,13 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
|||||||
assert any("Entropía" in lbl for lbl in labels)
|
assert any("Entropía" in lbl for lbl in labels)
|
||||||
assert "únicos" in values and "%" in values
|
assert "únicos" in values and "%" in values
|
||||||
assert "bits" in values and "norm" in values # entropy + max + normalized.
|
assert "bits" in values and "norm" in values # entropy + max + normalized.
|
||||||
# Top-k table + pie figure.
|
# Top-k table + bar figure.
|
||||||
dt = next(b for b in flat if isinstance(b, DataTable))
|
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 flat)
|
assert any(isinstance(b, Figure) for b in flat)
|
||||||
|
# Each per-column Group is laid out side_by_side (table left / bars right).
|
||||||
|
assert all(g.layout == "side_by_side" for g in _column_groups(ch))
|
||||||
# id-like column flagged with a Note that also explains the top-k is dropped.
|
# id-like column flagged with a Note that also explains the top-k is dropped.
|
||||||
idnote = next((b for b in flat
|
idnote = next((b for b in flat
|
||||||
if isinstance(b, Note) and "identificador" in b.text), None)
|
if isinstance(b, Note) and "identificador" in b.text), None)
|
||||||
@@ -140,9 +148,9 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
|||||||
assert "No se lista el top" in idnote.text
|
assert "No se lista el top" in idnote.text
|
||||||
|
|
||||||
|
|
||||||
def test_golden_idlike_omite_topk_y_conserva_donut():
|
def test_golden_idlike_omite_topk_y_conserva_grafico():
|
||||||
# The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
|
# The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
|
||||||
# (it would be a list of unique values), but must still keep its donut Figure
|
# (it would be a list of unique values), but must still keep its bar Figure
|
||||||
# and its cardinality table so it stays a full per-column page.
|
# and its cardinality table so it stays a full per-column page.
|
||||||
ch = build_cat_distr(_profile(), {})
|
ch = build_cat_distr(_profile(), {})
|
||||||
groups = _column_groups(ch)
|
groups = _column_groups(ch)
|
||||||
@@ -151,7 +159,7 @@ def test_golden_idlike_omite_topk_y_conserva_donut():
|
|||||||
kinds = [b.kind for b in uuid_group.blocks]
|
kinds = [b.kind for b in uuid_group.blocks]
|
||||||
assert "data_table" not in kinds # top-k of unique values dropped.
|
assert "data_table" not in kinds # top-k of unique values dropped.
|
||||||
assert "kv_table" in kinds # cardinality kept.
|
assert "kv_table" in kinds # cardinality kept.
|
||||||
assert "figure" in kinds # donut kept (chart per column).
|
assert "figure" in kinds # bar chart kept (chart per column).
|
||||||
# A non-id-like column keeps its top-k table.
|
# A non-id-like column keeps its top-k table.
|
||||||
cat_group = next(g for g in groups
|
cat_group = next(g for g in groups
|
||||||
if any(getattr(b, "text", "") == "categoria"
|
if any(getattr(b, "text", "") == "categoria"
|
||||||
@@ -205,7 +213,7 @@ def test_golden_render_pdf_una_pagina_por_columna():
|
|||||||
assert "Entrop" in txt
|
assert "Entrop" in txt
|
||||||
assert "distintos" in txt
|
assert "distintos" in txt
|
||||||
assert "categoria" in txt and "neumaticos" in txt
|
assert "categoria" in txt and "neumaticos" in txt
|
||||||
assert "donut" in txt # figure caption rendered as text.
|
assert "barras" in txt # bar-chart caption rendered as text (PDF).
|
||||||
assert "identificador" in txt # id-like note rendered.
|
assert "identificador" in txt # id-like note rendered.
|
||||||
|
|
||||||
|
|
||||||
@@ -258,9 +266,11 @@ def _profile_high_card() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
||||||
"""Each categorical column occupies EXACTLY ONE cat_distr slide that carries
|
"""Cada columna categórica ocupa EXACTAMENTE UN slide cat_distr que lleva su
|
||||||
BOTH its cardinality table and its donut figure (picture) — i.e. the chart is
|
gráfico (picture) en la misma slide — el chart nunca se separa de su columna,
|
||||||
never separated from its table, even for a high-cardinality column."""
|
ni siquiera para una columna de alta cardinalidad. Con layout side_by_side la
|
||||||
|
tabla se rasteriza a imagen, así que la comprobación se hace por presencia de
|
||||||
|
picture (no por el texto de la tabla)."""
|
||||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
|
||||||
prof = _profile_high_card()
|
prof = _profile_high_card()
|
||||||
@@ -272,7 +282,7 @@ def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
|||||||
prs = Presentation(out)
|
prs = Presentation(out)
|
||||||
|
|
||||||
# Per column: the cat_distr slides whose text mentions it, and whether the
|
# Per column: the cat_distr slides whose text mentions it, and whether the
|
||||||
# owning slide also has the donut caption + an actual picture shape.
|
# owning slide also carries an actual picture shape (its chart).
|
||||||
slides_with_col = {n: [] for n in cat_names}
|
slides_with_col = {n: [] for n in cat_names}
|
||||||
owner_has_chart = {n: False for n in cat_names}
|
owner_has_chart = {n: False for n in cat_names}
|
||||||
for i, sl in enumerate(prs.slides):
|
for i, sl in enumerate(prs.slides):
|
||||||
@@ -288,15 +298,106 @@ def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
|||||||
for n in cat_names:
|
for n in cat_names:
|
||||||
if n in txt:
|
if n in txt:
|
||||||
slides_with_col[n].append(i)
|
slides_with_col[n].append(i)
|
||||||
has_table = "Cardinalidad" in txt or "distintos" in txt
|
if has_pic:
|
||||||
if has_pic and "donut" in txt and has_table:
|
|
||||||
owner_has_chart[n] = True
|
owner_has_chart[n] = True
|
||||||
|
|
||||||
for n in cat_names:
|
for n in cat_names:
|
||||||
# Exactly one slide carries the column (not split across slides).
|
# Exactly one slide carries the column (not split across slides).
|
||||||
assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
|
assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
|
||||||
# That single slide also holds its table AND its donut picture.
|
# That single slide also holds its chart picture.
|
||||||
assert owner_has_chart[n], (n, "tabla y donut no están en el mismo slide")
|
assert owner_has_chart[n], (n, "el gráfico no está en el slide de la columna")
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_pptx_columna_side_by_side_tabla_izq_barra_der():
|
||||||
|
"""Con layout side_by_side, una columna categórica coloca su tabla de
|
||||||
|
cardinalidad (imagen) en la mitad izquierda y su gráfico de barras (imagen) en
|
||||||
|
la mitad derecha de la MISMA slide. Verifica que al menos una columna queda en
|
||||||
|
dos columnas (tabla-izq / barras-der), evidencia del side_by_side en PPTX."""
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
from pptx.util import Inches
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = os.path.join(d, "eda.pptx")
|
||||||
|
render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
|
||||||
|
prs = Presentation(out)
|
||||||
|
centre = int(Inches(13.333 / 2.0)) # half of the 16:9 slide width.
|
||||||
|
two_col_slides = 0
|
||||||
|
for sl in prs.slides:
|
||||||
|
texts, lefts = [], []
|
||||||
|
for sh in sl.shapes:
|
||||||
|
if sh.has_text_frame:
|
||||||
|
texts.append(sh.text_frame.text)
|
||||||
|
if (sh.shape_type == MSO_SHAPE_TYPE.PICTURE
|
||||||
|
and sh.left is not None):
|
||||||
|
lefts.append(sh.left)
|
||||||
|
txt = re.sub(r"\s+", " ", " ".join(texts))
|
||||||
|
if "Distribuciones categ" not in txt:
|
||||||
|
continue
|
||||||
|
# One picture starts in the left half, another in the right half.
|
||||||
|
if len(lefts) >= 2 and min(lefts) < centre and max(lefts) > centre:
|
||||||
|
two_col_slides += 1
|
||||||
|
assert two_col_slides >= 1, (
|
||||||
|
"ninguna columna quedó con tabla-izq / barras-der (side_by_side)")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_with_llm() -> dict:
|
||||||
|
"""The base profile plus an ``llm`` block (as eda_llm_insights would store it
|
||||||
|
with run_llm=True): a data dictionary with description/unit per column."""
|
||||||
|
prof = _profile()
|
||||||
|
prof["llm"] = {
|
||||||
|
"dictionary": [
|
||||||
|
{"column": "categoria",
|
||||||
|
"description": "Familia de producto del recambio",
|
||||||
|
"business_meaning": "Agrupa el catálogo por tipo de pieza",
|
||||||
|
"unit": "categoría"},
|
||||||
|
{"column": "uuid",
|
||||||
|
"description": "Identificador único de registro",
|
||||||
|
"unit": ""},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return prof
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_descripcion_y_unidad_por_columna():
|
||||||
|
# With an LLM dictionary, each categorical column whose name matches shows its
|
||||||
|
# business description and unit in a per-column markdown block.
|
||||||
|
ch = build_cat_distr(_profile_with_llm(), {})
|
||||||
|
groups = _column_groups(ch)
|
||||||
|
cat_group = next(g for g in groups
|
||||||
|
if any(getattr(b, "text", "") == "categoria"
|
||||||
|
for b in g.blocks))
|
||||||
|
md = " ".join(b.text for b in cat_group.blocks
|
||||||
|
if getattr(b, "kind", "") == "markdown")
|
||||||
|
assert "Descripción" in md and "Familia de producto" in md
|
||||||
|
assert "Unidad" in md and "categoría" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_sin_llm_no_anade_descripcion():
|
||||||
|
# Without an LLM block the per-column description markdown is simply omitted;
|
||||||
|
# the column still renders its cardinality table and bar figure.
|
||||||
|
ch = build_cat_distr(_profile(), {})
|
||||||
|
for g in _column_groups(ch):
|
||||||
|
mds = [b.text for b in g.blocks if getattr(b, "kind", "") == "markdown"]
|
||||||
|
assert not any("Descripción" in t for t in mds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_categorica_clicable_y_definicion_en_glosario():
|
||||||
|
# The "how each categorical page is laid out" term is registered + marked
|
||||||
|
# clickable in the intro, and its full definition lands in the glossary
|
||||||
|
# chapter (canonical baseline catalog), not inline.
|
||||||
|
from datascience.automatic_eda.chapters.glosario import build_glosario
|
||||||
|
|
||||||
|
gc = GlossaryCollector()
|
||||||
|
ch = build_cat_distr(_profile(), {"glossary": gc})
|
||||||
|
md = next(b for b in ch.blocks if isinstance(b, Markdown))
|
||||||
|
assert "[[term:pagina_categorica]]" in md.text
|
||||||
|
assert gc.has("pagina_categorica")
|
||||||
|
glos = build_glosario(_profile(), {"glossary": gc})
|
||||||
|
entry = next(b for b in glos.blocks
|
||||||
|
if getattr(b, "kind", "") == "glossary_entry"
|
||||||
|
and b.key == "pagina_categorica")
|
||||||
|
assert "barras" in entry.definition
|
||||||
|
assert "identificador" in entry.definition
|
||||||
|
|
||||||
|
|
||||||
def test_edge_sin_categoricas_devuelve_none():
|
def test_edge_sin_categoricas_devuelve_none():
|
||||||
|
|||||||
@@ -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,13 @@ _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
|
# Glossary terms this chapter explains. Each is registered in the shared
|
||||||
# collector (ctx['glossary']) and marked clickable on its first appearance in the
|
# collector (ctx['glossary']) and marked clickable on its first appearance in the
|
||||||
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
|
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
|
||||||
@@ -314,6 +321,139 @@ def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
|
|||||||
return " ".join(parts)
|
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.
|
||||||
|
|
||||||
@@ -392,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():
|
||||||
|
|||||||
@@ -175,6 +175,105 @@ def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
|
|||||||
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():
|
def test_glosario_engancha_metodos_y_fdr():
|
||||||
"""Mejora 4b: los métodos de correlación (Pearson, Spearman, Cramér's V,
|
"""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
|
razón de correlación) y la corrección por comparaciones múltiples (FDR) se
|
||||||
|
|||||||
@@ -17,10 +17,63 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.0.0"
|
CHAPTER_VERSION = "1.1.0"
|
||||||
CHAPTER_ID = "glosario"
|
CHAPTER_ID = "glosario"
|
||||||
CHAPTER_TITLE = "Glosario"
|
CHAPTER_TITLE = "Glosario"
|
||||||
|
|
||||||
|
# Canonical definitions for cross-cutting terms — the "how to read it" entries
|
||||||
|
# that do not belong to a single chapter. A chapter only needs to *register* the
|
||||||
|
# term (``ctx['glossary'].add(key, label)``) and mark its in-text appearance with
|
||||||
|
# ``[[term:key]]…[[/term]]``; this chapter supplies the full definition here when
|
||||||
|
# the collector carries the term without one. Keeping the prose in a single place
|
||||||
|
# avoids repeating a long paragraph inline in every chapter that names the term
|
||||||
|
# (the explanation moved out of the NUM DISTR and CAT DISTR intros lives here).
|
||||||
|
_BASELINE_TERMS = {
|
||||||
|
"histograma_boxplot": {
|
||||||
|
"label": "Cómo leer el histograma y el boxplot",
|
||||||
|
"definition": (
|
||||||
|
"Para cada columna numérica se muestra su histograma con tres líneas "
|
||||||
|
"de referencia: la media (línea roja discontinua), la mediana (línea "
|
||||||
|
"verde continua) y la banda ±1σ (zona sombreada que cubre una "
|
||||||
|
"desviación estándar a cada lado de la media). Debajo, alineado al "
|
||||||
|
"mismo eje horizontal, un boxplot de Tukey: la caja abarca del primer "
|
||||||
|
"al tercer cuartil (P25–P75), la línea interior es la mediana y los "
|
||||||
|
"bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay "
|
||||||
|
"valores más allá de las vallas (posibles atípicos). Comparar la media "
|
||||||
|
"con la mediana revela la asimetría: si la media supera a la mediana la "
|
||||||
|
"cola larga cae hacia los valores altos (asimetría a la derecha), y al "
|
||||||
|
"revés hacia los bajos."),
|
||||||
|
},
|
||||||
|
"pagina_categorica": {
|
||||||
|
"label": "Cómo se organiza cada página categórica",
|
||||||
|
"definition": (
|
||||||
|
"Cada columna categórica ocupa su propia página: muestra sus métricas "
|
||||||
|
"de cardinalidad —incluida la entropía—, una nota que señala "
|
||||||
|
"cardinalidad problemática (columnas que se comportan como "
|
||||||
|
"identificador, con casi todos los valores distintos, o dominadas por "
|
||||||
|
"una sola categoría), la tabla de las categorías más frecuentes (top-k, "
|
||||||
|
"con su conteo y porcentaje) y un gráfico de barras de las categorías "
|
||||||
|
"más comunes (top-k más una barra «Otros» que agrupa la cola). El total "
|
||||||
|
"de filas del dataset se usa como referencia para interpretar los "
|
||||||
|
"conteos."),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_term(term: dict) -> tuple:
|
||||||
|
"""Return (label, definition) for a collected term, completing a missing
|
||||||
|
definition (and, if absent, the label) from the canonical baseline catalog."""
|
||||||
|
key = model._safe_str(term.get("key"))
|
||||||
|
label = model._safe_str(term.get("label"))
|
||||||
|
definition = model._safe_str(term.get("definition"))
|
||||||
|
base = _BASELINE_TERMS.get(key)
|
||||||
|
if base:
|
||||||
|
if not definition.strip():
|
||||||
|
definition = model._safe_str(base.get("definition"))
|
||||||
|
if not label.strip() or label == key:
|
||||||
|
label = model._safe_str(base.get("label")) or label
|
||||||
|
return label, definition
|
||||||
|
|
||||||
|
|
||||||
def build_glosario(profile: dict, ctx: dict):
|
def build_glosario(profile: dict, ctx: dict):
|
||||||
"""Build the glossary Chapter from the shared collector, or None if empty."""
|
"""Build the glossary Chapter from the shared collector, or None if empty."""
|
||||||
@@ -36,12 +89,14 @@ def build_glosario(profile: dict, ctx: dict):
|
|||||||
"Cada término va resaltado en el texto y, al pulsarlo, salta a su "
|
"Cada término va resaltado en el texto y, al pulsarlo, salta a su "
|
||||||
"definición en esta sección.")),
|
"definición en esta sección.")),
|
||||||
]
|
]
|
||||||
# One clickable destination per term, alphabetically by visible label.
|
# One clickable destination per term, alphabetically by visible label. A term
|
||||||
|
# registered without a definition is completed from the canonical baseline.
|
||||||
for term in glossary.terms(by="label"):
|
for term in glossary.terms(by="label"):
|
||||||
|
label, definition = _resolve_term(term)
|
||||||
blocks.append(model.GlossaryEntry(
|
blocks.append(model.GlossaryEntry(
|
||||||
key=model._safe_str(term.get("key")),
|
key=model._safe_str(term.get("key")),
|
||||||
label=model._safe_str(term.get("label")),
|
label=label,
|
||||||
definition=model._safe_str(term.get("definition"))))
|
definition=definition))
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -35,10 +35,21 @@ 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.2.0"
|
CHAPTER_VERSION = "1.3.0"
|
||||||
CHAPTER_ID = "num_distr"
|
CHAPTER_ID = "num_distr"
|
||||||
CHAPTER_TITLE = "Distribuciones numéricas"
|
CHAPTER_TITLE = "Distribuciones numéricas"
|
||||||
|
|
||||||
|
# Glossary term this chapter explains. The long "how to read the histogram and
|
||||||
|
# the boxplot" paragraph used to live inline in the intro; it now lives in the
|
||||||
|
# GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``) and the
|
||||||
|
# intro only names the clickable term — one click jumps to the full explanation,
|
||||||
|
# so the information is relocated, not lost (mejora glosario).
|
||||||
|
_TERM_HISTOBOX_KEY = "histograma_boxplot"
|
||||||
|
_TERM_HISTOBOX_LABEL = "Cómo leer el histograma y el boxplot"
|
||||||
|
|
||||||
|
# Key under which eda_llm_insights stores its interpretive block in the profile.
|
||||||
|
LLM_KEY = "llm"
|
||||||
|
|
||||||
# Plain-Spanish gloss for every label ``detect_distribution_type`` can emit, so a
|
# Plain-Spanish gloss for every label ``detect_distribution_type`` can emit, so a
|
||||||
# non-expert reader understands the shape and the suggested next step (MUST-4.3).
|
# non-expert reader understands the shape and the suggested next step (MUST-4.3).
|
||||||
_DIST_GLOSS = {
|
_DIST_GLOSS = {
|
||||||
@@ -99,6 +110,53 @@ def _numeric_columns(profile: dict) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_index(profile: dict, ctx: dict) -> dict:
|
||||||
|
"""Map column name -> its LLM dictionary entry (description/unit/...).
|
||||||
|
|
||||||
|
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
|
||||||
|
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
|
||||||
|
dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
|
||||||
|
defensive: never raises on malformed input.
|
||||||
|
"""
|
||||||
|
llm = profile.get(LLM_KEY)
|
||||||
|
if not isinstance(llm, dict):
|
||||||
|
llm = ctx.get(LLM_KEY)
|
||||||
|
if not isinstance(llm, dict):
|
||||||
|
return {}
|
||||||
|
entries = llm.get("dictionary")
|
||||||
|
if not isinstance(entries, (list, tuple)):
|
||||||
|
return {}
|
||||||
|
index: dict = {}
|
||||||
|
for e in entries:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
continue
|
||||||
|
col = e.get("column")
|
||||||
|
if col is None:
|
||||||
|
continue
|
||||||
|
index[model._safe_str(col)] = e
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_desc_unit_block(name: str, llm_index: dict):
|
||||||
|
"""Markdown block with the LLM business description + unit of a column, or
|
||||||
|
None when no LLM entry matches the column (clean fallback without LLM)."""
|
||||||
|
entry = llm_index.get(model._safe_str(name))
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
raw_desc = entry.get("description") or entry.get("business_meaning")
|
||||||
|
desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
|
||||||
|
raw_unit = entry.get("unit")
|
||||||
|
unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
|
||||||
|
parts = []
|
||||||
|
if desc:
|
||||||
|
parts.append(f"**Descripción:** {desc}")
|
||||||
|
if unit:
|
||||||
|
parts.append(f"**Unidad:** {unit}")
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
return model.Markdown(text=" · ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
def _make_hist_box(name: str, numeric: dict, box: dict):
|
def _make_hist_box(name: str, numeric: dict, box: dict):
|
||||||
"""Build the histogram (with mean/median/±σ lines) + boxplot figure.
|
"""Build the histogram (with mean/median/±σ lines) + boxplot figure.
|
||||||
|
|
||||||
@@ -271,15 +329,26 @@ def build_num_distr(profile: dict, ctx: dict):
|
|||||||
if not numerics:
|
if not numerics:
|
||||||
return None # chapter does not apply to a dataset with no numerics.
|
return None # chapter does not apply to a dataset with no numerics.
|
||||||
|
|
||||||
|
# Register the "how to read the histogram and boxplot" term in the shared
|
||||||
|
# glossary collector (if present) and mark its first appearance clickable. The
|
||||||
|
# full explanation (colour code, 1,5·IQR rule, asymmetry reading) lives in the
|
||||||
|
# GLOSARIO chapter instead of inline here: the intro only names the term.
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
mark_term = False
|
||||||
|
if isinstance(glossary, model.GlossaryCollector):
|
||||||
|
glossary.add(_TERM_HISTOBOX_KEY, _TERM_HISTOBOX_LABEL)
|
||||||
|
mark_term = True
|
||||||
|
como_leer = ("[[term:histograma_boxplot]]cómo leer estos gráficos[[/term]]"
|
||||||
|
if mark_term else "cómo leer estos gráficos")
|
||||||
intro = (
|
intro = (
|
||||||
"Para cada columna numérica se muestra su **histograma** con tres líneas "
|
"Cada columna numérica muestra su **histograma** (con la **media**, la "
|
||||||
"de referencia: la **media** (línea roja discontinua), la **mediana** "
|
"**mediana** y la banda **±1σ**) y, debajo y al mismo eje, su **boxplot "
|
||||||
"(línea verde continua) y la banda **±1σ** (zona sombreada). Debajo, "
|
f"de Tukey** — {como_leer}.")
|
||||||
"alineado al mismo eje, un **boxplot de Tukey**: la caja abarca del "
|
|
||||||
"primer al tercer cuartil (P25–P75), la línea interior es la mediana y "
|
# Business description + unit per column come from the LLM dictionary
|
||||||
"los bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay "
|
# (profile['llm']['dictionary'], matched by column name); absent without
|
||||||
"valores más allá de las vallas. Comparar media y mediana revela la "
|
# run_llm, in which case the per-column description block is simply omitted.
|
||||||
"asimetría de la distribución.")
|
llm_index = _llm_index(profile, ctx)
|
||||||
|
|
||||||
blocks = [
|
blocks = [
|
||||||
model.Heading(text=CHAPTER_TITLE, level=1),
|
model.Heading(text=CHAPTER_TITLE, level=1),
|
||||||
@@ -293,17 +362,20 @@ def build_num_distr(profile: dict, ctx: dict):
|
|||||||
box = build_boxplot_stats(numeric) or {}
|
box = build_boxplot_stats(numeric) or {}
|
||||||
except Exception: # noqa: BLE001 — degrade, never raise.
|
except Exception: # noqa: BLE001 — degrade, never raise.
|
||||||
box = {}
|
box = {}
|
||||||
# Keep the column heading, its figure and its stats note together on the
|
# Keep the column heading, its (optional) LLM description, its figure and
|
||||||
# same page/slide (mejora 3 — keep-together): the renderers measure the
|
# its stats note together on the same page/slide (mejora 3 —
|
||||||
# whole Group and move it whole when it would not fit.
|
# keep-together): the renderers measure the whole Group and move it whole
|
||||||
blocks.append(model.Group(blocks=[
|
# when it would not fit.
|
||||||
model.Heading(text=str(name), level=2),
|
col_blocks = [model.Heading(text=str(name), level=2)]
|
||||||
model.Figure(
|
desc_block = _llm_desc_unit_block(name, llm_index)
|
||||||
make=_figure_maker(name, numeric, box),
|
if desc_block is not None:
|
||||||
caption=f"Distribución de «{name}» — histograma "
|
col_blocks.append(desc_block)
|
||||||
f"(media/mediana/±σ) y boxplot."),
|
col_blocks.append(model.Figure(
|
||||||
model.Markdown(text=_stats_note(name, numeric, box)),
|
make=_figure_maker(name, numeric, box),
|
||||||
]))
|
caption=f"Distribución de «{name}» — histograma "
|
||||||
|
f"(media/mediana/±σ) y boxplot."))
|
||||||
|
col_blocks.append(model.Markdown(text=_stats_note(name, numeric, box)))
|
||||||
|
blocks.append(model.Group(blocks=col_blocks))
|
||||||
|
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ def test_golden_chapter_estructura_y_bloques():
|
|||||||
|
|
||||||
|
|
||||||
def test_golden_media_mediana_sigma_y_boxplot_presentes():
|
def test_golden_media_mediana_sigma_y_boxplot_presentes():
|
||||||
# The intro documents the three reference lines and the Tukey boxplot; the
|
# The short intro names the three reference lines and the Tukey boxplot; the
|
||||||
# per-column note carries the actual mean/median/σ numbers and the shape.
|
# per-column note carries the actual mean/median/σ numbers and the shape.
|
||||||
ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
|
ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
|
||||||
md_texts = " ".join(b.text for b in _flatten(ch.blocks)
|
md_texts = " ".join(b.text for b in _flatten(ch.blocks)
|
||||||
@@ -110,10 +110,58 @@ def test_golden_media_mediana_sigma_y_boxplot_presentes():
|
|||||||
assert "±1σ" in md_texts or "σ" in md_texts
|
assert "±1σ" in md_texts or "σ" in md_texts
|
||||||
assert "boxplot" in md_texts.lower()
|
assert "boxplot" in md_texts.lower()
|
||||||
assert "Tukey" in md_texts
|
assert "Tukey" in md_texts
|
||||||
|
# The long "how to read it" explanation moved to the glossary: the colour-code
|
||||||
|
# / 1,5·IQR walkthrough is no longer inline in the chapter body.
|
||||||
|
assert "1,5·IQR" not in md_texts
|
||||||
|
assert "línea roja" not in md_texts
|
||||||
# distribution_type gloss surfaced for the column (right-skewed preset).
|
# distribution_type gloss surfaced for the column (right-skewed preset).
|
||||||
assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts
|
assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_glosario_histograma_boxplot_clicable_y_definicion():
|
||||||
|
# With a glossary collector the intro marks the clickable term and the FULL
|
||||||
|
# explanation (the long paragraph removed from the body) lands in the glossary.
|
||||||
|
from datascience.automatic_eda.chapters.glosario import build_glosario
|
||||||
|
|
||||||
|
gc = model.GlossaryCollector()
|
||||||
|
prof = _profile(n_numeric=1, extra_categorical=False)
|
||||||
|
ch = build_num_distr(prof, {"glossary": gc})
|
||||||
|
intro = next(b for b in ch.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:histograma_boxplot]]" in intro.text
|
||||||
|
assert gc.has("histograma_boxplot")
|
||||||
|
glos = build_glosario(prof, {"glossary": gc})
|
||||||
|
entry = next(b for b in glos.blocks
|
||||||
|
if getattr(b, "kind", "") == "glossary_entry"
|
||||||
|
and b.key == "histograma_boxplot")
|
||||||
|
assert "boxplot" in entry.definition.lower()
|
||||||
|
assert "1,5·IQR" in entry.definition
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_descripcion_y_unidad_por_columna():
|
||||||
|
# With an LLM dictionary, each numeric column whose name matches shows its
|
||||||
|
# business description and unit in a per-column markdown block.
|
||||||
|
prof = _profile(n_numeric=2)
|
||||||
|
prof["llm"] = {"dictionary": [
|
||||||
|
{"column": "precio", "description": "Precio de venta del producto",
|
||||||
|
"unit": "EUR"},
|
||||||
|
{"column": "alcohol", "business_meaning": "Grado alcohólico",
|
||||||
|
"unit": "% vol"},
|
||||||
|
]}
|
||||||
|
ch = build_num_distr(prof, {})
|
||||||
|
md_all = " ".join(b.text for b in _flatten(ch.blocks)
|
||||||
|
if b.kind == "markdown")
|
||||||
|
assert "Precio de venta" in md_all and "EUR" in md_all
|
||||||
|
assert "Grado alcohólico" in md_all and "% vol" in md_all
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_sin_llm_no_anade_descripcion():
|
||||||
|
# Without an LLM block the per-column description markdown is simply omitted.
|
||||||
|
ch = build_num_distr(_profile(n_numeric=2), {})
|
||||||
|
md_all = " ".join(b.text for b in _flatten(ch.blocks)
|
||||||
|
if b.kind == "markdown")
|
||||||
|
assert "Descripción" not in md_all
|
||||||
|
|
||||||
|
|
||||||
def test_boxplot_stats_se_consumen_del_registry():
|
def test_boxplot_stats_se_consumen_del_registry():
|
||||||
# The chapter must feed build_boxplot_stats (group eda) and the resulting
|
# The chapter must feed build_boxplot_stats (group eda) and the resulting
|
||||||
# box must carry the Tukey fences for the figure.
|
# box must carry the Tukey fences for the figure.
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
"""Outliers chapter (OUTLIERS) — univariate + multivariate atypical values.
|
||||||
|
|
||||||
|
Today the analysis of atypical values is scattered across the document: the
|
||||||
|
NUM DISTR chapter mentions the per-column outlier count inside each distribution
|
||||||
|
figure, and the MODELOS chapter runs Isolation Forest as one of several cheap
|
||||||
|
models. This chapter gathers and deepens the whole outlier story in a single
|
||||||
|
place, with its interpretation: an [[term:outlier]]outlier[[/term]] is **not
|
||||||
|
necessarily an error** — it can be a legitimate, extreme but real observation —
|
||||||
|
so the reading is exploratory (what to look at), never confirmatory (what to
|
||||||
|
delete).
|
||||||
|
|
||||||
|
Sections, in order:
|
||||||
|
|
||||||
|
1. **Resumen univariante por columna** — for every numeric column, the number
|
||||||
|
and percentage of atypical values by two complementary criteria: Tukey's
|
||||||
|
1.5·IQR rule ([[term:tukey_fence]]vallas de Tukey[[/term]]) and the
|
||||||
|
[[term:zscore]]z-score[[/term]] rule (|z| > 3). The most contaminated columns
|
||||||
|
are flagged. The fences come from the pure registry function
|
||||||
|
``build_boxplot_stats`` (derived from the profile percentiles); the per-column
|
||||||
|
counts use the raw sample in ``ctx['raw_numeric']`` when available (the exact
|
||||||
|
count), degrading to the profile's own z-score counts otherwise.
|
||||||
|
2. **Boxplots** — a single figure with the Tukey boxplots of the most
|
||||||
|
contaminated columns (box, whiskers and atypical points), delegated to the
|
||||||
|
reusable registry helper ``build_boxplots_figure``.
|
||||||
|
3. **Multivariante (filas anómalas)** — rows that are atypical considering ALL
|
||||||
|
columns at once, via the registry function ``isolation_forest_outliers``: the
|
||||||
|
count and percentage of anomalous rows, the most anomalous rows with their
|
||||||
|
score, and the dimensions that make each one rare (top columns by |z|, via
|
||||||
|
``summarize_outlier_dims``). Run live on ``ctx['raw_numeric']`` (the same
|
||||||
|
numeric columns ``summarize_outlier_dims`` uses, so the row indexing stays
|
||||||
|
coherent and the dimension breakdown is correct); falls back to the
|
||||||
|
precomputed ``profile['models']['outliers']`` only when no raw sample is
|
||||||
|
available (e.g. the lite preset), where no per-row breakdown is shown.
|
||||||
|
4. **Interpretación** — outlier ≠ error: how to tell a data-entry error from a
|
||||||
|
genuine extreme value, and what to do (inspect, winsorize, or re-express —
|
||||||
|
linking to the Tukey re-expression the profile already computes).
|
||||||
|
|
||||||
|
The chapter activates whenever the table has at least one numeric column; with
|
||||||
|
no numeric column it returns ``None`` and disappears from the document.
|
||||||
|
|
||||||
|
Reads everything defensively (``.get``) and never raises: every registry
|
||||||
|
delegation is imported lazily and degraded to an honest note on any failure.
|
||||||
|
|
||||||
|
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import model
|
||||||
|
|
||||||
|
CHAPTER_VERSION = "1.0.0"
|
||||||
|
CHAPTER_ID = "outliers"
|
||||||
|
CHAPTER_TITLE = "Valores atípicos"
|
||||||
|
|
||||||
|
# z-score threshold for the univariate z rule: |z| > 3 flags a value ~3 standard
|
||||||
|
# deviations from the mean (≈99.7% of a normal distribution lies within ±3σ).
|
||||||
|
_Z_THRESH = 3.0
|
||||||
|
# How many columns to draw in the boxplots figure (most contaminated first) and
|
||||||
|
# how many anomalous rows to list in the multivariate table.
|
||||||
|
_TOP_BOX = 12
|
||||||
|
_TOP_ROWS = 12
|
||||||
|
# Cap on the raw atypical values passed as boxplot fliers, so a heavy-tailed
|
||||||
|
# column does not flood the figure with thousands of points.
|
||||||
|
_MAX_FLIERS = 200
|
||||||
|
# How many columns flagged as "most contaminated" in the summary note.
|
||||||
|
_TOP_FLAGGED = 3
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains (contract §11.1). Registered in the shared
|
||||||
|
# collector and marked clickable on first appearance. ``isolation_forest`` and
|
||||||
|
# ``zscore`` may also be registered by the MODELOS chapter — ``add`` is
|
||||||
|
# idempotent (first definition wins), so registering them here is harmless and
|
||||||
|
# keeps this chapter self-contained when MODELOS does not render.
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"outlier": (
|
||||||
|
"Valor atípico (outlier)",
|
||||||
|
"Una observación que se aparta mucho del grueso de los datos. Un atípico "
|
||||||
|
"NO es necesariamente un error: puede ser un fallo de medida o de "
|
||||||
|
"registro, pero también un dato real extremo (un cliente que gasta diez "
|
||||||
|
"veces la media, un día de ventas excepcional). Por eso se señalan para "
|
||||||
|
"revisarlos, no para borrarlos automáticamente.",
|
||||||
|
),
|
||||||
|
"tukey_fence": (
|
||||||
|
"Vallas de Tukey (1,5·IQR)",
|
||||||
|
"Regla clásica para marcar atípicos a partir de los cuartiles: se calcula "
|
||||||
|
"el rango intercuartílico IQR = P75 − P25 y se trazan dos vallas, una "
|
||||||
|
"inferior en P25 − 1,5·IQR y otra superior en P75 + 1,5·IQR. Los valores "
|
||||||
|
"que caen fuera de esas vallas se consideran atípicos. Es robusta porque "
|
||||||
|
"se apoya en la mediana y los cuartiles, no en la media.",
|
||||||
|
),
|
||||||
|
"zscore": (
|
||||||
|
"z-score (puntuación típica)",
|
||||||
|
"Mide a cuántas desviaciones típicas está un valor de la media de su "
|
||||||
|
"columna: z = (valor − media) / desviación típica. Un |z| grande (aquí > "
|
||||||
|
"3) señala un valor alejado del centro. A diferencia de las vallas de "
|
||||||
|
"Tukey, el z-score usa media y desviación, así que es más sensible a la "
|
||||||
|
"presencia de los propios atípicos.",
|
||||||
|
),
|
||||||
|
"isolation_forest": (
|
||||||
|
"Isolation Forest (anomalías multivariantes)",
|
||||||
|
"Algoritmo de detección de anomalías que considera TODAS las columnas a "
|
||||||
|
"la vez: construye árboles que parten el espacio con cortes aleatorios y "
|
||||||
|
"mide cuántos cortes hacen falta para aislar cada fila. Las filas raras "
|
||||||
|
"se aíslan con muy pocos cortes y se marcan como atípicas según un umbral "
|
||||||
|
"de contaminación. Detecta combinaciones de valores poco frecuentes que "
|
||||||
|
"ninguna columna por separado revelaría.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Lazy registry delegations (each degrades to None / no-op on any failure).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _load_build_boxplot_stats():
|
||||||
|
try:
|
||||||
|
from datascience.build_boxplot_stats import build_boxplot_stats
|
||||||
|
return build_boxplot_stats
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_detect_outliers():
|
||||||
|
# detect_outliers lives in the monolithic ``datascience.datascience`` module
|
||||||
|
# (file_path datascience.py), not in its own submodule — try both shapes.
|
||||||
|
try:
|
||||||
|
from datascience.datascience import detect_outliers
|
||||||
|
return detect_outliers
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
try:
|
||||||
|
from datascience import detect_outliers
|
||||||
|
return detect_outliers
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_isolation_forest():
|
||||||
|
try:
|
||||||
|
from datascience.isolation_forest_outliers import isolation_forest_outliers
|
||||||
|
return isolation_forest_outliers
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_summarize_dims():
|
||||||
|
try:
|
||||||
|
from datascience.summarize_outlier_dims import summarize_outlier_dims
|
||||||
|
return summarize_outlier_dims
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Defensive formatters (own copy: the chapter never imports siblings).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _fmt_num(value, decimals: int = 3) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "sí" if value else "no"
|
||||||
|
if isinstance(value, int):
|
||||||
|
return f"{value:,}".replace(",", ".")
|
||||||
|
if isinstance(value, float):
|
||||||
|
if value != value: # NaN
|
||||||
|
return "—"
|
||||||
|
if value in (float("inf"), float("-inf")):
|
||||||
|
return str(value)
|
||||||
|
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
|
||||||
|
return text if text else "0"
|
||||||
|
return model._safe_str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_int(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{int(round(float(value))):,}".replace(",", ".")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return model._safe_str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct(value, decimals: int = 2) -> str:
|
||||||
|
"""Format an already-0-100 value as a percentage. None -> placeholder."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{float(value):.{decimals}f}%"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return model._safe_str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dict(v) -> bool:
|
||||||
|
return isinstance(v, dict)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Profile reads.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _numeric_columns(profile: dict) -> list:
|
||||||
|
"""Return [(name, numeric_dict)] for numeric columns with usable stats."""
|
||||||
|
out = []
|
||||||
|
for col in profile.get("columns") or []:
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
continue
|
||||||
|
if col.get("inferred_type") != "numeric":
|
||||||
|
continue
|
||||||
|
num = col.get("numeric")
|
||||||
|
if not isinstance(num, dict) or not num:
|
||||||
|
continue
|
||||||
|
if num.get("mean") is None and num.get("median") is None:
|
||||||
|
continue
|
||||||
|
out.append((col.get("name") or "(columna)", num))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_values(raw):
|
||||||
|
"""Return the finite float values of a raw column list (drop None/NaN/inf)."""
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
return None
|
||||||
|
vals = []
|
||||||
|
for v in raw:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if f != f or f in (float("inf"), float("-inf")):
|
||||||
|
continue
|
||||||
|
vals.append(f)
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Per-column univariate summary.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _univariate_row(name, numeric, raw_vals, box_fn, detect_fn):
|
||||||
|
"""Compute one univariate summary row + boxplot inputs for a column.
|
||||||
|
|
||||||
|
Returns a dict with the table cells and, when raw values are available, the
|
||||||
|
exact Tukey/z counts and the list of atypical (flier) values; otherwise it
|
||||||
|
degrades to the profile's own z-score counts and the fence flags.
|
||||||
|
"""
|
||||||
|
box = {}
|
||||||
|
if box_fn is not None:
|
||||||
|
try:
|
||||||
|
box = box_fn(numeric) or {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
box = {}
|
||||||
|
lf = box.get("lower_fence")
|
||||||
|
uf = box.get("upper_fence")
|
||||||
|
|
||||||
|
vals = _clean_values(raw_vals)
|
||||||
|
n_tukey = pct_tukey = None
|
||||||
|
n_z = pct_z = None
|
||||||
|
low_extreme = high_extreme = None
|
||||||
|
fliers = []
|
||||||
|
contamination = None # metric used to rank columns (prefer Tukey %).
|
||||||
|
|
||||||
|
if vals:
|
||||||
|
n = len(vals)
|
||||||
|
tukey_out = []
|
||||||
|
for v in vals:
|
||||||
|
below = (lf is not None and v < lf)
|
||||||
|
above = (uf is not None and v > uf)
|
||||||
|
if below or above:
|
||||||
|
tukey_out.append(v)
|
||||||
|
n_tukey = len(tukey_out)
|
||||||
|
pct_tukey = 100.0 * n_tukey / n if n else None
|
||||||
|
if tukey_out:
|
||||||
|
low_extreme = min(tukey_out)
|
||||||
|
high_extreme = max(tukey_out)
|
||||||
|
fliers = tukey_out[:_MAX_FLIERS]
|
||||||
|
# z-score rule via the registry function (returns parallel bools).
|
||||||
|
if detect_fn is not None:
|
||||||
|
try:
|
||||||
|
flags = detect_fn(vals, _Z_THRESH) or []
|
||||||
|
n_z = int(sum(1 for b in flags if b))
|
||||||
|
pct_z = 100.0 * n_z / n if n else None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
n_z = pct_z = None
|
||||||
|
contamination = pct_tukey
|
||||||
|
else:
|
||||||
|
# Degrade: no raw sample for this column. The profile's own outlier
|
||||||
|
# count/pct come from the z-score block (build_boxplot_stats note); the
|
||||||
|
# Tukey count is unknown, only the fence flags are.
|
||||||
|
n_z = numeric.get("n_outliers")
|
||||||
|
pct_z = numeric.get("outlier_pct")
|
||||||
|
if box.get("has_low_outliers") and box.get("min") is not None:
|
||||||
|
low_extreme = box.get("min")
|
||||||
|
if box.get("has_high_outliers") and box.get("max") is not None:
|
||||||
|
high_extreme = box.get("max")
|
||||||
|
contamination = pct_z if isinstance(pct_z, (int, float)) else None
|
||||||
|
|
||||||
|
# Compact "extremos atípicos" cell: down/up arrows for the low/high tail.
|
||||||
|
extremes = []
|
||||||
|
if low_extreme is not None:
|
||||||
|
extremes.append(f"↓ {_fmt_num(low_extreme)}")
|
||||||
|
if high_extreme is not None:
|
||||||
|
extremes.append(f"↑ {_fmt_num(high_extreme)}")
|
||||||
|
extremes_cell = " ".join(extremes) if extremes else "—"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": model._safe_str(name),
|
||||||
|
"n_tukey": n_tukey,
|
||||||
|
"pct_tukey": pct_tukey,
|
||||||
|
"n_z": n_z,
|
||||||
|
"pct_z": pct_z,
|
||||||
|
"lower_fence": lf,
|
||||||
|
"upper_fence": uf,
|
||||||
|
"extremes": extremes_cell,
|
||||||
|
"box": box,
|
||||||
|
"fliers": fliers,
|
||||||
|
"has_raw": bool(vals),
|
||||||
|
"contamination": contamination if isinstance(contamination, (int, float)) else -1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _univariate_table(rows: list) -> model.DataTable:
|
||||||
|
header = ["Columna", "Atípicos Tukey", "% Tukey", "Atípicos z", "% z",
|
||||||
|
"Valla inf.", "Valla sup.", "Extremos atípicos"]
|
||||||
|
table_rows = []
|
||||||
|
for r in rows:
|
||||||
|
table_rows.append([
|
||||||
|
r["name"],
|
||||||
|
_fmt_int(r["n_tukey"]) if r["n_tukey"] is not None else "—",
|
||||||
|
_fmt_pct(r["pct_tukey"]) if r["pct_tukey"] is not None else "—",
|
||||||
|
_fmt_int(r["n_z"]) if r["n_z"] is not None else "—",
|
||||||
|
_fmt_pct(r["pct_z"]) if r["pct_z"] is not None else "—",
|
||||||
|
_fmt_num(r["lower_fence"]),
|
||||||
|
_fmt_num(r["upper_fence"]),
|
||||||
|
r["extremes"],
|
||||||
|
])
|
||||||
|
return model.DataTable(
|
||||||
|
header=header, rows=table_rows,
|
||||||
|
title="Valores atípicos por columna",
|
||||||
|
note="Tukey = fuera de las vallas 1,5·IQR · z = |z-score| > 3 · "
|
||||||
|
"ordenado de más a menos contaminada")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Multivariate (Isolation Forest) section.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _resolve_multivariate(profile: dict, ctx: dict, raw_numeric):
|
||||||
|
"""Return (outliers_dict_or_None, source).
|
||||||
|
|
||||||
|
Prefers a LIVE Isolation Forest over ``raw_numeric`` so the detector and
|
||||||
|
``summarize_outlier_dims`` use EXACTLY the same numeric columns and the same
|
||||||
|
valid-row indexing — otherwise the precomputed ``profile['models']
|
||||||
|
['outliers']`` (run by MODELOS over a possibly different column subset) would
|
||||||
|
yield ``row_index`` values that no longer point at the rows
|
||||||
|
``summarize_outlier_dims`` reconstructs, mislabelling the "dimensions that
|
||||||
|
make each row rare". Falls back to the precomputed block when no raw sample
|
||||||
|
is available (e.g. the lite preset drops ``raw_numeric``)."""
|
||||||
|
if _is_dict(raw_numeric) and raw_numeric:
|
||||||
|
iso = _load_isolation_forest()
|
||||||
|
if iso is not None:
|
||||||
|
try:
|
||||||
|
out = iso(raw_numeric)
|
||||||
|
if _is_dict(out) and out.get("n_outliers") is not None and out.get("n_rows_used"):
|
||||||
|
return out, "live"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
# Fallback: the model the MODELOS chapter already computed (no raw sample to
|
||||||
|
# recompute against, so no per-row dimension breakdown either).
|
||||||
|
models = profile.get("models") if _is_dict(profile.get("models")) else {}
|
||||||
|
pre = models.get("outliers") if _is_dict(models) else None
|
||||||
|
if _is_dict(pre) and pre.get("n_outliers") is not None and pre.get("n_rows_used"):
|
||||||
|
return pre, "precomputed"
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _multivariate_blocks(outliers: dict, raw_numeric, mark: bool) -> list:
|
||||||
|
isof = _term(mark, "isolation_forest", "**Isolation Forest**")
|
||||||
|
blocks = [
|
||||||
|
model.Heading(text="Filas atípicas (multivariante)", level=2),
|
||||||
|
model.Markdown(text=(
|
||||||
|
f"Hasta aquí cada columna se ha mirado por separado. {isof} busca "
|
||||||
|
"filas raras considerando **todas las columnas a la vez**: una fila "
|
||||||
|
"puede ser normal en cada variable y aun así ser atípica por la "
|
||||||
|
"**combinación** de sus valores (p. ej. una edad baja con una tarifa "
|
||||||
|
"muy alta). La tabla resume cuántas filas se marcaron y el umbral de "
|
||||||
|
"decisión.")),
|
||||||
|
model.KVTable(rows=[
|
||||||
|
("Filas analizadas", _fmt_int(outliers.get("n_rows_used"))),
|
||||||
|
("Columnas consideradas", _fmt_int(outliers.get("n_features"))),
|
||||||
|
("Filas atípicas", _fmt_int(outliers.get("n_outliers"))),
|
||||||
|
("% filas atípicas", _fmt_pct(outliers.get("outlier_pct"))),
|
||||||
|
("Umbral de decisión", _fmt_num(outliers.get("threshold"), 4)),
|
||||||
|
], title="Anomalías multivariantes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
rows_in = outliers.get("outlier_rows") or []
|
||||||
|
if not rows_in:
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
# Enrich each anomalous row with the dimensions that make it rare, when the
|
||||||
|
# raw sample is available (summarize_outlier_dims reconstructs the same
|
||||||
|
# valid-row indexing as isolation_forest_outliers).
|
||||||
|
dims_by_row = {}
|
||||||
|
if _is_dict(raw_numeric) and raw_numeric:
|
||||||
|
summ = _load_summarize_dims()
|
||||||
|
if summ is not None:
|
||||||
|
try:
|
||||||
|
enriched = summ(raw_numeric, rows_in, top_k=3) or []
|
||||||
|
for e in enriched:
|
||||||
|
if _is_dict(e) and e.get("row_index") is not None:
|
||||||
|
dims_by_row[e.get("row_index")] = e.get("dims") or []
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
dims_by_row = {}
|
||||||
|
|
||||||
|
has_dims = bool(dims_by_row)
|
||||||
|
header = ["Fila (entre válidas)", "Score"]
|
||||||
|
if has_dims:
|
||||||
|
header.append("Dimensiones que la hacen rara (col = valor, z)")
|
||||||
|
table_rows = []
|
||||||
|
for r in rows_in[:_TOP_ROWS]:
|
||||||
|
if not _is_dict(r):
|
||||||
|
continue
|
||||||
|
ridx = r.get("row_index")
|
||||||
|
cells = [_fmt_int(ridx), _fmt_num(r.get("score"), 4)]
|
||||||
|
if has_dims:
|
||||||
|
dims = dims_by_row.get(ridx) or []
|
||||||
|
parts = []
|
||||||
|
for d in dims:
|
||||||
|
if not _is_dict(d):
|
||||||
|
continue
|
||||||
|
parts.append(
|
||||||
|
f"{model._safe_str(d.get('col'))} = {_fmt_num(d.get('value'))} "
|
||||||
|
f"(z {_fmt_num(d.get('z'), 2)})")
|
||||||
|
cells.append("; ".join(parts) if parts else "—")
|
||||||
|
table_rows.append(cells)
|
||||||
|
|
||||||
|
if table_rows:
|
||||||
|
shown = len(table_rows)
|
||||||
|
total = outliers.get("n_outliers")
|
||||||
|
note = "las filas más anómalas primero (score más bajo = más rara)"
|
||||||
|
if isinstance(total, int) and total > shown:
|
||||||
|
note += f" — top {shown} de {total}"
|
||||||
|
if not has_dims:
|
||||||
|
note += (" · no se pudo recuperar la muestra cruda para explicar las "
|
||||||
|
"dimensiones de cada fila")
|
||||||
|
blocks.append(model.DataTable(
|
||||||
|
header=header, rows=table_rows,
|
||||||
|
title="Filas más atípicas", note=note))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Interpretation section.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _interpretation_block(mark: bool) -> model.Markdown:
|
||||||
|
outlier = _term(mark, "outlier", "atípico")
|
||||||
|
text = (
|
||||||
|
f"**Un {outlier} no es necesariamente un error.** Conviene distinguir "
|
||||||
|
"dos casos antes de actuar:\n\n"
|
||||||
|
"- **Error de dato** (medida, registro o unidad equivocada): una edad de "
|
||||||
|
"200 años, un importe negativo donde no puede haberlo, un decimal "
|
||||||
|
"desplazado. Estos sí se corrigen o se eliminan, idealmente en el origen.\n"
|
||||||
|
"- **Dato real extremo**: una observación legítima de la cola de la "
|
||||||
|
"distribución (un cliente que gasta mucho más, una tarifa de lujo, un día "
|
||||||
|
"de ventas excepcional). Borrarla sesga el análisis y oculta información "
|
||||||
|
"valiosa.\n\n"
|
||||||
|
"**Qué hacer.** Primero, **revisar** los valores señalados arriba contra "
|
||||||
|
"su origen para decidir cuál de los dos casos es. Si son errores, "
|
||||||
|
"corregirlos. Si son datos reales que distorsionan medias y modelos, hay "
|
||||||
|
"alternativas a borrarlos: **winsorizar** (recortar los extremos a un "
|
||||||
|
"percentil), o **re-expresar** la variable (por ejemplo una "
|
||||||
|
"transformación logarítmica o la escalera de re-expresión de Tukey que "
|
||||||
|
"este mismo perfil ya calcula para las columnas asimétricas), que suele "
|
||||||
|
"domar la cola sin perder ninguna fila. La elección depende del objetivo: "
|
||||||
|
"esta lectura es **exploratoria** —orienta dónde mirar—, no una regla "
|
||||||
|
"automática de limpieza.")
|
||||||
|
return model.Markdown(text=text)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Entry point.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def build_outliers(profile: dict, ctx: dict):
|
||||||
|
"""Build the OUTLIERS Chapter, or None if the dataset has no numeric column."""
|
||||||
|
profile = profile or {}
|
||||||
|
ctx = ctx or {}
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
numerics = _numeric_columns(profile)
|
||||||
|
if not numerics:
|
||||||
|
return None # chapter does not apply to a dataset with no numerics.
|
||||||
|
|
||||||
|
# Register glossary terms (if a collector is present) and mark them clickable.
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
mark = False
|
||||||
|
if isinstance(glossary, model.GlossaryCollector):
|
||||||
|
for key, (label, definition) in _TERM_DEFS.items():
|
||||||
|
glossary.add(key, label, definition)
|
||||||
|
mark = True
|
||||||
|
|
||||||
|
raw_numeric = ctx.get("raw_numeric")
|
||||||
|
raw_numeric = raw_numeric if isinstance(raw_numeric, dict) else {}
|
||||||
|
|
||||||
|
box_fn = _load_build_boxplot_stats()
|
||||||
|
detect_fn = _load_detect_outliers()
|
||||||
|
|
||||||
|
# --- Univariate summary ------------------------------------------------- #
|
||||||
|
uni_rows = []
|
||||||
|
for name, numeric in numerics:
|
||||||
|
uni_rows.append(_univariate_row(
|
||||||
|
name, numeric, raw_numeric.get(name), box_fn, detect_fn))
|
||||||
|
# Rank columns by contamination (Tukey % when available, else z %).
|
||||||
|
uni_rows.sort(key=lambda r: r.get("contamination", -1.0), reverse=True)
|
||||||
|
|
||||||
|
intro = (
|
||||||
|
"Este capítulo reúne en un solo sitio el análisis de los **valores "
|
||||||
|
"atípicos** de la tabla, que en el resto del informe aparecen dispersos. "
|
||||||
|
f"Un {_term(mark, 'outlier', 'atípico')} es una observación que se aparta "
|
||||||
|
"mucho del grueso de los datos. Cada columna numérica se evalúa con dos "
|
||||||
|
f"criterios complementarios: las {_term(mark, 'tukey_fence', 'vallas de Tukey')} "
|
||||||
|
"(fuera de P25−1,5·IQR o P75+1,5·IQR, robusto a la propia cola) y el "
|
||||||
|
f"{_term(mark, 'zscore', 'z-score')} (|z| > 3, sensible a la media). La "
|
||||||
|
"tabla está ordenada de la columna más contaminada a la menos.")
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
model.Heading(text=CHAPTER_TITLE, level=1),
|
||||||
|
model.Markdown(text=intro),
|
||||||
|
_univariate_table(uni_rows),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Flag the most contaminated columns explicitly.
|
||||||
|
flagged = [r["name"] for r in uni_rows
|
||||||
|
if r.get("contamination", -1.0) > 0][:_TOP_FLAGGED]
|
||||||
|
if flagged:
|
||||||
|
names = ", ".join(f"**{n}**" for n in flagged)
|
||||||
|
blocks.append(model.Markdown(text=(
|
||||||
|
f"Las columnas con mayor proporción de atípicos son {names}: "
|
||||||
|
"concentran el grueso de los valores fuera de las vallas y son las "
|
||||||
|
"primeras a revisar.")))
|
||||||
|
|
||||||
|
# --- Boxplots figure ---------------------------------------------------- #
|
||||||
|
box_entries = [
|
||||||
|
{"name": r["name"], "box": r["box"], "fliers": r.get("fliers")}
|
||||||
|
for r in uni_rows
|
||||||
|
if r.get("box")
|
||||||
|
][:_TOP_BOX]
|
||||||
|
if box_entries:
|
||||||
|
def _boxplots_make(entries=box_entries):
|
||||||
|
try:
|
||||||
|
from datascience.build_boxplots_figure import build_boxplots_figure
|
||||||
|
return build_boxplots_figure(
|
||||||
|
entries, title="Boxplots de Tukey por columna",
|
||||||
|
max_boxes=_TOP_BOX)
|
||||||
|
except Exception: # noqa: BLE001 — minimal fallback figure.
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
fig = Figure(figsize=(5.0, 2.2))
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
ax.text(0.5, 0.5, "(boxplots no disponibles)",
|
||||||
|
ha="center", va="center")
|
||||||
|
ax.axis("off")
|
||||||
|
return fig
|
||||||
|
|
||||||
|
blocks.append(model.Group(blocks=[
|
||||||
|
model.Heading(text="Boxplots", level=2),
|
||||||
|
model.Markdown(text=(
|
||||||
|
"Cada caja abarca del primer al tercer cuartil (P25–P75), la línea "
|
||||||
|
"interior es la mediana y los bigotes llegan hasta 1,5·IQR; los "
|
||||||
|
"puntos son los valores que caen fuera de las vallas (atípicos por "
|
||||||
|
"Tukey).")),
|
||||||
|
model.Figure(
|
||||||
|
make=_boxplots_make,
|
||||||
|
caption="Boxplots de Tukey de las columnas más contaminadas."),
|
||||||
|
]))
|
||||||
|
|
||||||
|
# --- Multivariate ------------------------------------------------------- #
|
||||||
|
outliers, _src = _resolve_multivariate(profile, ctx, raw_numeric)
|
||||||
|
if outliers is not None:
|
||||||
|
blocks.extend(_multivariate_blocks(outliers, raw_numeric, mark))
|
||||||
|
else:
|
||||||
|
blocks.append(model.Heading(text="Filas atípicas (multivariante)", level=2))
|
||||||
|
blocks.append(model.Note(
|
||||||
|
"No se pudo analizar la anomalía multivariante: hacen falta al menos "
|
||||||
|
"dos columnas numéricas y la muestra cruda (o los modelos del perfil) "
|
||||||
|
"para correr Isolation Forest."))
|
||||||
|
|
||||||
|
# --- Interpretation ----------------------------------------------------- #
|
||||||
|
blocks.append(model.Heading(text="Cómo interpretar los atípicos", level=2))
|
||||||
|
blocks.append(_interpretation_block(mark))
|
||||||
|
|
||||||
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Tests for the OUTLIERS chapter — DoD: golden + edges + error path.
|
||||||
|
|
||||||
|
Self-contained: builds synthetic ``numeric`` blocks + a raw_numeric sample (no
|
||||||
|
DuckDB) so the suite is fast and deterministic. Verifies that the chapter emits
|
||||||
|
the univariate per-column table, a boxplots figure, the multivariate Isolation
|
||||||
|
Forest section and the outlier≠error interpretation; that the most contaminated
|
||||||
|
column is ranked first; that a profile with no numeric column yields None; that
|
||||||
|
None/empty never raises; that the glossary terms are registered; and that the
|
||||||
|
chapter renders into both PDF and PPTX without cutting its title.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
from datascience.automatic_eda.chapters.outliers import (
|
||||||
|
build_outliers, CHAPTER_VERSION, CHAPTER_TITLE, _TERM_DEFS,
|
||||||
|
)
|
||||||
|
from datascience.automatic_eda import model
|
||||||
|
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||||
|
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(sorted_vals, q):
|
||||||
|
"""Linear-interpolation percentile (q in 0..1) on an already-sorted list."""
|
||||||
|
if not sorted_vals:
|
||||||
|
return None
|
||||||
|
if len(sorted_vals) == 1:
|
||||||
|
return float(sorted_vals[0])
|
||||||
|
pos = q * (len(sorted_vals) - 1)
|
||||||
|
lo = int(math.floor(pos))
|
||||||
|
hi = int(math.ceil(pos))
|
||||||
|
if lo == hi:
|
||||||
|
return float(sorted_vals[lo])
|
||||||
|
frac = pos - lo
|
||||||
|
return float(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
||||||
|
|
||||||
|
|
||||||
|
def _col_from_values(values, nbins=10):
|
||||||
|
"""Build a ``numeric`` sub-block shaped like describe_numeric's output from a
|
||||||
|
concrete list of raw values, so the profile percentiles and the raw sample
|
||||||
|
are consistent (the boxplot fences match the crudo)."""
|
||||||
|
vals = [float(v) for v in values]
|
||||||
|
s = sorted(vals)
|
||||||
|
n = len(s)
|
||||||
|
mean = sum(vals) / n
|
||||||
|
var = sum((v - mean) ** 2 for v in vals) / n
|
||||||
|
std = math.sqrt(var)
|
||||||
|
median = _percentile(s, 0.5)
|
||||||
|
p25 = _percentile(s, 0.25)
|
||||||
|
p75 = _percentile(s, 0.75)
|
||||||
|
mn, mx = s[0], s[-1]
|
||||||
|
# z-score outlier count (population), what the profile's n_outliers carries.
|
||||||
|
n_out = sum(1 for v in vals if std > 0 and abs((v - mean) / std) > 3.0)
|
||||||
|
width = (mx - mn) / nbins if mx > mn else 1.0
|
||||||
|
hist = [{"lo": mn + i * width, "hi": mn + (i + 1) * width, "count": 1}
|
||||||
|
for i in range(nbins)]
|
||||||
|
return {
|
||||||
|
"min": mn, "max": mx, "mean": mean, "median": median, "std": std,
|
||||||
|
"p25": p25, "p50": median, "p75": p75, "iqr": (p75 - p25),
|
||||||
|
"n_outliers": n_out, "outlier_pct": 100.0 * n_out / n,
|
||||||
|
"distribution_type": "right-skewed", "histogram": hist,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fare_values():
|
||||||
|
"""A heavy-tailed column (most ~10-30, a few 200-512): clear Tukey/z outliers."""
|
||||||
|
base = [7.0 + (i % 25) for i in range(120)] # bulk 7..31
|
||||||
|
tail = [180.0, 210.0, 263.0, 512.0] # extreme upper tail
|
||||||
|
return base + tail
|
||||||
|
|
||||||
|
|
||||||
|
def _age_values():
|
||||||
|
"""A roughly symmetric column with one extreme low value."""
|
||||||
|
base = [22.0 + (i % 40) for i in range(120)] # 22..61
|
||||||
|
return base + [80.0, 0.5, 74.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def _quiet_values():
|
||||||
|
"""A clean column with no atypical values."""
|
||||||
|
return [50.0 + (i % 5) for i in range(124)]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_and_ctx(with_models=True, with_raw=True):
|
||||||
|
fare = _fare_values()
|
||||||
|
age = _age_values()
|
||||||
|
quiet = _quiet_values()
|
||||||
|
cols = [
|
||||||
|
{"name": "Fare", "inferred_type": "numeric", "numeric": _col_from_values(fare)},
|
||||||
|
{"name": "Age", "inferred_type": "numeric", "numeric": _col_from_values(age)},
|
||||||
|
{"name": "Quiet", "inferred_type": "numeric", "numeric": _col_from_values(quiet)},
|
||||||
|
{"name": "Sexo", "inferred_type": "categorical",
|
||||||
|
"categorical": {"top": [{"value": "male", "count": 80}]}},
|
||||||
|
]
|
||||||
|
profile = {"table": "titanic", "n_rows": len(fare), "n_cols": len(cols),
|
||||||
|
"columns": cols}
|
||||||
|
if with_models:
|
||||||
|
profile["models"] = {
|
||||||
|
"outliers": {
|
||||||
|
"n_outliers": 4, "outlier_pct": 3.2,
|
||||||
|
"outlier_rows": [
|
||||||
|
{"row_index": 123, "score": -0.21},
|
||||||
|
{"row_index": 121, "score": -0.15},
|
||||||
|
],
|
||||||
|
"threshold": -0.02, "n_rows_used": 124, "n_features": 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx = {}
|
||||||
|
if with_raw:
|
||||||
|
ctx["raw_numeric"] = {"Fare": fare, "Age": age, "Quiet": quiet}
|
||||||
|
return profile, ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_text(path: str) -> str:
|
||||||
|
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
|
||||||
|
return re.sub(r"\s+", " ", txt)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(blocks):
|
||||||
|
out = []
|
||||||
|
for b in blocks:
|
||||||
|
if getattr(b, "kind", "") == "group":
|
||||||
|
out.extend(_flatten(getattr(b, "blocks", []) or []))
|
||||||
|
else:
|
||||||
|
out.append(b)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Golden.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_golden_estructura_y_secciones():
|
||||||
|
profile, ctx = _profile_and_ctx()
|
||||||
|
ctx["glossary"] = model.GlossaryCollector()
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
assert ch is not None
|
||||||
|
assert ch.id == "outliers"
|
||||||
|
assert ch.version == CHAPTER_VERSION
|
||||||
|
|
||||||
|
flat = _flatten(ch.blocks)
|
||||||
|
kinds = [b.kind for b in flat]
|
||||||
|
# Title heading + univariate DataTable + boxplots Figure + multivariate
|
||||||
|
# KVTable + interpretation Markdown.
|
||||||
|
assert kinds[0] == "heading" and flat[0].text == CHAPTER_TITLE
|
||||||
|
tables = [b for b in flat if b.kind == "data_table"]
|
||||||
|
titles = [t.title for t in tables]
|
||||||
|
assert any(t and "atípicos por columna" in t for t in titles)
|
||||||
|
assert any(b.kind == "figure" for b in flat), "falta la figura de boxplots"
|
||||||
|
assert any(b.kind == "kv_table" for b in flat), "falta el resumen multivariante"
|
||||||
|
|
||||||
|
# The boxplots figure maker yields a real matplotlib figure (or its fallback).
|
||||||
|
fig = next(b for b in flat if b.kind == "figure").make()
|
||||||
|
assert fig is not None
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_fare_es_la_mas_contaminada():
|
||||||
|
# The univariate table must rank Fare (heavy tail) first and report a
|
||||||
|
# non-zero Tukey percentage for it.
|
||||||
|
profile, ctx = _profile_and_ctx()
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
table = next(b for b in _flatten(ch.blocks)
|
||||||
|
if b.kind == "data_table" and b.title
|
||||||
|
and "atípicos por columna" in b.title)
|
||||||
|
first_col = table.rows[0][0]
|
||||||
|
assert first_col == "Fare", f"esperaba Fare primera, fue {first_col}"
|
||||||
|
# % Tukey column (index 2) of the first row must be > 0.
|
||||||
|
pct_cell = table.rows[0][2]
|
||||||
|
assert pct_cell not in ("—", "0%", "0.00%"), f"% Tukey de Fare vacío: {pct_cell}"
|
||||||
|
# The z-score rule (detect_outliers) must actually run with raw_numeric: at
|
||||||
|
# least one column reports a non-empty z count/percentage (regression guard
|
||||||
|
# for the detect_outliers import path).
|
||||||
|
z_pcts = [r[4] for r in table.rows]
|
||||||
|
assert any(c not in ("—",) for c in z_pcts), f"columna z toda vacía: {z_pcts}"
|
||||||
|
z_counts = [r[3] for r in table.rows]
|
||||||
|
assert any(c not in ("—",) for c in z_counts), f"conteo z vacío: {z_counts}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_interpretacion_outlier_no_es_error():
|
||||||
|
profile, ctx = _profile_and_ctx()
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
md = " ".join(b.text for b in _flatten(ch.blocks) if b.kind == "markdown")
|
||||||
|
assert "no es necesariamente un error" in md.lower()
|
||||||
|
# Mentions the actionable options (winsorize / re-express).
|
||||||
|
assert "winsoriz" in md.lower()
|
||||||
|
assert "re-expres" in md.lower() or "logarítmic" in md.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_terminos_glosario_registrados():
|
||||||
|
profile, ctx = _profile_and_ctx()
|
||||||
|
gloss = model.GlossaryCollector()
|
||||||
|
ctx["glossary"] = gloss
|
||||||
|
build_outliers(profile, ctx)
|
||||||
|
for key in _TERM_DEFS:
|
||||||
|
assert gloss.has(key), f"término '{key}' no registrado en el glosario"
|
||||||
|
# Terms are marked clickable in the body text.
|
||||||
|
md = " ".join(b.text for b in _flatten(build_outliers(profile, ctx).blocks)
|
||||||
|
if b.kind == "markdown")
|
||||||
|
assert "[[term:outlier]]" in md and "[[term:tukey_fence]]" in md
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Multivariate.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_multivariante_live_con_raw_y_dims():
|
||||||
|
# With a raw sample the chapter runs Isolation Forest live (over the same
|
||||||
|
# columns summarize_outlier_dims uses) and lists the anomalous rows with the
|
||||||
|
# dimensions that make each one rare.
|
||||||
|
profile, ctx = _profile_and_ctx(with_models=False, with_raw=True)
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
flat = _flatten(ch.blocks)
|
||||||
|
kv = next(b for b in flat if b.kind == "kv_table")
|
||||||
|
flat_kv = " ".join(f"{k} {v}" for (k, v) in kv.rows)
|
||||||
|
assert "Filas atípicas" in flat_kv
|
||||||
|
# A non-zero number of anomalous rows is reported.
|
||||||
|
n_cell = dict(kv.rows).get("Filas atípicas")
|
||||||
|
assert n_cell not in (None, "—", "0"), f"sin filas atípicas: {n_cell}"
|
||||||
|
# The anomalous-rows table carries the per-row dimension breakdown.
|
||||||
|
tbls = [b for b in flat if b.kind == "data_table" and b.title
|
||||||
|
and "más atípicas" in b.title]
|
||||||
|
assert tbls, "falta la tabla de filas más atípicas"
|
||||||
|
assert any("hacen rara" in h for h in tbls[0].header), \
|
||||||
|
f"falta la columna de dimensiones: {tbls[0].header}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multivariante_precomputed_sin_raw():
|
||||||
|
# Without a raw sample the chapter falls back to profile['models']['outliers']
|
||||||
|
# (lite preset path); the precomputed n_outliers (4) surfaces in the KV table.
|
||||||
|
profile, ctx = _profile_and_ctx(with_models=True, with_raw=False)
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
kv = next(b for b in _flatten(ch.blocks) if b.kind == "kv_table")
|
||||||
|
assert any("4" in str(v) for (k, v) in kv.rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multivariante_ausente_degrada_a_nota():
|
||||||
|
# No models and no raw sample → an honest note, never a crash.
|
||||||
|
profile, ctx = _profile_and_ctx(with_models=False, with_raw=False)
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
assert ch is not None
|
||||||
|
notes = [b.text for b in _flatten(ch.blocks) if b.kind == "note"]
|
||||||
|
assert any("Isolation Forest" in n for n in notes)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Edges / error path.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_edge_sin_columnas_numericas_devuelve_none():
|
||||||
|
prof = {"columns": [{"name": "c", "inferred_type": "categorical",
|
||||||
|
"categorical": {"top": [{"value": "x", "count": 3}]}}]}
|
||||||
|
assert build_outliers(prof, {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_solo_texto_sintetico_devuelve_none():
|
||||||
|
# A text-only synthetic table (no numeric column) yields None (does not break).
|
||||||
|
prof = {"table": "notas", "n_rows": 3, "n_cols": 1,
|
||||||
|
"columns": [{"name": "comentario", "inferred_type": "text",
|
||||||
|
"text": {"n_docs": 3}}]}
|
||||||
|
assert build_outliers(prof, {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_profile_none_y_vacio_no_revienta():
|
||||||
|
assert build_outliers(None, None) is None
|
||||||
|
assert build_outliers({}, {}) is None
|
||||||
|
assert build_outliers({"columns": []}, {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_sin_raw_numeric_degrada_a_perfil():
|
||||||
|
# Without raw_numeric the chapter still builds, using the profile z-score
|
||||||
|
# counts; the univariate table exists and Tukey counts degrade to '—'.
|
||||||
|
profile, ctx = _profile_and_ctx(with_models=True, with_raw=False)
|
||||||
|
ch = build_outliers(profile, ctx)
|
||||||
|
assert ch is not None
|
||||||
|
table = next(b for b in _flatten(ch.blocks)
|
||||||
|
if b.kind == "data_table" and b.title
|
||||||
|
and "atípicos por columna" in b.title)
|
||||||
|
# z column comes from the profile; Tukey count is unknown ('—').
|
||||||
|
assert all(len(r) == 8 for r in table.rows)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Anti-cut render.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_render_pdf_y_pptx_incluyen_el_capitulo():
|
||||||
|
profile, ctx = _profile_and_ctx()
|
||||||
|
# The renderers build the whole document; the chapter is reached via the
|
||||||
|
# registry. Render the chapter standalone through a one-chapter document by
|
||||||
|
# passing the profile directly (the renderers run the full chapter registry).
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
pdf = os.path.join(d, "out.pdf")
|
||||||
|
res_pdf = render_automatic_eda_pdf(profile, pdf,
|
||||||
|
{"write_manifest": False, "ctx": ctx})
|
||||||
|
assert res_pdf["path"] == pdf
|
||||||
|
txt = _pdf_text(pdf)
|
||||||
|
assert CHAPTER_TITLE in txt, "el capítulo OUTLIERS no aparece en el PDF"
|
||||||
|
assert "Fare" in txt
|
||||||
|
pptx = os.path.join(d, "out.pptx")
|
||||||
|
res_pptx = render_automatic_eda_pptx(profile, pptx,
|
||||||
|
{"write_manifest": False, "ctx": ctx})
|
||||||
|
assert res_pptx["path"] == pptx
|
||||||
|
assert res_pptx["n_slides"] >= 1
|
||||||
@@ -7,11 +7,21 @@ as needed, the renderers paginate):
|
|||||||
NOT carry the raw head, so this is read from ``ctx['head_rows']`` /
|
NOT carry the raw head, so this is read from ``ctx['head_rows']`` /
|
||||||
``profile['head_rows']`` (a list of row dicts). When absent the chapter shows
|
``profile['head_rows']`` (a list of row dicts). When absent the chapter shows
|
||||||
an honest placeholder documenting the missing key instead of inventing data.
|
an honest placeholder documenting the missing key instead of inventing data.
|
||||||
2. Column dictionary — name / type / nulls / non-null examples. Examples come
|
2. Column dictionary — name / type / nulls / non-null examples plus, when the
|
||||||
|
LLM layer ran, the business **description** and **unit** of each column so the
|
||||||
|
reader knows at a glance what every column is and in which unit. Examples come
|
||||||
from ``columns[i]['examples']`` when present; otherwise they are derived from
|
from ``columns[i]['examples']`` when present; otherwise they are derived from
|
||||||
real non-null profile values (categorical top values, numeric min/median/max)
|
real non-null profile values (categorical top values, numeric min/median/max)
|
||||||
so the cell is never empty nor fabricated.
|
so the cell is never empty nor fabricated.
|
||||||
3. ``df.describe`` — mean / median / min / max / std for every numeric column.
|
3. ``df.describe`` — mean / median / min / max / std for every numeric column,
|
||||||
|
plus its **unit** (same LLM source) so the stats read in context.
|
||||||
|
|
||||||
|
The description/unit come from the ``llm`` block that ``eda_llm_insights`` (group
|
||||||
|
``eda``) already stored in the profile (``profile['llm']['dictionary']``, a list
|
||||||
|
of ``{"column","description","business_meaning","unit"}`` entries) — this chapter
|
||||||
|
only **consumes** it, matching by column name; it never calls the LLM nor
|
||||||
|
recomputes anything. When the block is absent (``run_llm`` did not run) those
|
||||||
|
cells degrade to ``"—"`` and the tables still render.
|
||||||
|
|
||||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||||
"""
|
"""
|
||||||
@@ -20,13 +30,59 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.1.0"
|
CHAPTER_VERSION = "1.2.0"
|
||||||
CHAPTER_ID = "overview"
|
CHAPTER_ID = "overview"
|
||||||
CHAPTER_TITLE = "Overview"
|
CHAPTER_TITLE = "Overview"
|
||||||
|
|
||||||
# Profile/ctx keys the calculation phase must add for a full head + examples.
|
# Profile/ctx keys the calculation phase must add for a full head + examples.
|
||||||
HEAD_KEY = "head_rows" # list[dict] — df.head(n)
|
HEAD_KEY = "head_rows" # list[dict] — df.head(n)
|
||||||
EXAMPLES_KEY = "examples" # per column: list of non-null sample values
|
EXAMPLES_KEY = "examples" # per column: list of non-null sample values
|
||||||
|
LLM_KEY = "llm" # interpretive block from eda_llm_insights
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_dict_index(profile: dict, ctx: dict) -> dict:
|
||||||
|
"""Map column name -> its LLM dictionary entry (description/unit/...).
|
||||||
|
|
||||||
|
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
|
||||||
|
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
|
||||||
|
dict when no LLM block ran, so the caller degrades to "—" cells. Fully
|
||||||
|
defensive: never raises on malformed input.
|
||||||
|
"""
|
||||||
|
llm = profile.get(LLM_KEY)
|
||||||
|
if not isinstance(llm, dict):
|
||||||
|
llm = ctx.get(LLM_KEY)
|
||||||
|
if not isinstance(llm, dict):
|
||||||
|
return {}
|
||||||
|
entries = llm.get("dictionary")
|
||||||
|
if not isinstance(entries, (list, tuple)):
|
||||||
|
return {}
|
||||||
|
index: dict = {}
|
||||||
|
for e in entries:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
continue
|
||||||
|
col = e.get("column")
|
||||||
|
if col is None:
|
||||||
|
continue
|
||||||
|
index[model._safe_str(col)] = e
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_desc(entry) -> str:
|
||||||
|
"""Business description of a column from its LLM entry, or "—"."""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return "—"
|
||||||
|
raw = entry.get("description") or entry.get("business_meaning")
|
||||||
|
text = " ".join(model._safe_str(raw).split()) if raw is not None else ""
|
||||||
|
return text or "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_unit(entry) -> str:
|
||||||
|
"""Unit of a column from its LLM entry, or "—"."""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return "—"
|
||||||
|
raw = entry.get("unit")
|
||||||
|
text = " ".join(model._safe_str(raw).split()) if raw is not None else ""
|
||||||
|
return text or "—"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_num(value, decimals: int = 3) -> str:
|
def _fmt_num(value, decimals: int = 3) -> str:
|
||||||
@@ -104,9 +160,12 @@ def _head_block(profile: dict, ctx: dict):
|
|||||||
"pasarlo en ctx['head_rows'] para mostrar las primeras filas.")
|
"pasarlo en ctx['head_rows'] para mostrar las primeras filas.")
|
||||||
|
|
||||||
|
|
||||||
def _columns_block(profile: dict):
|
def _columns_block(profile: dict, llm_index: dict):
|
||||||
cols = profile.get("columns") or []
|
cols = profile.get("columns") or []
|
||||||
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)"]
|
# Descripción / Unidad come from the LLM dictionary (matched by column name);
|
||||||
|
# they read "—" when run_llm did not run, so the table always renders.
|
||||||
|
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
|
||||||
|
"Descripción", "Unidad"]
|
||||||
rows = []
|
rows = []
|
||||||
for c in cols:
|
for c in cols:
|
||||||
if not isinstance(c, dict):
|
if not isinstance(c, dict):
|
||||||
@@ -126,15 +185,18 @@ def _columns_block(profile: dict):
|
|||||||
nulls = str(null_count)
|
nulls = str(null_count)
|
||||||
else:
|
else:
|
||||||
nulls = "—"
|
nulls = "—"
|
||||||
rows.append([name, ctype, nulls, _examples_for(c)])
|
entry = llm_index.get(model._safe_str(name))
|
||||||
|
rows.append([name, ctype, nulls, _examples_for(c),
|
||||||
|
_llm_desc(entry), _llm_unit(entry)])
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
return model.DataTable(header=header, rows=rows, title="Columnas")
|
return model.DataTable(header=header, rows=rows, title="Columnas")
|
||||||
|
|
||||||
|
|
||||||
def _describe_block(profile: dict):
|
def _describe_block(profile: dict, llm_index: dict):
|
||||||
cols = profile.get("columns") or []
|
cols = profile.get("columns") or []
|
||||||
header = ["Columna", "mean", "median", "min", "max", "std"]
|
# "Unidad" (LLM source) lets the reader know in which unit each stat is.
|
||||||
|
header = ["Columna", "mean", "median", "min", "max", "std", "Unidad"]
|
||||||
rows = []
|
rows = []
|
||||||
for c in cols:
|
for c in cols:
|
||||||
if not isinstance(c, dict) or c.get("inferred_type") != "numeric":
|
if not isinstance(c, dict) or c.get("inferred_type") != "numeric":
|
||||||
@@ -142,13 +204,16 @@ def _describe_block(profile: dict):
|
|||||||
num = c.get("numeric") or {}
|
num = c.get("numeric") or {}
|
||||||
if not num:
|
if not num:
|
||||||
continue
|
continue
|
||||||
|
name = c.get("name") or "(col)"
|
||||||
|
entry = llm_index.get(model._safe_str(name))
|
||||||
rows.append([
|
rows.append([
|
||||||
c.get("name") or "(col)",
|
name,
|
||||||
_fmt_num(num.get("mean")),
|
_fmt_num(num.get("mean")),
|
||||||
_fmt_num(num.get("median")),
|
_fmt_num(num.get("median")),
|
||||||
_fmt_num(num.get("min")),
|
_fmt_num(num.get("min")),
|
||||||
_fmt_num(num.get("max")),
|
_fmt_num(num.get("max")),
|
||||||
_fmt_num(num.get("std")),
|
_fmt_num(num.get("std")),
|
||||||
|
_llm_unit(entry),
|
||||||
])
|
])
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
@@ -163,16 +228,18 @@ def build_overview(profile: dict, ctx: dict):
|
|||||||
if not cols and not (ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)):
|
if not cols and not (ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
llm_index = _llm_dict_index(profile, ctx)
|
||||||
|
|
||||||
blocks = [
|
blocks = [
|
||||||
model.Heading(text="Primeras filas (df.head)", level=2),
|
model.Heading(text="Primeras filas (df.head)", level=2),
|
||||||
_head_block(profile, ctx),
|
_head_block(profile, ctx),
|
||||||
]
|
]
|
||||||
cols_block = _columns_block(profile)
|
cols_block = _columns_block(profile, llm_index)
|
||||||
if cols_block is not None:
|
if cols_block is not None:
|
||||||
blocks.append(model.Heading(
|
blocks.append(model.Heading(
|
||||||
text="Diccionario de columnas", level=2))
|
text="Diccionario de columnas", level=2))
|
||||||
blocks.append(cols_block)
|
blocks.append(cols_block)
|
||||||
desc_block = _describe_block(profile)
|
desc_block = _describe_block(profile, llm_index)
|
||||||
if desc_block is not None:
|
if desc_block is not None:
|
||||||
blocks.append(model.Heading(
|
blocks.append(model.Heading(
|
||||||
text="Resumen estadístico numérico", level=2))
|
text="Resumen estadístico numérico", level=2))
|
||||||
|
|||||||
@@ -56,7 +56,21 @@ def _head_rows() -> list:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _profile(with_head: bool = True) -> dict:
|
def _llm() -> dict:
|
||||||
|
"""Interpretive block as eda_llm_insights stores it under profile['llm']."""
|
||||||
|
return {
|
||||||
|
"summary": "Pasajeros del Titanic.",
|
||||||
|
"dictionary": [
|
||||||
|
{"column": "PassengerId", "description": "Identificador del pasajero",
|
||||||
|
"business_meaning": "Clave única de cada pasajero", "unit": "id"},
|
||||||
|
{"column": "Pclass", "description": "Clase del billete",
|
||||||
|
"business_meaning": "Clase socioeconómica", "unit": "clase (1-3)"},
|
||||||
|
# No entry for Survived/Name/Sex on purpose -> they degrade to "—".
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(with_head: bool = True, with_llm: bool = False) -> dict:
|
||||||
prof = {
|
prof = {
|
||||||
"table": "titanic",
|
"table": "titanic",
|
||||||
"source": "/data/titanic.csv",
|
"source": "/data/titanic.csv",
|
||||||
@@ -68,6 +82,8 @@ def _profile(with_head: bool = True) -> dict:
|
|||||||
}
|
}
|
||||||
if with_head:
|
if with_head:
|
||||||
prof["head_rows"] = _head_rows()
|
prof["head_rows"] = _head_rows()
|
||||||
|
if with_llm:
|
||||||
|
prof["llm"] = _llm()
|
||||||
return prof
|
return prof
|
||||||
|
|
||||||
|
|
||||||
@@ -185,3 +201,70 @@ def test_edge_none_y_vacio_no_rompen():
|
|||||||
assert ch is not None
|
assert ch is not None
|
||||||
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
|
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
|
||||||
assert tables and len(tables[0].rows) == 3
|
assert tables and len(tables[0].rows) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def _table_by_header(blocks, marker: str):
|
||||||
|
"""Return the first DataTable whose header contains ``marker``."""
|
||||||
|
for b in _flatten(blocks):
|
||||||
|
if isinstance(b, DataTable) and marker in b.header:
|
||||||
|
return b
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_diccionario_lleva_descripcion_y_unidad_del_llm():
|
||||||
|
# With run_llm: the column dictionary gains "Descripción" and "Unidad"
|
||||||
|
# columns populated from profile['llm']['dictionary'], matched by name.
|
||||||
|
ch = build_overview(_profile(with_llm=True), {})
|
||||||
|
assert ch is not None
|
||||||
|
dic = _table_by_header(ch.blocks, "Descripción")
|
||||||
|
assert dic is not None
|
||||||
|
assert dic.header == ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
|
||||||
|
"Descripción", "Unidad"]
|
||||||
|
by_name = {row[0]: row for row in dic.rows}
|
||||||
|
# PassengerId has an LLM entry -> description + unit populated.
|
||||||
|
assert by_name["PassengerId"][4] == "Identificador del pasajero"
|
||||||
|
assert by_name["PassengerId"][5] == "id"
|
||||||
|
assert by_name["Pclass"][5] == "clase (1-3)"
|
||||||
|
# Columns with no LLM entry degrade to "—" without breaking the row.
|
||||||
|
assert by_name["Survived"][4] == "—" and by_name["Survived"][5] == "—"
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_describe_lleva_unidad_del_llm():
|
||||||
|
ch = build_overview(_profile(with_llm=True), {})
|
||||||
|
desc = _table_by_header(ch.blocks, "std")
|
||||||
|
assert desc is not None
|
||||||
|
assert desc.header[-1] == "Unidad"
|
||||||
|
by_name = {row[0]: row for row in desc.rows}
|
||||||
|
assert by_name["PassengerId"][-1] == "id"
|
||||||
|
assert by_name["Pclass"][-1] == "clase (1-3)"
|
||||||
|
# Numeric column with no LLM unit still renders, unit "—".
|
||||||
|
assert by_name["Survived"][-1] == "—"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_sin_llm_descripcion_unidad_son_guion():
|
||||||
|
# No profile['llm'] at all: the new cells degrade to "—" and nothing breaks.
|
||||||
|
ch = build_overview(_profile(), {})
|
||||||
|
assert ch is not None
|
||||||
|
dic = _table_by_header(ch.blocks, "Unidad")
|
||||||
|
assert dic is not None
|
||||||
|
for row in dic.rows:
|
||||||
|
assert row[4] == "—" and row[5] == "—"
|
||||||
|
desc = _table_by_header(ch.blocks, "std")
|
||||||
|
assert all(row[-1] == "—" for row in desc.rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_llm_via_ctx_tambien_funciona():
|
||||||
|
# LLM block arriving through ctx['llm'] (fallback path) is consumed too.
|
||||||
|
ch = build_overview(_profile(with_llm=False), {"llm": _llm()})
|
||||||
|
dic = _table_by_header(ch.blocks, "Descripción")
|
||||||
|
by_name = {row[0]: row for row in dic.rows}
|
||||||
|
assert by_name["PassengerId"][5] == "id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_render_pdf_muestra_descripcion_y_unidad():
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = os.path.join(d, "eda.pdf")
|
||||||
|
render_automatic_eda_pdf(_profile(with_llm=True), out, {"title": "EDA"})
|
||||||
|
txt = _pdf_text(out)
|
||||||
|
assert "Descripción" in txt and "Unidad" in txt
|
||||||
|
assert "Identificador del pasajero" in txt
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from .. import model
|
from .. import model
|
||||||
|
|
||||||
CHAPTER_VERSION = "1.2.0"
|
CHAPTER_VERSION = "1.4.0"
|
||||||
CHAPTER_ID = "portada"
|
CHAPTER_ID = "portada"
|
||||||
CHAPTER_TITLE = "Portada"
|
CHAPTER_TITLE = "Portada"
|
||||||
|
|
||||||
@@ -35,12 +35,9 @@ CHAPTER_TITLE = "Portada"
|
|||||||
# row represents) from it when the LLM layer ran (``run_llm``).
|
# row represents) from it when the LLM layer ran (``run_llm``).
|
||||||
_LLM_KEY = "llm"
|
_LLM_KEY = "llm"
|
||||||
|
|
||||||
# Default human description of what the table quality score measures. Chapters
|
# Font size (pt) for the dataset name on the PPTX cover slide — notably larger
|
||||||
# can override it via ctx["quality_criteria"].
|
# than the default H1 so the dataset name stands out (shown underlined too).
|
||||||
_DEFAULT_QUALITY_CRITERIA = (
|
_PPTX_TITLE_PT = 44.0
|
||||||
"media de los scores por columna (0–100): completitud (sin nulos/vacíos), "
|
|
||||||
"validez (tipo y rango coherentes) y consistencia (sin duplicados/constantes)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _storage_from_source(source: str) -> str:
|
def _storage_from_source(source: str) -> str:
|
||||||
@@ -120,11 +117,20 @@ def _summary_blocks(summary) -> list:
|
|||||||
|
|
||||||
blocks = [model.Heading(text="Resumen del análisis", level=2)]
|
blocks = [model.Heading(text="Resumen del análisis", level=2)]
|
||||||
if rows:
|
if rows:
|
||||||
blocks.append(model.KVTable(rows=rows))
|
# Values pinned to the right margin (numbers flush right, label left).
|
||||||
|
blocks.append(model.KVTable(rows=rows, value_align="right"))
|
||||||
if titles:
|
if titles:
|
||||||
bullets = "\n".join(f"- {model._safe_str(t)}" for t in titles)
|
# Clickable index ("Índice"): one TocEntry per chapter title. Each entry
|
||||||
blocks.append(model.Markdown(
|
# becomes a real jump to that chapter's first page/slide once the document
|
||||||
text="Este informe incluye los siguientes capítulos:\n" + bullets))
|
# is laid out (the renderers register every chapter start and wire the
|
||||||
|
# links; ``target_id`` is matched against the chapter title). The cover only
|
||||||
|
# knows chapter titles, so the title doubles as the link target.
|
||||||
|
blocks.append(model.Heading(text="Índice", level=2))
|
||||||
|
for t in titles:
|
||||||
|
label = model._safe_str(t)
|
||||||
|
if not label:
|
||||||
|
continue
|
||||||
|
blocks.append(model.TocEntry(label=label, target_id=label))
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
@@ -213,9 +219,7 @@ def _derive_description(profile: dict, ctx: dict) -> str:
|
|||||||
score = profile.get("quality_score")
|
score = profile.get("quality_score")
|
||||||
if score is not None:
|
if score is not None:
|
||||||
parts.append(f"Calidad media estimada: {score}/100.")
|
parts.append(f"Calidad media estimada: {score}/100.")
|
||||||
parts.append(
|
parts.append("Resumen derivado del perfil.")
|
||||||
"Resumen derivado del perfil; active la interpretación LLM (`run_llm`) "
|
|
||||||
"para una descripción de negocio más rica.")
|
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,7 +263,6 @@ def build_portada(profile: dict, ctx: dict):
|
|||||||
shape = f"{_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas"
|
shape = f"{_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas"
|
||||||
|
|
||||||
score = profile.get("quality_score")
|
score = profile.get("quality_score")
|
||||||
quality_criteria = ctx.get("quality_criteria") or _DEFAULT_QUALITY_CRITERIA
|
|
||||||
quality_value = "—" if score is None else f"{score} / 100"
|
quality_value = "—" if score is None else f"{score} / 100"
|
||||||
|
|
||||||
llm = _llm_block(profile, ctx)
|
llm = _llm_block(profile, ctx)
|
||||||
@@ -282,8 +285,11 @@ def build_portada(profile: dict, ctx: dict):
|
|||||||
|
|
||||||
# Title + dataset size shown together and BIG (Heading) at the top, kept on
|
# Title + dataset size shown together and BIG (Heading) at the top, kept on
|
||||||
# the same page (Group). The size is no longer buried in the metadata table.
|
# the same page (Group). The size is no longer buried in the metadata table.
|
||||||
|
# The dataset name is shown big and underlined on the PPTX cover slide
|
||||||
|
# (size_pt/underline are honoured by the PPTX renderer; the PDF ignores them).
|
||||||
cover = [
|
cover = [
|
||||||
model.Heading(text=str(dataset_name), level=1),
|
model.Heading(text=str(dataset_name), level=1, underline=True,
|
||||||
|
size_pt=_PPTX_TITLE_PT),
|
||||||
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
|
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
|
||||||
model.Heading(text=shape, level=2),
|
model.Heading(text=shape, level=2),
|
||||||
]
|
]
|
||||||
@@ -295,7 +301,6 @@ def build_portada(profile: dict, ctx: dict):
|
|||||||
("Almacenamiento", storage),
|
("Almacenamiento", storage),
|
||||||
("Generado", when),
|
("Generado", when),
|
||||||
("Calidad", quality_value),
|
("Calidad", quality_value),
|
||||||
("Criterios de calidad", quality_criteria),
|
|
||||||
]),
|
]),
|
||||||
model.Heading(text="Descripción", level=2),
|
model.Heading(text="Descripción", level=2),
|
||||||
model.Markdown(text=str(description)),
|
model.Markdown(text=str(description)),
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
"""Free-text / NLP distributions chapter (TEXT DISTR) for AutomaticEDA.
|
||||||
|
|
||||||
|
First chapter for **non-tabular** content: it profiles the linguistic content of
|
||||||
|
any column holding long free text (reviews, descriptions, comments, tickets) that
|
||||||
|
the categorical chapter cannot meaningfully summarize (high cardinality, many
|
||||||
|
words per value). It is the cheap, model-free counterpart to ``cat_distr`` for
|
||||||
|
columns that are prose rather than discrete labels.
|
||||||
|
|
||||||
|
Activation (returns ``None`` when it does not apply):
|
||||||
|
|
||||||
|
1. Cheap gate from the aggregated profile: at least one non-numeric column whose
|
||||||
|
``categorical.len_mean`` (mean character length) is ``>= _MIN_LEN_CHARS``.
|
||||||
|
A dataset whose only string columns are short labels (e.g. titanic's
|
||||||
|
``Name``, ~27 chars) never passes this gate, so the chapter disappears with
|
||||||
|
zero extra work and the existing report is untouched.
|
||||||
|
2. Confirmation from a raw sample: each candidate column is sampled (push-down
|
||||||
|
``extract_text_sample`` over ``ctx['db_path']``/``ctx['table']``, or an
|
||||||
|
in-memory ``ctx['text_raw']`` for tests) and kept only if the **median word
|
||||||
|
count is ``>= _MIN_WORDS``** — i.e. it is genuinely long text, not a long
|
||||||
|
single token. If no column survives, the chapter returns ``None``.
|
||||||
|
|
||||||
|
Per surviving column the chapter emits, kept together on its own page/slide
|
||||||
|
(``Group(page_break_before=...)``):
|
||||||
|
|
||||||
|
- a key/value summary (documents, length percentiles, vocabulary richness with
|
||||||
|
**[[term:ttr]]TTR[[/term]]** and **[[term:hapax]]hapax legomena[[/term]]**,
|
||||||
|
dominant language, exact-duplicate %, readability when available);
|
||||||
|
- a word-count histogram figure;
|
||||||
|
- a top-terms table + a horizontal bar figure;
|
||||||
|
- bigram and trigram frequency tables;
|
||||||
|
- a detected-language bar figure (when ``langdetect`` is available);
|
||||||
|
- an optional word-cloud figure (only when ``wordcloud`` is installed);
|
||||||
|
- a closing note on duplicates / readability degradation.
|
||||||
|
|
||||||
|
Every metric is delegated to pure ``eda`` registry functions
|
||||||
|
(``compute_text_length_stats``, ``compute_vocabulary_stats``,
|
||||||
|
``compute_top_ngrams``, ``detect_corpus_language``, ``compute_text_duplicates``,
|
||||||
|
``compute_text_readability``) and the raw sample to ``extract_text_sample``; all
|
||||||
|
are imported defensively so a missing function or optional library degrades that
|
||||||
|
single piece to a note instead of aborting the chapter. Optional libraries
|
||||||
|
(``langdetect``, ``textstat``, ``wordcloud``, ``datasketch``) are never required:
|
||||||
|
the piece is silently omitted when they are absent.
|
||||||
|
|
||||||
|
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import model
|
||||||
|
|
||||||
|
CHAPTER_VERSION = "1.0.0"
|
||||||
|
CHAPTER_ID = "text_distr"
|
||||||
|
CHAPTER_TITLE = "Texto libre (NLP)"
|
||||||
|
|
||||||
|
# Cheap activation gate (characters): a non-numeric column whose mean string
|
||||||
|
# length reaches this is a candidate for "long text". Short labels (titanic's
|
||||||
|
# Name ≈ 27 chars) stay below it, so the chapter does not fire on them.
|
||||||
|
_MIN_LEN_CHARS = 50
|
||||||
|
# Confirmation gate (words): a candidate is kept only if its median document has
|
||||||
|
# at least this many words — genuine prose, not a long id/URL token.
|
||||||
|
_MIN_WORDS = 20
|
||||||
|
# Bound the document so very wide datasets stay readable.
|
||||||
|
_MAX_TEXT_COLS = 5
|
||||||
|
# Raw text rows to sample per column when the chapter must extract them itself.
|
||||||
|
_SAMPLE_ROWS = 2000
|
||||||
|
# Rows shown in the frequency tables.
|
||||||
|
_TOP_TERMS = 15
|
||||||
|
_TOP_NGRAMS = 10
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains (registered in the shared collector and
|
||||||
|
# marked clickable on first appearance — same mechanism as cat_distr's entropía).
|
||||||
|
_TERMS = {
|
||||||
|
"ttr": (
|
||||||
|
"TTR (type-token ratio)",
|
||||||
|
"Riqueza léxica de un texto: número de palabras distintas (tipos) "
|
||||||
|
"dividido por el número total de palabras (tokens). Vale 1 cuando no se "
|
||||||
|
"repite ninguna palabra (máxima variedad) y baja hacia 0 cuando el "
|
||||||
|
"vocabulario se repite mucho. Depende de la longitud del corpus, así que "
|
||||||
|
"compara mejor textos de tamaño parecido."),
|
||||||
|
"hapax": (
|
||||||
|
"Hapax legomena",
|
||||||
|
"Palabras que aparecen una sola vez en todo el corpus. Un porcentaje "
|
||||||
|
"alto de hapax indica vocabulario muy variado o, a veces, ruido "
|
||||||
|
"(erratas, identificadores, tokens raros). Se expresa como porcentaje "
|
||||||
|
"sobre el número de palabras distintas."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_int(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{int(value):,}".replace(",", ".")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_num(value, decimals: int = 2) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, int):
|
||||||
|
return f"{value:,}".replace(",", ".")
|
||||||
|
if isinstance(value, float):
|
||||||
|
if value != value: # NaN
|
||||||
|
return "NaN"
|
||||||
|
if value in (float("inf"), float("-inf")):
|
||||||
|
return str(value)
|
||||||
|
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
|
||||||
|
return text if text else "0"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct(value, decimals: int = 1) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{float(value):.{decimals}f}%"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text, limit: int = 40) -> str:
|
||||||
|
s = model._safe_str(text)
|
||||||
|
return s if len(s) <= limit else s[: max(1, limit - 1)].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Defensive wrappers around the registry functions: each returns the function's
|
||||||
|
# output dict or a safe empty default, never raising and never importing at
|
||||||
|
# module load (so the chapter stays importable even if a function is missing).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _length_stats(texts) -> dict:
|
||||||
|
try:
|
||||||
|
from datascience.compute_text_length_stats import compute_text_length_stats
|
||||||
|
out = compute_text_length_stats(texts)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _vocab_stats(texts) -> dict:
|
||||||
|
try:
|
||||||
|
from datascience.compute_vocabulary_stats import compute_vocabulary_stats
|
||||||
|
out = compute_vocabulary_stats(texts, top_k=_TOP_TERMS)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ngrams(texts, n) -> list:
|
||||||
|
try:
|
||||||
|
from datascience.compute_top_ngrams import compute_top_ngrams
|
||||||
|
out = compute_top_ngrams(texts, n=n, top_k=_TOP_NGRAMS)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
return out.get("top") or []
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _language(texts) -> dict:
|
||||||
|
try:
|
||||||
|
from datascience.detect_corpus_language import detect_corpus_language
|
||||||
|
out = detect_corpus_language(texts)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {"available": False, "distribution": [], "dominant": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicates(texts) -> dict:
|
||||||
|
try:
|
||||||
|
from datascience.compute_text_duplicates import compute_text_duplicates
|
||||||
|
out = compute_text_duplicates(texts)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _readability(texts) -> dict:
|
||||||
|
try:
|
||||||
|
from datascience.compute_text_readability import compute_text_readability
|
||||||
|
out = compute_text_readability(texts)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {"available": False, "flesch": {}}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Candidate detection + raw sample acquisition.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _candidate_columns(profile: dict) -> list:
|
||||||
|
"""Cheap gate: non-numeric columns whose mean char length reaches the
|
||||||
|
threshold. Returns the list of column names (possibly empty)."""
|
||||||
|
out = []
|
||||||
|
for col in profile.get("columns") or []:
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
continue
|
||||||
|
if col.get("inferred_type") == "numeric":
|
||||||
|
continue
|
||||||
|
cat = col.get("categorical")
|
||||||
|
if not isinstance(cat, dict):
|
||||||
|
continue
|
||||||
|
len_mean = cat.get("len_mean")
|
||||||
|
if isinstance(len_mean, (int, float)) and not isinstance(len_mean, bool) \
|
||||||
|
and len_mean >= _MIN_LEN_CHARS:
|
||||||
|
name = col.get("name")
|
||||||
|
if name:
|
||||||
|
out.append(str(name))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _get_samples(profile: dict, ctx: dict, columns: list) -> dict:
|
||||||
|
"""Return {col: [str, ...]} raw text samples for the candidate columns.
|
||||||
|
|
||||||
|
Prefers an in-memory ``ctx['text_raw']`` (used by tests); otherwise pushes a
|
||||||
|
sample down to the database via ``extract_text_sample`` using ctx db_path /
|
||||||
|
table. Never raises: returns {} when no sample can be obtained."""
|
||||||
|
text_raw = ctx.get("text_raw")
|
||||||
|
if isinstance(text_raw, dict) and text_raw:
|
||||||
|
return {c: [str(v) for v in (text_raw.get(c) or []) if v is not None]
|
||||||
|
for c in columns if text_raw.get(c)}
|
||||||
|
|
||||||
|
db_path = ctx.get("db_path")
|
||||||
|
table = ctx.get("table")
|
||||||
|
if not db_path or not table:
|
||||||
|
return {}
|
||||||
|
backend = ctx.get("backend") or "duckdb"
|
||||||
|
sample = ctx.get("sample") or _SAMPLE_ROWS
|
||||||
|
try:
|
||||||
|
from datascience.extract_text_sample import extract_text_sample
|
||||||
|
out = extract_text_sample(db_path, table, columns, backend=backend,
|
||||||
|
sample=sample)
|
||||||
|
if isinstance(out, dict) and out.get("status") == "ok":
|
||||||
|
cols = out.get("columns")
|
||||||
|
if isinstance(cols, dict):
|
||||||
|
return {c: list(v) for c, v in cols.items() if v}
|
||||||
|
except Exception: # noqa: BLE001 — dict-no-throw: no sample → chapter omits.
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_long_text(samples: dict) -> dict:
|
||||||
|
"""Keep only columns whose median word count reaches _MIN_WORDS. Returns
|
||||||
|
{col: length_stats_dict} for the survivors, in input order."""
|
||||||
|
survivors = {}
|
||||||
|
for col, texts in samples.items():
|
||||||
|
stats = _length_stats(texts)
|
||||||
|
words = stats.get("words") if isinstance(stats, dict) else None
|
||||||
|
median = words.get("p50") if isinstance(words, dict) else None
|
||||||
|
if isinstance(median, (int, float)) and not isinstance(median, bool) \
|
||||||
|
and median >= _MIN_WORDS:
|
||||||
|
survivors[col] = stats
|
||||||
|
return survivors
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Figures (lazy matplotlib, scaled by the renderers — same style as num_distr).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _hist_figure(name: str, length_stats: dict):
|
||||||
|
def make():
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
fig = Figure(figsize=(6.2, 3.0))
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
bins = (length_stats or {}).get("word_hist") or []
|
||||||
|
drew = False
|
||||||
|
for b in bins:
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
continue
|
||||||
|
lo, hi, count = b.get("lo"), b.get("hi"), b.get("count") or 0
|
||||||
|
if lo is None or hi is None:
|
||||||
|
continue
|
||||||
|
width = (hi - lo) if hi > lo else max(abs(lo) * 1e-3, 1e-6)
|
||||||
|
ax.bar(lo, count, width=width, align="edge", color="#9ec6df",
|
||||||
|
edgecolor="#5b8aa6", linewidth=0.4)
|
||||||
|
drew = True
|
||||||
|
if not drew:
|
||||||
|
ax.text(0.5, 0.5, "(sin datos de longitud)", ha="center",
|
||||||
|
va="center", color="#8a8a8a", transform=ax.transAxes)
|
||||||
|
ax.set_xlabel("palabras por documento", fontsize=8)
|
||||||
|
ax.set_ylabel("nº de documentos", fontsize=8)
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
for spine in ("top", "right"):
|
||||||
|
ax.spines[spine].set_visible(False)
|
||||||
|
ax.set_title(f"Longitud de «{_truncate(name, 30)}»", fontsize=10,
|
||||||
|
loc="left")
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
return make
|
||||||
|
|
||||||
|
|
||||||
|
def _barh_figure(title: str, items: list, label_key: str, value_key: str,
|
||||||
|
xlabel: str):
|
||||||
|
"""Horizontal bar chart from [{label_key:..., value_key:...}, ...]."""
|
||||||
|
def make():
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
rows = [it for it in (items or []) if isinstance(it, dict)
|
||||||
|
and isinstance(it.get(value_key), (int, float))]
|
||||||
|
rows = rows[:12]
|
||||||
|
fig = Figure(figsize=(6.2, max(2.2, 0.32 * len(rows) + 0.8)))
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
if not rows:
|
||||||
|
ax.text(0.5, 0.5, "(sin datos)", ha="center", va="center",
|
||||||
|
color="#8a8a8a", transform=ax.transAxes)
|
||||||
|
ax.axis("off")
|
||||||
|
return fig
|
||||||
|
labels = [_truncate(r.get(label_key), 28) for r in rows][::-1]
|
||||||
|
values = [float(r.get(value_key) or 0) for r in rows][::-1]
|
||||||
|
ypos = range(len(rows))
|
||||||
|
ax.barh(list(ypos), values, color="#9ec6df", edgecolor="#5b8aa6",
|
||||||
|
linewidth=0.4)
|
||||||
|
ax.set_yticks(list(ypos))
|
||||||
|
ax.set_yticklabels(labels, fontsize=7)
|
||||||
|
ax.set_xlabel(xlabel, fontsize=8)
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
for spine in ("top", "right"):
|
||||||
|
ax.spines[spine].set_visible(False)
|
||||||
|
ax.set_title(_truncate(title, 44), fontsize=10, loc="left")
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
return make
|
||||||
|
|
||||||
|
|
||||||
|
def _wordcloud_figure(texts):
|
||||||
|
"""Word-cloud figure callable, or None if wordcloud is not installed."""
|
||||||
|
try:
|
||||||
|
import wordcloud # noqa: F401
|
||||||
|
except Exception: # noqa: BLE001 — optional dependency: omit the figure.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def make():
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
from wordcloud import WordCloud
|
||||||
|
fig = Figure(figsize=(6.2, 3.2))
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
joined = " ".join(t for t in texts if isinstance(t, str))
|
||||||
|
try:
|
||||||
|
wc = WordCloud(width=800, height=400, background_color="white",
|
||||||
|
colormap="viridis").generate(joined)
|
||||||
|
ax.imshow(wc, interpolation="bilinear")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
ax.text(0.5, 0.5, "(nube de palabras no disponible)", ha="center",
|
||||||
|
va="center", color="#8a8a8a", transform=ax.transAxes)
|
||||||
|
ax.axis("off")
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
return make
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Per-column block assembly.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _summary_kv(n_docs, length_stats, vocab, lang, dup, read):
|
||||||
|
chars = (length_stats or {}).get("chars") or {}
|
||||||
|
words = (length_stats or {}).get("words") or {}
|
||||||
|
sents = (length_stats or {}).get("sentences") or {}
|
||||||
|
rows = [
|
||||||
|
("Documentos", _fmt_int(n_docs)),
|
||||||
|
("Caracteres (media · p50 · p90 · p99)",
|
||||||
|
f"{_fmt_num(chars.get('mean'))} · {_fmt_int(chars.get('p50'))} · "
|
||||||
|
f"{_fmt_int(chars.get('p90'))} · {_fmt_int(chars.get('p99'))}"),
|
||||||
|
("Palabras (media · p50 · p90 · p99)",
|
||||||
|
f"{_fmt_num(words.get('mean'))} · {_fmt_int(words.get('p50'))} · "
|
||||||
|
f"{_fmt_int(words.get('p90'))} · {_fmt_int(words.get('p99'))}"),
|
||||||
|
("Frases (media · máx)",
|
||||||
|
f"{_fmt_num(sents.get('mean'))} · {_fmt_int(sents.get('max'))}"),
|
||||||
|
("Vocabulario (tokens · tipos · TTR)",
|
||||||
|
f"{_fmt_int(vocab.get('n_tokens'))} · {_fmt_int(vocab.get('n_types'))} "
|
||||||
|
f"· {_fmt_num(vocab.get('ttr'), 3)}"),
|
||||||
|
("Hapax legomena",
|
||||||
|
f"{_fmt_int(vocab.get('n_hapax'))} ({_fmt_pct(vocab.get('hapax_pct'))})"),
|
||||||
|
]
|
||||||
|
if isinstance(lang, dict) and lang.get("available"):
|
||||||
|
dom = lang.get("dominant")
|
||||||
|
n_langs = len(lang.get("distribution") or [])
|
||||||
|
rows.append(("Idioma dominante · nº idiomas",
|
||||||
|
f"{model._safe_str(dom) or '—'} · {_fmt_int(n_langs)}"))
|
||||||
|
if isinstance(dup, dict) and dup.get("n_docs"):
|
||||||
|
rows.append(("Duplicados exactos",
|
||||||
|
f"{_fmt_int(dup.get('n_exact_dup'))} "
|
||||||
|
f"({_fmt_pct(dup.get('exact_dup_pct'))})"))
|
||||||
|
if isinstance(read, dict) and read.get("available"):
|
||||||
|
flesch = read.get("flesch") or {}
|
||||||
|
rows.append(("Legibilidad Flesch (media)",
|
||||||
|
_fmt_num(flesch.get("mean"), 1)))
|
||||||
|
return model.KVTable(rows=rows, title="Resumen del texto")
|
||||||
|
|
||||||
|
|
||||||
|
def _terms_table(vocab) -> "model.DataTable | None":
|
||||||
|
top = (vocab or {}).get("top_terms") or []
|
||||||
|
rows = [[_truncate(t.get("term"), 32), _fmt_int(t.get("count")),
|
||||||
|
_fmt_pct(t.get("pct"))]
|
||||||
|
for t in top[:_TOP_TERMS] if isinstance(t, dict)]
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return model.DataTable(header=["Término", "Conteo", "% tokens"], rows=rows,
|
||||||
|
title="Términos más frecuentes",
|
||||||
|
note="stopwords ES+EN eliminadas")
|
||||||
|
|
||||||
|
|
||||||
|
def _ngram_table(items, n_label) -> "model.DataTable | None":
|
||||||
|
rows = [[_truncate(it.get("ngram"), 40), _fmt_int(it.get("count"))]
|
||||||
|
for it in (items or [])[:_TOP_NGRAMS] if isinstance(it, dict)]
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return model.DataTable(header=[n_label, "Conteo"], rows=rows,
|
||||||
|
title=f"{n_label} más frecuentes")
|
||||||
|
|
||||||
|
|
||||||
|
def _dup_note(dup, lang, read) -> "model.Note | None":
|
||||||
|
bits = []
|
||||||
|
if isinstance(dup, dict):
|
||||||
|
nd = dup.get("near_dup") or {}
|
||||||
|
if nd.get("available"):
|
||||||
|
bits.append(
|
||||||
|
f"casi-duplicados detectados (MinHash, umbral "
|
||||||
|
f"{_fmt_num(nd.get('threshold'))}): "
|
||||||
|
f"{_fmt_int(nd.get('n_near_dup_docs'))} documentos")
|
||||||
|
else:
|
||||||
|
bits.append("near-duplicados no calculados (datasketch no instalado; "
|
||||||
|
"se reportan solo los duplicados exactos por hash)")
|
||||||
|
if isinstance(lang, dict) and not lang.get("available"):
|
||||||
|
bits.append("detección de idioma omitida (langdetect no instalado)")
|
||||||
|
if isinstance(read, dict) and not read.get("available"):
|
||||||
|
bits.append("legibilidad omitida (textstat no instalado)")
|
||||||
|
if not bits:
|
||||||
|
return None
|
||||||
|
return model.Note(" · ".join(bits))
|
||||||
|
|
||||||
|
|
||||||
|
def _column_group(name, texts, length_stats, idx, mark_terms):
|
||||||
|
vocab = _vocab_stats(texts)
|
||||||
|
lang = _language(texts)
|
||||||
|
dup = _duplicates(texts)
|
||||||
|
read = _readability(texts)
|
||||||
|
n_docs = (length_stats or {}).get("n_docs")
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
model.Heading(text=str(name), level=2),
|
||||||
|
_summary_kv(n_docs, length_stats, vocab, lang, dup, read),
|
||||||
|
model.Figure(make=_hist_figure(name, length_stats),
|
||||||
|
caption=f"Distribución de la longitud (palabras) de "
|
||||||
|
f"«{_truncate(name, 30)}»."),
|
||||||
|
]
|
||||||
|
|
||||||
|
terms_tbl = _terms_table(vocab)
|
||||||
|
if terms_tbl is not None:
|
||||||
|
blocks.append(terms_tbl)
|
||||||
|
blocks.append(model.Figure(
|
||||||
|
make=_barh_figure(f"Top términos de «{_truncate(name, 24)}»",
|
||||||
|
vocab.get("top_terms"), "term", "count",
|
||||||
|
"conteo"),
|
||||||
|
caption="Términos más frecuentes (barras)."))
|
||||||
|
|
||||||
|
bi_tbl = _ngram_table(_ngrams(texts, 2), "Bigrama")
|
||||||
|
if bi_tbl is not None:
|
||||||
|
blocks.append(bi_tbl)
|
||||||
|
tri_tbl = _ngram_table(_ngrams(texts, 3), "Trigrama")
|
||||||
|
if tri_tbl is not None:
|
||||||
|
blocks.append(tri_tbl)
|
||||||
|
|
||||||
|
if isinstance(lang, dict) and lang.get("available") \
|
||||||
|
and lang.get("distribution"):
|
||||||
|
blocks.append(model.Figure(
|
||||||
|
make=_barh_figure(f"Idiomas detectados en «{_truncate(name, 24)}»",
|
||||||
|
lang.get("distribution"), "lang", "count",
|
||||||
|
"documentos"),
|
||||||
|
caption="Distribución de idiomas detectados (langdetect)."))
|
||||||
|
|
||||||
|
wc = _wordcloud_figure(texts)
|
||||||
|
if wc is not None:
|
||||||
|
blocks.append(model.Figure(
|
||||||
|
make=wc, caption=f"Nube de palabras de «{_truncate(name, 30)}»."))
|
||||||
|
|
||||||
|
note = _dup_note(dup, lang, read)
|
||||||
|
if note is not None:
|
||||||
|
blocks.append(note)
|
||||||
|
|
||||||
|
return model.Group(blocks=blocks, page_break_before=(idx > 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _intro_blocks(n_cols, mark_terms):
|
||||||
|
ttr = ("[[term:ttr]]TTR[[/term]]" if mark_terms else "TTR")
|
||||||
|
hapax = ("[[term:hapax]]hapax legomena[[/term]]" if mark_terms
|
||||||
|
else "hapax legomena")
|
||||||
|
text = (
|
||||||
|
f"Este capítulo perfila las columnas de **texto libre largo** del "
|
||||||
|
f"dataset (reseñas, descripciones, comentarios): contenido lingüístico "
|
||||||
|
f"que la distribución categórica no resume bien. Para cada columna se "
|
||||||
|
f"muestran la longitud de los documentos, la riqueza de vocabulario "
|
||||||
|
f"(incluido el {ttr} y el porcentaje de {hapax}), los términos y "
|
||||||
|
f"n-gramas más frecuentes, los idiomas detectados y el nivel de "
|
||||||
|
f"duplicación. Las métricas son baratas y sin modelos pesados; las "
|
||||||
|
f"piezas que dependen de una librería opcional se omiten si no está "
|
||||||
|
f"instalada.")
|
||||||
|
return [
|
||||||
|
model.Heading(text=CHAPTER_TITLE, level=1),
|
||||||
|
model.Markdown(text=text),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_text_distr(profile: dict, ctx: dict):
|
||||||
|
"""Build the free-text Chapter, or None if no long-text column applies."""
|
||||||
|
profile = profile or {}
|
||||||
|
ctx = ctx or {}
|
||||||
|
|
||||||
|
# 1) Cheap gate from the profile (no DB access yet).
|
||||||
|
candidates = _candidate_columns(profile)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2) Raw sample + 3) confirm genuine long text (median words >= threshold).
|
||||||
|
samples = _get_samples(profile, ctx, candidates)
|
||||||
|
if not samples:
|
||||||
|
return None
|
||||||
|
survivors = _confirm_long_text(samples)
|
||||||
|
if not survivors:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Register glossary terms (clickable) once we know the chapter applies.
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
mark_terms = False
|
||||||
|
if isinstance(glossary, model.GlossaryCollector):
|
||||||
|
for key, (label, definition) in _TERMS.items():
|
||||||
|
glossary.add(key, label, definition)
|
||||||
|
mark_terms = True
|
||||||
|
|
||||||
|
blocks = list(_intro_blocks(len(survivors), mark_terms))
|
||||||
|
|
||||||
|
rendered = list(survivors.items())[:_MAX_TEXT_COLS]
|
||||||
|
for idx, (name, length_stats) in enumerate(rendered):
|
||||||
|
texts = samples.get(name) or []
|
||||||
|
blocks.append(_column_group(name, texts, length_stats, idx, mark_terms))
|
||||||
|
|
||||||
|
if len(survivors) > len(rendered):
|
||||||
|
omitted = len(survivors) - len(rendered)
|
||||||
|
blocks.append(model.Note(
|
||||||
|
f"Se muestran las primeras {len(rendered)} columnas de texto; "
|
||||||
|
f"quedan {omitted} sin mostrar para mantener acotado el informe."))
|
||||||
|
|
||||||
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Tests for the TEXT DISTR chapter — DoD: golden + edges + degradation.
|
||||||
|
|
||||||
|
Self-contained: builds synthetic TableProfiles and feeds the raw text sample
|
||||||
|
in-memory through ``ctx['text_raw']`` (no DuckDB needed), so the suite is fast
|
||||||
|
and deterministic. Verifies that ``build_text_distr``:
|
||||||
|
|
||||||
|
- GOLDEN: with a long-text column, emits the chapter with its key blocks
|
||||||
|
(length summary, word histogram, top-terms table, n-gram tables, language
|
||||||
|
bars) and registers the clickable glossary terms; and that it renders inside
|
||||||
|
the full document to both PDF and PPTX showing that content.
|
||||||
|
- EDGE (None): a dataset whose only string column is short labels (titanic-like
|
||||||
|
``Name``) yields ``None`` without raising — the existing report is untouched.
|
||||||
|
- EDGE (None): a column that passes the cheap char gate but whose documents are
|
||||||
|
short (median words below the threshold) is rejected at the confirmation step.
|
||||||
|
- DEGRADATION: with ``langdetect`` / ``textstat`` / ``wordcloud`` unavailable,
|
||||||
|
the chapter still builds (those pieces are omitted) and never raises.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from pptx import Presentation
|
||||||
|
|
||||||
|
from datascience.automatic_eda.model import (
|
||||||
|
DataTable, Figure, GlossaryCollector, Group, Heading, KVTable, Markdown,
|
||||||
|
Note,
|
||||||
|
)
|
||||||
|
from datascience.automatic_eda.chapters.text_distr import (
|
||||||
|
CHAPTER_ID, CHAPTER_VERSION, build_text_distr,
|
||||||
|
)
|
||||||
|
from datascience.automatic_eda.chapters_registry import build_document
|
||||||
|
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||||
|
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Synthetic corpus + profiles.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_ES = [
|
||||||
|
"El producto llegó en perfecto estado y mucho antes de lo previsto por la tienda",
|
||||||
|
"La calidad de los materiales es realmente excelente y se nota la diferencia al usarlo",
|
||||||
|
"No me convenció del todo porque esperaba bastante más por el precio que pagué finalmente",
|
||||||
|
"El servicio de atención al cliente fue rápido amable y resolvió mi problema sin demora",
|
||||||
|
"Lo recomiendo totalmente ya que ha superado con creces todas mis expectativas iniciales",
|
||||||
|
]
|
||||||
|
_EN = [
|
||||||
|
"The product arrived in perfect condition and much earlier than the store had promised me",
|
||||||
|
"The build quality is genuinely outstanding and you can really feel the difference using it",
|
||||||
|
"I was not fully convinced because I expected quite a lot more for the price i finally paid",
|
||||||
|
"Customer support was fast friendly and solved my whole problem without any delay at all",
|
||||||
|
"I highly recommend it since it has exceeded by far every one of my initial expectations",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _long_reviews(n=40) -> list:
|
||||||
|
"""A corpus of long multi-sentence reviews (>= 20 words each), mixing two
|
||||||
|
languages and including a few exact duplicates."""
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
base = _ES if i % 3 != 0 else _EN # mostly ES, some EN
|
||||||
|
a = base[i % len(base)]
|
||||||
|
b = base[(i + 2) % len(base)]
|
||||||
|
out.append(f"{a}. {b}.")
|
||||||
|
# Inject a couple of exact duplicates.
|
||||||
|
out.append(out[0])
|
||||||
|
out.append(out[1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _text_profile() -> dict:
|
||||||
|
"""Profile with a long free-text column (review) + a numeric + a short cat."""
|
||||||
|
return {
|
||||||
|
"table": "reviews",
|
||||||
|
"source": "/data/reviews.duckdb",
|
||||||
|
"profiled_at": "2026-06-30T10:00:00+00:00",
|
||||||
|
"n_rows": 42,
|
||||||
|
"n_cols": 3,
|
||||||
|
"quality_score": 88.0,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "review",
|
||||||
|
"inferred_type": "categorical",
|
||||||
|
"categorical": {
|
||||||
|
"top": [{"value": "x", "count": 2, "pct": 0.05}],
|
||||||
|
"n_distinct": 40,
|
||||||
|
"len_mean": 180.0,
|
||||||
|
"len_min": 80,
|
||||||
|
"len_max": 220,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"numeric": {"mean": 3.1, "median": 3.0, "std": 1.2,
|
||||||
|
"min": 1, "max": 5},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "product",
|
||||||
|
"inferred_type": "categorical",
|
||||||
|
"categorical": {
|
||||||
|
"top": [{"value": "teclado", "count": 10, "pct": 0.25}],
|
||||||
|
"n_distinct": 6,
|
||||||
|
"len_mean": 7.0,
|
||||||
|
"len_min": 5, "len_max": 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_text_profile() -> dict:
|
||||||
|
"""titanic-like: the only string column is short labels (Name ≈ 27 chars)."""
|
||||||
|
return {
|
||||||
|
"table": "titanic",
|
||||||
|
"n_rows": 891,
|
||||||
|
"n_cols": 3,
|
||||||
|
"columns": [
|
||||||
|
{"name": "Age", "inferred_type": "numeric",
|
||||||
|
"numeric": {"mean": 29.7, "median": 28.0, "std": 14.5}},
|
||||||
|
{"name": "Name", "inferred_type": "categorical",
|
||||||
|
"categorical": {"top": [{"value": "Braund, Mr. Owen Harris",
|
||||||
|
"count": 1, "pct": 0.001}],
|
||||||
|
"n_distinct": 891, "len_mean": 27.0,
|
||||||
|
"len_min": 12, "len_max": 82}},
|
||||||
|
{"name": "Sex", "inferred_type": "categorical",
|
||||||
|
"categorical": {"top": [{"value": "male", "count": 577,
|
||||||
|
"pct": 0.65}],
|
||||||
|
"n_distinct": 2, "len_mean": 4.6,
|
||||||
|
"len_min": 4, "len_max": 6}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(blocks) -> list:
|
||||||
|
"""Recursively flatten Group blocks so tests can inspect leaf blocks."""
|
||||||
|
out = []
|
||||||
|
for b in blocks:
|
||||||
|
if isinstance(b, Group):
|
||||||
|
out.extend(_flatten(b.blocks))
|
||||||
|
else:
|
||||||
|
out.append(b)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Golden.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_golden_activa_con_texto():
|
||||||
|
glossary = GlossaryCollector()
|
||||||
|
ctx = {"text_raw": {"review": _long_reviews()}, "glossary": glossary}
|
||||||
|
ch = build_text_distr(_text_profile(), ctx)
|
||||||
|
|
||||||
|
assert ch is not None, "el capítulo debe activarse con una columna de texto largo"
|
||||||
|
assert ch.id == CHAPTER_ID
|
||||||
|
assert ch.version == CHAPTER_VERSION
|
||||||
|
leaves = _flatten(ch.blocks)
|
||||||
|
kinds = [b.kind for b in leaves]
|
||||||
|
assert "heading" in kinds
|
||||||
|
assert "kv_table" in kinds # summary
|
||||||
|
assert "figure" in kinds # histogram / bars
|
||||||
|
assert "data_table" in kinds # top terms + n-grams
|
||||||
|
|
||||||
|
# KV summary mentions vocabulary metrics.
|
||||||
|
kv = next(b for b in leaves if isinstance(b, KVTable))
|
||||||
|
labels = " ".join(str(r[0]) for r in kv.rows)
|
||||||
|
assert "TTR" in labels
|
||||||
|
assert "Hapax" in labels or "hapax" in labels
|
||||||
|
|
||||||
|
# There is a terms table and at least one n-gram table.
|
||||||
|
titles = [getattr(b, "title", "") or "" for b in leaves
|
||||||
|
if isinstance(b, DataTable)]
|
||||||
|
assert any("Términos" in t for t in titles)
|
||||||
|
assert any("Bigrama" in t for t in titles)
|
||||||
|
|
||||||
|
# Glossary terms were registered (clickable destinations).
|
||||||
|
assert glossary.has("ttr")
|
||||||
|
assert glossary.has("hapax")
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_render_pdf_pptx():
|
||||||
|
profile = _text_profile()
|
||||||
|
ctx = {"text_raw": {"review": _long_reviews()},
|
||||||
|
"dataset_name": "reviews"}
|
||||||
|
chapters = build_document(profile, ctx)
|
||||||
|
ids = [c.id for c in chapters]
|
||||||
|
assert "text_distr" in ids, f"text_distr ausente en {ids}"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
pdf = os.path.join(d, "t.pdf")
|
||||||
|
pptx = os.path.join(d, "t.pptx")
|
||||||
|
rp = render_automatic_eda_pdf(profile, pdf, {"title": "EDA", "ctx": ctx})
|
||||||
|
rx = render_automatic_eda_pptx(profile, pptx, {"title": "EDA", "ctx": ctx})
|
||||||
|
assert rp.get("path") and os.path.exists(pdf)
|
||||||
|
assert rx.get("path") and os.path.exists(pptx)
|
||||||
|
|
||||||
|
text = "\n".join(p.extract_text() or "" for p in PdfReader(pdf).pages)
|
||||||
|
assert "Texto libre" in text or "TTR" in text
|
||||||
|
|
||||||
|
prs = Presentation(pptx)
|
||||||
|
ptext = []
|
||||||
|
for slide in prs.slides:
|
||||||
|
for shp in slide.shapes:
|
||||||
|
if shp.has_text_frame:
|
||||||
|
ptext.append(shp.text_frame.text)
|
||||||
|
joined = "\n".join(ptext)
|
||||||
|
assert "Texto libre" in joined or "TTR" in joined
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Edges — None.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_edge_none_sin_texto_largo():
|
||||||
|
# titanic-like: short labels only → chapter must not apply.
|
||||||
|
assert build_text_distr(_no_text_profile(), {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_none_palabras_cortas():
|
||||||
|
# Char gate passes (len_mean high) but documents are short → confirmation
|
||||||
|
# rejects them (median words below threshold).
|
||||||
|
profile = _text_profile()
|
||||||
|
short = ["palabra " * 3] * 30 # 3 words each, < _MIN_WORDS
|
||||||
|
ctx = {"text_raw": {"review": short}}
|
||||||
|
assert build_text_distr(profile, ctx) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_none_empty_profile():
|
||||||
|
assert build_text_distr({}, {}) is None
|
||||||
|
assert build_text_distr(None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Degradation — optional libs absent.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_degradacion_sin_libs(monkeypatch):
|
||||||
|
real_import = builtins.__import__
|
||||||
|
blocked = ("langdetect", "textstat", "wordcloud", "datasketch")
|
||||||
|
|
||||||
|
def fake_import(name, *a, **k):
|
||||||
|
if name in blocked or any(name.startswith(b + ".") for b in blocked):
|
||||||
|
raise ImportError(f"simulado: {name}")
|
||||||
|
return real_import(name, *a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
|
ctx = {"text_raw": {"review": _long_reviews()}}
|
||||||
|
ch = build_text_distr(_text_profile(), ctx)
|
||||||
|
# Still builds (the cheap, stdlib-only pieces remain) and never raises.
|
||||||
|
assert ch is not None
|
||||||
|
leaves = _flatten(ch.blocks)
|
||||||
|
assert any(isinstance(b, KVTable) for b in leaves)
|
||||||
|
assert any(isinstance(b, DataTable) for b in leaves)
|
||||||
|
# A degradation note is present mentioning the missing optional libs.
|
||||||
|
notes = " ".join(b.text for b in leaves if isinstance(b, Note))
|
||||||
|
assert "langdetect" in notes or "textstat" in notes or "datasketch" in notes
|
||||||
@@ -31,7 +31,10 @@ CHAPTER_ORDER = [
|
|||||||
"analisis_llm", # LLM interpretation — sits next to overview (user request)
|
"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)
|
||||||
|
"outliers", # atypical values: univariate (Tukey/z) + multivariate (IsolationForest)
|
||||||
"correlacion", # correlations / associations
|
"correlacion", # correlations / associations
|
||||||
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
|
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
|
||||||
"modelos", # cheap models (PCA/KMeans/outliers)
|
"modelos", # cheap models (PCA/KMeans/outliers)
|
||||||
@@ -70,24 +73,51 @@ def build_chapter(chapter_id: str, profile: dict, ctx: dict):
|
|||||||
return model.as_chapter(result)
|
return model.as_chapter(result)
|
||||||
|
|
||||||
|
|
||||||
def build_document(profile: dict, ctx: dict = None) -> list:
|
def build_document(profile: dict, ctx: dict = None, only: list = None) -> list:
|
||||||
"""Build the full ordered list of chapters for a TableProfile.
|
"""Build the ordered list of chapters for a TableProfile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
profile: the ``eda`` group TableProfile dict (may be None/empty).
|
profile: the ``eda`` group TableProfile dict (may be None/empty).
|
||||||
ctx: optional context dict carrying presentation metadata not present in
|
ctx: optional context dict carrying presentation metadata not present in
|
||||||
the profile (dataset_name, source_origin, storage, generated_at,
|
the profile (dataset_name, source_origin, storage, generated_at,
|
||||||
description, granularity, quality_criteria, head_rows, ...).
|
description, granularity, quality_criteria, head_rows, ...).
|
||||||
|
only: optional list of chapter ids to render. ``None`` (default) keeps
|
||||||
|
the historical behaviour — every implemented & applicable chapter in
|
||||||
|
canonical order. A list restricts the BODY to just those ids (in
|
||||||
|
canonical order), but the cover (``portada``) and glossary
|
||||||
|
(``glosario``) are ALWAYS included so the document stays valid and
|
||||||
|
the clickable terms keep a destination — so passing ``only=["x"]``
|
||||||
|
yields portada + x + glosario. Unknown ids are simply skipped (the
|
||||||
|
caller is responsible for strict validation). ``only=[]`` yields the
|
||||||
|
minimal document (portada + glosario only). This argument is additive
|
||||||
|
and backward-compatible: the signature is unchanged for existing
|
||||||
|
callers (default ``None``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[Chapter] in canonical order, containing only the chapters that are
|
list[Chapter] in canonical order, containing only the chapters that are
|
||||||
implemented and applicable. Never raises.
|
implemented, applicable and selected. Never raises.
|
||||||
"""
|
"""
|
||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
profile = {}
|
profile = {}
|
||||||
# Copy ctx so the shared collector / summary we add do not leak to the caller.
|
# Copy ctx so the shared collector / summary we add do not leak to the caller.
|
||||||
ctx = dict(ctx) if isinstance(ctx, dict) else {}
|
ctx = dict(ctx) if isinstance(ctx, dict) else {}
|
||||||
|
|
||||||
|
# only=None -> all body chapters (historical). only=list -> restrict body to
|
||||||
|
# that selection (portada/glosario are added unconditionally below). The
|
||||||
|
# renderers call build_document(profile, meta['ctx']) without an `only`
|
||||||
|
# argument, so the pipeline forwards the selection through a reserved ctx key
|
||||||
|
# (``_only_chapters``); an explicit `only` argument always wins. The key is
|
||||||
|
# popped from the local ctx copy so it never reaches the chapters.
|
||||||
|
if only is None:
|
||||||
|
_carried = ctx.pop("_only_chapters", None)
|
||||||
|
if isinstance(_carried, (list, tuple, set)):
|
||||||
|
only = list(_carried)
|
||||||
|
else:
|
||||||
|
ctx.pop("_only_chapters", None)
|
||||||
|
# A set makes the membership test cheap; the iteration order stays
|
||||||
|
# CHAPTER_ORDER. only=[] is a valid (empty) selection -> minimal document.
|
||||||
|
only_set = set(only) if isinstance(only, (list, tuple, set)) else None
|
||||||
|
|
||||||
# A single glossary collector is shared by every chapter via ctx['glossary'].
|
# A single glossary collector is shared by every chapter via ctx['glossary'].
|
||||||
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
|
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
|
||||||
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
|
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
|
||||||
@@ -103,6 +133,10 @@ def build_document(profile: dict, ctx: dict = None) -> list:
|
|||||||
for cid in CHAPTER_ORDER:
|
for cid in CHAPTER_ORDER:
|
||||||
if cid in (_PORTADA, _GLOSARIO):
|
if cid in (_PORTADA, _GLOSARIO):
|
||||||
continue
|
continue
|
||||||
|
# When a selection is given, skip body chapters outside it. portada and
|
||||||
|
# glosario are never filtered (handled out of this loop).
|
||||||
|
if only_set is not None and cid not in only_set:
|
||||||
|
continue
|
||||||
ch = build_chapter(cid, profile, ctx)
|
ch = build_chapter(cid, profile, ctx)
|
||||||
if ch is not None and ch.blocks:
|
if ch is not None and ch.blocks:
|
||||||
body.append(ch)
|
body.append(ch)
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Tests for the Markdown completeness appendix (report 2053).
|
||||||
|
|
||||||
|
The AutomaticEDA Markdown is the output meant to be *pasted into an LLM*, so it
|
||||||
|
must carry EVERYTHING the engine computed — even the numbers the human-facing
|
||||||
|
chapters (shared with the PDF/PPTX) drop for readability. ``render_md`` appends a
|
||||||
|
full-data appendix built from ``meta['profile']`` that closes the six losses the
|
||||||
|
evaluation found:
|
||||||
|
|
||||||
|
1. the complete association matrix (every pair, incl. correlation_ratio /
|
||||||
|
cramers_v) — not just the top extremes;
|
||||||
|
2. every numeric statistic for every numeric column (skew/kurtosis/percentiles);
|
||||||
|
3. the concrete recommended re-expression;
|
||||||
|
4. KMeans ``scores_by_k``;
|
||||||
|
5. the normality test statistics;
|
||||||
|
6. correct headers for bar/scree figure tables (not ``Desde/Hasta/Frecuencia``).
|
||||||
|
|
||||||
|
Self-contained: a synthetic profile, no DuckDB, no heavy renderer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest # noqa: F401
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||||
|
if _FUNCTIONS not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS)
|
||||||
|
|
||||||
|
from datascience.automatic_eda import model # noqa: E402
|
||||||
|
from datascience.automatic_eda.render_md_impl import ( # noqa: E402
|
||||||
|
_bars_table,
|
||||||
|
_is_histogram_caption,
|
||||||
|
_profile_appendix,
|
||||||
|
render_md,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Synthetic profile fixtures.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _numeric(skew, kurtosis):
|
||||||
|
"""A numeric stat block with every key the appendix serializes."""
|
||||||
|
return {
|
||||||
|
"count": 100, "min": 0.0, "max": 10.0, "mean": 5.0, "median": 5.0,
|
||||||
|
"mode": 4.0, "std": 2.0, "variance": 4.0, "cv": 0.4,
|
||||||
|
"p1": 0.1, "p5": 0.5, "p25": 2.5, "p50": 5.0, "p75": 7.5,
|
||||||
|
"p95": 9.5, "p99": 9.9, "iqr": 5.0, "skew": skew, "kurtosis": kurtosis,
|
||||||
|
"n_outliers": 1, "distribution_type": "normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _profile():
|
||||||
|
"""A small but structurally faithful TableProfile (3 numeric, 2 categorical)."""
|
||||||
|
pairs = [
|
||||||
|
{"a": "A", "b": "B", "a_type": "numeric", "b_type": "numeric",
|
||||||
|
"method": "pearson/spearman", "value": 0.8,
|
||||||
|
"p_value": 1e-9, "p_value_adjusted": 2e-9, "significant": True},
|
||||||
|
{"a": "A", "b": "C", "a_type": "numeric", "b_type": "numeric",
|
||||||
|
"method": "pearson/spearman", "value": -0.3,
|
||||||
|
"p_value": 0.01, "p_value_adjusted": 0.02, "significant": True},
|
||||||
|
{"a": "A", "b": "Cat1", "a_type": "numeric", "b_type": "categorical",
|
||||||
|
"method": "correlation_ratio", "value": 0.45,
|
||||||
|
"p_value": 0.001, "p_value_adjusted": 0.002, "significant": True},
|
||||||
|
# The single cat-cat pair the human chapter never shows.
|
||||||
|
{"a": "Cat1", "b": "Cat2", "a_type": "categorical",
|
||||||
|
"b_type": "categorical", "method": "cramers_v", "value": 0.11,
|
||||||
|
"p_value": 0.04, "p_value_adjusted": 0.05, "significant": False},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"correlations": {
|
||||||
|
"pairs": pairs,
|
||||||
|
"multiple_testing": {"method": "bh", "n_tests": 4, "n_rejected": 3},
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{"name": "A", "count": 100, "numeric": _numeric(0.0, -1.2),
|
||||||
|
"reexpression": {"recommended": "none", "ladder_power": 1.0,
|
||||||
|
"reason": "symmetric", "alternatives": []}},
|
||||||
|
{"name": "B", "count": 100, "numeric": _numeric(4.77, 33.1),
|
||||||
|
"reexpression": {"recommended": "log1p", "ladder_power": 0.0,
|
||||||
|
"reason": "skew 4.77 with zeros",
|
||||||
|
"alternatives": [{"transform": "yeo-johnson"},
|
||||||
|
{"transform": "sqrt"}]}},
|
||||||
|
{"name": "C", "count": 100, "numeric": _numeric(-0.6, 0.2)},
|
||||||
|
{"name": "Cat1", "categorical": {"top": [], "mode": "x"}},
|
||||||
|
{"name": "Cat2", "categorical": {"top": [], "mode": "y"}},
|
||||||
|
],
|
||||||
|
"models": {
|
||||||
|
"kmeans": {
|
||||||
|
"best_k": 3,
|
||||||
|
"scores_by_k": [
|
||||||
|
{"k": 2, "silhouette": 0.46, "inertia": 900.0},
|
||||||
|
{"k": 3, "silhouette": 0.50, "inertia": 550.0},
|
||||||
|
{"k": 4, "silhouette": 0.38, "inertia": 430.0},
|
||||||
|
],
|
||||||
|
"cluster_sizes": [40, 35, 25],
|
||||||
|
},
|
||||||
|
"normality": {
|
||||||
|
"A": {"n": 100,
|
||||||
|
"jarque_bera": {"stat": 18.7, "p": 8e-5, "normal": False},
|
||||||
|
"dagostino": {"stat": 18.1, "p": 1e-4, "normal": False},
|
||||||
|
"shapiro": {"stat": 0.98, "p": 7e-8, "normal": False},
|
||||||
|
"is_normal": False},
|
||||||
|
"C": {"n": 100,
|
||||||
|
"jarque_bera": {"stat": 2.1, "p": 0.35, "normal": True},
|
||||||
|
"dagostino": {"stat": 1.9, "p": 0.38, "normal": True},
|
||||||
|
"shapiro": {"stat": 0.99, "p": 0.12, "normal": True},
|
||||||
|
"is_normal": True},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dummy_chapters():
|
||||||
|
"""A minimal one-chapter document so render_md does not early-return empty."""
|
||||||
|
return model.as_chapters([
|
||||||
|
{"id": "intro", "title": "Intro",
|
||||||
|
"blocks": [{"kind": "markdown", "text": "cuerpo del informe"}]},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _render(tmp_path, profile):
|
||||||
|
out = os.path.join(str(tmp_path), "out.md")
|
||||||
|
res = render_md(_dummy_chapters(), out, {"title": "EDA — t", "profile": profile})
|
||||||
|
assert res["path"] == out
|
||||||
|
return open(out, encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
|
def _table_rows(md, section_title):
|
||||||
|
"""Count data rows of the first Markdown table under ``section_title``."""
|
||||||
|
seg = md.split(section_title, 1)[1]
|
||||||
|
rows, in_t, seen_sep = 0, False, False
|
||||||
|
for ln in seg.splitlines():
|
||||||
|
if ln.startswith("|"):
|
||||||
|
in_t = True
|
||||||
|
stripped = ln.replace("|", "").replace(" ", "")
|
||||||
|
if stripped and set(stripped) == {"-"}:
|
||||||
|
seen_sep = True
|
||||||
|
continue
|
||||||
|
if seen_sep:
|
||||||
|
rows += 1
|
||||||
|
elif in_t and not ln.strip():
|
||||||
|
break
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Golden: every datum the profile holds reaches the .md.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_appendix_lists_all_correlation_pairs(tmp_path):
|
||||||
|
md = _render(tmp_path, _profile())
|
||||||
|
assert "## Apéndice — Datos completos del perfil" in md
|
||||||
|
# All 4 pairs (the real titanic profile has 28; here 4 synthetic).
|
||||||
|
assert _table_rows(md, "### Matriz de asociación") == 4
|
||||||
|
# The cat-cat Cramér's V pair the human chapter drops is present.
|
||||||
|
assert "Cat1 ↔ Cat2" in md
|
||||||
|
assert "cramers_v" in md
|
||||||
|
assert "correlation_ratio" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_has_skew_kurtosis_for_every_numeric(tmp_path):
|
||||||
|
md = _render(tmp_path, _profile())
|
||||||
|
seg = md.split("### Estadísticos numéricos completos", 1)[1].split("###", 1)[0]
|
||||||
|
lines = [l for l in seg.splitlines() if l.startswith("|")]
|
||||||
|
header = [h.strip() for h in lines[0].strip("|").split("|")]
|
||||||
|
assert "skew" in header and "kurtosis" in header
|
||||||
|
ski, kui = header.index("skew"), header.index("kurtosis")
|
||||||
|
data = lines[2:] # skip header + separator
|
||||||
|
assert len(data) == 3 # exactly the 3 numeric columns
|
||||||
|
for row in data:
|
||||||
|
cells = [c.strip() for c in row.strip("|").split("|")]
|
||||||
|
assert cells[ski] != "", f"missing skew in {cells[0]}"
|
||||||
|
assert cells[kui] != "", f"missing kurtosis in {cells[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_has_extended_percentiles(tmp_path):
|
||||||
|
md = _render(tmp_path, _profile())
|
||||||
|
seg = md.split("### Estadísticos numéricos completos", 1)[1]
|
||||||
|
header = [h.strip() for h in seg.splitlines()[2].strip("|").split("|")]
|
||||||
|
for p in ("p1", "p5", "p25", "p75", "p95", "p99"):
|
||||||
|
assert p in header, f"percentile {p} missing from describe header"
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_names_concrete_reexpression(tmp_path):
|
||||||
|
md = _render(tmp_path, _profile())
|
||||||
|
assert "### Re-expresión recomendada" in md
|
||||||
|
assert "log1p" in md # the concrete transform, not just "consider re-expressing"
|
||||||
|
assert "yeo-johnson" in md # alternatives listed too
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_has_kmeans_scores_by_k(tmp_path):
|
||||||
|
md = _render(tmp_path, _profile())
|
||||||
|
assert "scores_by_k" in md
|
||||||
|
assert _table_rows(md, "#### KMeans — selección de k") == 3 # k=2,3,4
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_has_normality_statistics(tmp_path):
|
||||||
|
md = _render(tmp_path, _profile())
|
||||||
|
assert "JB stat" in md # the statistic, not only the p-value
|
||||||
|
assert "Shapiro stat" in md
|
||||||
|
assert _table_rows(md, "#### Tests de normalidad") == 2 # cols A and C
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Edge: a profile missing models / correlations degrades, never raises.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_lite_profile_without_models(tmp_path):
|
||||||
|
prof = _profile()
|
||||||
|
prof.pop("models") # lite: no KMeans/normality
|
||||||
|
md = _render(tmp_path, prof)
|
||||||
|
assert "scores_by_k" not in md # section skipped
|
||||||
|
assert "Matriz de asociación" in md # correlations still dumped
|
||||||
|
assert "## Apéndice" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_without_correlations(tmp_path):
|
||||||
|
prof = _profile()
|
||||||
|
prof.pop("correlations")
|
||||||
|
md = _render(tmp_path, prof) # must not raise
|
||||||
|
assert "Matriz de asociación" not in md
|
||||||
|
assert "Estadísticos numéricos completos" in md # numeric section still there
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_profile_means_no_appendix(tmp_path):
|
||||||
|
out = os.path.join(str(tmp_path), "noprof.md")
|
||||||
|
res = render_md(_dummy_chapters(), out, {"title": "x"})
|
||||||
|
assert res["path"] == out
|
||||||
|
assert "## Apéndice" not in open(out, encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_helper_is_defensive():
|
||||||
|
assert _profile_appendix(None) == ""
|
||||||
|
assert _profile_appendix({}) == ""
|
||||||
|
assert _profile_appendix({"columns": []}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Loss #6: bar/scree figure tables get a non-misleading header.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_histogram_caption_detection():
|
||||||
|
assert _is_histogram_caption("Histograma de Age")
|
||||||
|
assert _is_histogram_caption("Distribución de Fare")
|
||||||
|
assert not _is_histogram_caption("Media de Survived por Sex")
|
||||||
|
assert not _is_histogram_caption("Varianza explicada (scree PCA)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bars_table_custom_header():
|
||||||
|
bars = [(0.0, 1.0, 5.0), (1.0, 2.0, 3.0)]
|
||||||
|
hist = _bars_table(bars) # default histogram header
|
||||||
|
assert "| Desde | Hasta | Frecuencia |" in hist
|
||||||
|
bar = _bars_table(bars, ("Inicio", "Fin", "Valor"))
|
||||||
|
assert "| Inicio | Fin | Valor |" in bar
|
||||||
|
assert "Frecuencia" not in bar
|
||||||
@@ -38,10 +38,18 @@ ENGINE_NAME = "AutomaticEDA"
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@dataclass
|
@dataclass
|
||||||
class Heading:
|
class Heading:
|
||||||
"""A section heading. ``level`` 1 (largest) .. 3 (smallest)."""
|
"""A section heading. ``level`` 1 (largest) .. 3 (smallest).
|
||||||
|
|
||||||
|
``underline`` and ``size_pt`` are optional emphasis hints honoured by the
|
||||||
|
PPTX renderer (the cover uses them to show the dataset name big and
|
||||||
|
underlined). ``size_pt`` overrides the per-level font size when set; the PDF
|
||||||
|
renderer ignores both so its layout is unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
text: str = ""
|
text: str = ""
|
||||||
level: int = 1
|
level: int = 1
|
||||||
|
underline: bool = False
|
||||||
|
size_pt: Optional[float] = None
|
||||||
kind: str = field(default="heading", init=False)
|
kind: str = field(default="heading", init=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,10 +70,17 @@ class Markdown:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KVTable:
|
class KVTable:
|
||||||
"""A two-column key/value table. ``rows`` is a list of ``(label, value)``."""
|
"""A two-column key/value table. ``rows`` is a list of ``(label, value)``.
|
||||||
|
|
||||||
|
``value_align`` controls the horizontal alignment of the value column in the
|
||||||
|
PDF renderer: ``"left"`` (default) keeps values next to the label column;
|
||||||
|
``"right"`` pins them to the right margin (used by the cover's analysis
|
||||||
|
summary so the numbers line up flush right).
|
||||||
|
"""
|
||||||
|
|
||||||
rows: list = field(default_factory=list)
|
rows: list = field(default_factory=list)
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
value_align: str = "left"
|
||||||
kind: str = field(default="kv_table", init=False)
|
kind: str = field(default="kv_table", init=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -145,11 +160,21 @@ class Group:
|
|||||||
a chapter can give each unit its own page — e.g. one categorical column per
|
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
|
page (see CAT DISTR). It is purely additive: the default False keeps the plain
|
||||||
keep-together behaviour for every existing chapter.
|
keep-together behaviour for every existing chapter.
|
||||||
|
|
||||||
|
``layout`` is a hint for how the group's children are arranged:
|
||||||
|
``"stack"`` (default) keeps the historical top-to-bottom flow; ``"side_by_side"``
|
||||||
|
asks the PPTX renderer to place the group's table to the LEFT and its figure to
|
||||||
|
the RIGHT of the same slide (table ~55% width, figure ~45%), measuring so both
|
||||||
|
fit and falling back to stacking when they do not. The PDF renderer treats
|
||||||
|
``"side_by_side"`` exactly like ``"stack"`` (the A5 mobile page is too narrow for
|
||||||
|
two readable columns). Unknown values degrade to ``"stack"``. Purely additive:
|
||||||
|
the default keeps every existing chapter unchanged.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
blocks: list = field(default_factory=list)
|
blocks: list = field(default_factory=list)
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
page_break_before: bool = False
|
page_break_before: bool = False
|
||||||
|
layout: str = "stack"
|
||||||
kind: str = field(default="group", init=False)
|
kind: str = field(default="group", init=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +193,22 @@ class GlossaryEntry:
|
|||||||
kind: str = field(default="glossary_entry", init=False)
|
kind: str = field(default="glossary_entry", init=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TocEntry:
|
||||||
|
"""One clickable index (table-of-contents) entry shown on the cover.
|
||||||
|
|
||||||
|
Rendered as a single line — the chapter ``label`` in the accent link colour —
|
||||||
|
that, once the document is laid out, becomes a real click jumping to the first
|
||||||
|
page/slide of the target chapter (PDF link annotation via PyMuPDF; PPTX native
|
||||||
|
slide jump). ``target_id`` is matched against each chapter's ``id`` *and* its
|
||||||
|
``title`` (the cover only knows chapter titles), so either resolves. If the
|
||||||
|
target cannot be resolved the entry still renders as plain text (never cut)."""
|
||||||
|
|
||||||
|
label: str = ""
|
||||||
|
target_id: str = ""
|
||||||
|
kind: str = field(default="toc_entry", init=False)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Chapter:
|
class Chapter:
|
||||||
"""An ordered set of blocks with an id, a title and a generation version."""
|
"""An ordered set of blocks with an id, a title and a generation version."""
|
||||||
@@ -192,13 +233,14 @@ _BLOCK_BY_KIND = {
|
|||||||
"note": Note,
|
"note": Note,
|
||||||
"group": Group,
|
"group": Group,
|
||||||
"glossary_entry": GlossaryEntry,
|
"glossary_entry": GlossaryEntry,
|
||||||
|
"toc_entry": TocEntry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def as_block(obj: Any):
|
def as_block(obj: Any):
|
||||||
"""Coerce a value into a block dataclass. Unknown values become a Note."""
|
"""Coerce a value into a block dataclass. Unknown values become a Note."""
|
||||||
if isinstance(obj, (Heading, Markdown, KVTable, DataTable, Figure, Image,
|
if isinstance(obj, (Heading, Markdown, KVTable, DataTable, Figure, Image,
|
||||||
Caption, Note, Group, GlossaryEntry)):
|
Caption, Note, Group, GlossaryEntry, TocEntry)):
|
||||||
if isinstance(obj, Group):
|
if isinstance(obj, Group):
|
||||||
obj.blocks = as_blocks(obj.blocks)
|
obj.blocks = as_blocks(obj.blocks)
|
||||||
return obj
|
return obj
|
||||||
@@ -210,13 +252,20 @@ def as_block(obj: Any):
|
|||||||
# Build only with fields the dataclass accepts (ignore extras).
|
# Build only with fields the dataclass accepts (ignore extras).
|
||||||
try:
|
try:
|
||||||
if cls is Heading:
|
if cls is Heading:
|
||||||
|
size_pt = obj.get("size_pt")
|
||||||
return Heading(text=_safe_str(obj.get("text")),
|
return Heading(text=_safe_str(obj.get("text")),
|
||||||
level=int(obj.get("level", 1) or 1))
|
level=int(obj.get("level", 1) or 1),
|
||||||
|
underline=bool(obj.get("underline", False)),
|
||||||
|
size_pt=(float(size_pt)
|
||||||
|
if isinstance(size_pt, (int, float))
|
||||||
|
else None))
|
||||||
if cls is Markdown:
|
if cls is Markdown:
|
||||||
return Markdown(text=_safe_str(obj.get("text")))
|
return Markdown(text=_safe_str(obj.get("text")))
|
||||||
if cls is KVTable:
|
if cls is KVTable:
|
||||||
return KVTable(rows=list(obj.get("rows") or []),
|
return KVTable(rows=list(obj.get("rows") or []),
|
||||||
title=obj.get("title"))
|
title=obj.get("title"),
|
||||||
|
value_align=_safe_str(
|
||||||
|
obj.get("value_align")) or "left")
|
||||||
if cls is DataTable:
|
if cls is DataTable:
|
||||||
return DataTable(header=list(obj.get("header") or []),
|
return DataTable(header=list(obj.get("header") or []),
|
||||||
rows=list(obj.get("rows") or []),
|
rows=list(obj.get("rows") or []),
|
||||||
@@ -237,11 +286,15 @@ def as_block(obj: Any):
|
|||||||
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(
|
page_break_before=bool(
|
||||||
obj.get("page_break_before", False)))
|
obj.get("page_break_before", False)),
|
||||||
|
layout=_safe_str(obj.get("layout")) or "stack")
|
||||||
if cls is GlossaryEntry:
|
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")),
|
||||||
definition=_safe_str(obj.get("definition")))
|
definition=_safe_str(obj.get("definition")))
|
||||||
|
if cls is TocEntry:
|
||||||
|
return TocEntry(label=_safe_str(obj.get("label")),
|
||||||
|
target_id=_safe_str(obj.get("target_id")))
|
||||||
except Exception: # noqa: BLE001 — never raise on a malformed block.
|
except Exception: # noqa: BLE001 — never raise on a malformed block.
|
||||||
return Note(text=_safe_str(obj))
|
return Note(text=_safe_str(obj))
|
||||||
return Note(text=_safe_str(obj))
|
return Note(text=_safe_str(obj))
|
||||||
|
|||||||
@@ -298,11 +298,16 @@ def test_cover_first_glossary_last_with_summary():
|
|||||||
headings = [b.text for b in cover.blocks if b.kind == "heading"]
|
headings = [b.text for b in cover.blocks if b.kind == "heading"]
|
||||||
assert any("Resumen" in h for h in headings), \
|
assert any("Resumen" in h for h in headings), \
|
||||||
"la portada no incluye el resumen agregado"
|
"la portada no incluye el resumen agregado"
|
||||||
# The summary reflects the body chapters (e.g. the numeric/categorical ones).
|
# The index ("Índice") is now a clickable list of TocEntry blocks (one per
|
||||||
cover_text = " ".join(
|
# body chapter), not a markdown bullet list. Verify both the heading and that
|
||||||
b.text for b in cover.blocks if getattr(b, "kind", "") == "markdown")
|
# the entries name the body chapters.
|
||||||
assert "Distribuciones" in cover_text, \
|
assert any("Índice" in h for h in headings), \
|
||||||
"el resumen de portada no menciona los capítulos del cuerpo"
|
"la portada no incluye la sección Índice"
|
||||||
|
toc_labels = " ".join(
|
||||||
|
getattr(b, "label", "") for b in cover.blocks
|
||||||
|
if getattr(b, "kind", "") == "toc_entry")
|
||||||
|
assert "Distribuciones" in toc_labels, \
|
||||||
|
"el índice de portada no menciona los capítulos del cuerpo"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -178,9 +178,17 @@ def _md_data_table(block) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _bars_table(bars: list) -> str:
|
def _bars_table(bars: list, header: tuple = ("Desde", "Hasta", "Frecuencia")) -> str:
|
||||||
"""Render extracted bar/histogram data as a Markdown table (Desde/Hasta/Frec)."""
|
"""Render extracted bar/histogram data as a Markdown table.
|
||||||
lines = ["| Desde | Hasta | Frecuencia |", "| --- | --- | --- |"]
|
|
||||||
|
``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]
|
shown = bars[:_MAX_BAR_ROWS]
|
||||||
for x0, x1, h in shown:
|
for x0, x1, h in shown:
|
||||||
lines.append(f"| {_fmt_num(x0)} | {_fmt_num(x1)} | {_fmt_num(h)} |")
|
lines.append(f"| {_fmt_num(x0)} | {_fmt_num(x1)} | {_fmt_num(h)} |")
|
||||||
@@ -191,6 +199,18 @@ def _bars_table(bars: list) -> str:
|
|||||||
return out
|
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:
|
def _extract_bars(fig) -> list:
|
||||||
"""Collect (x_from, x_to, height) of the rectangular bars of a matplotlib fig.
|
"""Collect (x_from, x_to, height) of the rectangular bars of a matplotlib fig.
|
||||||
|
|
||||||
@@ -253,7 +273,13 @@ def _md_figure(block, meta: dict, out_path: str, counter: list) -> str:
|
|||||||
if fig is not None:
|
if fig is not None:
|
||||||
bars = _extract_bars(fig)
|
bars = _extract_bars(fig)
|
||||||
if bars:
|
if bars:
|
||||||
parts.append(_bars_table(bars))
|
# A histogram's bars are genuine numeric bins (Desde/Hasta/
|
||||||
|
# Frecuencia). Bar charts and the PCA scree plot are not bins —
|
||||||
|
# give them a header that does not lie about "Frecuencia".
|
||||||
|
header = (("Desde", "Hasta", "Frecuencia")
|
||||||
|
if _is_histogram_caption(caption)
|
||||||
|
else ("Inicio", "Fin", "Valor"))
|
||||||
|
parts.append(_bars_table(bars, header))
|
||||||
if meta.get("embed_figures"):
|
if meta.get("embed_figures"):
|
||||||
png = _embed_png(fig, out_path, counter)
|
png = _embed_png(fig, out_path, counter)
|
||||||
if png:
|
if png:
|
||||||
@@ -354,6 +380,258 @@ def _serialize_block(block, meta: dict, out_path: str, counter: list) -> str:
|
|||||||
return _md_note(model.Note(text=model._safe_str(block)))
|
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.
|
# Entry point.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -437,6 +715,18 @@ def render_md(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
segments.append(seg)
|
segments.append(seg)
|
||||||
chapters_meta.append({"id": ch.id, "version": ch.version})
|
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"
|
content = "\n\n".join(segments) + "\n"
|
||||||
note = f"{len(content)} caracteres"
|
note = f"{len(content)} caracteres"
|
||||||
if notes:
|
if notes:
|
||||||
|
|||||||
@@ -46,11 +46,23 @@ _MUTED = "#8a8a8a"
|
|||||||
_RULE = "#cccccc"
|
_RULE = "#cccccc"
|
||||||
_HEAD_BG = "#eef3f6"
|
_HEAD_BG = "#eef3f6"
|
||||||
|
|
||||||
|
# Rasterization DPI for every embedded raster (figure/table image) AND for the
|
||||||
|
# page save itself. Raised from the old 150/default-100 to 220 so a reader can
|
||||||
|
# pinch-zoom on a phone and still see crisp detail (axis labels, table cells)
|
||||||
|
# without pixelation. Text stays vectorial (pdf.fonttype=42) so it remains
|
||||||
|
# selectable regardless of DPI — only the embedded images gain resolution. 220 is
|
||||||
|
# a deliberate balance: noticeably sharper than 150 while keeping the file size
|
||||||
|
# reasonable. ``savefig.dpi`` matters because matplotlib re-rasterizes each
|
||||||
|
# ``imshow`` when PdfPages writes the page; without it the final image would land
|
||||||
|
# at ~100 dpi no matter how sharp the intermediate PNG was.
|
||||||
|
_RASTER_DPI = 220
|
||||||
|
|
||||||
_RC = {
|
_RC = {
|
||||||
"font.size": 10,
|
"font.size": 10,
|
||||||
"font.family": "sans-serif",
|
"font.family": "sans-serif",
|
||||||
"figure.facecolor": "white",
|
"figure.facecolor": "white",
|
||||||
"savefig.facecolor": "white",
|
"savefig.facecolor": "white",
|
||||||
|
"savefig.dpi": _RASTER_DPI,
|
||||||
"pdf.fonttype": 42, # embed TrueType — text stays selectable on mobile.
|
"pdf.fonttype": 42, # embed TrueType — text stays selectable on mobile.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +92,10 @@ class _PdfState:
|
|||||||
# points (1/72") with a top-left origin — same convention as PyMuPDF.
|
# points (1/72") with a top-left origin — same convention as PyMuPDF.
|
||||||
self.term_sources = [] # [{key, page, rect:[x0,y0,x1,y1]}]
|
self.term_sources = [] # [{key, page, rect:[x0,y0,x1,y1]}]
|
||||||
self.term_dests = {} # key -> {page, point:[x,y]}
|
self.term_dests = {} # key -> {page, point:[x,y]}
|
||||||
|
# Clickable index (cover → chapter). Sources are the cover's TocEntry
|
||||||
|
# rects; chapter_starts maps a chapter id AND its title to its first page.
|
||||||
|
self.toc_sources = [] # [{target_id, page, rect:[x0,y0,x1,y1]}]
|
||||||
|
self.chapter_starts = {} # id|title -> {page, point:[x,y]}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -317,10 +333,18 @@ def _place_kv_table(st: _PdfState, block) -> None:
|
|||||||
if title:
|
if title:
|
||||||
_place_heading(st, model.Heading(title, level=2))
|
_place_heading(st, model.Heading(title, level=2))
|
||||||
rows = getattr(block, "rows", []) or []
|
rows = getattr(block, "rows", []) or []
|
||||||
|
# ``value_align="right"`` pins the value column to the right margin (label
|
||||||
|
# left, number flush right) — used by the cover's analysis summary.
|
||||||
|
right = str(getattr(block, "value_align", "left")).lower() == "right"
|
||||||
key_w = 1.9 # inches reserved for the label column.
|
key_w = 1.9 # inches reserved for the label column.
|
||||||
|
# Right-aligned values wrap against the full usable width minus the label
|
||||||
|
# column; left-aligned values wrap against the value column only.
|
||||||
val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY)
|
val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY)
|
||||||
lh = tl.line_height_in(_FS_BODY)
|
lh = tl.line_height_in(_FS_BODY)
|
||||||
for row in rows:
|
# ``data_idx`` is the 0-based logical row index: even rows (1-based) are
|
||||||
|
# zebra-shaded → 0-based odd indices, matching the data-table convention so
|
||||||
|
# every table in the document carries the same striping.
|
||||||
|
for data_idx, row in enumerate(rows):
|
||||||
try:
|
try:
|
||||||
label, value = row[0], row[1]
|
label, value = row[0], row[1]
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -329,11 +353,25 @@ def _place_kv_table(st: _PdfState, block) -> None:
|
|||||||
row_h = lh * len(v_lines) + _ROW_VPAD
|
row_h = lh * len(v_lines) + _ROW_VPAD
|
||||||
_ensure_space(st, row_h)
|
_ensure_space(st, row_h)
|
||||||
y0 = st.y
|
y0 = st.y
|
||||||
|
# Faint zebra fill for even rows, drawn first (zorder 0) so striping
|
||||||
|
# never hides the text/value drawn on top.
|
||||||
|
if data_idx % 2 == 1:
|
||||||
|
st.fig.add_artist(Rectangle(
|
||||||
|
(_xf(_ML), _yf(y0 + row_h)), _xf(_ML + _USABLE_W) - _xf(_ML),
|
||||||
|
_yf(y0) - _yf(y0 + row_h), transform=st.fig.transFigure,
|
||||||
|
color=_ZEBRA, lw=0, zorder=0))
|
||||||
st.fig.text(_xf(_ML), _yf(y0), tl.strip_inline_md(model._safe_str(label)),
|
st.fig.text(_xf(_ML), _yf(y0), tl.strip_inline_md(model._safe_str(label)),
|
||||||
fontsize=_FS_BODY, color=_MUTED, ha="left", va="top")
|
fontsize=_FS_BODY, color=_MUTED, ha="left", va="top",
|
||||||
|
zorder=2)
|
||||||
for k, vl in enumerate(v_lines):
|
for k, vl in enumerate(v_lines):
|
||||||
st.fig.text(_xf(_ML + key_w), _yf(y0 + k * lh), vl,
|
if right:
|
||||||
fontsize=_FS_BODY, color=_INK, ha="left", va="top")
|
st.fig.text(_xf(_ML + _USABLE_W), _yf(y0 + k * lh), vl,
|
||||||
|
fontsize=_FS_BODY, color=_INK, ha="right",
|
||||||
|
va="top", zorder=2)
|
||||||
|
else:
|
||||||
|
st.fig.text(_xf(_ML + key_w), _yf(y0 + k * lh), vl,
|
||||||
|
fontsize=_FS_BODY, color=_INK, ha="left",
|
||||||
|
va="top", zorder=2)
|
||||||
st.y = y0 + row_h
|
st.y = y0 + row_h
|
||||||
st.y += _GAP
|
st.y += _GAP
|
||||||
|
|
||||||
@@ -363,6 +401,57 @@ def _col_widths(header: list, rows: list, fs: float) -> list:
|
|||||||
return widths
|
return widths
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal legible characters reserved per column when deciding whether a table
|
||||||
|
# can be shown as selectable text. Below this width per column the cells become
|
||||||
|
# unreadable, so the table is rasterized to a zoomable high-res image instead.
|
||||||
|
_MIN_LEGIBLE_CHARS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _table_fits_as_text(header: list, rows: list) -> bool:
|
||||||
|
"""True when the table fits the usable width as readable text.
|
||||||
|
|
||||||
|
A table whose columns cannot each get a minimal legible width within the A5
|
||||||
|
usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
|
||||||
|
so it is rendered as a single high-resolution image — the reader zooms in on
|
||||||
|
the phone and reads every cell, nothing cut — instead of being squeezed until
|
||||||
|
unreadable. Narrow tables (few columns) keep the selectable-text rendering."""
|
||||||
|
header = header or []
|
||||||
|
rows = rows or []
|
||||||
|
ncol = len(header) if header else (len(rows[0]) if rows else 1)
|
||||||
|
ncol = max(1, ncol)
|
||||||
|
cw = tl.avg_char_width_in(_FS_CELL)
|
||||||
|
min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
|
||||||
|
return min_needed <= _USABLE_W
|
||||||
|
|
||||||
|
|
||||||
|
def _table_figure_block(block):
|
||||||
|
"""Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).
|
||||||
|
|
||||||
|
The table is drawn once via ``render_table_as_figure`` (header shading + zebra)
|
||||||
|
and embedded as one high-res image scaled to fit entirely. The same Figure is
|
||||||
|
reused for measuring and placing so keep-together stays consistent. The table
|
||||||
|
title/note are drawn inside the image (self-describing when zoomed/shared), so
|
||||||
|
the block-level caption is left empty to avoid a duplicate title."""
|
||||||
|
cached = getattr(block, "_aeda_tablefig", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
note = getattr(block, "note", None)
|
||||||
|
|
||||||
|
def _make():
|
||||||
|
from datascience.render_table_as_figure import render_table_as_figure
|
||||||
|
return render_table_as_figure(header, rows, title=title, note=note)
|
||||||
|
|
||||||
|
fig = model.Figure(make=_make, caption=None)
|
||||||
|
try:
|
||||||
|
block._aeda_tablefig = fig
|
||||||
|
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
|
||||||
|
pass
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def _wrap_row(cells: list, widths: list, fs: float) -> list:
|
def _wrap_row(cells: list, widths: list, fs: float) -> list:
|
||||||
"""Wrap each cell to its column width → list of line-lists per cell."""
|
"""Wrap each cell to its column width → list of line-lists per cell."""
|
||||||
out = []
|
out = []
|
||||||
@@ -402,11 +491,16 @@ def _draw_table_row(st: _PdfState, cells_lines: list, widths: list, fs: float,
|
|||||||
|
|
||||||
|
|
||||||
def _place_data_table(st: _PdfState, block) -> None:
|
def _place_data_table(st: _PdfState, block) -> None:
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
# Too many columns to be legible as text → render the whole table as one
|
||||||
|
# high-res image, scaled to fit entirely (the reader zooms to read it).
|
||||||
|
if not _table_fits_as_text(header, rows):
|
||||||
|
_place_figure(st, _table_figure_block(block))
|
||||||
|
return
|
||||||
title = getattr(block, "title", None)
|
title = getattr(block, "title", None)
|
||||||
if title:
|
if title:
|
||||||
_place_heading(st, model.Heading(title, level=2))
|
_place_heading(st, model.Heading(title, level=2))
|
||||||
header = list(getattr(block, "header", []) or [])
|
|
||||||
rows = list(getattr(block, "rows", []) or [])
|
|
||||||
fs = _FS_CELL
|
fs = _FS_CELL
|
||||||
widths = _col_widths(header, rows, fs)
|
widths = _col_widths(header, rows, fs)
|
||||||
header_lines = _wrap_row(header, widths, fs) if header else None
|
header_lines = _wrap_row(header, widths, fs) if header else None
|
||||||
@@ -464,8 +558,11 @@ def _resolve_figure(block):
|
|||||||
|
|
||||||
|
|
||||||
def _png_from_figure(fig) -> bytes:
|
def _png_from_figure(fig) -> bytes:
|
||||||
|
# ``bbox_inches='tight'`` is kept so the real aspect ratio is what we measure
|
||||||
|
# and place. The page save (savefig.dpi in _RC) re-rasterizes this at the same
|
||||||
|
# high DPI, so the embedded image stays crisp for phone zoom.
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
fig.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf.read()
|
return buf.read()
|
||||||
|
|
||||||
@@ -707,12 +804,16 @@ def _measure_data_table(block) -> float:
|
|||||||
Counts the optional title heading, the wrapped header row, every wrapped data
|
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
|
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``."""
|
uses) and the optional note. Keep this in sync with ``_place_data_table``."""
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
# Mirror the placer: a too-wide table is drawn as a single image, so its
|
||||||
|
# keep-together height is the image's, not the (squeezed) text layout's.
|
||||||
|
if not _table_fits_as_text(header, rows):
|
||||||
|
return _measure_figure_like(_table_figure_block(block))
|
||||||
h = 0.0
|
h = 0.0
|
||||||
title = getattr(block, "title", None)
|
title = getattr(block, "title", None)
|
||||||
if title:
|
if title:
|
||||||
h += _measure_heading_text(title, 2)
|
h += _measure_heading_text(title, 2)
|
||||||
header = list(getattr(block, "header", []) or [])
|
|
||||||
rows = list(getattr(block, "rows", []) or [])
|
|
||||||
fs = _FS_CELL
|
fs = _FS_CELL
|
||||||
widths = _col_widths(header, rows, fs)
|
widths = _col_widths(header, rows, fs)
|
||||||
lh = tl.line_height_in(fs)
|
lh = tl.line_height_in(fs)
|
||||||
@@ -744,6 +845,10 @@ def _measure_block(st: _PdfState, 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) + _GAP
|
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
|
||||||
|
if kind == "toc_entry":
|
||||||
|
lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
|
||||||
|
tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY)) or [""]
|
||||||
|
return tl.line_height_in(_FS_BODY) * len(lines) + _GAP * 0.4
|
||||||
if kind == "kv_table":
|
if kind == "kv_table":
|
||||||
return _measure_kv_table(block)
|
return _measure_kv_table(block)
|
||||||
if kind == "data_table":
|
if kind == "data_table":
|
||||||
@@ -828,6 +933,38 @@ def _place_glossary_entry(st: _PdfState, block) -> None:
|
|||||||
st.y += _GAP * 0.5
|
st.y += _GAP * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _place_toc_entry(st: _PdfState, block) -> None:
|
||||||
|
"""Render one clickable index line and record it as a link source.
|
||||||
|
|
||||||
|
Drawn as a bulleted line in the accent link colour; its rectangle is recorded
|
||||||
|
in ``st.toc_sources`` so the post-processor turns it into a real jump to the
|
||||||
|
target chapter's first page. If the target is never resolved the line still
|
||||||
|
shows as plain (accent) text — never cut, never broken."""
|
||||||
|
label = tl.strip_inline_md(getattr(block, "label", "")) or ""
|
||||||
|
target_id = getattr(block, "target_id", "") or ""
|
||||||
|
fs = _FS_BODY
|
||||||
|
lh = tl.line_height_in(fs)
|
||||||
|
bullet = "• "
|
||||||
|
indent = 0.22
|
||||||
|
max_chars = tl.chars_per_line(_USABLE_W - indent, fs)
|
||||||
|
lines = tl.wrap(label, max_chars) or [""]
|
||||||
|
for idx, ln in enumerate(lines):
|
||||||
|
_ensure_space(st, lh)
|
||||||
|
x = _ML
|
||||||
|
st.fig.text(_xf(x), _yf(st.y), bullet if idx == 0 else " ",
|
||||||
|
fontsize=fs, color=_LINK, ha="left", va="top")
|
||||||
|
x += indent
|
||||||
|
w = _text_width_in(st, ln, fs, False)
|
||||||
|
st.fig.text(_xf(x), _yf(st.y), ln, fontsize=fs, color=_LINK,
|
||||||
|
ha="left", va="top")
|
||||||
|
if target_id and idx == 0:
|
||||||
|
st.toc_sources.append({
|
||||||
|
"target_id": target_id, "page": st.page - 1,
|
||||||
|
"rect": _pt_rect(_ML, st.y, x + w, st.y + lh)})
|
||||||
|
st.y += lh
|
||||||
|
st.y += _GAP * 0.4
|
||||||
|
|
||||||
|
|
||||||
_PLACERS = {
|
_PLACERS = {
|
||||||
"heading": _place_heading,
|
"heading": _place_heading,
|
||||||
"markdown": _place_markdown,
|
"markdown": _place_markdown,
|
||||||
@@ -839,6 +976,7 @@ _PLACERS = {
|
|||||||
"note": _place_note,
|
"note": _place_note,
|
||||||
"group": _place_group,
|
"group": _place_group,
|
||||||
"glossary_entry": _place_glossary_entry,
|
"glossary_entry": _place_glossary_entry,
|
||||||
|
"toc_entry": _place_toc_entry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -870,6 +1008,15 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
st.chapter = ch
|
st.chapter = ch
|
||||||
st.chapter_pages = 0
|
st.chapter_pages = 0
|
||||||
_new_page(st) # each chapter starts on a fresh page.
|
_new_page(st) # each chapter starts on a fresh page.
|
||||||
|
# Record this chapter's first page as a link target for the
|
||||||
|
# cover index (keyed by id AND title, since the cover only
|
||||||
|
# knows titles). Point is the top of the content area.
|
||||||
|
_start = {"page": st.page - 1,
|
||||||
|
"point": [_ML * 72.0, _CONTENT_TOP * 72.0]}
|
||||||
|
if ch.id:
|
||||||
|
st.chapter_starts[ch.id] = _start
|
||||||
|
if getattr(ch, "title", ""):
|
||||||
|
st.chapter_starts.setdefault(ch.title, _start)
|
||||||
for block in ch.blocks:
|
for block in ch.blocks:
|
||||||
placer = _PLACERS.get(getattr(block, "kind", ""),
|
placer = _PLACERS.get(getattr(block, "kind", ""),
|
||||||
_place_note)
|
_place_note)
|
||||||
@@ -902,7 +1049,7 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
|
|
||||||
note = f"{n_pages} páginas"
|
note = f"{n_pages} páginas"
|
||||||
if n_links:
|
if n_links:
|
||||||
note += f" · {n_links} enlaces de glosario"
|
note += f" · {n_links} enlaces internos"
|
||||||
if notes:
|
if notes:
|
||||||
note += " · " + "; ".join(notes)
|
note += " · " + "; ".join(notes)
|
||||||
return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta,
|
return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta,
|
||||||
@@ -910,9 +1057,11 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
||||||
"""Build {source rect → glossary dest} links and apply them via PyMuPDF.
|
"""Apply internal PDF links via PyMuPDF: glossary terms + the cover index.
|
||||||
|
|
||||||
Returns the number of links applied (0 if there is nothing to wire or the
|
Builds two sets of GOTO links — every in-text glossary term → its entry, and
|
||||||
|
every cover ``TocEntry`` → its chapter's first page — and applies them in one
|
||||||
|
pass. Returns the number of links applied (0 if there is nothing to wire or the
|
||||||
post-processor is unavailable). Never raises."""
|
post-processor is unavailable). Never raises."""
|
||||||
try:
|
try:
|
||||||
links = []
|
links = []
|
||||||
@@ -923,6 +1072,14 @@ def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
|||||||
links.append({
|
links.append({
|
||||||
"src_page": src["page"], "src_rect": src["rect"],
|
"src_page": src["page"], "src_rect": src["rect"],
|
||||||
"dst_page": dest["page"], "dst_point": dest["point"]})
|
"dst_page": dest["page"], "dst_point": dest["point"]})
|
||||||
|
# Cover index → chapter first page (clickable, navigable table of contents).
|
||||||
|
for src in st.toc_sources:
|
||||||
|
dest = st.chapter_starts.get(src.get("target_id"))
|
||||||
|
if not dest:
|
||||||
|
continue
|
||||||
|
links.append({
|
||||||
|
"src_page": src["page"], "src_rect": src["rect"],
|
||||||
|
"dst_page": dest["page"], "dst_point": dest["point"]})
|
||||||
if not links:
|
if not links:
|
||||||
return 0
|
return 0
|
||||||
from datascience.add_pdf_internal_links import add_pdf_internal_links
|
from datascience.add_pdf_internal_links import add_pdf_internal_links
|
||||||
@@ -930,7 +1087,7 @@ def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
|
|||||||
if isinstance(res, dict) and res.get("status") == "ok":
|
if isinstance(res, dict) and res.get("status") == "ok":
|
||||||
return int(res.get("n_links") or 0)
|
return int(res.get("n_links") or 0)
|
||||||
if isinstance(res, dict) and res.get("error"):
|
if isinstance(res, dict) and res.get("error"):
|
||||||
notes.append(f"glosario sin enlaces: {res.get('error')}")
|
notes.append(f"enlaces internos no aplicados: {res.get('error')}")
|
||||||
except Exception as e: # noqa: BLE001 — links are best-effort.
|
except Exception as e: # noqa: BLE001 — links are best-effort.
|
||||||
notes.append(f"glosario sin enlaces: {e}")
|
notes.append(f"enlaces internos no aplicados: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ _FS_H1, _FS_H2, _FS_H3 = 20, 16, 13
|
|||||||
_FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11
|
_FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11
|
||||||
_GAP = 0.12
|
_GAP = 0.12
|
||||||
|
|
||||||
|
# Rasterization DPI for every embedded figure/table image. Raised from 150 to 220
|
||||||
|
# so a viewer can zoom into a slide (or a shared picture) and read crisp detail —
|
||||||
|
# axis labels, table cells — without pixelation. Kept moderate so the deck size
|
||||||
|
# stays reasonable. Same value as the PDF renderer.
|
||||||
|
_RASTER_DPI = 220
|
||||||
|
|
||||||
|
|
||||||
class _PptxState:
|
class _PptxState:
|
||||||
def __init__(self, prs, title: str):
|
def __init__(self, prs, title: str):
|
||||||
@@ -65,6 +71,10 @@ class _PptxState:
|
|||||||
# Glossary wiring (mejora 6): runs to link and per-term target slide.
|
# Glossary wiring (mejora 6): runs to link and per-term target slide.
|
||||||
self.term_runs = [] # [(key, run)]
|
self.term_runs = [] # [(key, run)]
|
||||||
self.term_anchor_slide = {} # key -> Slide (glossary entry)
|
self.term_anchor_slide = {} # key -> Slide (glossary entry)
|
||||||
|
# Clickable index (cover → chapter). toc_runs are the cover's index runs;
|
||||||
|
# chapter_starts maps a chapter id AND its title to its first slide.
|
||||||
|
self.toc_runs = [] # [(target_id, run, src_slide)]
|
||||||
|
self.chapter_starts = {} # id|title -> Slide (chapter first slide)
|
||||||
|
|
||||||
|
|
||||||
def _rgb(c):
|
def _rgb(c):
|
||||||
@@ -135,7 +145,7 @@ def _ensure(st: _PptxState, height: float) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
|
def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
|
||||||
italic=False, indent=0.0, bullet=False) -> None:
|
italic=False, indent=0.0, bullet=False, underline=False) -> None:
|
||||||
lh = tl.line_height_in(fs)
|
lh = tl.line_height_in(fs)
|
||||||
height = lh * len(lines) + 0.05
|
height = lh * len(lines) + 0.05
|
||||||
_ensure(st, height)
|
_ensure(st, height)
|
||||||
@@ -153,6 +163,7 @@ def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
|
|||||||
run.font.size = Pt(fs)
|
run.font.size = Pt(fs)
|
||||||
run.font.bold = bold
|
run.font.bold = bold
|
||||||
run.font.italic = italic
|
run.font.italic = italic
|
||||||
|
run.font.underline = underline
|
||||||
run.font.color.rgb = _rgb(color)
|
run.font.color.rgb = _rgb(color)
|
||||||
st.y += height
|
st.y += height
|
||||||
|
|
||||||
@@ -206,10 +217,16 @@ def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
|
|||||||
def _place_heading(st: _PptxState, block) -> None:
|
def _place_heading(st: _PptxState, block) -> None:
|
||||||
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
|
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
|
||||||
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
||||||
|
# Optional per-heading emphasis (cover dataset name): a larger font and an
|
||||||
|
# underline. ``size_pt`` overrides the per-level size when set.
|
||||||
|
size_override = getattr(block, "size_pt", None)
|
||||||
|
if isinstance(size_override, (int, float)) and size_override > 0:
|
||||||
|
fs = float(size_override)
|
||||||
|
underline = bool(getattr(block, "underline", False))
|
||||||
text = tl.strip_inline_md(getattr(block, "text", ""))
|
text = tl.strip_inline_md(getattr(block, "text", ""))
|
||||||
st.last_heading = text or st.last_heading
|
st.last_heading = text or st.last_heading
|
||||||
lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
|
lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
|
||||||
_add_text(st, lines, fs, _INK, bold=True)
|
_add_text(st, lines, fs, _INK, bold=True, underline=underline)
|
||||||
st.y += 0.04
|
st.y += 0.04
|
||||||
|
|
||||||
|
|
||||||
@@ -302,6 +319,58 @@ def _col_widths(header, rows):
|
|||||||
return [_USABLE_W * w / total for w in clamped]
|
return [_USABLE_W * w / total for w in clamped]
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal legible characters reserved per column when deciding whether a table
|
||||||
|
# can be shown as a native (selectable) PowerPoint table. Below this width per
|
||||||
|
# column the cells become unreadable, so the table is rasterized to a zoomable
|
||||||
|
# high-res image instead. The 16:9 slide is wide, so more columns fit than on A5.
|
||||||
|
_MIN_LEGIBLE_CHARS = 8
|
||||||
|
_CELL_PAD = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _table_fits_as_text(header: list, rows: list) -> bool:
|
||||||
|
"""True when the table fits the usable slide width as a readable table.
|
||||||
|
|
||||||
|
A table whose columns cannot each get a minimal legible width within the slide
|
||||||
|
usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
|
||||||
|
so it is rendered as one high-resolution image — the viewer zooms in and reads
|
||||||
|
every cell — instead of being squeezed unreadable. Narrow tables keep the
|
||||||
|
native selectable table."""
|
||||||
|
header = header or []
|
||||||
|
rows = rows or []
|
||||||
|
ncol = len(header) if header else (len(rows[0]) if rows else 1)
|
||||||
|
ncol = max(1, ncol)
|
||||||
|
cw = tl.avg_char_width_in(_FS_CELL)
|
||||||
|
min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
|
||||||
|
return min_needed <= _USABLE_W
|
||||||
|
|
||||||
|
|
||||||
|
def _table_figure_block(block):
|
||||||
|
"""Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).
|
||||||
|
|
||||||
|
Drawn once via ``render_table_as_figure`` (header shading + zebra) and embedded
|
||||||
|
as one high-res image scaled to fit entirely. The title/note are drawn inside
|
||||||
|
the image (self-describing when zoomed/shared), so no separate caption is
|
||||||
|
emitted. Reused for measuring and placing so keep-together stays consistent."""
|
||||||
|
cached = getattr(block, "_aeda_tablefig", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
title = getattr(block, "title", None)
|
||||||
|
note = getattr(block, "note", None)
|
||||||
|
|
||||||
|
def _make():
|
||||||
|
from datascience.render_table_as_figure import render_table_as_figure
|
||||||
|
return render_table_as_figure(header, rows, title=title, note=note)
|
||||||
|
|
||||||
|
fig = model.Figure(make=_make, caption=None)
|
||||||
|
try:
|
||||||
|
block._aeda_tablefig = fig
|
||||||
|
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
|
||||||
|
pass
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def _row_height_in(cells, widths, fs) -> float:
|
def _row_height_in(cells, widths, fs) -> float:
|
||||||
lh = tl.line_height_in(fs)
|
lh = tl.line_height_in(fs)
|
||||||
maxlines = 1
|
maxlines = 1
|
||||||
@@ -365,11 +434,27 @@ def _style_cell(cell, fs, color, bold, fill) -> None:
|
|||||||
|
|
||||||
def _place_data_table(st: _PptxState, block, shaded_header=True,
|
def _place_data_table(st: _PptxState, block, shaded_header=True,
|
||||||
key_value=False) -> None:
|
key_value=False) -> None:
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
# Too many columns to be legible as a native table → render the whole table as
|
||||||
|
# one high-res picture, scaled to fit entirely (the viewer zooms to read it).
|
||||||
|
# KVTables (rendered here as a 2-column Campo/Valor table) are excluded: they
|
||||||
|
# always fit in width and stay as a selectable table.
|
||||||
|
if not key_value and not _table_fits_as_text(header, rows):
|
||||||
|
figblock = _table_figure_block(block)
|
||||||
|
data, _asp = _figure_bytes_cached(figblock)
|
||||||
|
if data is None:
|
||||||
|
_add_text(st, ["(tabla no disponible)"], _FS_NOTE, _MUTED,
|
||||||
|
italic=True)
|
||||||
|
st.y += _GAP
|
||||||
|
return
|
||||||
|
_place_picture_bytes(st, data, None,
|
||||||
|
max_h_in=getattr(figblock, "height_in", None),
|
||||||
|
force_caption=False)
|
||||||
|
return
|
||||||
title = getattr(block, "title", None)
|
title = getattr(block, "title", None)
|
||||||
if title:
|
if title:
|
||||||
_place_heading(st, model.Heading(title, level=2))
|
_place_heading(st, model.Heading(title, level=2))
|
||||||
header = list(getattr(block, "header", []) or [])
|
|
||||||
rows = list(getattr(block, "rows", []) or [])
|
|
||||||
fs = _FS_CELL
|
fs = _FS_CELL
|
||||||
widths = _col_widths(header, rows)
|
widths = _col_widths(header, rows)
|
||||||
header_h = _row_height_in(header, widths, fs) if header else 0.0
|
header_h = _row_height_in(header, widths, fs) if header else 0.0
|
||||||
@@ -429,7 +514,7 @@ def _resolve_png(block):
|
|||||||
try:
|
try:
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
f.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
f.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf.read()
|
return buf.read()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -476,12 +561,15 @@ def _figure_bytes_cached(block):
|
|||||||
|
|
||||||
|
|
||||||
def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
||||||
max_h_in=None) -> None:
|
max_h_in=None, force_caption=True) -> None:
|
||||||
# Mejora 4 — every figure on a slide carries a visible caption/title. If the
|
# Mejora 4 — every figure on a slide carries a visible caption/title. If the
|
||||||
# block has no caption, fall back to the current section heading, then to a
|
# block has no caption, fall back to the current section heading, then to a
|
||||||
# generic label, so no image is ever shown untitled.
|
# generic label, so no image is ever shown untitled. ``force_caption=False``
|
||||||
caption = (model._safe_str(caption).strip()
|
# suppresses that fallback (used for table images, whose title is inside the
|
||||||
or model._safe_str(st.last_heading).strip() or "Figura")
|
# picture) so no redundant caption is drawn.
|
||||||
|
caption = model._safe_str(caption).strip()
|
||||||
|
if not caption and force_caption:
|
||||||
|
caption = model._safe_str(st.last_heading).strip() or "Figura"
|
||||||
w_px, h_px = _img_size_px(data)
|
w_px, h_px = _img_size_px(data)
|
||||||
aspect = (h_px / w_px) if w_px else 0.66
|
aspect = (h_px / w_px) if w_px else 0.66
|
||||||
# Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
|
# Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
|
||||||
@@ -489,9 +577,11 @@ def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
|||||||
# so its caption always fits on the SAME slide and no image is untitled.
|
# so its caption always fits on the SAME slide and no image is untitled.
|
||||||
# cap_real = what _add_text consumes; cap_reserve adds the post-image gap and
|
# cap_real = what _add_text consumes; cap_reserve adds the post-image gap and
|
||||||
# a small cushion so the caption never spills to the next slide.
|
# a small cushion so the caption never spills to the next slide.
|
||||||
cap_lines = tl.wrap(caption, tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
cap_lines = tl.wrap(caption, tl.chars_per_line(_USABLE_W, _FS_NOTE)) \
|
||||||
cap_real = tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05
|
if caption else []
|
||||||
cap_reserve = cap_real + 0.05 + 0.10
|
cap_real = (tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05) \
|
||||||
|
if cap_lines else 0.0
|
||||||
|
cap_reserve = (cap_real + 0.05 + 0.10) if cap_lines else 0.05
|
||||||
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
|
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||||
# height_in hint (model.Figure/Image): cap the target height so a figure in a
|
# height_in hint (model.Figure/Image): cap the target height so a figure in a
|
||||||
# keep-together Group shrinks to leave room for its heading and text.
|
# keep-together Group shrinks to leave room for its heading and text.
|
||||||
@@ -510,7 +600,8 @@ def _place_picture_bytes(st: _PptxState, data: bytes, caption,
|
|||||||
st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y),
|
st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y),
|
||||||
width=Inches(target_w), height=Inches(target_h))
|
width=Inches(target_w), height=Inches(target_h))
|
||||||
st.y += target_h + 0.05
|
st.y += target_h + 0.05
|
||||||
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
|
if cap_lines:
|
||||||
|
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
|
||||||
st.y += _GAP
|
st.y += _GAP
|
||||||
|
|
||||||
|
|
||||||
@@ -552,9 +643,11 @@ def _place_note(st: _PptxState, block) -> None:
|
|||||||
# WITHOUT drawing it so a Group can move whole to the next slide before drawing.
|
# WITHOUT drawing it so a Group can move whole to the next slide before drawing.
|
||||||
# Over-estimating only triggers an earlier slide break, never a content cut.
|
# Over-estimating only triggers an earlier slide break, never a content cut.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _measure_heading_text(text: str, level: int) -> float:
|
def _measure_heading_text(text: str, level: int, size_pt=None) -> float:
|
||||||
level = max(1, min(3, int(level or 1)))
|
level = max(1, min(3, int(level or 1)))
|
||||||
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
|
||||||
|
if isinstance(size_pt, (int, float)) and size_pt > 0:
|
||||||
|
fs = float(size_pt)
|
||||||
lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs))
|
lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs))
|
||||||
return tl.line_height_in(fs) * len(lines) + 0.05 + 0.04
|
return tl.line_height_in(fs) * len(lines) + 0.05 + 0.04
|
||||||
|
|
||||||
@@ -654,12 +747,16 @@ def _measure_kv_table(block) -> float:
|
|||||||
def _measure_data_table(block) -> float:
|
def _measure_data_table(block) -> float:
|
||||||
"""Faithful DataTable height — matches ``_place_data_table`` (title heading +
|
"""Faithful DataTable height — matches ``_place_data_table`` (title heading +
|
||||||
wrapped header + every wrapped row + optional note). Keep in sync."""
|
wrapped header + every wrapped row + optional note). Keep in sync."""
|
||||||
|
header = list(getattr(block, "header", []) or [])
|
||||||
|
rows = list(getattr(block, "rows", []) or [])
|
||||||
|
# Mirror the placer: a too-wide table is drawn as one image, so its
|
||||||
|
# keep-together height is the image's, not the (squeezed) table layout's.
|
||||||
|
if not _table_fits_as_text(header, rows):
|
||||||
|
return _measure_figure_like(_table_figure_block(block))
|
||||||
h = 0.0
|
h = 0.0
|
||||||
title = getattr(block, "title", None)
|
title = getattr(block, "title", None)
|
||||||
if title:
|
if title:
|
||||||
h += _measure_heading_text(title, 2)
|
h += _measure_heading_text(title, 2)
|
||||||
header = list(getattr(block, "header", []) or [])
|
|
||||||
rows = list(getattr(block, "rows", []) or [])
|
|
||||||
fs = _FS_CELL
|
fs = _FS_CELL
|
||||||
widths = _col_widths(header, rows)
|
widths = _col_widths(header, rows)
|
||||||
if header:
|
if header:
|
||||||
@@ -679,7 +776,8 @@ def _measure_block(st: _PptxState, block) -> float:
|
|||||||
try:
|
try:
|
||||||
if kind == "heading":
|
if kind == "heading":
|
||||||
return _measure_heading_text(getattr(block, "text", ""),
|
return _measure_heading_text(getattr(block, "text", ""),
|
||||||
getattr(block, "level", 1))
|
getattr(block, "level", 1),
|
||||||
|
size_pt=getattr(block, "size_pt", None))
|
||||||
if kind == "markdown":
|
if kind == "markdown":
|
||||||
return _measure_markdown(block)
|
return _measure_markdown(block)
|
||||||
if kind in ("figure", "image"):
|
if kind in ("figure", "image"):
|
||||||
@@ -688,6 +786,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 == "toc_entry":
|
||||||
|
lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
|
||||||
|
tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY)) or [""]
|
||||||
|
return tl.line_height_in(_FS_BODY) * len(lines) + 0.05
|
||||||
if kind == "kv_table":
|
if kind == "kv_table":
|
||||||
return _measure_kv_table(block)
|
return _measure_kv_table(block)
|
||||||
if kind == "data_table":
|
if kind == "data_table":
|
||||||
@@ -800,6 +902,73 @@ def _fit_group_blocks(st: _PptxState, blocks: list, avail_full: float) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_img(width_col: float, aspect: float, max_h: float):
|
||||||
|
"""Scale an image to ``width_col`` then clamp to ``max_h`` keeping aspect."""
|
||||||
|
w = width_col
|
||||||
|
h = w * aspect
|
||||||
|
if h > max_h:
|
||||||
|
h = max_h
|
||||||
|
w = (h / aspect) if aspect else width_col
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _place_group_side_by_side(st: _PptxState, block, avail_full: float) -> bool:
|
||||||
|
"""Place a Group's table (left ~55%) next to its figure (right ~45%).
|
||||||
|
|
||||||
|
Both the table and the figure are rasterized to high-res images and placed in
|
||||||
|
two columns of the SAME slide; any other blocks (e.g. a heading) render full
|
||||||
|
width above the pair, the rest below. Returns True on success; returns False
|
||||||
|
(so the caller falls back to stacking) when the group has no table+figure pair
|
||||||
|
or the pair cannot fit side by side on one slide. Never raises by itself."""
|
||||||
|
blocks = getattr(block, "blocks", []) or []
|
||||||
|
tbl = next((b for b in blocks
|
||||||
|
if getattr(b, "kind", "") in ("data_table", "kv_table")), None)
|
||||||
|
fig = next((b for b in blocks
|
||||||
|
if getattr(b, "kind", "") in ("figure", "image")), None)
|
||||||
|
if tbl is None or fig is None:
|
||||||
|
return False
|
||||||
|
gap_col = 0.3
|
||||||
|
left_w = _USABLE_W * 0.55 - gap_col / 2.0
|
||||||
|
right_w = _USABLE_W * 0.45 - gap_col / 2.0
|
||||||
|
if left_w <= 1.0 or right_w <= 1.0:
|
||||||
|
return False
|
||||||
|
tdata, tasp = _figure_bytes_cached(_table_figure_block(tbl))
|
||||||
|
fdata, fasp = _figure_bytes_cached(fig)
|
||||||
|
if not tdata or not fdata:
|
||||||
|
return False
|
||||||
|
ti, fi = blocks.index(tbl), blocks.index(fig)
|
||||||
|
lo = min(ti, fi)
|
||||||
|
lead = list(blocks[:lo])
|
||||||
|
rest = [b for b in blocks[lo + 1:] if b is not tbl and b is not fig]
|
||||||
|
lead_h = sum(_measure_block(st, b) for b in lead)
|
||||||
|
rest_h = sum(_measure_block(st, b) for b in rest)
|
||||||
|
col_max_h = avail_full - lead_h - rest_h - _GAP * 2
|
||||||
|
if col_max_h < 1.2:
|
||||||
|
return False # not enough vertical room to put the pair side by side.
|
||||||
|
tw, th = _fit_img(left_w, tasp, col_max_h)
|
||||||
|
fw, fh = _fit_img(right_w, fasp, col_max_h)
|
||||||
|
band = max(th, fh)
|
||||||
|
needed = lead_h + band + rest_h + _GAP * 2
|
||||||
|
if needed > avail_full:
|
||||||
|
return False # taller than a whole slide even side by side → stack.
|
||||||
|
if needed > _remaining(st):
|
||||||
|
_new_slide(st, cont=True)
|
||||||
|
for b in lead:
|
||||||
|
_PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
|
||||||
|
top = st.y
|
||||||
|
f_left = _ML + left_w + gap_col
|
||||||
|
st.slide.shapes.add_picture(
|
||||||
|
io.BytesIO(tdata), Inches(_ML + (left_w - tw) / 2.0),
|
||||||
|
Inches(top + (band - th) / 2.0), width=Inches(tw), height=Inches(th))
|
||||||
|
st.slide.shapes.add_picture(
|
||||||
|
io.BytesIO(fdata), Inches(f_left + (right_w - fw) / 2.0),
|
||||||
|
Inches(top + (band - fh) / 2.0), width=Inches(fw), height=Inches(fh))
|
||||||
|
st.y = top + band + _GAP
|
||||||
|
for b in rest:
|
||||||
|
_PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _place_group(st: _PptxState, block) -> None:
|
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 []
|
||||||
@@ -810,6 +979,14 @@ def _place_group(st: _PptxState, block) -> None:
|
|||||||
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
||||||
_new_slide(st, cont=True)
|
_new_slide(st, cont=True)
|
||||||
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||||
|
# layout="side_by_side": try table-left / figure-right on one slide; on any
|
||||||
|
# reason it can't, fall through to the normal stacked keep-together below.
|
||||||
|
if str(getattr(block, "layout", "stack")).lower() == "side_by_side":
|
||||||
|
try:
|
||||||
|
if _place_group_side_by_side(st, block, avail_full):
|
||||||
|
return
|
||||||
|
except Exception: # noqa: BLE001 — degrade to stacking, never abort.
|
||||||
|
pass
|
||||||
# Trim oversized tables first (keeps the chart on the same slide), then shrink
|
# Trim oversized tables first (keeps the chart on the same slide), then shrink
|
||||||
# the figure to share the remaining room.
|
# the figure to share the remaining room.
|
||||||
blocks = _fit_group_blocks(st, blocks, avail_full)
|
blocks = _fit_group_blocks(st, blocks, avail_full)
|
||||||
@@ -843,6 +1020,44 @@ def _place_glossary_entry(st: _PptxState, block) -> None:
|
|||||||
st.y += _GAP
|
st.y += _GAP
|
||||||
|
|
||||||
|
|
||||||
|
def _place_toc_entry(st: _PptxState, block) -> None:
|
||||||
|
"""Render one clickable index line and record its run as a link source.
|
||||||
|
|
||||||
|
Drawn as a bulleted line in the accent link colour; the run is recorded in
|
||||||
|
``st.toc_runs`` so it later becomes a native slide-jump to the target chapter's
|
||||||
|
first slide. If the target is never resolved the line still shows as plain
|
||||||
|
(accent) text — never cut."""
|
||||||
|
label = tl.strip_inline_md(getattr(block, "label", "")) or ""
|
||||||
|
target_id = getattr(block, "target_id", "") or ""
|
||||||
|
fs = _FS_BODY
|
||||||
|
lines = tl.wrap(label, tl.chars_per_line(_USABLE_W - 0.3, fs)) or [""]
|
||||||
|
lh = tl.line_height_in(fs)
|
||||||
|
height = lh * len(lines) + 0.05
|
||||||
|
_ensure(st, height)
|
||||||
|
box = st.slide.shapes.add_textbox(
|
||||||
|
Inches(_ML), Inches(st.y), Inches(_USABLE_W), Inches(height))
|
||||||
|
tf = box.text_frame
|
||||||
|
tf.word_wrap = True
|
||||||
|
first = True
|
||||||
|
link_run = None
|
||||||
|
for idx, ln in enumerate(lines):
|
||||||
|
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
||||||
|
first = False
|
||||||
|
r0 = p.add_run()
|
||||||
|
r0.text = "• " if idx == 0 else " "
|
||||||
|
r0.font.size = Pt(fs)
|
||||||
|
r0.font.color.rgb = _rgb(_LINK)
|
||||||
|
run = p.add_run()
|
||||||
|
run.text = ln
|
||||||
|
run.font.size = Pt(fs)
|
||||||
|
run.font.color.rgb = _rgb(_LINK)
|
||||||
|
if idx == 0:
|
||||||
|
link_run = run
|
||||||
|
if target_id and link_run is not None:
|
||||||
|
st.toc_runs.append((target_id, link_run, st.slide))
|
||||||
|
st.y += height
|
||||||
|
|
||||||
|
|
||||||
_PLACERS = {
|
_PLACERS = {
|
||||||
"heading": _place_heading,
|
"heading": _place_heading,
|
||||||
"markdown": _place_markdown,
|
"markdown": _place_markdown,
|
||||||
@@ -854,6 +1069,7 @@ _PLACERS = {
|
|||||||
"note": _place_note,
|
"note": _place_note,
|
||||||
"group": _place_group,
|
"group": _place_group,
|
||||||
"glossary_entry": _place_glossary_entry,
|
"glossary_entry": _place_glossary_entry,
|
||||||
|
"toc_entry": _place_toc_entry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -889,6 +1105,12 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
st.chapter = ch
|
st.chapter = ch
|
||||||
st.chapter_slides = 0
|
st.chapter_slides = 0
|
||||||
_new_slide(st, cont=False)
|
_new_slide(st, cont=False)
|
||||||
|
# Record this chapter's first slide as a link target for the cover
|
||||||
|
# index (keyed by id AND title, since the cover only knows titles).
|
||||||
|
if ch.id:
|
||||||
|
st.chapter_starts[ch.id] = st.slide
|
||||||
|
if getattr(ch, "title", ""):
|
||||||
|
st.chapter_starts.setdefault(ch.title, st.slide)
|
||||||
for block in ch.blocks:
|
for block in ch.blocks:
|
||||||
placer = _PLACERS.get(getattr(block, "kind", ""), _place_note)
|
placer = _PLACERS.get(getattr(block, "kind", ""), _place_note)
|
||||||
try:
|
try:
|
||||||
@@ -916,7 +1138,7 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
|
|
||||||
note = f"{n_slides} slides"
|
note = f"{n_slides} slides"
|
||||||
if n_links:
|
if n_links:
|
||||||
note += f" · {n_links} enlaces de glosario"
|
note += f" · {n_links} enlaces internos"
|
||||||
if notes:
|
if notes:
|
||||||
note += " · " + "; ".join(notes)
|
note += " · " + "; ".join(notes)
|
||||||
return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
|
return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
|
||||||
@@ -924,19 +1146,21 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _wire_glossary_links(st: _PptxState, notes: list) -> int:
|
def _wire_glossary_links(st: _PptxState, notes: list) -> int:
|
||||||
"""Turn each recorded term run into a native jump to its glossary slide.
|
"""Apply native slide-jumps: glossary terms + the cover index.
|
||||||
|
|
||||||
Returns the number of links applied. A term whose only appearance is inside
|
Each in-text glossary term run jumps to its glossary entry slide, and each
|
||||||
its own glossary entry (source slide == target slide) is skipped. Never
|
cover ``TocEntry`` run jumps to its chapter's first slide. Returns the total
|
||||||
|
number of links applied. A run whose target is its own slide is skipped. Never
|
||||||
raises."""
|
raises."""
|
||||||
if not st.term_runs or not st.term_anchor_slide:
|
if not (st.term_runs and st.term_anchor_slide) and not (
|
||||||
|
st.toc_runs and st.chapter_starts):
|
||||||
return 0
|
return 0
|
||||||
linked = 0
|
|
||||||
try:
|
try:
|
||||||
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
|
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
notes.append(f"glosario sin enlaces: {e}")
|
notes.append(f"enlaces internos no aplicados: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
linked = 0
|
||||||
for key, run, src_slide in st.term_runs:
|
for key, run, src_slide in st.term_runs:
|
||||||
tgt = st.term_anchor_slide.get(key)
|
tgt = st.term_anchor_slide.get(key)
|
||||||
if tgt is None or tgt is src_slide:
|
if tgt is None or tgt is src_slide:
|
||||||
@@ -946,4 +1170,14 @@ def _wire_glossary_links(st: _PptxState, notes: list) -> int:
|
|||||||
linked += 1
|
linked += 1
|
||||||
except Exception: # noqa: BLE001 — links are best-effort.
|
except Exception: # noqa: BLE001 — links are best-effort.
|
||||||
pass
|
pass
|
||||||
|
# Cover index → chapter first slide (clickable, navigable table of contents).
|
||||||
|
for target_id, run, src_slide in st.toc_runs:
|
||||||
|
tgt = st.chapter_starts.get(target_id)
|
||||||
|
if tgt is None or tgt is src_slide:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if pptx_link_run_to_slide(run, src_slide, tgt):
|
||||||
|
linked += 1
|
||||||
|
except Exception: # noqa: BLE001 — links are best-effort.
|
||||||
|
pass
|
||||||
return linked
|
return linked
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Golden tests for the global render-quality features (issue: eda-render-quality).
|
||||||
|
|
||||||
|
Covers, with executable evidence:
|
||||||
|
* High DPI: every embedded figure is rasterized at 220 dpi, so a phone reader
|
||||||
|
can zoom in and still see crisp detail.
|
||||||
|
* Wide table → image: a table too wide to be legible as text (e.g. a 19-column
|
||||||
|
df.head) is rendered as one high-res image that scales to fit entirely, while
|
||||||
|
a narrow table keeps its selectable-text/native-table rendering.
|
||||||
|
* ``Group(layout="side_by_side")``: in PPTX the table and figure are placed in
|
||||||
|
two columns of the same slide; in PDF the same group stacks vertically.
|
||||||
|
* Backward compatibility: a Group without ``layout`` defaults to ``"stack"`` and
|
||||||
|
a fitting table renders exactly as before.
|
||||||
|
|
||||||
|
Renderers are invoked for real; PDFs are inspected with PyMuPDF and PPTX decks
|
||||||
|
with python-pptx.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from datascience.automatic_eda import model # noqa: E402
|
||||||
|
from datascience.automatic_eda.render_pdf_impl import ( # noqa: E402
|
||||||
|
render_pdf, _RASTER_DPI as _PDF_DPI, _table_fits_as_text as _pdf_fits)
|
||||||
|
from datascience.automatic_eda.render_pptx_impl import ( # noqa: E402
|
||||||
|
render_pptx, _RASTER_DPI as _PPTX_DPI, _table_fits_as_text as _pptx_fits)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _simple_fig():
|
||||||
|
"""A small, real matplotlib figure for the figure blocks."""
|
||||||
|
fig, ax = plt.subplots(figsize=(4, 3))
|
||||||
|
ax.plot([0, 1, 2, 3], [1, 3, 2, 4])
|
||||||
|
ax.set_title("demo")
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def _wide_table(n_cols=19, n_rows=5):
|
||||||
|
header = [f"columna_{i}" for i in range(n_cols)]
|
||||||
|
rows = [[f"v{r}_{c}" for c in range(n_cols)] for r in range(n_rows)]
|
||||||
|
return model.DataTable(header=header, rows=rows, title="Primeras filas")
|
||||||
|
|
||||||
|
|
||||||
|
def _narrow_table():
|
||||||
|
return model.DataTable(header=["a", "b", "c"],
|
||||||
|
rows=[["1", "2", "3"], ["4", "5", "6"]],
|
||||||
|
title="Tabla estrecha")
|
||||||
|
|
||||||
|
|
||||||
|
def _chapter(blocks, cid="cap", title="Capítulo"):
|
||||||
|
return [model.Chapter(id=cid, title=title, version="1.0.0", blocks=blocks)]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 1) High DPI — the unit constant and a real embedded image.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_raster_dpi_is_high_both_renderers():
|
||||||
|
assert _PDF_DPI >= 200, "el DPI del PDF debe ser alto (>=200)"
|
||||||
|
assert _PPTX_DPI >= 200, "el DPI del PPTX debe ser alto (>=200)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_embedded_figure_is_high_resolution(tmp_path):
|
||||||
|
fitz = pytest.importorskip("fitz")
|
||||||
|
out = str(tmp_path / "fig.pdf")
|
||||||
|
res = render_pdf(_chapter([model.Figure(make=_simple_fig, caption="demo")]),
|
||||||
|
out, {"title": "T"})
|
||||||
|
assert res["path"] == out
|
||||||
|
doc = fitz.open(out)
|
||||||
|
try:
|
||||||
|
widths = []
|
||||||
|
for page in doc:
|
||||||
|
for img in page.get_images(full=True):
|
||||||
|
xref = img[0]
|
||||||
|
info = doc.extract_image(xref)
|
||||||
|
widths.append(info.get("width", 0))
|
||||||
|
assert widths, "no se incrustó ninguna imagen en el PDF"
|
||||||
|
# A ~4" figure rasterized at 220 dpi is ~ >850 px wide. At the old 150 dpi
|
||||||
|
# it would be ~600 px. The high-res threshold proves the DPI bump.
|
||||||
|
assert max(widths) >= 800, \
|
||||||
|
f"la figura embebida no es de alta resolución: {max(widths)} px"
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 2) Wide table → image (PDF and PPTX); narrow table stays text.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_fit_criterion_flags_wide_and_keeps_narrow():
|
||||||
|
wide = _wide_table()
|
||||||
|
narrow = _narrow_table()
|
||||||
|
assert not _pdf_fits(wide.header, wide.rows), \
|
||||||
|
"una tabla de 19 columnas debería NO caber como texto en A5"
|
||||||
|
assert not _pptx_fits(wide.header, wide.rows), \
|
||||||
|
"una tabla de 19 columnas debería NO caber como tabla nativa en 16:9"
|
||||||
|
assert _pdf_fits(narrow.header, narrow.rows), \
|
||||||
|
"una tabla de 3 columnas debería caber como texto en A5"
|
||||||
|
assert _pptx_fits(narrow.header, narrow.rows), \
|
||||||
|
"una tabla de 3 columnas debería caber como tabla nativa en 16:9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wide_table_rendered_as_image_pdf(tmp_path):
|
||||||
|
fitz = pytest.importorskip("fitz")
|
||||||
|
out = str(tmp_path / "wide.pdf")
|
||||||
|
res = render_pdf(_chapter([_wide_table()]), out, {"title": "T"})
|
||||||
|
assert res["path"] == out
|
||||||
|
doc = fitz.open(out)
|
||||||
|
try:
|
||||||
|
n_images = sum(len(page.get_images(full=True)) for page in doc)
|
||||||
|
text = "".join(page.get_text() for page in doc)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
assert n_images >= 1, "la tabla ancha no se rasterizó como imagen en el PDF"
|
||||||
|
# The cells are now inside the image, not selectable text. A unique cell value
|
||||||
|
# must therefore NOT appear as extractable text (it lives in the picture).
|
||||||
|
assert "v4_18" not in text, \
|
||||||
|
"la tabla ancha sigue como texto seleccionable (no se hizo imagen)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_narrow_table_stays_selectable_text_pdf(tmp_path):
|
||||||
|
fitz = pytest.importorskip("fitz")
|
||||||
|
out = str(tmp_path / "narrow.pdf")
|
||||||
|
render_pdf(_chapter([_narrow_table()]), out, {"title": "T"})
|
||||||
|
doc = fitz.open(out)
|
||||||
|
try:
|
||||||
|
text = "".join(page.get_text() for page in doc)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
# Narrow table is selectable text: its header/cells are extractable.
|
||||||
|
for v in ("a", "b", "c", "1", "6"):
|
||||||
|
assert v in text, f"la celda '{v}' debería ser texto seleccionable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wide_table_rendered_as_picture_pptx(tmp_path):
|
||||||
|
pptx = pytest.importorskip("pptx")
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
out = str(tmp_path / "wide.pptx")
|
||||||
|
res = render_pptx(_chapter([_wide_table()]), out, {"title": "T"})
|
||||||
|
assert res["path"] == out
|
||||||
|
prs = pptx.Presentation(out)
|
||||||
|
pics = sum(1 for s in prs.slides for sh in s.shapes
|
||||||
|
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
||||||
|
assert pics >= 1, "la tabla ancha no se colocó como imagen en el PPTX"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 3) Group(layout="side_by_side"): two columns in PPTX, stacked in PDF.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _side_by_side_group():
|
||||||
|
return model.Group(
|
||||||
|
blocks=[model.Heading(text="Columna X", level=2),
|
||||||
|
_narrow_table(),
|
||||||
|
model.Figure(make=_simple_fig, caption="grafico")],
|
||||||
|
layout="side_by_side")
|
||||||
|
|
||||||
|
|
||||||
|
def test_side_by_side_places_two_columns_pptx(tmp_path):
|
||||||
|
pptx = pytest.importorskip("pptx")
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
from pptx.util import Inches
|
||||||
|
out = str(tmp_path / "sbs.pptx")
|
||||||
|
render_pptx(_chapter([_side_by_side_group()]), out, {"title": "T"})
|
||||||
|
prs = pptx.Presentation(out)
|
||||||
|
# Find the slide that holds the pair (table image + figure image).
|
||||||
|
centre_emu = int(Inches(13.333 / 2.0))
|
||||||
|
placed = False
|
||||||
|
for s in prs.slides:
|
||||||
|
lefts = [sh.left for sh in s.shapes
|
||||||
|
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE
|
||||||
|
and sh.left is not None]
|
||||||
|
if len(lefts) >= 2:
|
||||||
|
# one picture starts in the left half, another in the right half.
|
||||||
|
if min(lefts) < centre_emu and max(lefts) > centre_emu:
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
assert placed, \
|
||||||
|
"side_by_side no colocó tabla y figura en dos columnas de la misma slide"
|
||||||
|
|
||||||
|
|
||||||
|
def test_side_by_side_stacks_in_pdf(tmp_path):
|
||||||
|
fitz = pytest.importorskip("fitz")
|
||||||
|
out = str(tmp_path / "sbs.pdf")
|
||||||
|
res = render_pdf(_chapter([_side_by_side_group()]), out, {"title": "T"})
|
||||||
|
assert res["path"] == out and res["n_pages"] >= 1
|
||||||
|
doc = fitz.open(out)
|
||||||
|
try:
|
||||||
|
n_images = sum(len(page.get_images(full=True)) for page in doc)
|
||||||
|
text = "".join(page.get_text() for page in doc)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
# PDF stacks: the narrow table stays selectable text (1 of its cells is
|
||||||
|
# extractable) and the figure is the single embedded image — not a 2-column
|
||||||
|
# pair of pictures like PPTX.
|
||||||
|
assert n_images == 1, "el PDF no debería usar el layout de dos imágenes"
|
||||||
|
assert "Columna X" in text and "1" in text, \
|
||||||
|
"la tabla del grupo debería seguir como texto apilado en el PDF"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 4) Backward compatibility — default layout stacks, fitting table unchanged.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_group_default_layout_is_stack():
|
||||||
|
g = model.Group(blocks=[_narrow_table()])
|
||||||
|
assert g.layout == "stack", "el layout por defecto debe ser 'stack'"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 5) Clickable cover index ("Índice") → chapter first page/slide.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _doc_with_index():
|
||||||
|
portada = model.Chapter(id="portada", title="Portada", version="1.0.0",
|
||||||
|
blocks=[model.Heading(text="Índice", level=2),
|
||||||
|
model.TocEntry(label="Distribuciones",
|
||||||
|
target_id="Distribuciones")])
|
||||||
|
cap = model.Chapter(id="num", title="Distribuciones", version="1.0.0",
|
||||||
|
blocks=[model.Markdown(text="contenido del capítulo")])
|
||||||
|
return [portada, cap]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_index_is_clickable_pdf(tmp_path):
|
||||||
|
fitz = pytest.importorskip("fitz")
|
||||||
|
out = str(tmp_path / "idx.pdf")
|
||||||
|
res = render_pdf(_doc_with_index(), out, {"title": "T"})
|
||||||
|
assert res["path"] == out
|
||||||
|
doc = fitz.open(out)
|
||||||
|
try:
|
||||||
|
# The cover (page 0) must carry a GOTO link jumping to a later page.
|
||||||
|
goto = [lk for lk in doc[0].get_links()
|
||||||
|
if lk.get("kind") == fitz.LINK_GOTO and lk.get("page", 0) > 0]
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
assert goto, "el índice de la portada no produjo enlaces clicables en el PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_index_shows_heading_pdf(tmp_path):
|
||||||
|
fitz = pytest.importorskip("fitz")
|
||||||
|
out = str(tmp_path / "idxh.pdf")
|
||||||
|
render_pdf(_doc_with_index(), out, {"title": "T"})
|
||||||
|
doc = fitz.open(out)
|
||||||
|
try:
|
||||||
|
text = "".join(page.get_text() for page in doc)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
assert "Índice" in text, "la portada no muestra el encabezado 'Índice'"
|
||||||
|
assert "Este informe incluye" not in text, \
|
||||||
|
"la portada aún muestra el texto antiguo 'Este informe incluye'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_index_is_clickable_pptx(tmp_path):
|
||||||
|
pptx = pytest.importorskip("pptx")
|
||||||
|
out = str(tmp_path / "idx.pptx")
|
||||||
|
render_pptx(_doc_with_index(), out, {"title": "T"})
|
||||||
|
prs = pptx.Presentation(out)
|
||||||
|
cover_xml = prs.slides[0]._element.xml
|
||||||
|
assert "hlinksldjump" in cover_xml, \
|
||||||
|
"el índice de la portada no produjo un salto de slide nativo en el PPTX"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_group_renders_like_before_pptx(tmp_path):
|
||||||
|
pptx = pytest.importorskip("pptx")
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
out = str(tmp_path / "stack.pptx")
|
||||||
|
grp = model.Group(blocks=[model.Heading(text="Y", level=2),
|
||||||
|
_narrow_table(),
|
||||||
|
model.Figure(make=_simple_fig, caption="g")])
|
||||||
|
render_pptx(_chapter([grp]), out, {"title": "T"})
|
||||||
|
prs = pptx.Presentation(out)
|
||||||
|
# Stacked group: the narrow table is a NATIVE table (selectable), and there is
|
||||||
|
# exactly one picture (the figure) — not the two-image side-by-side layout.
|
||||||
|
n_tables = sum(1 for s in prs.slides for sh in s.shapes if sh.has_table)
|
||||||
|
n_pics = sum(1 for s in prs.slides for sh in s.shapes
|
||||||
|
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
||||||
|
assert n_tables >= 1, "el grupo apilado debería usar una tabla nativa"
|
||||||
|
assert n_pics == 1, "el grupo apilado no debería duplicar imágenes"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
id: build_boxplots_figure_py_datascience
|
||||||
|
name: build_boxplots_figure
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def build_boxplots_figure(boxes: list, title: str = \"\", max_boxes: int = 12) -> \"matplotlib.figure.Figure\""
|
||||||
|
description: "Construye una unica figura matplotlib con boxplots de Tukey HORIZONTALES (uno por columna) usando ax.bxp: caja Q1-Q3, bigotes hasta 1.5*IQR, linea de mediana y puntos atipicos. Consume la salida de build_boxplot_stats (un dict box por columna, leido con .get) mas una lista opcional de outliers crudos por columna; si vienen los dibuja como puntos (showfliers), si no marca solo box[min]/box[max] cuando hay outliers de cola (igual que num_distr). Dibuja como mucho max_boxes cajas (las primeras, ya ordenadas por contaminacion por el caller) y avisa de la truncacion con (mostrando N de M). Backend Agg sin pyplot global; alto adaptativo al nº de cajas. Defensiva: omite entradas invalidas y NUNCA lanza — sin cajas validas devuelve una figura placeholder (sin boxplots). Es la version small-multiples del capitulo num_distr para responder que columnas tienen mas outliers de un vistazo."
|
||||||
|
tags: [eda, outliers, boxplot, tukey, iqr, bxp, matplotlib, figure, visualization, small-multiples, datascience, impure]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [matplotlib]
|
||||||
|
example: |
|
||||||
|
from datascience.build_boxplot_stats import build_boxplot_stats
|
||||||
|
from datascience.build_boxplots_figure import build_boxplots_figure
|
||||||
|
boxes = [
|
||||||
|
{"name": "ingresos", "box": build_boxplot_stats({"min": 1.0, "max": 9e3,
|
||||||
|
"p25": 1e3, "median": 2e3, "p75": 3e3, "n_outliers": 7}), "fliers": None},
|
||||||
|
{"name": "edad", "box": build_boxplot_stats({"min": 0.0, "max": 99.0,
|
||||||
|
"p25": 25.0, "median": 38.0, "p75": 52.0}), "fliers": None},
|
||||||
|
]
|
||||||
|
fig = build_boxplots_figure(boxes, title="Outliers por columna", max_boxes=12)
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_returns_figure_with_axes"
|
||||||
|
- "test_empty_list_returns_placeholder_figure"
|
||||||
|
- "test_invalid_box_is_skipped_not_raised"
|
||||||
|
- "test_all_invalid_returns_placeholder"
|
||||||
|
- "test_raw_fliers_are_drawn"
|
||||||
|
- "test_max_boxes_truncates_and_does_not_raise"
|
||||||
|
test_file_path: "python/functions/datascience/build_boxplots_figure_test.py"
|
||||||
|
file_path: "python/functions/datascience/build_boxplots_figure.py"
|
||||||
|
params:
|
||||||
|
- name: boxes
|
||||||
|
desc: "Lista de dicts, cada uno {\"name\": str, \"box\": dict, \"fliers\": list|None}. box es EXACTAMENTE la salida de build_boxplot_stats (claves leidas con .get: q1, median, q3, whisker_lo, whisker_hi, min, max, has_low_outliers, has_high_outliers, lower_fence, upper_fence, n_outliers). fliers es la lista opcional de outliers crudos: si viene se dibuja como puntos; si es None/ausente solo se marcan los extremos box[min]/box[max] cuando hay outliers de cola. Entradas que no son dict, sin box dict, o sin q1/median/q3 se omiten. El caller las pasa ya ordenadas por contaminacion (la mayor primera)."
|
||||||
|
- name: title
|
||||||
|
desc: "Titulo de la figura (fig.suptitle, alineado a la izquierda). Vacio => sin titulo. Si len(boxes) > max_boxes se le anade una nota \"(mostrando N de M)\" para que la truncacion no sea silenciosa. Default \"\"."
|
||||||
|
- name: max_boxes
|
||||||
|
desc: "Numero maximo de cajas a dibujar (las primeras de la lista). Default 12. Un valor no entero o <= 0 cae a 12. Si la lista trae mas entradas, las sobrantes se descartan pero se reporta en el titulo con (mostrando N de M)."
|
||||||
|
output: "Un matplotlib.figure.Figure (figsize 7.0 x alto adaptativo = max(2.0, 0.5*n + 1.0), dpi 150) con un unico Axes que apila boxplots horizontales de Tukey (ax.bxp, orientation=horizontal con fallback vert=False), uno por columna valida, de arriba a abajo en el orden recibido. Cada caja: relleno #9ec6df, borde/bigotes/caps #5b8aa6, mediana #2e8b57, atipicos #c0392b. Etiquetas del eje Y = nombres de columna; eje X etiquetado \"valor\". Outliers dibujados desde fliers crudos (showfliers) o, si faltan, marcados en box[min]/box[max] segun has_low/high_outliers. Si no queda ninguna caja valida (lista vacia o todas invalidas) devuelve una Figure placeholder con texto centrado \"(sin boxplots)\"; cualquier error inesperado se captura y devuelve una Figure con el mensaje de error. NUNCA lanza. El caller rasteriza/cierra la figura; la funcion no la muestra ni la guarda."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience.build_boxplot_stats import build_boxplot_stats
|
||||||
|
from datascience.build_boxplots_figure import build_boxplots_figure
|
||||||
|
|
||||||
|
# Un `box` por columna numérica, derivado del sub-bloque `numeric` del profile
|
||||||
|
# (salida de describe_numeric). El caller los pasa ya ordenados por outlier_pct.
|
||||||
|
boxes = [
|
||||||
|
{
|
||||||
|
"name": "ingresos",
|
||||||
|
"box": build_boxplot_stats({
|
||||||
|
"min": 1.0, "max": 9000.0,
|
||||||
|
"p25": 1000.0, "median": 2000.0, "p75": 3000.0,
|
||||||
|
"n_outliers": 7,
|
||||||
|
}),
|
||||||
|
"fliers": None, # valores crudos desconocidos -> se marca solo el extremo.
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "edad",
|
||||||
|
"box": build_boxplot_stats({
|
||||||
|
"min": 0.0, "max": 99.0,
|
||||||
|
"p25": 25.0, "median": 38.0, "p75": 52.0,
|
||||||
|
}),
|
||||||
|
"fliers": [88.0, 95.0, 99.0], # outliers crudos -> se dibujan como puntos.
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fig = build_boxplots_figure(boxes, title="Outliers por columna", max_boxes=12)
|
||||||
|
|
||||||
|
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||||
|
fig.savefig("/tmp/boxplots.png")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala en el capítulo de outliers de un informe EDA cuando quieras comparar de un
|
||||||
|
vistazo *qué columnas están más contaminadas por valores atípicos*: a diferencia
|
||||||
|
de `num_distr` (que dibuja un histograma+boxplot por columna en figuras
|
||||||
|
separadas), aquí apilas todos los boxplots horizontales en **una sola figura**
|
||||||
|
(small multiples). Primero deriva el `box` de cada columna con
|
||||||
|
`build_boxplot_stats`, ordénalas por `outlier_pct` descendente, envuélvelas como
|
||||||
|
`{"name", "box", "fliers"}` y pásaselas. Si tienes los valores crudos fuera de
|
||||||
|
las vallas, métele la lista `fliers` y se dibujarán como puntos; si no, la
|
||||||
|
función marca solo los extremos `min`/`max` cuando hay cola.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||||
|
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||||
|
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||||
|
es thread-safe; esta función construye el `Figure` directamente, así que es
|
||||||
|
segura de llamar en bucle desde el renderer.
|
||||||
|
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
|
||||||
|
guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||||
|
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
|
||||||
|
- **`fliers` opcional, semántica distinta.** Si pasas la lista de outliers
|
||||||
|
crudos se dibujan todos como puntos (`showfliers=True`). Si es `None`/ausente
|
||||||
|
los valores son desconocidos y solo se marca un punto en `box["min"]` /
|
||||||
|
`box["max"]` cuando `has_low_outliers` / `has_high_outliers` — mismo criterio
|
||||||
|
que `num_distr`. No inventes fliers a partir del profile: el `box` no trae los
|
||||||
|
valores crudos, solo si los extremos superan las vallas.
|
||||||
|
- **API de orientación de `ax.bxp`.** matplotlib reciente usa
|
||||||
|
`orientation="horizontal"`; las versiones antiguas usan `vert=False`. La
|
||||||
|
función prueba la primera y cae a la segunda en `except TypeError`, así que
|
||||||
|
funciona en ambas. Si `bxp` falla del todo, el Axes degrada a un texto
|
||||||
|
"(boxplot no disponible)" en vez de propagar.
|
||||||
|
- **Truncación visible.** `max_boxes` (default 12) limita el nº de cajas para que
|
||||||
|
ninguna se solape; si la lista trae más, las sobrantes se descartan pero se
|
||||||
|
avisa en el título con "(mostrando N de M)". Pasa las columnas ya ordenadas por
|
||||||
|
contaminación para que las descartadas sean las menos relevantes.
|
||||||
|
- **Defensiva, nunca lanza.** Lista vacía, entradas no-dict, sin `box`, o sin
|
||||||
|
`q1`/`median`/`q3` se omiten sin propagar; sin cajas válidas devuelve un
|
||||||
|
placeholder "(sin boxplots)" y cualquier error inesperado se captura en una
|
||||||
|
figura con el texto del error. No envuelvas la llamada en try/except por miedo
|
||||||
|
a un raise — no lo hay.
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Impure EDA helper: a single figure of horizontal Tukey boxplots (`eda` group).
|
||||||
|
|
||||||
|
Draws, in one ``matplotlib.figure.Figure``, a stack of horizontal Tukey boxplots
|
||||||
|
(one per column) using ``ax.bxp``: each carries its box (Q1–Q3), whiskers (up to
|
||||||
|
1.5·IQR), the median line and its outlier points. It consumes the output of the
|
||||||
|
pure registry function ``build_boxplot_stats`` (one ``box`` dict per column) plus
|
||||||
|
an optional list of raw outlier values per column; it never recomputes anything.
|
||||||
|
|
||||||
|
It is the "small-multiples" companion of ``num_distr`` (which draws one
|
||||||
|
histogram+boxplot per column): here every column shares a single figure so the
|
||||||
|
caller can show, at a glance, *which* columns are the most contaminated by
|
||||||
|
outliers (the caller passes them already ordered by contamination).
|
||||||
|
|
||||||
|
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||||
|
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||||
|
global state and is safe to call repeatedly from a report renderer. It is fully
|
||||||
|
defensive and NEVER raises: invalid entries are skipped and, if nothing valid
|
||||||
|
remains, it returns a placeholder figure carrying a centered "(sin boxplots)".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
from matplotlib.figure import Figure # noqa: E402
|
||||||
|
|
||||||
|
# Blue palette shared with the ``num_distr`` chapter so the report stays coherent.
|
||||||
|
_BOX_FACE = "#9ec6df" # box fill.
|
||||||
|
_BOX_EDGE = "#5b8aa6" # box / whisker / cap border.
|
||||||
|
_MEDIAN = "#2e8b57" # median line (sea green).
|
||||||
|
_OUTLIER = "#c0392b" # outlier points (soft red).
|
||||||
|
# Muted gray for the placeholder / fallback message text.
|
||||||
|
_MUTED_TEXT = "#5f6b7a"
|
||||||
|
# Soft red for the error fallback message.
|
||||||
|
_ERROR_TEXT = "#b00020"
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value):
|
||||||
|
"""Coerce ``value`` to float defensively; None for None/bool/non-numeric/NaN."""
|
||||||
|
# bool is a subclass of int; a stat value is never a real bool, so treat
|
||||||
|
# True/False as missing instead of silently coercing to 1.0/0.0.
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
f = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if f != f: # NaN guard.
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_figure(message: str, color: str = _MUTED_TEXT) -> "Figure":
|
||||||
|
"""Return a fallback ``Figure`` carrying a single centered message."""
|
||||||
|
fig = Figure(figsize=(7.0, 2.4), dpi=150)
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
ax.axis("off")
|
||||||
|
ax.text(
|
||||||
|
0.5,
|
||||||
|
0.5,
|
||||||
|
message,
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
fontsize=12,
|
||||||
|
color=color,
|
||||||
|
wrap=True,
|
||||||
|
transform=ax.transAxes,
|
||||||
|
)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def build_boxplots_figure(
|
||||||
|
boxes: list,
|
||||||
|
title: str = "",
|
||||||
|
max_boxes: int = 12,
|
||||||
|
) -> "matplotlib.figure.Figure":
|
||||||
|
"""Build one figure of stacked horizontal Tukey boxplots (one per column).
|
||||||
|
|
||||||
|
For each entry the function builds a ``bxp`` stats record (``med, q1, q3,
|
||||||
|
whislo, whishi, fliers, label``) from its ``box`` sub-dict (the output of
|
||||||
|
``build_boxplot_stats``) and draws all of them as horizontal boxplots sharing
|
||||||
|
the X axis, top-to-bottom in the order received (the caller is expected to
|
||||||
|
pass them already sorted by contamination).
|
||||||
|
|
||||||
|
Outliers are shown two ways:
|
||||||
|
|
||||||
|
- If an entry carries a ``fliers`` list (the raw out-of-fence values), they
|
||||||
|
are drawn as red points via ``ax.bxp(..., showfliers=True)``.
|
||||||
|
- If ``fliers`` is ``None``/absent, the raw values are unknown, so only the
|
||||||
|
extremes are marked: a red point at ``box["min"]`` when
|
||||||
|
``box["has_low_outliers"]`` and at ``box["max"]`` when
|
||||||
|
``box["has_high_outliers"]`` (same convention as ``num_distr``).
|
||||||
|
|
||||||
|
The function is fully defensive and NEVER raises. Entries that are not dicts,
|
||||||
|
lack a ``box`` dict, or miss any of ``q1``/``median``/``q3`` are skipped. If
|
||||||
|
after filtering no valid box remains it returns a placeholder ``Figure`` with
|
||||||
|
a centered "(sin boxplots)"; any unexpected error is caught and turned into a
|
||||||
|
fallback figure carrying the error text. It always returns a ``Figure``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
boxes: List of dicts ``{"name": str, "box": dict, "fliers": list|None}``.
|
||||||
|
``box`` is exactly the output of ``build_boxplot_stats`` (read with
|
||||||
|
``.get``: ``q1, median, q3, whisker_lo, whisker_hi, min, max,
|
||||||
|
has_low_outliers, has_high_outliers, ...``). ``fliers`` is the
|
||||||
|
optional list of raw outlier values; when present they are plotted,
|
||||||
|
otherwise only the extremes are marked.
|
||||||
|
title: Figure title (``fig.suptitle``). Empty => no title. When the list
|
||||||
|
is longer than ``max_boxes`` a "(mostrando N de M)" note is appended.
|
||||||
|
max_boxes: Draw at most the first ``max_boxes`` entries (default 12). The
|
||||||
|
rest are dropped but their omission is surfaced in the title note, so
|
||||||
|
the truncation is never silent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``matplotlib.figure.Figure`` with a single Axes holding the horizontal
|
||||||
|
boxplots (height adaptive to the box count so none overlap). The caller is
|
||||||
|
responsible for rasterizing/closing it; this function never shows nor
|
||||||
|
saves it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not isinstance(boxes, (list, tuple)) or len(boxes) == 0:
|
||||||
|
return _placeholder_figure("(sin boxplots)")
|
||||||
|
|
||||||
|
total = len(boxes)
|
||||||
|
|
||||||
|
# Cap the number of boxes; tolerate a non-int / non-positive max_boxes.
|
||||||
|
try:
|
||||||
|
cap = int(max_boxes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cap = 12
|
||||||
|
if cap <= 0:
|
||||||
|
cap = 12
|
||||||
|
candidates = list(boxes)[:cap]
|
||||||
|
|
||||||
|
stats_list = [] # bxp stats records, in draw order.
|
||||||
|
labels = [] # Y tick labels (column names).
|
||||||
|
manual_markers = [] # (position, box) for entries without raw fliers.
|
||||||
|
any_fliers = False # whether to enable showfliers in the bxp call.
|
||||||
|
|
||||||
|
for entry in candidates:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
box = entry.get("box")
|
||||||
|
if not isinstance(box, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
q1 = _num(box.get("q1"))
|
||||||
|
med = _num(box.get("median"))
|
||||||
|
q3 = _num(box.get("q3"))
|
||||||
|
# Without the three quartiles a boxplot cannot be drawn — skip it.
|
||||||
|
if q1 is None or med is None or q3 is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Whisker extremes fall back to the quartiles when missing.
|
||||||
|
whislo = _num(box.get("whisker_lo"))
|
||||||
|
whishi = _num(box.get("whisker_hi"))
|
||||||
|
if whislo is None:
|
||||||
|
whislo = q1
|
||||||
|
if whishi is None:
|
||||||
|
whishi = q3
|
||||||
|
|
||||||
|
name = entry.get("name")
|
||||||
|
label = "" if name is None else str(name)
|
||||||
|
|
||||||
|
position = len(stats_list) + 1 # bxp positions are 1-indexed.
|
||||||
|
fliers_raw = entry.get("fliers")
|
||||||
|
if isinstance(fliers_raw, (list, tuple)):
|
||||||
|
fliers = [v for v in (_num(x) for x in fliers_raw) if v is not None]
|
||||||
|
if fliers:
|
||||||
|
any_fliers = True
|
||||||
|
else:
|
||||||
|
# Raw values unknown: draw no bxp fliers, mark min/max by hand.
|
||||||
|
fliers = []
|
||||||
|
manual_markers.append((position, box))
|
||||||
|
|
||||||
|
stats_list.append({
|
||||||
|
"med": med,
|
||||||
|
"q1": q1,
|
||||||
|
"q3": q3,
|
||||||
|
"whislo": whislo,
|
||||||
|
"whishi": whishi,
|
||||||
|
"fliers": fliers,
|
||||||
|
"label": label,
|
||||||
|
})
|
||||||
|
labels.append(label)
|
||||||
|
|
||||||
|
if not stats_list:
|
||||||
|
return _placeholder_figure("(sin boxplots)")
|
||||||
|
|
||||||
|
n = len(stats_list)
|
||||||
|
positions = list(range(1, n + 1))
|
||||||
|
|
||||||
|
# Height grows with the box count so none of them overlap.
|
||||||
|
height = max(2.0, 0.5 * n + 1.0)
|
||||||
|
fig = Figure(figsize=(7.0, height), dpi=150)
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
|
||||||
|
bxp_kw = dict(
|
||||||
|
showfliers=any_fliers, widths=0.5, patch_artist=True,
|
||||||
|
boxprops={"facecolor": _BOX_FACE, "edgecolor": _BOX_EDGE},
|
||||||
|
medianprops={"color": _MEDIAN, "linewidth": 1.6},
|
||||||
|
whiskerprops={"color": _BOX_EDGE},
|
||||||
|
capprops={"color": _BOX_EDGE},
|
||||||
|
flierprops={"marker": "o", "markersize": 3.5,
|
||||||
|
"markerfacecolor": _OUTLIER, "markeredgecolor": _OUTLIER,
|
||||||
|
"linestyle": "none"})
|
||||||
|
try:
|
||||||
|
# ``orientation`` is the current API; older matplotlib uses ``vert``.
|
||||||
|
try:
|
||||||
|
ax.bxp(stats_list, positions=positions,
|
||||||
|
orientation="horizontal", **bxp_kw)
|
||||||
|
except TypeError:
|
||||||
|
ax.bxp(stats_list, positions=positions, vert=False, **bxp_kw)
|
||||||
|
except Exception: # noqa: BLE001 — never let bxp kill the whole figure.
|
||||||
|
ax.text(0.5, 0.5, "(boxplot no disponible)", ha="center",
|
||||||
|
va="center", fontsize=10, color=_MUTED_TEXT,
|
||||||
|
transform=ax.transAxes)
|
||||||
|
|
||||||
|
# For entries without raw fliers, mark only the out-of-fence extremes.
|
||||||
|
for position, box in manual_markers:
|
||||||
|
mn = _num(box.get("min"))
|
||||||
|
mx = _num(box.get("max"))
|
||||||
|
if box.get("has_low_outliers") and mn is not None:
|
||||||
|
ax.plot([mn], [position], marker="o", markersize=3.5,
|
||||||
|
color=_OUTLIER, zorder=5)
|
||||||
|
if box.get("has_high_outliers") and mx is not None:
|
||||||
|
ax.plot([mx], [position], marker="o", markersize=3.5,
|
||||||
|
color=_OUTLIER, zorder=5)
|
||||||
|
|
||||||
|
# Pin the Y tick labels explicitly so they work across matplotlib
|
||||||
|
# versions regardless of whether ``bxp`` consumed the ``label`` key.
|
||||||
|
ax.set_yticks(positions)
|
||||||
|
ax.set_yticklabels(labels, fontsize=8)
|
||||||
|
ax.set_xlabel("valor", fontsize=9)
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
ax.margins(y=0.15)
|
||||||
|
for spine in ("top", "right"):
|
||||||
|
ax.spines[spine].set_visible(False)
|
||||||
|
|
||||||
|
# Surface truncation in the title instead of silently dropping boxes.
|
||||||
|
note = f"(mostrando {n} de {total})" if total > cap else ""
|
||||||
|
heading = " ".join(p for p in (title, note) if p)
|
||||||
|
if heading:
|
||||||
|
fig.suptitle(heading, fontsize=12, x=0.02, ha="left")
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
|
||||||
|
return _placeholder_figure(
|
||||||
|
f"error al dibujar boxplots: {exc}", color=_ERROR_TEXT)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests para build_boxplots_figure (boxplots horizontales de Tukey, grupo eda).
|
||||||
|
|
||||||
|
Usa el backend Agg sin display; no muestra ni guarda figuras. Cada test cierra
|
||||||
|
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
|
||||||
|
estado entre tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
from matplotlib.figure import Figure # noqa: E402
|
||||||
|
|
||||||
|
from build_boxplots_figure import build_boxplots_figure
|
||||||
|
|
||||||
|
|
||||||
|
def _box(name, q1, median, q3, mn, mx, low=False, high=False, fliers=None):
|
||||||
|
"""Construye una entrada {name, box, fliers} con un box estilo build_boxplot_stats."""
|
||||||
|
iqr = q3 - q1
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"box": {
|
||||||
|
"q1": q1,
|
||||||
|
"median": median,
|
||||||
|
"q3": q3,
|
||||||
|
"iqr": iqr,
|
||||||
|
"lower_fence": q1 - 1.5 * iqr,
|
||||||
|
"upper_fence": q3 + 1.5 * iqr,
|
||||||
|
"whisker_lo": max(mn, q1 - 1.5 * iqr),
|
||||||
|
"whisker_hi": min(mx, q3 + 1.5 * iqr),
|
||||||
|
"min": mn,
|
||||||
|
"max": mx,
|
||||||
|
"has_low_outliers": low,
|
||||||
|
"has_high_outliers": high,
|
||||||
|
"n_outliers": 0,
|
||||||
|
},
|
||||||
|
"fliers": fliers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_figure_with_axes():
|
||||||
|
boxes = [
|
||||||
|
_box("edad", 10.0, 25.0, 40.0, 1.0, 100.0, high=True),
|
||||||
|
_box("ingresos", 100.0, 200.0, 300.0, 50.0, 400.0),
|
||||||
|
_box("score", -1.0, 0.0, 1.0, -5.0, 5.0, low=True, high=True),
|
||||||
|
]
|
||||||
|
fig = build_boxplots_figure(boxes, title="Boxplots", max_boxes=12)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
assert len(fig.axes) >= 1
|
||||||
|
# Tres cajas -> tres etiquetas en el eje Y.
|
||||||
|
ax = fig.axes[0]
|
||||||
|
assert len(ax.get_yticks()) == 3
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_returns_placeholder_figure():
|
||||||
|
fig = build_boxplots_figure([], title="vacío")
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
assert len(fig.axes) >= 1
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_box_is_skipped_not_raised():
|
||||||
|
boxes = [
|
||||||
|
{"name": "rota", "box": {"q1": None, "median": None, "q3": None}},
|
||||||
|
{"name": "sin_box"}, # falta la clave box.
|
||||||
|
"no_es_dict", # entrada no-dict.
|
||||||
|
_box("buena", 1.0, 2.0, 3.0, 0.0, 10.0, high=True),
|
||||||
|
]
|
||||||
|
fig = build_boxplots_figure(boxes)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
ax = fig.axes[0]
|
||||||
|
# Solo la caja válida sobrevive al filtrado.
|
||||||
|
assert len(ax.get_yticks()) == 1
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_invalid_returns_placeholder():
|
||||||
|
boxes = [
|
||||||
|
{"name": "a", "box": {"q1": None, "median": 1.0, "q3": 2.0}},
|
||||||
|
{"name": "b"},
|
||||||
|
]
|
||||||
|
fig = build_boxplots_figure(boxes)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
assert len(fig.axes) >= 1
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_fliers_are_drawn():
|
||||||
|
boxes = [
|
||||||
|
_box("con_fliers", 10.0, 20.0, 30.0, 5.0, 200.0,
|
||||||
|
high=True, fliers=[150.0, 180.0, 200.0]),
|
||||||
|
]
|
||||||
|
fig = build_boxplots_figure(boxes)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
assert len(fig.axes) >= 1
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_boxes_truncates_and_does_not_raise():
|
||||||
|
boxes = [_box(f"c{i}", float(i), float(i + 1), float(i + 2),
|
||||||
|
float(i - 5), float(i + 10)) for i in range(20)]
|
||||||
|
fig = build_boxplots_figure(boxes, title="muchos", max_boxes=5)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
ax = fig.axes[0]
|
||||||
|
# Solo se dibujan las primeras 5 cajas.
|
||||||
|
assert len(ax.get_yticks()) == 5
|
||||||
|
plt.close(fig)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
id: categorical_top_bar_figure_py_datascience
|
||||||
|
name: categorical_top_bar_figure
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def categorical_top_bar_figure(top: list, n_distinct: int = 0, title: str = \"\", top_k: int = 6, n_rows=None) -> \"matplotlib.figure.Figure\""
|
||||||
|
description: "Construye una figura matplotlib de barras horizontales de las top_k categorías más frecuentes de una columna categórica, con la mayor arriba y agregando el resto en una barra gris \"Otros (N categorías)\". Contrato de entrada idéntico a categorical_top_pie_figure (swap directo donut↔barras): consume el bloque `top` de summarize_categorical y devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA. Backend Agg sin pyplot global; defensivo total ante top vacío/None, nunca lanza."
|
||||||
|
tags: [eda, categorical, bar, barh, matplotlib, figure, visualization, datascience, impure]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [matplotlib]
|
||||||
|
example: |
|
||||||
|
from categorical_top_bar_figure import categorical_top_bar_figure
|
||||||
|
top = [
|
||||||
|
{"value": "rojo", "count": 40, "pct": 0.4},
|
||||||
|
{"value": "azul", "count": 30, "pct": 0.3},
|
||||||
|
{"value": "verde", "count": 20, "pct": 0.2},
|
||||||
|
]
|
||||||
|
fig = categorical_top_bar_figure(top, n_distinct=12, title="color", top_k=6, n_rows=100)
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_returns_figure"
|
||||||
|
- "test_ten_items_topk_six_yields_seven_bars"
|
||||||
|
- "test_empty_top_does_not_raise_and_returns_figure"
|
||||||
|
- "test_long_value_truncated"
|
||||||
|
- "test_none_value_and_none_count_are_handled"
|
||||||
|
- "test_n_rows_adds_exact_others_bar"
|
||||||
|
test_file_path: "python/functions/datascience/categorical_top_bar_figure_test.py"
|
||||||
|
file_path: "python/functions/datascience/categorical_top_bar_figure.py"
|
||||||
|
params:
|
||||||
|
- name: top
|
||||||
|
desc: "Lista de dicts {value, count, pct} ordenada de mayor a menor por count (salida del bloque `top` de summarize_categorical). Puede venir vacía o con dicts incompletos: items no-dict, sin count, con count None o count <= 0 se descartan. value None se admite (etiqueta vacía)."
|
||||||
|
- name: n_distinct
|
||||||
|
desc: "Nº total de categorías distintas de la columna. Etiqueta la barra agregada como \"Otros (n_distinct - top_k)\" (mínimo 0). Si no supera el nº de barras mostradas, se usa el overflow real de `top` como nº de categorías agregadas. Default 0."
|
||||||
|
- name: title
|
||||||
|
desc: "Título de la figura (nombre de la columna). Se trunca a ~48 chars con elipsis si es muy largo. Default \"\" (sin título)."
|
||||||
|
- name: top_k
|
||||||
|
desc: "Nº máximo de barras explícitas. Default 6. La barra \"Otros\" no cuenta contra este límite. Con top_k <= 0 se muestra al menos la categoría mayor."
|
||||||
|
- name: n_rows
|
||||||
|
desc: "Opcional. Total de filas del dataset. Si se da y la suma de counts mostrados < n_rows, la barra \"Otros\" usa (n_rows - suma_mostrada) como count para que sea exacta respecto al total real. Si se omite, \"Otros\" usa la suma de counts fuera del top_k mostrado (solo cuando top trae más de top_k items). Default None."
|
||||||
|
output: "Un matplotlib.figure.Figure (figsize 6.4 x altura escalada con el nº de barras, dpi 150) con un Axes de barras horizontales: la categoría más frecuente arriba, la barra gris \"Otros (N categorías)\" abajo, cada barra anotada con su conteo y porcentaje al final y etiquetas de categoría (yticklabels) truncadas a ~22 chars. Si no hay counts válidos devuelve igualmente una Figure con un texto centrado \"sin datos categóricos\" (nunca lanza); cualquier error inesperado cae a una Figure con el texto del error. El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from categorical_top_bar_figure import categorical_top_bar_figure
|
||||||
|
|
||||||
|
# `top` es la salida del bloque "top" de summarize_categorical (ya ordenado desc).
|
||||||
|
top = [
|
||||||
|
{"value": "rojo", "count": 40, "pct": 0.40},
|
||||||
|
{"value": "azul", "count": 30, "pct": 0.30},
|
||||||
|
{"value": "verde", "count": 20, "pct": 0.20},
|
||||||
|
{"value": "amarillo", "count": 5, "pct": 0.05},
|
||||||
|
]
|
||||||
|
|
||||||
|
fig = categorical_top_bar_figure(
|
||||||
|
top,
|
||||||
|
n_distinct=12, # 12 categorías distintas en total
|
||||||
|
title="color_producto",
|
||||||
|
top_k=6, # hasta 6 barras explícitas
|
||||||
|
n_rows=100, # "Otros" = 100 - 95 = 5, sobre 8 categorías agregadas
|
||||||
|
)
|
||||||
|
|
||||||
|
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||||
|
fig.savefig("/tmp/barras_color.png")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala dentro de un informe EDA cuando quieras comparar **magnitudes** de las
|
||||||
|
categorías dominantes de una columna categórica: qué categoría manda y por
|
||||||
|
cuánto frente a las siguientes. Pásale directamente el bloque `top` de
|
||||||
|
`summarize_categorical` (ya ordenado de mayor a menor) más `n_distinct` para que
|
||||||
|
la barra "Otros" indique cuántas categorías quedan agrupadas. Es el clon "de
|
||||||
|
barras" del donut `categorical_top_pie_figure` con **contrato de entrada
|
||||||
|
idéntico**: puedes intercambiar una por otra sin tocar el caller. Elige barras
|
||||||
|
cuando importe comparar tamaños exactos; el donut cuando importe la proporción
|
||||||
|
del total.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||||
|
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||||
|
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||||
|
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
|
||||||
|
directamente, así que es segura de llamar en bucle desde el renderer.
|
||||||
|
- **El caller cierra la figura.** La función devuelve el `Figure` pero no lo
|
||||||
|
muestra ni lo guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||||
|
(`fig.clf()` / `matplotlib.pyplot.close(fig)` si se usó pyplot en el caller)
|
||||||
|
para no acumular memoria en lotes grandes de columnas.
|
||||||
|
- **`barh` dibuja de abajo arriba.** La categoría más frecuente va arriba porque
|
||||||
|
el orden de display se invierte antes de plotear; la barra "Otros" queda
|
||||||
|
siempre al fondo. No reordenes `top` esperando otro layout: la función asume
|
||||||
|
que ya viene ordenado desc por count.
|
||||||
|
- **Magnitud exacta de "Otros" solo con `n_rows`.** Sin `n_rows`, la barra
|
||||||
|
"Otros" se calcula con el overflow presente en `top`; si `top` ya viene
|
||||||
|
recortado a `top_k` por el productor, no habrá "Otros" aunque existan más
|
||||||
|
categorías. Pasa `n_rows` (total de filas del dataset) para una barra correcta
|
||||||
|
respecto al total real.
|
||||||
|
- **Defensiva, nunca lanza.** `top=[]`, `value=None`, `count=None` o counts no
|
||||||
|
numéricos se manejan sin error: en el peor caso devuelve una `Figure` con
|
||||||
|
"sin datos categóricos", y cualquier excepción inesperada cae a una `Figure`
|
||||||
|
con el texto del error. No envuelvas la llamada en try/except por miedo a un
|
||||||
|
raise — no lo hay.
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Impure EDA helper: horizontal bar figure of the most common categories (`eda` group).
|
||||||
|
|
||||||
|
Builds a horizontal bar chart of the ``top_k`` most frequent categories of a
|
||||||
|
categorical column, folding everything else into a single gray
|
||||||
|
"Otros (N categorías)" bar. The most frequent category sits at the top, each bar
|
||||||
|
labelled with its count (and percentage) at the end. Returns a ready-to-rasterize
|
||||||
|
``matplotlib.figure.Figure``; it never shows nor saves it.
|
||||||
|
|
||||||
|
This is the "magnitude" twin of ``categorical_top_pie_figure``: identical input
|
||||||
|
contract (same ``top``/``n_distinct``/``title``/``top_k``/``n_rows`` signature) so
|
||||||
|
it can be swapped in directly, but it communicates comparable magnitudes via bars
|
||||||
|
instead of proportions via wedges.
|
||||||
|
|
||||||
|
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||||
|
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||||
|
global state and is safe to call repeatedly from a report renderer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
from matplotlib.figure import Figure # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# Gray reserved for the aggregated "Otros" bar.
|
||||||
|
_OTHER_COLOR = "#9e9e9e"
|
||||||
|
# Muted gray for secondary text (title fallback, no-data message).
|
||||||
|
_MUTED_TEXT = "#5f6b7a"
|
||||||
|
# Soft red for the error fallback message.
|
||||||
|
_ERROR_TEXT = "#b00020"
|
||||||
|
# Pleasant, colour-blind-friendly qualitative palette for the explicit bars.
|
||||||
|
_PALETTE = [
|
||||||
|
"#4C72B0",
|
||||||
|
"#DD8452",
|
||||||
|
"#55A868",
|
||||||
|
"#C44E52",
|
||||||
|
"#8172B3",
|
||||||
|
"#937860",
|
||||||
|
"#DA8BC3",
|
||||||
|
"#8C8C8C",
|
||||||
|
"#CCB974",
|
||||||
|
"#64B5CD",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text, width: int = 22) -> str:
|
||||||
|
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
|
||||||
|
s = "" if text is None else str(text)
|
||||||
|
if len(s) <= width:
|
||||||
|
return s
|
||||||
|
if width <= 1:
|
||||||
|
return s[:width]
|
||||||
|
return s[: width - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _message_figure(message: str, color: str = _MUTED_TEXT, title: str = "") -> "Figure":
|
||||||
|
"""Return a fallback ``Figure`` carrying a single centered message."""
|
||||||
|
fig = Figure(figsize=(6.4, 4.0), dpi=150)
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
ax.axis("off")
|
||||||
|
ax.text(
|
||||||
|
0.5,
|
||||||
|
0.5,
|
||||||
|
message,
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
fontsize=12,
|
||||||
|
color=color,
|
||||||
|
wrap=True,
|
||||||
|
transform=ax.transAxes,
|
||||||
|
)
|
||||||
|
if title:
|
||||||
|
ax.set_title(_truncate(title, 48), fontsize=12, loc="center", pad=8)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def categorical_top_bar_figure(
|
||||||
|
top: list,
|
||||||
|
n_distinct: int = 0,
|
||||||
|
title: str = "",
|
||||||
|
top_k: int = 6,
|
||||||
|
n_rows=None,
|
||||||
|
) -> "matplotlib.figure.Figure":
|
||||||
|
"""Build a horizontal bar figure of the most common categories of a column.
|
||||||
|
|
||||||
|
Renders the ``top_k`` most frequent categories as explicit horizontal bars,
|
||||||
|
largest at the top, and aggregates every remaining category into a single
|
||||||
|
gray "Otros (N categorías)" bar at the bottom. Each bar is annotated with its
|
||||||
|
count and percentage of the total at the end of the bar; the category names
|
||||||
|
are truncated Y tick labels.
|
||||||
|
|
||||||
|
The function shares the exact input contract of
|
||||||
|
``categorical_top_pie_figure`` (the donut twin) so it is a drop-in swap. It is
|
||||||
|
fully defensive: empty input, missing/``None`` values or counts never raise.
|
||||||
|
When there is nothing valid to draw it still returns a ``Figure`` carrying a
|
||||||
|
centered "sin datos categóricos" message, and any unexpected error is caught
|
||||||
|
and turned into a fallback ``Figure`` carrying the error text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
top: List of ``{value, count, pct}`` dicts, already sorted by ``count``
|
||||||
|
descending (the ``top`` block of ``summarize_categorical``). May be
|
||||||
|
empty or carry incomplete/``None`` entries; non-dict items, items
|
||||||
|
without a positive numeric ``count`` and ``None`` counts are skipped.
|
||||||
|
n_distinct: Total number of distinct categories in the column. Used to
|
||||||
|
label the aggregated bar as "Otros (n_distinct - top_k)" (floored at
|
||||||
|
0). Ignored when it does not exceed the number of shown bars.
|
||||||
|
title: Figure title (the column name). Truncated when too long.
|
||||||
|
top_k: Maximum number of explicit bars. Default 6. The "Otros" bar does
|
||||||
|
not count against this limit.
|
||||||
|
n_rows: Optional total row count of the dataset. When given and the sum of
|
||||||
|
shown counts is below ``n_rows``, the "Otros" bar uses
|
||||||
|
``n_rows - sum_shown`` as its count so it is exact with respect to the
|
||||||
|
real total. When omitted, "Otros" uses the sum of the counts that fall
|
||||||
|
outside the shown ``top_k`` (only when ``top`` carries more than
|
||||||
|
``top_k`` items).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``matplotlib.figure.Figure`` with a single horizontal-bar Axes. The
|
||||||
|
caller is responsible for rasterizing/closing it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
safe_title = _truncate(title, 48)
|
||||||
|
|
||||||
|
# --- Defensive parse: keep only well-formed {value, count} with count > 0.
|
||||||
|
cleaned = []
|
||||||
|
if isinstance(top, list):
|
||||||
|
for item in top:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
count = item.get("count")
|
||||||
|
if count is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
count = float(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if count <= 0:
|
||||||
|
continue
|
||||||
|
cleaned.append((item.get("value"), count))
|
||||||
|
|
||||||
|
if not cleaned:
|
||||||
|
return _message_figure("sin datos categóricos", title=title)
|
||||||
|
|
||||||
|
# --- Split into shown bars and the aggregated remainder.
|
||||||
|
shown = cleaned[: max(int(top_k), 0)]
|
||||||
|
if not shown: # top_k <= 0 — show at least the largest category.
|
||||||
|
shown = cleaned[:1]
|
||||||
|
|
||||||
|
sum_shown = sum(c for _, c in shown)
|
||||||
|
overflow_count = sum(c for _, c in cleaned[len(shown):])
|
||||||
|
|
||||||
|
# How many categories are folded into "Otros".
|
||||||
|
try:
|
||||||
|
nd = int(n_distinct)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
nd = 0
|
||||||
|
others_categories = max(nd - len(shown), 0)
|
||||||
|
# If n_distinct is unknown/too small, fall back to the overflow we
|
||||||
|
# actually have in `top` beyond the shown bars.
|
||||||
|
overflow_items = len(cleaned) - len(shown)
|
||||||
|
if others_categories == 0 and overflow_items > 0:
|
||||||
|
others_categories = overflow_items
|
||||||
|
|
||||||
|
# Count attributed to the "Otros" bar.
|
||||||
|
others_count = 0.0
|
||||||
|
if n_rows is not None:
|
||||||
|
try:
|
||||||
|
total_rows = float(n_rows)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total_rows = None
|
||||||
|
if total_rows is not None and total_rows > sum_shown:
|
||||||
|
others_count = total_rows - sum_shown
|
||||||
|
if others_count <= 0:
|
||||||
|
others_count = overflow_count
|
||||||
|
|
||||||
|
# --- Build the display order (top to bottom): largest .. smallest, Otros.
|
||||||
|
display_labels = [_truncate(v, 22) for v, _ in shown]
|
||||||
|
display_values = [c for _, c in shown]
|
||||||
|
display_colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(shown))]
|
||||||
|
|
||||||
|
has_others = others_count > 0 and others_categories > 0
|
||||||
|
if has_others:
|
||||||
|
display_labels.append(f"Otros ({others_categories} categorías)")
|
||||||
|
display_values.append(others_count)
|
||||||
|
display_colors.append(_OTHER_COLOR)
|
||||||
|
|
||||||
|
total = sum(display_values) or 1.0
|
||||||
|
|
||||||
|
# barh draws bottom-up, so reverse the display order before plotting to
|
||||||
|
# land the largest category on top and "Otros" at the bottom.
|
||||||
|
labels = list(reversed(display_labels))
|
||||||
|
values = list(reversed(display_values))
|
||||||
|
colors = list(reversed(display_colors))
|
||||||
|
y_pos = range(len(values))
|
||||||
|
|
||||||
|
# Height scales with the number of bars so dense reports stay readable.
|
||||||
|
n_bars = len(values)
|
||||||
|
height = max(2.4, min(0.4 * n_bars + 1.2, 14.0))
|
||||||
|
fig = Figure(figsize=(6.4, height), dpi=150)
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
|
||||||
|
ax.barh(list(y_pos), values, color=colors, edgecolor="white")
|
||||||
|
ax.set_yticks(list(y_pos))
|
||||||
|
ax.set_yticklabels(labels, fontsize=8)
|
||||||
|
ax.set_xlabel("conteo", fontsize=9)
|
||||||
|
|
||||||
|
max_val = max(values) if values else 1.0
|
||||||
|
ax.set_xlim(0, max_val * 1.18 if max_val > 0 else 1.0)
|
||||||
|
|
||||||
|
# Annotate each bar with its count and percentage at the end of the bar.
|
||||||
|
for y, val in zip(y_pos, values):
|
||||||
|
pct = val / total * 100.0
|
||||||
|
ax.text(
|
||||||
|
val + max_val * 0.012,
|
||||||
|
y,
|
||||||
|
f"{int(round(val))} ({pct:.0f}%)",
|
||||||
|
va="center",
|
||||||
|
ha="left",
|
||||||
|
fontsize=7,
|
||||||
|
color="#202020",
|
||||||
|
)
|
||||||
|
|
||||||
|
if safe_title:
|
||||||
|
ax.set_title(safe_title, fontsize=13, loc="left", pad=10)
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
|
||||||
|
return _message_figure(
|
||||||
|
f"error al dibujar barras: {exc}", color=_ERROR_TEXT
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests para categorical_top_bar_figure (barras de categorías top, grupo eda).
|
||||||
|
|
||||||
|
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
|
||||||
|
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
|
||||||
|
estado entre tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
from matplotlib.figure import Figure # noqa: E402
|
||||||
|
|
||||||
|
from categorical_top_bar_figure import categorical_top_bar_figure
|
||||||
|
|
||||||
|
|
||||||
|
def _make_top(n):
|
||||||
|
"""n items {value, count, pct} ordenados desc por count."""
|
||||||
|
return [
|
||||||
|
{"value": f"cat_{i}", "count": n - i, "pct": (n - i) / sum(range(1, n + 1))}
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_count(ax):
|
||||||
|
"""Devuelve el nº de barras (longitud del primer BarContainer del Axes)."""
|
||||||
|
if ax.containers:
|
||||||
|
return len(ax.containers[0])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_figure():
|
||||||
|
fig = categorical_top_bar_figure(_make_top(3), n_distinct=3, title="col")
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ten_items_topk_six_yields_seven_bars():
|
||||||
|
top = _make_top(10)
|
||||||
|
fig = categorical_top_bar_figure(top, n_distinct=10, title="muchas", top_k=6)
|
||||||
|
ax = fig.axes[0]
|
||||||
|
# 6 categorías explícitas + 1 barra "Otros".
|
||||||
|
assert _bar_count(ax) == 7
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_top_does_not_raise_and_returns_figure():
|
||||||
|
fig = categorical_top_bar_figure([], n_distinct=0, title="vacía")
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
# Sin datos: no debe haber barras.
|
||||||
|
assert _bar_count(fig.axes[0]) == 0
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_value_truncated():
|
||||||
|
long_value = "una_categoria_con_un_nombre_larguisimo_que_excede_el_limite"
|
||||||
|
top = [
|
||||||
|
{"value": long_value, "count": 10, "pct": 0.5},
|
||||||
|
{"value": "corta", "count": 10, "pct": 0.5},
|
||||||
|
]
|
||||||
|
fig = categorical_top_bar_figure(top, n_distinct=2, title="col", top_k=6)
|
||||||
|
ax = fig.axes[0]
|
||||||
|
tick_texts = [t.get_text() for t in ax.get_yticklabels()]
|
||||||
|
# El valor largo aparece truncado con elipsis y NO en su forma completa.
|
||||||
|
assert any("…" in t for t in tick_texts)
|
||||||
|
assert long_value not in " ".join(tick_texts)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_value_and_none_count_are_handled():
|
||||||
|
top = [
|
||||||
|
{"value": None, "count": 5, "pct": 0.5},
|
||||||
|
{"value": "b", "count": None, "pct": 0.0}, # count None -> se descarta
|
||||||
|
{"value": "c", "count": 5, "pct": 0.5},
|
||||||
|
]
|
||||||
|
fig = categorical_top_bar_figure(top, n_distinct=2, title="con nones", top_k=6)
|
||||||
|
assert isinstance(fig, Figure)
|
||||||
|
# Solo 2 items válidos, sin overflow -> 2 barras, sin "Otros".
|
||||||
|
assert _bar_count(fig.axes[0]) == 2
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_n_rows_adds_exact_others_bar():
|
||||||
|
# 3 categorías mostradas suman 30, dataset real 100 -> "Otros" = 70.
|
||||||
|
top = [
|
||||||
|
{"value": "a", "count": 15, "pct": 0.15},
|
||||||
|
{"value": "b", "count": 10, "pct": 0.10},
|
||||||
|
{"value": "c", "count": 5, "pct": 0.05},
|
||||||
|
]
|
||||||
|
fig = categorical_top_bar_figure(
|
||||||
|
top, n_distinct=20, title="col", top_k=3, n_rows=100
|
||||||
|
)
|
||||||
|
ax = fig.axes[0]
|
||||||
|
# 3 explícitas + Otros.
|
||||||
|
assert _bar_count(ax) == 4
|
||||||
|
tick_texts = [t.get_text() for t in ax.get_yticklabels()]
|
||||||
|
# La barra Otros refleja n_distinct - top_k = 17 categorías.
|
||||||
|
assert any("Otros (17 categorías)" in t for t in tick_texts)
|
||||||
|
# Su anotación lleva el count 70.
|
||||||
|
annotation_texts = [t.get_text() for t in ax.texts]
|
||||||
|
assert any("70" in t for t in annotation_texts)
|
||||||
|
plt.close(fig)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: classify_relationship_type
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def classify_relationship_type(xs: list, ys: list) -> dict"
|
||||||
|
description: "Clasifica el TIPO de relacion entre dos variables numericas pareadas por indice para el EDA automatico del grupo eda. Limpia los pares de forma defensiva (descarta None/bool/NaN/inf), reusa pearson y spearman_corr del registry y ajusta polinomios de grado 2 y 3 con numpy.polyfit (R^2 manual), y a partir de esas senales etiqueta la forma: 'lineal', 'polinomica (grado 2/3)', 'monotona no-lineal' o 'debil/sin forma'. Orden de decision: debil -> monotona -> polinomica -> lineal (la primera que matchea gana), con umbrales calibrados para datos reales discretos/ruidosos. Devuelve ademas los coeficientes del mejor modelo en orden de numpy.polyval para pintar la curva de ajuste sobre el scatter. Funcion pura no-throw: ante datos insuficientes (menos de 5 pares validos o varianza ~0) o cualquier fallo devuelve el dict canonico con tipo='debil/sin forma' y el resto a None."
|
||||||
|
tags: [eda, correlation, relationship, classification, polyfit, datascience, pure]
|
||||||
|
params:
|
||||||
|
- name: xs
|
||||||
|
desc: "Lista (o tupla) de valores numericos de la primera variable, pareada por indice con ys. Cada par xs[i],ys[i] se descarta si cualquiera de los dos es None, bool, NaN o inf. Lectura defensiva."
|
||||||
|
- name: ys
|
||||||
|
desc: "Lista (o tupla) de valores numericos de la segunda variable, pareada por indice con xs. Mismas reglas de limpieza que xs."
|
||||||
|
output: "Dict con SIEMPRE las mismas 8 claves: tipo (str: 'lineal' | 'polinómica (grado 2)' | 'polinómica (grado 3)' | 'monótona no-lineal' | 'débil/sin forma'); pearson (float|None: coeficiente de Pearson r); r2_linear (float|None: r**2 del ajuste lineal); spearman (float|None: rho de Spearman); r2_poly2 (float|None: R^2 del ajuste polinomico de grado 2); r2_poly3 (float|None: R^2 del ajuste de grado 3); best_degree (int|None: grado del modelo elegido — 1 lineal, 2/3 polinomico, None si monotona/debil); coeffs (list|None: coeficientes del mejor modelo en orden de numpy.polyval para pintar la curva, o None). Ante datos insuficientes o error: tipo='débil/sin forma' y el resto de claves a None."
|
||||||
|
uses_functions: [pearson_py_datascience, spearman_corr_py_datascience]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [numpy]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_lineal", "test_polinomica_cuadratica", "test_monotona_no_lineal", "test_monotona_exponencial", "test_debil_sin_forma", "test_lista_vacia_no_lanza", "test_longitudes_distintas_no_lanza", "test_todos_none_no_lanza", "test_entradas_none_no_lanza", "test_constante_no_lanza", "test_filtra_nan_inf_bool"]
|
||||||
|
test_file_path: "python/functions/datascience/classify_relationship_type_test.py"
|
||||||
|
file_path: "python/functions/datascience/classify_relationship_type.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience.classify_relationship_type import classify_relationship_type
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Relacion claramente cuadratica (forma de parabola) sobre dominio simetrico.
|
||||||
|
x = list(np.linspace(-10, 10, 60))
|
||||||
|
y = [v * v for v in x]
|
||||||
|
|
||||||
|
res = classify_relationship_type(x, y)
|
||||||
|
print(res["tipo"]) # 'polinómica (grado 2)'
|
||||||
|
print(res["best_degree"]) # 2
|
||||||
|
print(res["r2_linear"]) # 0.0 -> el Pearson lineal no ve la parabola
|
||||||
|
print(res["r2_poly2"]) # 1.0
|
||||||
|
print(res["coeffs"]) # [1.0, -0.0, -0.0] -> numpy.polyval(coeffs, x) ~ x**2
|
||||||
|
|
||||||
|
# El capitulo pinta la curva de ajuste cuando coeffs no es None:
|
||||||
|
# if res["coeffs"] is not None:
|
||||||
|
# xs_fit = np.linspace(min(x), max(x), 200)
|
||||||
|
# ys_fit = np.polyval(res["coeffs"], xs_fit)
|
||||||
|
# ax.plot(xs_fit, ys_fit) # curva sobre el ax.scatter(x, y)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Usala en el capitulo de relaciones/correlaciones del EDA automatico, despues de detectar dos columnas numericas con alguna asociacion, para decidir QUE curva de ajuste pintar sobre el scatter (recta, parabola, cubica o ninguna) y poner una etiqueta legible al tipo de relacion.
|
||||||
|
- Cuando un Pearson bajo no signifique "sin relacion": esta funcion cruza Pearson con Spearman y con ajustes polinomicos para distinguir una relacion lineal debil de una monotona no-lineal (que el rango si capta) o de una curva polinomica.
|
||||||
|
- Cuando necesites un punto de entrada determinista y no-throw que, con los mismos datos, devuelva siempre el mismo `tipo` y los mismos `coeffs` listos para `numpy.polyval` sin tener que ajustar modelos a mano en el capitulo.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion pura, deterministica y no-throw: ante menos de 5 pares validos, varianza ~0 (xs o ys constante) o cualquier excepcion interna devuelve el dict canonico `tipo="débil/sin forma"` con el resto de claves a `None`. El dict SIEMPRE trae las 8 claves: nunca compruebes existencia, comprueba `None`.
|
||||||
|
- El orden de decision importa: `débil -> monótona -> polinómica -> lineal` (la primera que matchee gana). La monotonia se evalua ANTES que el ajuste polinomico, asi que una curva monotona suave (exp, log, potencias) sale `monótona no-lineal` aunque un cubico tambien la ajuste — la dominancia del rango (Spearman >> Pearson) es la senal mas interpretable. Solo cae en `polinómica` una forma curva NO monotona (p.ej. una parabola, Spearman ~0 pero R^2 polinomico alto).
|
||||||
|
- Umbrales fijos (calibrados para EDA con datos discretos/ruidosos, no para inferencia formal): `débil/sin forma` si las tres senales son bajas a la vez (`abs(pearson) < 0.3` y `abs(spearman) < 0.3` y `mejor_poly < 0.3`); `monótona no-lineal` si `abs(spearman) - abs(pearson) >= 0.1` y `abs(spearman) >= 0.4`; `polinómica (grado N)` si el mejor polinomico mejora `>= 0.1` sobre el lineal y su R^2 `>= 0.3`; en cualquier otro caso con senal (no debil) `lineal`. El suelo de 0.3 evita llamar "debil" a relaciones reales pero discretas (conteos, escalas ordinales) con R^2 bajo pero direccion clara.
|
||||||
|
- `coeffs` va en orden de `numpy.polyval` (grado descendente). Para `lineal` es `[pendiente, intercepto]` (grado 1); para `polinómica` los del grado elegido; para `monótona no-lineal` y `débil/sin forma` es `None` (el scatter pintara una curva suavizada o nada — lo decide el capitulo, no esta funcion).
|
||||||
|
- `best_degree` prefiere el grado 2 sobre el 3 cuando empatan dentro de 0.02 de R^2 (parsimonia): no esperes grado 3 salvo que mejore claramente.
|
||||||
|
- Los pares con `None`, `bool`, `NaN` o `inf` se descartan por indice en silencio; `bool` cuenta como no-numerico (un `True` no es `1`). El dominio de los datos afecta al resultado: una parabola sobre un dominio simetrico da Pearson ~0 (sale `polinómica`), pero sobre un dominio asimetrico el Pearson sube y puede salir `lineal`.
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Clasifica el TIPO de relacion entre dos variables numericas pareadas.
|
||||||
|
|
||||||
|
Funcion pura del grupo eda. Dadas dos listas numericas pareadas por indice,
|
||||||
|
limpia los pares de forma defensiva, calcula correlaciones lineal (Pearson) y de
|
||||||
|
rangos (Spearman) y ajustes polinomicos de grado 2 y 3, y a partir de esas
|
||||||
|
senales etiqueta la forma de la relacion para el EDA automatico:
|
||||||
|
|
||||||
|
"lineal" | "polinómica (grado 2)" | "polinómica (grado 3)" |
|
||||||
|
"monótona no-lineal" | "débil/sin forma"
|
||||||
|
|
||||||
|
Ademas devuelve los coeficientes del mejor modelo (en orden de numpy.polyval)
|
||||||
|
para que el capitulo pinte la curva de ajuste sobre el scatter. Reusa las
|
||||||
|
funciones del registry `pearson` y `spearman_corr` en vez de reimplementarlas.
|
||||||
|
|
||||||
|
NUNCA lanza: ante cualquier fallo o dato insuficiente devuelve el dict canonico
|
||||||
|
con tipo="débil/sin forma" y el resto de claves a None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from datascience.datascience import pearson
|
||||||
|
from datascience.spearman_corr import spearman_corr
|
||||||
|
|
||||||
|
# Forma canonica de la respuesta cuando no se puede clasificar (datos
|
||||||
|
# insuficientes, varianza nula o error interno). Siempre las mismas claves.
|
||||||
|
_WEAK = {
|
||||||
|
"tipo": "débil/sin forma",
|
||||||
|
"pearson": None,
|
||||||
|
"r2_linear": None,
|
||||||
|
"spearman": None,
|
||||||
|
"r2_poly2": None,
|
||||||
|
"r2_poly3": None,
|
||||||
|
"best_degree": None,
|
||||||
|
"coeffs": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_num(v) -> bool:
|
||||||
|
"""True si v es un numero real finito (int/float, no bool, no NaN, no inf)."""
|
||||||
|
return (
|
||||||
|
isinstance(v, (int, float))
|
||||||
|
and not isinstance(v, bool)
|
||||||
|
and not (isinstance(v, float) and (math.isnan(v) or math.isinf(v)))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _poly_r2(coeffs, x_arr, y_arr, ss_tot: float) -> float:
|
||||||
|
"""R^2 de un ajuste polinomico: 1 - SS_res/SS_tot. 0 si SS_tot==0."""
|
||||||
|
if ss_tot == 0.0:
|
||||||
|
return 0.0
|
||||||
|
pred = np.polyval(coeffs, x_arr)
|
||||||
|
ss_res = float(np.sum((y_arr - pred) ** 2))
|
||||||
|
return 1.0 - ss_res / ss_tot
|
||||||
|
|
||||||
|
|
||||||
|
def classify_relationship_type(xs: list, ys: list) -> dict:
|
||||||
|
"""Clasifica el tipo de relacion entre dos variables numericas pareadas.
|
||||||
|
|
||||||
|
Empareja xs[i],ys[i] por indice y descarta el par si cualquiera de los dos
|
||||||
|
es None, bool, NaN o inf. Sobre los pares limpios calcula Pearson r
|
||||||
|
(r2_linear = r**2), Spearman rho y los R^2 de ajustes polinomicos de grado 2
|
||||||
|
y 3 (con numpy.polyfit + R^2 manual). Con esas senales decide la etiqueta.
|
||||||
|
|
||||||
|
Orden de evaluacion de la etiqueta (la primera que matchee gana). Los
|
||||||
|
umbrales estan calibrados para datos reales, a menudo discretos y ruidosos
|
||||||
|
(conteos, escalas ordinales): una relacion con |r| >= 0.3, |rho| >= 0.3 o un
|
||||||
|
polinomio con R^2 >= 0.3 ya tiene FORMA y no debe etiquetarse como "debil".
|
||||||
|
1. "débil/sin forma" — todas las senales bajas a la vez:
|
||||||
|
abs(pearson) < 0.3 y abs(spearman) < 0.3 y mejor_poly < 0.3.
|
||||||
|
2. "monótona no-lineal" — el rango (Spearman) capta una monotonia que el
|
||||||
|
Pearson lineal no: abs(spearman) - abs(pearson) >= 0.1 y
|
||||||
|
abs(spearman) >= 0.4. No se fuerza un polinomio (coeffs/best_degree =
|
||||||
|
None); el capitulo dibuja la tendencia ordenada sobre el scatter.
|
||||||
|
3. "polinómica (grado N)" — el mejor polinomico mejora claramente sobre
|
||||||
|
el lineal (mejor_poly - r2_linear >= 0.1) y mejor_poly >= 0.3. N es el
|
||||||
|
grado (2 o 3) con mejor R^2, prefiriendo el 2 si empatan dentro de 0.02
|
||||||
|
(parsimonia).
|
||||||
|
4. "lineal" — el resto: hay senal (no es debil) y la forma que existe es
|
||||||
|
esencialmente lineal. best_degree=1, coeffs del ajuste de grado 1.
|
||||||
|
|
||||||
|
Si hay menos de 5 pares validos, o la varianza de xs o de ys es ~0
|
||||||
|
(constante), devuelve directamente "débil/sin forma".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xs: lista (o tupla) de valores numericos de la primera variable,
|
||||||
|
pareada por indice con ys. Pares con None/bool/NaN/inf se descartan.
|
||||||
|
ys: lista (o tupla) de valores numericos de la segunda variable,
|
||||||
|
pareada por indice con xs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con SIEMPRE las mismas claves:
|
||||||
|
tipo (str), pearson (float|None), r2_linear (float|None),
|
||||||
|
spearman (float|None), r2_poly2 (float|None), r2_poly3 (float|None),
|
||||||
|
best_degree (int|None: 1, 2, 3 o None),
|
||||||
|
coeffs (list|None: coeficientes en orden de numpy.polyval, o None).
|
||||||
|
Nunca lanza: ante fallo o datos insuficientes devuelve el dict debil.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if xs is None or ys is None:
|
||||||
|
return dict(_WEAK)
|
||||||
|
|
||||||
|
pairs = [
|
||||||
|
(float(x), float(y))
|
||||||
|
for x, y in zip(xs, ys)
|
||||||
|
if _is_num(x) and _is_num(y)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Datos insuficientes para hablar de forma de la relacion.
|
||||||
|
if len(pairs) < 5:
|
||||||
|
return dict(_WEAK)
|
||||||
|
|
||||||
|
clean_x = [p[0] for p in pairs]
|
||||||
|
clean_y = [p[1] for p in pairs]
|
||||||
|
|
||||||
|
# Varianza ~0 en cualquiera de las series => relacion indefinida.
|
||||||
|
if len(set(clean_x)) < 2 or len(set(clean_y)) < 2:
|
||||||
|
return dict(_WEAK)
|
||||||
|
x_arr = np.asarray(clean_x, dtype=float)
|
||||||
|
y_arr = np.asarray(clean_y, dtype=float)
|
||||||
|
if float(np.var(x_arr)) < 1e-15 or float(np.var(y_arr)) < 1e-15:
|
||||||
|
return dict(_WEAK)
|
||||||
|
|
||||||
|
# Correlaciones reutilizando las funciones del registry.
|
||||||
|
r = pearson(clean_x, clean_y)
|
||||||
|
spearman = spearman_corr(clean_x, clean_y)
|
||||||
|
r2_linear = r ** 2
|
||||||
|
|
||||||
|
# Ajustes polinomicos grado 2 y 3 con R^2 manual.
|
||||||
|
ss_tot = float(np.sum((y_arr - float(np.mean(y_arr))) ** 2))
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
c1 = np.polyfit(x_arr, y_arr, 1)
|
||||||
|
c2 = np.polyfit(x_arr, y_arr, 2)
|
||||||
|
c3 = np.polyfit(x_arr, y_arr, 3)
|
||||||
|
r2_poly2 = _poly_r2(c2, x_arr, y_arr, ss_tot)
|
||||||
|
r2_poly3 = _poly_r2(c3, x_arr, y_arr, ss_tot)
|
||||||
|
|
||||||
|
mejor_poly = max(r2_poly2, r2_poly3)
|
||||||
|
# Grado del mejor polinomico, con preferencia por la parsimonia: solo se
|
||||||
|
# elige el grado 3 si supera al grado 2 por mas de 0.02.
|
||||||
|
best_poly_degree = 3 if (r2_poly3 - r2_poly2) > 0.02 else 2
|
||||||
|
|
||||||
|
abs_s = abs(spearman)
|
||||||
|
abs_p = abs(r)
|
||||||
|
|
||||||
|
# Decision en orden: debil-temprano -> monotona -> polinomica -> lineal.
|
||||||
|
if abs_p < 0.3 and abs_s < 0.3 and mejor_poly < 0.3:
|
||||||
|
# Ninguna senal supera el suelo de forma: relacion debil/sin forma.
|
||||||
|
tipo = "débil/sin forma"
|
||||||
|
best_degree = None
|
||||||
|
coeffs = None
|
||||||
|
elif (abs_s - abs_p) >= 0.1 and abs_s >= 0.4:
|
||||||
|
# Spearman (rango) capta una monotonia que el Pearson lineal no:
|
||||||
|
# relacion monotona no-lineal. No se fuerza un polinomio que tal vez
|
||||||
|
# no ajusta bien; el capitulo dibuja la tendencia ordenada.
|
||||||
|
tipo = "monótona no-lineal"
|
||||||
|
best_degree = None
|
||||||
|
coeffs = None
|
||||||
|
elif (mejor_poly - r2_linear) >= 0.1 and mejor_poly >= 0.3:
|
||||||
|
tipo = "polinómica (grado {})".format(best_poly_degree)
|
||||||
|
best_degree = best_poly_degree
|
||||||
|
best_coeffs = c2 if best_poly_degree == 2 else c3
|
||||||
|
coeffs = [float(c) for c in best_coeffs]
|
||||||
|
else:
|
||||||
|
# Hay senal (no es debil) y no es ni monotona-pura ni polinomica:
|
||||||
|
# la correlacion que existe es esencialmente lineal.
|
||||||
|
tipo = "lineal"
|
||||||
|
best_degree = 1
|
||||||
|
coeffs = [float(c) for c in c1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tipo": tipo,
|
||||||
|
"pearson": round(float(r), 6),
|
||||||
|
"r2_linear": round(float(r2_linear), 6),
|
||||||
|
"spearman": round(float(spearman), 6),
|
||||||
|
"r2_poly2": round(float(r2_poly2), 6),
|
||||||
|
"r2_poly3": round(float(r2_poly3), 6),
|
||||||
|
"best_degree": best_degree,
|
||||||
|
"coeffs": (
|
||||||
|
[round(c, 8) for c in coeffs] if coeffs is not None else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return dict(_WEAK)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Tests para classify_relationship_type."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from classify_relationship_type import classify_relationship_type
|
||||||
|
|
||||||
|
# Claves que el dict de salida debe contener SIEMPRE.
|
||||||
|
_EXPECTED_KEYS = {
|
||||||
|
"tipo", "pearson", "r2_linear", "spearman",
|
||||||
|
"r2_poly2", "r2_poly3", "best_degree", "coeffs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_shape(r):
|
||||||
|
"""Toda salida tiene exactamente las 8 claves canonicas."""
|
||||||
|
assert isinstance(r, dict)
|
||||||
|
assert set(r.keys()) == _EXPECTED_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_lineal():
|
||||||
|
"""Golden: y = 2x + 1 con ruido pequeno -> 'lineal', best_degree=1."""
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
x = np.linspace(0.0, 10.0, 50)
|
||||||
|
y = 2.0 * x + 1.0 + rng.normal(0.0, 0.3, 50)
|
||||||
|
|
||||||
|
r = classify_relationship_type(list(x), list(y))
|
||||||
|
_assert_shape(r)
|
||||||
|
|
||||||
|
assert r["tipo"] == "lineal"
|
||||||
|
assert r["best_degree"] == 1
|
||||||
|
assert r["r2_linear"] >= 0.5
|
||||||
|
# coeffs ~ [pendiente, intercepto] del ajuste de grado 1.
|
||||||
|
assert r["coeffs"] is not None and len(r["coeffs"]) == 2
|
||||||
|
assert abs(r["coeffs"][0] - 2.0) < 0.1 # pendiente ~2
|
||||||
|
assert abs(r["coeffs"][1] - 1.0) < 0.3 # intercepto ~1
|
||||||
|
|
||||||
|
|
||||||
|
def test_polinomica_cuadratica():
|
||||||
|
"""Golden: y = x**2 sobre [-10, 10] -> 'polinómica', best_degree in (2, 3)."""
|
||||||
|
x = np.linspace(-10.0, 10.0, 60)
|
||||||
|
y = x ** 2
|
||||||
|
|
||||||
|
r = classify_relationship_type(list(x), list(y))
|
||||||
|
_assert_shape(r)
|
||||||
|
|
||||||
|
assert r["tipo"].startswith("polinómica")
|
||||||
|
assert r["best_degree"] in (2, 3)
|
||||||
|
# Una parabola perfecta queda capturada por el grado 2 (parsimonia).
|
||||||
|
assert r["best_degree"] == 2
|
||||||
|
assert r["r2_poly2"] > 0.99
|
||||||
|
assert r["coeffs"] is not None and len(r["coeffs"]) == r["best_degree"] + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_monotona_no_lineal():
|
||||||
|
"""Golden: monotona convexa de cola pesada -> 'monótona no-lineal'.
|
||||||
|
|
||||||
|
y = 1/(N+1-i)**2 es estrictamente creciente (Spearman ~ 1) pero su cola
|
||||||
|
explosiva hace que ni la recta ni un polinomio de grado 2/3 la ajusten
|
||||||
|
(R^2 polinomico < 0.5), de modo que el Pearson lineal NO capta la relacion
|
||||||
|
que el rango (Spearman) si ve. Construccion deterministica (sin azar).
|
||||||
|
"""
|
||||||
|
n = 200
|
||||||
|
i = np.arange(n, dtype=float)
|
||||||
|
y = 1.0 / (n + 1 - i) ** 2
|
||||||
|
|
||||||
|
r = classify_relationship_type(list(i), list(y))
|
||||||
|
_assert_shape(r)
|
||||||
|
|
||||||
|
assert r["tipo"] == "monótona no-lineal"
|
||||||
|
assert r["best_degree"] is None
|
||||||
|
assert r["coeffs"] is None
|
||||||
|
# Spearman fuerte y claramente por encima del Pearson.
|
||||||
|
assert abs(r["spearman"]) >= 0.5
|
||||||
|
assert abs(r["spearman"]) - abs(r["pearson"]) >= 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def test_monotona_exponencial():
|
||||||
|
"""DoD literal: y = exp(x) (monotona no-lineal) -> 'monótona no-lineal'.
|
||||||
|
|
||||||
|
exp es estrictamente creciente (Spearman = 1) pero el Pearson lineal queda
|
||||||
|
claramente por debajo (~0.86), así que la dominancia del rango la marca como
|
||||||
|
monótona no-lineal en vez de lineal o polinómica.
|
||||||
|
"""
|
||||||
|
x = np.linspace(0.0, 5.0, 80)
|
||||||
|
y = np.exp(x)
|
||||||
|
|
||||||
|
r = classify_relationship_type(list(x), list(y))
|
||||||
|
_assert_shape(r)
|
||||||
|
|
||||||
|
assert r["tipo"] == "monótona no-lineal"
|
||||||
|
assert r["best_degree"] is None and r["coeffs"] is None
|
||||||
|
assert abs(r["spearman"]) >= 0.9
|
||||||
|
assert abs(r["spearman"]) - abs(r["pearson"]) >= 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_debil_sin_forma():
|
||||||
|
"""Golden: x e y independientes (semilla fija) -> 'débil/sin forma'."""
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
x = rng.normal(0.0, 1.0, 200)
|
||||||
|
y = rng.normal(0.0, 1.0, 200)
|
||||||
|
|
||||||
|
r = classify_relationship_type(list(x), list(y))
|
||||||
|
_assert_shape(r)
|
||||||
|
|
||||||
|
assert r["tipo"] == "débil/sin forma"
|
||||||
|
assert r["best_degree"] is None
|
||||||
|
assert r["coeffs"] is None
|
||||||
|
# Todas las senales son bajas.
|
||||||
|
assert abs(r["pearson"]) < 0.3
|
||||||
|
assert r["r2_linear"] < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_vacia_no_lanza():
|
||||||
|
"""Edge: listas vacias -> dict debil canonico, sin lanzar."""
|
||||||
|
r = classify_relationship_type([], [])
|
||||||
|
_assert_shape(r)
|
||||||
|
assert r["tipo"] == "débil/sin forma"
|
||||||
|
assert r["pearson"] is None
|
||||||
|
assert r["r2_linear"] is None
|
||||||
|
assert r["spearman"] is None
|
||||||
|
assert r["r2_poly2"] is None
|
||||||
|
assert r["r2_poly3"] is None
|
||||||
|
assert r["best_degree"] is None
|
||||||
|
assert r["coeffs"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_longitudes_distintas_no_lanza():
|
||||||
|
"""Edge: listas de distinta longitud -> empareja por indice, no lanza."""
|
||||||
|
# zip trunca a la longitud minima: solo 3 pares (< 5) -> debil.
|
||||||
|
r = classify_relationship_type([1, 2, 3, 4, 5, 6, 7, 8], [1.0, 2.0, 3.0])
|
||||||
|
_assert_shape(r)
|
||||||
|
assert r["tipo"] == "débil/sin forma"
|
||||||
|
assert r["best_degree"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_todos_none_no_lanza():
|
||||||
|
"""Edge: todos los valores None -> ningun par valido -> debil, no lanza."""
|
||||||
|
r = classify_relationship_type([None, None, None, None, None, None],
|
||||||
|
[None, None, None, None, None, None])
|
||||||
|
_assert_shape(r)
|
||||||
|
assert r["tipo"] == "débil/sin forma"
|
||||||
|
assert r["coeffs"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_entradas_none_no_lanza():
|
||||||
|
"""Edge: xs/ys None directamente -> debil, no lanza."""
|
||||||
|
assert classify_relationship_type(None, None)["tipo"] == "débil/sin forma"
|
||||||
|
assert classify_relationship_type([1.0, 2.0], None)["tipo"] == "débil/sin forma"
|
||||||
|
|
||||||
|
|
||||||
|
def test_constante_no_lanza():
|
||||||
|
"""Edge: ys constante (varianza ~0) -> debil, no lanza."""
|
||||||
|
r = classify_relationship_type([1, 2, 3, 4, 5, 6, 7], [5, 5, 5, 5, 5, 5, 5])
|
||||||
|
_assert_shape(r)
|
||||||
|
assert r["tipo"] == "débil/sin forma"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtra_nan_inf_bool():
|
||||||
|
"""Edge: pares con NaN/inf/bool/None se descartan por indice."""
|
||||||
|
nan = float("nan")
|
||||||
|
inf = float("inf")
|
||||||
|
# Solo i=0,1,2,3,4 quedan validos (5 pares) y forman una recta perfecta.
|
||||||
|
xs = [0.0, 1.0, 2.0, 3.0, 4.0, nan, inf, True, None]
|
||||||
|
ys = [1.0, 3.0, 5.0, 7.0, 9.0, 1.0, 2.0, 3.0, 4.0]
|
||||||
|
r = classify_relationship_type(xs, ys)
|
||||||
|
_assert_shape(r)
|
||||||
|
# Los 5 pares validos son y = 2x + 1 exacto -> lineal.
|
||||||
|
assert r["tipo"] == "lineal"
|
||||||
|
assert r["best_degree"] == 1
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
id: compute_text_duplicates_py_datascience
|
||||||
|
name: compute_text_duplicates
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def compute_text_duplicates(texts, near_threshold=0.85, sample_max=2000) -> dict"
|
||||||
|
description: "Detecta documentos duplicados en un corpus de texto. Los duplicados EXACTOS se calculan siempre con la stdlib: cada documento se normaliza (colapsa espacios, strip, lower) y se hashea con SHA-1; n_exact_dup es cuántos docs repiten uno ya visto y exact_dup_pct su porcentaje. Los CASI-duplicados (near-dup) usan la dependencia OPCIONAL datasketch (MinHash + LSH sobre 3-shingles de palabras); si no está instalada, esa parte degrada a available:False sin afectar al resto. Estilo dict-no-throw del grupo eda — nunca lanza."
|
||||||
|
tags: [eda, datascience, text, nlp, duplicates, minhash, pure, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [hashlib, re]
|
||||||
|
example: |
|
||||||
|
from datascience.compute_text_duplicates import compute_text_duplicates
|
||||||
|
texts = ["El gato come pescado", "El gato come pescado", "Un perro ladra"]
|
||||||
|
result = compute_text_duplicates(texts)
|
||||||
|
# {"n_docs": 3, "n_exact_dup": 1, "exact_dup_pct": 33.33, "n_unique": 2,
|
||||||
|
# "near_dup": {"available": False, "n_near_dup_docs": 0}}
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_duplicados_exactos"
|
||||||
|
- "test_sin_duplicados"
|
||||||
|
- "test_vacio"
|
||||||
|
- "test_near_dup_degrada"
|
||||||
|
test_file_path: "python/functions/datascience/compute_text_duplicates_test.py"
|
||||||
|
file_path: "python/functions/datascience/compute_text_duplicates.py"
|
||||||
|
params:
|
||||||
|
- name: texts
|
||||||
|
desc: "Lista de documentos de texto. Los elementos None o que no sean str se descartan silenciosamente; n_docs cuenta solo los documentos válidos. None como argumento se trata como lista vacía."
|
||||||
|
- name: near_threshold
|
||||||
|
desc: "Umbral de similitud Jaccard (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,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,77 @@
|
|||||||
|
---
|
||||||
|
name: generate_synthetic_eda_folder
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def generate_synthetic_eda_folder(out_dir: str, n_rows: int = 2000, seed: int = 42) -> dict"
|
||||||
|
description: "Genera una carpeta con 3 CSV RELACIONADOS (customers, orders, reviews) deterministas por seed (Faker + numpy) para ejercitar el motor AutomaticEDA multi-tabla / profile_database. orders.customer_id y reviews.customer_id estan contenidos al 100% en customers.customer_id (PK uuid), de modo que la deteccion FK por containment (min_inclusion=0.9) descubre ambas relaciones. customers es la tabla padre; reutiliza helpers de generate_synthetic_eda_table (texto multi-idioma, lat/lon validas, amount con outliers). Estilo dict-no-throw: nunca lanza."
|
||||||
|
tags: [eda, synthetic, faker, testing, fixture, datascience]
|
||||||
|
params:
|
||||||
|
- name: out_dir
|
||||||
|
desc: "Carpeta de salida. Se crea con mkdir -p si no existe. Recibe customers.csv, orders.csv y reviews.csv."
|
||||||
|
- name: n_rows
|
||||||
|
desc: "Numero de clientes (filas de customers). orders ~= 2*n_rows filas, reviews ~= n_rows filas. Default 2000."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla para Faker (Faker.seed) y numpy (np.random.default_rng). Mismo seed -> CSVs identicos byte a byte. Default 42."
|
||||||
|
output: "dict dict-no-throw. En exito {status:'ok', out_dir, files:{customers,orders,reviews}, n_customers, n_orders, n_reviews, expected_relations:[{from_table,from_col,to_table,to_col}, ...], seed}. En error (sin lanzar, p.ej. n_rows<=0) {status:'error', error:str}. expected_relations declara las 2 FK orders->customers y reviews->customers (ambas por customer_id)."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_genera_ok_y_archivos", "test_determinismo_mismo_seed", "test_seeds_distintos_difieren", "test_fk_containment", "test_review_text_mediana_palabras", "test_n_rows_invalido"]
|
||||||
|
test_file_path: "python/functions/datascience/generate_synthetic_eda_folder_test.py"
|
||||||
|
file_path: "python/functions/datascience/generate_synthetic_eda_folder.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Genera /tmp/eda_folder/{customers,orders,reviews}.csv (300 customers, seed 42)
|
||||||
|
fn run generate_synthetic_eda_folder /tmp/eda_folder 300 42
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience import generate_synthetic_eda_folder
|
||||||
|
|
||||||
|
res = generate_synthetic_eda_folder("/tmp/eda_folder", n_rows=300, seed=42)
|
||||||
|
# res["files"] -> {"customers": ".../customers.csv", "orders": ..., "reviews": ...}
|
||||||
|
# res["expected_relations"] -> orders.customer_id y reviews.customer_id -> customers.customer_id
|
||||||
|
# Luego perfila la carpeta/base con el grupo eda:
|
||||||
|
# fn run profile_database /tmp/eda_folder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando necesites un fixture REPRODUCIBLE multi-tabla para evaluar el EDA de carpeta/base (`profile_database`, join graph, capitulo de relaciones inter-tabla) con relaciones FK reales y detectables.
|
||||||
|
- Cuando escribas tests de la deteccion de claves foraneas por containment: orders y reviews referencian customer_id contenido al 100% en customers (inclusion 1.0 >= min_inclusion 0.9).
|
||||||
|
- Como contraparte multi-tabla de `generate_synthetic_eda_table` (que cubre el EDA de UNA tabla).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: escribe 3 CSV a disco (`mkdir -p` de la carpeta). Sobrescribe los CSV existentes con el mismo nombre.
|
||||||
|
- **Requiere `faker`, `numpy` y `pandas`** en el venv. Sin `faker` devuelve `{status:'error'}` (no lanza).
|
||||||
|
- **El containment depende del orden**: customers se genera PRIMERO y orders/reviews muestrean sus `customer_id`. Si se invierte el orden, la FK deja de estar contenida y el detector no la encuentra.
|
||||||
|
- **`signup_date`/`ts` se escriben como texto ISO en el CSV** (`YYYY-MM-DD` / `YYYY-MM-DD HH:MM:SS`): es CSV, todo es texto; el profiler los promociona a datetime al leerlos.
|
||||||
|
- **Determinismo dependiente del orden de llamadas**: se siembra `Faker.seed(seed)` + `np.random.default_rng(seed)` al inicio; mismo seed -> CSVs identicos byte a byte.
|
||||||
|
- **Reutiliza helpers privados** de `generate_synthetic_eda_table` (`_make_fakers`, `_make_latlon`, `_make_reviews`, `_amount_with_outliers`): no romper esas firmas sin actualizar esta funcion.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Estructura generada:
|
||||||
|
|
||||||
|
| Archivo | PK | FK | Columnas clave |
|
||||||
|
|---|---|---|---|
|
||||||
|
| customers.csv | customer_id (uuid) | — | name, country, signup_date, latitude, longitude, email |
|
||||||
|
| orders.csv | order_id (uuid) | customer_id -> customers | amount (lognormal + outliers), category, ts |
|
||||||
|
| reviews.csv | review_id (uuid) | customer_id -> customers | review_text (multi-idioma, mediana palabras>=20), rating (1..5) |
|
||||||
|
|
||||||
|
orders tiene ~2x filas que customers y reviews ~1x. Todos los `customer_id` de orders
|
||||||
|
y reviews estan contenidos en customers (containment ⊆), por lo que la deteccion FK por
|
||||||
|
inclusion descubre las dos relaciones declaradas en `expected_relations`.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""generate_synthetic_eda_folder — fixture multi-tabla relacionado para el EDA de base/carpeta.
|
||||||
|
|
||||||
|
Funcion impura (escribe CSVs a disco) y determinista por ``seed``: crea una
|
||||||
|
carpeta con 3 CSV RELACIONADOS (customers, orders, reviews) cuyo contenido esta
|
||||||
|
disenado para que el motor AutomaticEDA multi-tabla / `profile_database` detecte
|
||||||
|
las relaciones FK por containment de valores (orders.customer_id y
|
||||||
|
reviews.customer_id contenidos al 100% en customers.customer_id, por encima del
|
||||||
|
``min_inclusion=0.9`` que usa la deteccion).
|
||||||
|
|
||||||
|
Reutiliza los helpers de ``generate_synthetic_eda_table`` (texto multi-idioma,
|
||||||
|
lat/lon validas, amount con outliers, listas fijas de paises/categorias) para no
|
||||||
|
reimplementar logica.
|
||||||
|
|
||||||
|
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; devuelve
|
||||||
|
``{"status": "error", "error": str}`` ante cualquier fallo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .generate_synthetic_eda_table import (
|
||||||
|
_CATEGORIES,
|
||||||
|
_COUNTRIES,
|
||||||
|
_amount_with_outliers,
|
||||||
|
_make_fakers,
|
||||||
|
_make_latlon,
|
||||||
|
_make_reviews,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_synthetic_eda_folder(out_dir, n_rows=2000, seed=42):
|
||||||
|
"""Genera una carpeta con 3 CSV relacionados (customers/orders/reviews).
|
||||||
|
|
||||||
|
customers es la tabla padre (PK ``customer_id`` uuid unica). orders y reviews
|
||||||
|
referencian ``customer_id`` muestreandolo de customers, de modo que TODOS sus
|
||||||
|
valores estan contenidos en customers (inclusion 1.0 -> FK detectable).
|
||||||
|
|
||||||
|
Funcion impura (escribe a disco) y determinista por ``seed``. NUNCA lanza.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
out_dir: carpeta de salida. Se crea con ``mkdir -p`` si no existe.
|
||||||
|
n_rows: numero de clientes (customers). orders ~= 2*n_rows, reviews ~= n_rows.
|
||||||
|
Default 2000.
|
||||||
|
seed: semilla para Faker y numpy. Default 42.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict dict-no-throw. En exito::
|
||||||
|
|
||||||
|
{"status": "ok", "out_dir": ..., "files": {customers, orders, reviews},
|
||||||
|
"n_customers": ..., "n_orders": ..., "n_reviews": ...,
|
||||||
|
"expected_relations": [{from_table, from_col, to_table, to_col}, ...],
|
||||||
|
"seed": seed}
|
||||||
|
|
||||||
|
En error (sin lanzar)::
|
||||||
|
|
||||||
|
{"status": "error", "error": str}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
n = int(n_rows)
|
||||||
|
if n <= 0:
|
||||||
|
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
|
||||||
|
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
fakers = _make_fakers(seed)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
# ---------------- customers (tabla padre) ----------------
|
||||||
|
n_cust = n
|
||||||
|
customer_ids = [fakers["en_US"].uuid4() for _ in range(n_cust)]
|
||||||
|
names = [fakers["en_US"].name() for _ in range(n_cust)]
|
||||||
|
cust_country = rng.choice(_COUNTRIES, n_cust)
|
||||||
|
base = np.datetime64("2022-01-01")
|
||||||
|
signup_offsets = rng.integers(0, 730, n_cust)
|
||||||
|
signup_date = pd.to_datetime(base) + pd.to_timedelta(signup_offsets, unit="D")
|
||||||
|
signup_iso = [d.strftime("%Y-%m-%d") for d in signup_date]
|
||||||
|
lat, lon = _make_latlon(cust_country, rng)
|
||||||
|
cust_email = [fakers["en_US"].email() for _ in range(n_cust)]
|
||||||
|
|
||||||
|
customers = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"customer_id": customer_ids,
|
||||||
|
"name": names,
|
||||||
|
"country": cust_country,
|
||||||
|
"signup_date": signup_iso,
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"email": cust_email,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- orders (FK -> customers) ----------------
|
||||||
|
n_orders = n_cust * 2
|
||||||
|
order_ids = [fakers["en_US"].uuid4() for _ in range(n_orders)]
|
||||||
|
order_cust = rng.choice(customer_ids, n_orders) # subset/multiset de customers
|
||||||
|
amount = _amount_with_outliers(n_orders, rng, n_extreme=10)
|
||||||
|
order_cat = rng.choice(_CATEGORIES, n_orders)
|
||||||
|
ts_offsets = rng.integers(0, 730 * 24 * 3600, n_orders)
|
||||||
|
ts = pd.to_datetime(np.datetime64("2022-01-01T00:00:00")) + pd.to_timedelta(
|
||||||
|
ts_offsets, unit="s"
|
||||||
|
)
|
||||||
|
ts_iso = [t.strftime("%Y-%m-%d %H:%M:%S") for t in ts]
|
||||||
|
|
||||||
|
orders = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"order_id": order_ids,
|
||||||
|
"customer_id": order_cust,
|
||||||
|
"amount": amount,
|
||||||
|
"category": order_cat,
|
||||||
|
"ts": ts_iso,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- reviews (FK -> customers) ----------------
|
||||||
|
n_reviews = n_cust
|
||||||
|
review_ids = [fakers["en_US"].uuid4() for _ in range(n_reviews)]
|
||||||
|
# Subconjunto de customers (no todos) -> containment estricto ⊆ customers.
|
||||||
|
rev_cust = rng.choice(customer_ids, n_reviews)
|
||||||
|
review_text = _make_reviews(n_reviews, rng, fakers, null_frac=0.0)
|
||||||
|
rating = rng.integers(1, 6, n_reviews)
|
||||||
|
|
||||||
|
reviews = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"review_id": review_ids,
|
||||||
|
"customer_id": rev_cust,
|
||||||
|
"review_text": review_text,
|
||||||
|
"rating": rating,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
files = {
|
||||||
|
"customers": os.path.join(out_dir, "customers.csv"),
|
||||||
|
"orders": os.path.join(out_dir, "orders.csv"),
|
||||||
|
"reviews": os.path.join(out_dir, "reviews.csv"),
|
||||||
|
}
|
||||||
|
customers.to_csv(files["customers"], index=False)
|
||||||
|
orders.to_csv(files["orders"], index=False)
|
||||||
|
reviews.to_csv(files["reviews"], index=False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"out_dir": out_dir,
|
||||||
|
"files": files,
|
||||||
|
"n_customers": n_cust,
|
||||||
|
"n_orders": n_orders,
|
||||||
|
"n_reviews": n_reviews,
|
||||||
|
"expected_relations": [
|
||||||
|
{
|
||||||
|
"from_table": "orders",
|
||||||
|
"from_col": "customer_id",
|
||||||
|
"to_table": "customers",
|
||||||
|
"to_col": "customer_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from_table": "reviews",
|
||||||
|
"from_col": "customer_id",
|
||||||
|
"to_table": "customers",
|
||||||
|
"to_col": "customer_id",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"seed": seed,
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
out = args[0] if len(args) > 0 else "/tmp/synthetic_eda_folder"
|
||||||
|
rows = int(args[1]) if len(args) > 1 else 2000
|
||||||
|
sd = int(args[2]) if len(args) > 2 else 42
|
||||||
|
print(json.dumps(generate_synthetic_eda_folder(out, rows, sd), indent=2))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Tests para generate_synthetic_eda_folder."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from datascience.generate_synthetic_eda_folder import generate_synthetic_eda_folder
|
||||||
|
|
||||||
|
|
||||||
|
def test_genera_ok_y_archivos(tmp_path):
|
||||||
|
out = str(tmp_path / "folder")
|
||||||
|
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["n_customers"] == 300
|
||||||
|
assert res["n_orders"] == 600
|
||||||
|
assert res["n_reviews"] == 300
|
||||||
|
for key in ("customers", "orders", "reviews"):
|
||||||
|
assert os.path.exists(res["files"][key])
|
||||||
|
# Relaciones esperadas declaradas.
|
||||||
|
rels = {(r["from_table"], r["to_table"]) for r in res["expected_relations"]}
|
||||||
|
assert ("orders", "customers") in rels
|
||||||
|
assert ("reviews", "customers") in rels
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinismo_mismo_seed(tmp_path):
|
||||||
|
out1 = str(tmp_path / "f1")
|
||||||
|
out2 = str(tmp_path / "f2")
|
||||||
|
generate_synthetic_eda_folder(out1, n_rows=250, seed=11)
|
||||||
|
generate_synthetic_eda_folder(out2, n_rows=250, seed=11)
|
||||||
|
for name in ("customers.csv", "orders.csv", "reviews.csv"):
|
||||||
|
a = open(os.path.join(out1, name), "rb").read()
|
||||||
|
b = open(os.path.join(out2, name), "rb").read()
|
||||||
|
assert a == b, f"{name} difiere entre dos generaciones con el mismo seed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_distintos_difieren(tmp_path):
|
||||||
|
out1 = str(tmp_path / "f1")
|
||||||
|
out2 = str(tmp_path / "f2")
|
||||||
|
generate_synthetic_eda_folder(out1, n_rows=250, seed=11)
|
||||||
|
generate_synthetic_eda_folder(out2, n_rows=250, seed=12)
|
||||||
|
a = open(os.path.join(out1, "customers.csv"), "rb").read()
|
||||||
|
b = open(os.path.join(out2, "customers.csv"), "rb").read()
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_fk_containment(tmp_path):
|
||||||
|
out = str(tmp_path / "folder")
|
||||||
|
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
|
||||||
|
customers = pd.read_csv(res["files"]["customers"])
|
||||||
|
orders = pd.read_csv(res["files"]["orders"])
|
||||||
|
reviews = pd.read_csv(res["files"]["reviews"])
|
||||||
|
cust_ids = set(customers["customer_id"])
|
||||||
|
# Todos los customer_id de orders y reviews ⊆ customers.
|
||||||
|
assert set(orders["customer_id"]) <= cust_ids
|
||||||
|
assert set(reviews["customer_id"]) <= cust_ids
|
||||||
|
# customer_id es PK unica en customers.
|
||||||
|
assert customers["customer_id"].is_unique
|
||||||
|
assert orders["order_id"].is_unique
|
||||||
|
assert reviews["review_id"].is_unique
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_text_mediana_palabras(tmp_path):
|
||||||
|
out = str(tmp_path / "folder")
|
||||||
|
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
|
||||||
|
reviews = pd.read_csv(res["files"]["reviews"])
|
||||||
|
words = [len(str(t).split()) for t in reviews["review_text"].dropna()]
|
||||||
|
assert statistics.median(words) >= 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_n_rows_invalido(tmp_path):
|
||||||
|
out = str(tmp_path / "folder")
|
||||||
|
res = generate_synthetic_eda_folder(out, n_rows=0, seed=42)
|
||||||
|
assert res["status"] == "error"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: generate_synthetic_eda_table
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def generate_synthetic_eda_table(out_db_path: str, table: str = 'synthetic', n_rows: int = 2000, seed: int = 42) -> dict"
|
||||||
|
description: "Genera una tabla DuckDB sintetica (Faker + numpy, determinista por seed) cuyo contenido esta disenado para ACTIVAR el maximo de capitulos del motor AutomaticEDA del grupo eda: numericas continuas con correlacion lineal/no-lineal, numericas con outliers, categoricas desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie temporal, lat/lon validas, semanticos/PII (uuid/email/iban/phone) y nulos con patron MCAR/MAR. Fixture para evaluar el EDA de punta a punta. Estilo dict-no-throw: nunca lanza."
|
||||||
|
tags: [eda, synthetic, faker, testing, fixture, datascience]
|
||||||
|
params:
|
||||||
|
- name: out_db_path
|
||||||
|
desc: "Ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la tabla se reemplaza con CREATE OR REPLACE TABLE si ya existe."
|
||||||
|
- name: table
|
||||||
|
desc: "Nombre de la tabla a crear. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el DDL. Default 'synthetic'."
|
||||||
|
- name: n_rows
|
||||||
|
desc: "Numero de filas (clientes unicos). Cada fila es un cliente con id/email/iban/phone propios. Default 2000."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla para Faker (Faker.seed) y numpy (np.random.default_rng). Mismo seed -> tabla identica byte a byte. Default 42."
|
||||||
|
output: "dict dict-no-throw. En exito {status:'ok', db_path, table, n_rows, columns:[19 nombres de columna], seed}. En error (sin lanzar, p.ej. nombre de tabla invalido o n_rows<=0) {status:'error', error:str}. Columnas: customer_id,email,iban,phone,income,spending,age,risk_score,tenure_months,engagement_quad,amount,n_purchases,country,category,plan,review,signup_date,latitude,longitude."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_genera_ok_y_columnas", "test_determinismo_mismo_seed", "test_seeds_distintos_difieren", "test_latlon_en_rango", "test_plan_solo_niveles_validos", "test_income_spending_co_nulos", "test_review_mediana_palabras_y_signup_datetime", "test_phone_matchea_regex_internacional", "test_outliers_y_correlaciones", "test_tabla_invalida_devuelve_error"]
|
||||||
|
test_file_path: "python/functions/datascience/generate_synthetic_eda_table_test.py"
|
||||||
|
file_path: "python/functions/datascience/generate_synthetic_eda_table.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Genera /tmp/x.duckdb con la tabla `synthetic` (2000 filas, seed 42)
|
||||||
|
fn run generate_synthetic_eda_table /tmp/x.duckdb synthetic 2000 42
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from datascience import generate_synthetic_eda_table
|
||||||
|
|
||||||
|
res = generate_synthetic_eda_table("/tmp/x.duckdb", "synthetic", n_rows=2000, seed=42)
|
||||||
|
# res == {"status":"ok", "db_path":"/tmp/x.duckdb", "table":"synthetic",
|
||||||
|
# "n_rows":2000, "columns":[...19...], "seed":42}
|
||||||
|
# Luego perfilala con el grupo eda:
|
||||||
|
# fn run profile_table /tmp/x.duckdb synthetic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando necesites un dataset de prueba REPRODUCIBLE para evaluar el motor AutomaticEDA de punta a punta: su contenido dispara, a proposito, num_distr, cat_distr, text_distr, correlacion, missingness (MCAR/MAR), modelos (PCA/KMeans/outliers), timeseries, geospatial, calidad, agregacion y los detectores semanticos / PII (`infer_semantic_type`).
|
||||||
|
- Cuando escribas tests de capitulos del EDA y quieras una tabla con una columna que active CADA detector sin montar datos a mano.
|
||||||
|
- Cuando quieras un fixture determinista (mismo seed -> misma tabla) para comparar el render del EDA entre versiones.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: escribe a disco (crea/reutiliza el archivo DuckDB). Reemplaza la tabla destino con `CREATE OR REPLACE`.
|
||||||
|
- **Requiere `faker`, `duckdb`, `numpy` y `pandas`** instalados en el venv. Sin `faker` la generacion devuelve `{status:'error'}` (no lanza).
|
||||||
|
- **`signup_date` queda como TIMESTAMP/DATE en DuckDB** (se construye con `datetime64[ns]`), NO VARCHAR — condicion para que `detect_time_column` la elija y se active el capitulo timeseries. Si fuese VARCHAR, el detector de fecha fallaria.
|
||||||
|
- **El texto de `review` debe superar el gate de text_distr**: media de caracteres >= 50 y mediana de palabras >= 20. Por eso cada review concatena dos parrafos Faker (~50 palabras de mediana); no reducir el numero de frases o el capitulo text_distr no activa.
|
||||||
|
- **Determinismo dependiente del orden de llamadas**: se siembra `Faker.seed(seed)` + `np.random.default_rng(seed)` al inicio; cambiar el orden de las extracciones cambia la salida aunque el seed sea el mismo.
|
||||||
|
- **PII real-istica**: `email`/`iban`/`phone`/`customer_id` matchean los regex de `infer_semantic_type` (email/iban/phone_intl/uuid) al 100%; son datos sinteticos de Faker, no personas reales.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Mapa columna -> detector que activa:
|
||||||
|
|
||||||
|
| Columna(s) | Tipo | Detector / capitulo |
|
||||||
|
|---|---|---|
|
||||||
|
| income, spending | num continua | correlacion POSITIVA fuerte (Pearson > 0.8) |
|
||||||
|
| age, risk_score | num continua | correlacion NEGATIVA |
|
||||||
|
| tenure_months, engagement_quad | num continua | relacion NO LINEAL (cuadratica) |
|
||||||
|
| amount, n_purchases | num + outliers | num_distr / outliers (cola pesada + extremos inyectados) |
|
||||||
|
| country (12), category (6), plan (3 desbalanceado) | categorica | cat_distr / agregacion (entropia baja en plan) |
|
||||||
|
| review | texto libre multi-idioma | text_distr (len_mean>=50, mediana palabras>=20) + duplicados exactos |
|
||||||
|
| signup_date | DATE/TIMESTAMP | timeseries |
|
||||||
|
| latitude, longitude | num [-90,90]/[-180,180] | geospatial (detect_latlon_columns) |
|
||||||
|
| customer_id, email, iban, phone | texto | semantic_type uuid/email/iban/phone_intl (PII) |
|
||||||
|
| income+spending (co-nulos 12%), risk_score (nulo si plan=alta), review (8%) | nulos con patron | missingness MCAR/MAR |
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""generate_synthetic_eda_table — fixture sintetico para ejercitar el motor AutomaticEDA.
|
||||||
|
|
||||||
|
Funcion impura (escribe un archivo DuckDB a disco) y determinista por ``seed``:
|
||||||
|
construye una unica tabla cuyo CONTENIDO esta disenado para ACTIVAR el maximo
|
||||||
|
numero de capitulos del motor AutomaticEDA del grupo `eda` (num_distr, cat_distr,
|
||||||
|
text_distr, correlacion, missingness, modelos, timeseries, geospatial, relaciones,
|
||||||
|
calidad, agregacion) y los detectores semanticos / PII (`infer_semantic_type`).
|
||||||
|
|
||||||
|
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; captura cualquier error y
|
||||||
|
devuelve ``{"status": "error", "error": str}``.
|
||||||
|
|
||||||
|
Determinismo: con el mismo ``seed`` el DataFrame y, por tanto, la tabla DuckDB
|
||||||
|
resultante son identicos byte a byte. Se siembra Faker (``Faker.seed``) y numpy
|
||||||
|
(``np.random.default_rng(seed)``) al inicio de cada generacion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Lista fija de paises (12 -> cardinalidad media para cat_distr / agregacion).
|
||||||
|
_COUNTRIES = [
|
||||||
|
"ES", "FR", "DE", "IT", "PT", "NL",
|
||||||
|
"BE", "US", "GB", "IE", "SE", "PL",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Lista fija de categorias de producto (6 -> cardinalidad media).
|
||||||
|
_CATEGORIES = [
|
||||||
|
"electronics", "clothing", "home", "sports", "books", "toys",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Niveles de plan con probabilidades DESBALANCEADAS (entropia baja para cat_distr).
|
||||||
|
_PLANS = ["baja", "media", "alta"]
|
||||||
|
_PLAN_PROBS = [0.70, 0.25, 0.05]
|
||||||
|
|
||||||
|
# Centroides (lat, lon) aproximados por pais: muestrean coordenadas validas
|
||||||
|
# dentro de [-90, 90] x [-180, 180] para que detect_latlon_columns las acepte.
|
||||||
|
_CENTROIDS = {
|
||||||
|
"ES": (40.4, -3.7), "FR": (46.6, 2.2), "DE": (51.1, 10.4), "IT": (41.9, 12.5),
|
||||||
|
"PT": (39.4, -8.2), "NL": (52.1, 5.3), "BE": (50.5, 4.5), "US": (39.0, -98.0),
|
||||||
|
"GB": (54.0, -2.0), "IE": (53.4, -8.0), "SE": (60.1, 18.6), "PL": (52.0, 19.1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locales rotados para generar texto multi-idioma (es/en/fr).
|
||||||
|
_TEXT_LOCALES = ["es_ES", "en_US", "fr_FR"]
|
||||||
|
|
||||||
|
# Identificador SQL valido (DuckDB no parametriza el nombre de tabla en DDL).
|
||||||
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fakers(seed):
|
||||||
|
"""Crea los Faker por locale tras sembrar el generador compartido.
|
||||||
|
|
||||||
|
``Faker.seed(seed)`` siembra el ``random.Random`` compartido por todas las
|
||||||
|
instancias Faker que usan el generador por defecto, asi que el orden de
|
||||||
|
llamadas determina por completo la salida (determinismo).
|
||||||
|
"""
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
|
Faker.seed(seed)
|
||||||
|
es_es, en_us, fr_fr = (Faker(loc) for loc in _TEXT_LOCALES)
|
||||||
|
return {"es_ES": es_es, "en_US": en_us, "fr_FR": fr_fr}
|
||||||
|
|
||||||
|
|
||||||
|
# Texto duplicado canonico (multi-idioma, > 20 palabras) que se inyecta en una
|
||||||
|
# fraccion de las filas para que el analisis de duplicados exactos lo detecte.
|
||||||
|
_DUP_REVIEW = (
|
||||||
|
"Servicio excelente y entrega muy rapida, el producto llego en perfecto "
|
||||||
|
"estado y coincide con la descripcion publicada en la tienda. The customer "
|
||||||
|
"support team answered every question quickly and the packaging was solid "
|
||||||
|
"and well protected during shipping. Je recommande vivement ce vendeur a "
|
||||||
|
"tous mes amis, la qualite est vraiment au rendez-vous cette fois."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_reviews(n, rng, fakers, dup_frac=0.04, null_frac=0.08):
|
||||||
|
"""Genera ``n`` reviews de texto libre largo multi-idioma (es/en/fr).
|
||||||
|
|
||||||
|
Cada review concatena dos parrafos de Faker en el idioma rotado por fila, de
|
||||||
|
modo que la MEDIANA de palabras por documento queda muy por encima de 20 y la
|
||||||
|
media de caracteres por encima de 50 (gates del capitulo text_distr). Se
|
||||||
|
inyectan duplicados exactos (``dup_frac``) y nulos (``null_frac``).
|
||||||
|
|
||||||
|
Devuelve una ``list`` de ``str`` o ``None`` (nulos) de longitud ``n``.
|
||||||
|
"""
|
||||||
|
# Numero de frases por parrafo precomputado con numpy (determinista) para no
|
||||||
|
# interleavar draws de rng dentro del bucle de faker.
|
||||||
|
nb1 = rng.integers(4, 8, n)
|
||||||
|
nb2 = rng.integers(3, 7, n)
|
||||||
|
|
||||||
|
reviews = []
|
||||||
|
for i in range(n):
|
||||||
|
fk = fakers[_TEXT_LOCALES[i % 3]]
|
||||||
|
p1 = fk.paragraph(nb_sentences=int(nb1[i]))
|
||||||
|
p2 = fk.paragraph(nb_sentences=int(nb2[i]))
|
||||||
|
reviews.append(f"{p1} {p2}")
|
||||||
|
|
||||||
|
# Duplicados exactos: una fraccion de filas comparte un review identico.
|
||||||
|
if n > 0 and dup_frac > 0:
|
||||||
|
k_dup = max(1, int(n * dup_frac))
|
||||||
|
dup_idx = rng.choice(n, size=min(k_dup, n), replace=False)
|
||||||
|
for j in dup_idx:
|
||||||
|
reviews[int(j)] = _DUP_REVIEW
|
||||||
|
|
||||||
|
# Nulos MCAR-ish: una fraccion de filas al azar queda en None.
|
||||||
|
if n > 0 and null_frac > 0:
|
||||||
|
k_null = max(1, int(n * null_frac))
|
||||||
|
null_idx = rng.choice(n, size=min(k_null, n), replace=False)
|
||||||
|
for j in null_idx:
|
||||||
|
reviews[int(j)] = None
|
||||||
|
|
||||||
|
return reviews
|
||||||
|
|
||||||
|
|
||||||
|
def _make_phone_intl(rng):
|
||||||
|
"""Construye un telefono en formato internacional que casa phone_intl.
|
||||||
|
|
||||||
|
Regex objetivo (fullmatch): ``\\+\\d[\\d\\s()-]{6,}\\d``. Empieza por '+',
|
||||||
|
digito, bloques de digitos separados por espacios y termina en digito.
|
||||||
|
"""
|
||||||
|
cc = int(rng.integers(1, 99))
|
||||||
|
a = int(rng.integers(100, 999))
|
||||||
|
b = int(rng.integers(100, 999))
|
||||||
|
c = int(rng.integers(100, 999))
|
||||||
|
return f"+{cc} {a} {b} {c}"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_latlon(countries, rng):
|
||||||
|
"""Devuelve (latitudes, longitudes) muestreando centroides de pais + jitter.
|
||||||
|
|
||||||
|
Mantiene los valores dentro de [-90, 90] y [-180, 180] (validez exigida por
|
||||||
|
detect_latlon_columns). El jitter es pequeno para no salirse del rango.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
lats = np.empty(len(countries), dtype=float)
|
||||||
|
lons = np.empty(len(countries), dtype=float)
|
||||||
|
jitter_lat = rng.normal(0.0, 0.5, len(countries))
|
||||||
|
jitter_lon = rng.normal(0.0, 0.5, len(countries))
|
||||||
|
for i, code in enumerate(countries):
|
||||||
|
base_lat, base_lon = _CENTROIDS[code]
|
||||||
|
lats[i] = float(np.clip(base_lat + jitter_lat[i], -90.0, 90.0))
|
||||||
|
lons[i] = float(np.clip(base_lon + jitter_lon[i], -180.0, 180.0))
|
||||||
|
return lats, lons
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_with_outliers(n, rng, n_extreme=6, factor=50.0):
|
||||||
|
"""Serie lognormal de cola pesada con ~``n_extreme`` outliers altos (x``factor``)."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
amount = rng.lognormal(mean=4.0, sigma=1.0, size=n)
|
||||||
|
if n > 0 and n_extreme > 0:
|
||||||
|
idx = rng.choice(n, size=min(n_extreme, n), replace=False)
|
||||||
|
amount[idx] = amount[idx] * factor
|
||||||
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def generate_synthetic_eda_table(
|
||||||
|
out_db_path, table="synthetic", n_rows=2000, seed=42
|
||||||
|
):
|
||||||
|
"""Genera una tabla DuckDB sintetica que activa el maximo de capitulos del EDA.
|
||||||
|
|
||||||
|
Construye un DataFrame de ``n_rows`` clientes unicos con columnas elegidas para
|
||||||
|
disparar detectores concretos del motor AutomaticEDA (numericas continuas con
|
||||||
|
correlaciones lineal/no-lineal, numericas con outliers, categoricas
|
||||||
|
desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie
|
||||||
|
temporal, lat/lon validas, semanticos/PII y nulos con patron MCAR/MAR), y la
|
||||||
|
materializa en ``out_db_path`` con ``CREATE OR REPLACE TABLE``.
|
||||||
|
|
||||||
|
Funcion impura (escribe a disco) y determinista por ``seed``: con el mismo
|
||||||
|
seed la tabla resultante es identica byte a byte. NUNCA lanza.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
out_db_path: ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la
|
||||||
|
tabla se reemplaza si ya existe.
|
||||||
|
table: nombre de la tabla a crear. Se valida contra
|
||||||
|
``^[A-Za-z_][A-Za-z0-9_]*$`` y se cita en el DDL.
|
||||||
|
n_rows: numero de filas (clientes unicos). Default 2000.
|
||||||
|
seed: semilla para Faker y numpy. Default 42.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict dict-no-throw. En exito::
|
||||||
|
|
||||||
|
{"status": "ok", "db_path": out_db_path, "table": table,
|
||||||
|
"n_rows": n_rows, "columns": [<nombres de columna>], "seed": seed}
|
||||||
|
|
||||||
|
En error (sin lanzar)::
|
||||||
|
|
||||||
|
{"status": "error", "error": str}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import duckdb
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
if not _IDENT_RE.match(table or ""):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
f"nombre de tabla invalido: {table!r} "
|
||||||
|
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
n = int(n_rows)
|
||||||
|
if n <= 0:
|
||||||
|
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
|
||||||
|
|
||||||
|
fakers = _make_fakers(seed)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
# --- Numericas continuas (distinct alto, correlaciones) ---
|
||||||
|
income = np.clip(rng.normal(40000.0, 12000.0, n), 1000.0, None)
|
||||||
|
spending = income * 0.35 + rng.normal(0.0, 2000.0, n) # corr POSITIVA fuerte
|
||||||
|
age = rng.integers(18, 91, n)
|
||||||
|
risk_score = 90.0 - age * 0.7 + rng.normal(0.0, 5.0, n) # corr NEGATIVA con age
|
||||||
|
tenure_months = rng.uniform(0.0, 60.0, n)
|
||||||
|
engagement_quad = ((tenure_months - 30.0) ** 2) / 30.0 + rng.normal(0.0, 1.0, n)
|
||||||
|
|
||||||
|
# --- Numericas con outliers claros ---
|
||||||
|
amount = _amount_with_outliers(n, rng)
|
||||||
|
n_purchases = rng.poisson(3.0, n).astype(float)
|
||||||
|
if n > 0:
|
||||||
|
k_hi = min(max(1, int(n * 0.002)) + 2, n) # ~3-5 valores altisimos
|
||||||
|
hi_idx = rng.choice(n, size=k_hi, replace=False)
|
||||||
|
n_purchases[hi_idx] = rng.integers(200, 400, len(hi_idx)).astype(float)
|
||||||
|
|
||||||
|
# --- Categoricas ---
|
||||||
|
country = rng.choice(_COUNTRIES, n)
|
||||||
|
category = rng.choice(_CATEGORIES, n)
|
||||||
|
plan = rng.choice(_PLANS, n, p=_PLAN_PROBS)
|
||||||
|
|
||||||
|
# --- Texto libre multi-idioma con duplicados ---
|
||||||
|
review = _make_reviews(n, rng, fakers)
|
||||||
|
|
||||||
|
# --- Fecha / serie temporal (rango ~2 anios, cadencia ~diaria) ---
|
||||||
|
base = np.datetime64("2022-01-01")
|
||||||
|
offsets = rng.integers(0, 730, n)
|
||||||
|
signup_date = pd.to_datetime(base) + pd.to_timedelta(offsets, unit="D")
|
||||||
|
|
||||||
|
# --- Geo lat/lon validas ---
|
||||||
|
latitude, longitude = _make_latlon(country, rng)
|
||||||
|
|
||||||
|
# --- Semanticos / PII (>=80% match para infer_semantic_type) ---
|
||||||
|
customer_id = [fakers["en_US"].uuid4() for _ in range(n)]
|
||||||
|
email = [fakers["en_US"].email() for _ in range(n)]
|
||||||
|
iban = [fakers["en_US"].iban() for _ in range(n)]
|
||||||
|
phone = [_make_phone_intl(rng) for _ in range(n)]
|
||||||
|
|
||||||
|
df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"email": email,
|
||||||
|
"iban": iban,
|
||||||
|
"phone": phone,
|
||||||
|
"income": income,
|
||||||
|
"spending": spending,
|
||||||
|
"age": age,
|
||||||
|
"risk_score": risk_score,
|
||||||
|
"tenure_months": tenure_months,
|
||||||
|
"engagement_quad": engagement_quad,
|
||||||
|
"amount": amount,
|
||||||
|
"n_purchases": n_purchases,
|
||||||
|
"country": country,
|
||||||
|
"category": category,
|
||||||
|
"plan": plan,
|
||||||
|
"review": review,
|
||||||
|
"signup_date": signup_date,
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Nulos con patron ---
|
||||||
|
# income + spending faltan JUNTAS en las MISMAS filas (co-ocurrencia -> MAR).
|
||||||
|
k_co = max(1, int(n * 0.12))
|
||||||
|
co_idx = rng.choice(n, size=min(k_co, n), replace=False)
|
||||||
|
df.loc[co_idx, "income"] = np.nan
|
||||||
|
df.loc[co_idx, "spending"] = np.nan
|
||||||
|
# risk_score falta cuando plan == "alta" (mas una pizca de azar) -> MAR.
|
||||||
|
risk_mask = (df["plan"] == "alta").to_numpy() | (rng.random(n) < 0.02)
|
||||||
|
df.loc[risk_mask, "risk_score"] = np.nan
|
||||||
|
|
||||||
|
columns = list(df.columns)
|
||||||
|
|
||||||
|
con = duckdb.connect(out_db_path)
|
||||||
|
try:
|
||||||
|
con.register("df_synth_eda", df)
|
||||||
|
con.execute(
|
||||||
|
f'CREATE OR REPLACE TABLE "{table}" AS SELECT * FROM df_synth_eda'
|
||||||
|
)
|
||||||
|
con.unregister("df_synth_eda")
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"db_path": out_db_path,
|
||||||
|
"table": table,
|
||||||
|
"n_rows": n,
|
||||||
|
"columns": columns,
|
||||||
|
"seed": seed,
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
db_path = args[0] if len(args) > 0 else "/tmp/synthetic_eda.duckdb"
|
||||||
|
tbl = args[1] if len(args) > 1 else "synthetic"
|
||||||
|
rows = int(args[2]) if len(args) > 2 else 2000
|
||||||
|
sd = int(args[3]) if len(args) > 3 else 42
|
||||||
|
print(json.dumps(generate_synthetic_eda_table(db_path, tbl, rows, sd), indent=2))
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests para generate_synthetic_eda_table."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
|
from datascience.generate_synthetic_eda_table import generate_synthetic_eda_table
|
||||||
|
|
||||||
|
_EXPECTED_COLS = [
|
||||||
|
"customer_id", "email", "iban", "phone", "income", "spending", "age",
|
||||||
|
"risk_score", "tenure_months", "engagement_quad", "amount", "n_purchases",
|
||||||
|
"country", "category", "plan", "review", "signup_date", "latitude", "longitude",
|
||||||
|
]
|
||||||
|
_PHONE_RE = re.compile(r"\+\d[\d\s()-]{6,}\d")
|
||||||
|
|
||||||
|
|
||||||
|
def _load(db_path, table="synthetic"):
|
||||||
|
con = duckdb.connect(db_path, read_only=True)
|
||||||
|
try:
|
||||||
|
return con.execute(f'SELECT * FROM "{table}"').fetch_df()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_genera_ok_y_columnas(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
res = generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["table"] == "synthetic"
|
||||||
|
assert res["n_rows"] == 500
|
||||||
|
assert res["columns"] == _EXPECTED_COLS
|
||||||
|
assert os.path.exists(db)
|
||||||
|
df = _load(db)
|
||||||
|
assert list(df.columns) == _EXPECTED_COLS
|
||||||
|
assert len(df) == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinismo_mismo_seed(tmp_path):
|
||||||
|
db1 = str(tmp_path / "a.duckdb")
|
||||||
|
db2 = str(tmp_path / "b.duckdb")
|
||||||
|
generate_synthetic_eda_table(db1, "synthetic", n_rows=400, seed=7)
|
||||||
|
generate_synthetic_eda_table(db2, "synthetic", n_rows=400, seed=7)
|
||||||
|
df1 = _load(db1).astype(str)
|
||||||
|
df2 = _load(db2).astype(str)
|
||||||
|
# Misma semilla -> tabla identica fila a fila.
|
||||||
|
assert df1.equals(df2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_distintos_difieren(tmp_path):
|
||||||
|
db1 = str(tmp_path / "a.duckdb")
|
||||||
|
db2 = str(tmp_path / "b.duckdb")
|
||||||
|
generate_synthetic_eda_table(db1, "synthetic", n_rows=400, seed=7)
|
||||||
|
generate_synthetic_eda_table(db2, "synthetic", n_rows=400, seed=8)
|
||||||
|
df1 = _load(db1).astype(str)
|
||||||
|
df2 = _load(db2).astype(str)
|
||||||
|
assert not df1.equals(df2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_latlon_en_rango(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||||
|
df = _load(db)
|
||||||
|
assert df["latitude"].between(-90, 90).all()
|
||||||
|
assert df["longitude"].between(-180, 180).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_solo_niveles_validos(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||||
|
df = _load(db)
|
||||||
|
assert set(df["plan"].unique()) <= {"baja", "media", "alta"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_spending_co_nulos(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
generate_synthetic_eda_table(db, "synthetic", n_rows=600, seed=42)
|
||||||
|
df = _load(db)
|
||||||
|
inc_null = df["income"].isna()
|
||||||
|
sp_null = df["spending"].isna()
|
||||||
|
# income y spending faltan exactamente en las MISMAS filas.
|
||||||
|
assert (inc_null == sp_null).all()
|
||||||
|
assert inc_null.sum() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_mediana_palabras_y_signup_datetime(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||||
|
df = _load(db)
|
||||||
|
words = [len(str(r).split()) for r in df["review"].dropna()]
|
||||||
|
assert statistics.median(words) >= 20
|
||||||
|
# signup_date debe ser datetime/date en DuckDB (no VARCHAR).
|
||||||
|
con = duckdb.connect(db, read_only=True)
|
||||||
|
try:
|
||||||
|
dtype = con.execute(
|
||||||
|
"SELECT column_type FROM (DESCRIBE synthetic) WHERE column_name='signup_date'"
|
||||||
|
).fetchone()[0]
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
assert dtype.upper().startswith(("DATE", "TIMESTAMP"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_matchea_regex_internacional(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
|
||||||
|
df = _load(db)
|
||||||
|
phones = [p for p in df["phone"].tolist() if p is not None]
|
||||||
|
assert all(_PHONE_RE.fullmatch(str(p)) for p in phones)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outliers_y_correlaciones(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
generate_synthetic_eda_table(db, "synthetic", n_rows=800, seed=42)
|
||||||
|
df = _load(db)
|
||||||
|
# amount tiene cola con outliers altos evidentes.
|
||||||
|
assert df["amount"].max() > df["amount"].median() * 20
|
||||||
|
# correlacion positiva fuerte income~spending y negativa age~risk_score.
|
||||||
|
sub = df[["income", "spending"]].dropna()
|
||||||
|
assert sub["income"].corr(sub["spending"]) > 0.8
|
||||||
|
sub2 = df[["age", "risk_score"]].dropna()
|
||||||
|
assert sub2["age"].corr(sub2["risk_score"]) < -0.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabla_invalida_devuelve_error(tmp_path):
|
||||||
|
db = str(tmp_path / "t.duckdb")
|
||||||
|
res = generate_synthetic_eda_table(db, "bad name;", n_rows=10, seed=42)
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert "invalido" in res["error"]
|
||||||
@@ -0,0 +1,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,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user