Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c1b7dd0f3 |
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: paper-reviewer
|
||||
description: "Revisor académico adversarial (read-only) para los papers del subsistema `papers/`. Recibe el directorio de un paper (`papers/<slug>/`) y su `preregistration.md`, y lo juzga sin piedad: puntúa novedad, rigor, reproducibilidad y validez (0-5 cada uno), intenta REFUTAR cada claim contra la evidencia citada, detecta HARKing contra el pre-registro, y emite un veredicto estructurado (accept|major_revision|reject) con default conservador. Es el gate anti paper-mill: NO modifica el paper, solo lo evalúa."
|
||||
model: opus
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
# Agente Paper-Reviewer — peer review adversarial
|
||||
|
||||
Eres un revisor académico **hostil pero justo**. Tu trabajo NO es ayudar al autor a sentirse bien: es proteger la integridad del registro científico. Asumes la posición de un revisor de conferencia top que ha visto cientos de papers inflados y sabe oler el humo. Por defecto **desconfías** de cada afirmación hasta que la evidencia citada la sostenga. Eres específico, citas líneas y archivos, y no rellenas con elogios.
|
||||
|
||||
Este agente es el **gate anti paper-mill** del subsistema `papers/`. El riesgo que combates: papers que *parecen* rigurosos (estructura IMRaD impecable, lenguaje académico, tablas bonitas) pero sin sustancia — hipótesis que no podían fallar, estadística de teatro, claims que exceden la evidencia, análisis inventados después de ver los datos. Si no hubo riesgo real de refutación, no es un paper.
|
||||
|
||||
---
|
||||
|
||||
## REGLA FUNDAMENTAL: read-only, solo juzgas
|
||||
|
||||
- **Lectura:** `paper.md`, `preregistration.md`, `references.md`/`.bib`, y todo lo que haya en `experiments/`, `data/`, `figures/`, `reviews/` del paper.
|
||||
- **Escritura:** NINGUNA. No tienes Edit ni Write. No modificas el paper, no arreglas su prosa, no corriges sus tablas. Solo emites un veredicto.
|
||||
- **Bash es read-only:** úsalo para inspeccionar evidencia (`ls`, `cat`, `head`, `wc`, `grep`, re-correr un script de análisis que YA exista en `experiments/` para verificar un número reportado, contar filas de un dataset, comprobar que una figura referenciada existe). NUNCA escribas archivos, NUNCA borres, NUNCA mutes estado externo (sin red con efectos, sin deploys).
|
||||
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
Recibes el path de un directorio de paper:
|
||||
|
||||
- `paper_dir` (ej. `papers/0001-bucle-reactivo-calls`). Dentro esperas al menos `paper.md`; idealmente también `preregistration.md`, `experiments/`, `data/`, `figures/`.
|
||||
|
||||
Si falta `paper.md`, reporta que no hay paper que revisar y sal. Si falta `preregistration.md`, NO es excusa para aprobar: la ausencia de pre-registro es en sí misma una **amenaza grave a la validez** (no puedes distinguir análisis confirmatorios de exploratorios) y debe bajar el eje de rigor y reproducibilidad.
|
||||
|
||||
---
|
||||
|
||||
## Algoritmo de revisión
|
||||
|
||||
### 1. Lee todo el material primero
|
||||
|
||||
- `paper.md` completo (frontmatter + cuerpo IMRaD).
|
||||
- `preregistration.md` (H0/H1, plan de análisis congelado, timestamp/hash si lo tiene).
|
||||
- Inventaria la evidencia: `ls -R experiments/ data/ figures/`. Anota qué tablas, figuras, scripts y datasets existen REALMENTE en disco.
|
||||
- Si hay `reviews/` previos, léelos para no repetir y para ver si el autor respondió a críticas anteriores.
|
||||
|
||||
No puntúes nada hasta haber leído el material. Una revisión sin abrir la evidencia es la enfermedad que combates.
|
||||
|
||||
### 2. Extrae y enumera los CLAIMS
|
||||
|
||||
Recorre Results y Discussion. Lista cada **afirmación de resultado** verificable (no las de contexto). Ejemplos de claim: "el método A reduce el error un 23%", "la diferencia es significativa (p<0.01)", "el efecto es grande (d=0.8)", "el patrón se mantiene en los 3 datasets". Para cada claim anota la evidencia que el paper cita (tabla X, figura Y, sección de `experiments/`).
|
||||
|
||||
### 3. Intenta REFUTAR cada claim
|
||||
|
||||
Para cada claim, posición de partida: **"no soportada"**. Solo lo marcas "soportada" si:
|
||||
|
||||
- La evidencia citada EXISTE en disco (la tabla/figura/dato está realmente ahí, no solo mencionada).
|
||||
- El número del texto COINCIDE con el de la evidencia (si puedes re-derivarlo de un script o un CSV en `experiments/`/`data/`, hazlo con Bash y compáralo).
|
||||
- La inferencia es válida: el claim no extrapola más allá de lo que el dato muestra (no confunde correlación con causalidad sin diseño que lo permita; no generaliza fuera de la población muestreada).
|
||||
|
||||
Si la evidencia no aparece, si el número no cuadra, o si no puedes reproducir el cálculo con lo descrito → claim **no soportada**. Apúntala en `claims_unsupported` con el motivo concreto (qué falta, qué no cuadra).
|
||||
|
||||
### 4. Puntúa los 4 ejes (0-5 cada uno)
|
||||
|
||||
Sé tacaño. 5 es excepcional y raro; 3 es "aceptable con reservas"; 0-2 es rechazo en ese eje. Justifica cada número con una frase concreta.
|
||||
|
||||
- **novelty (novedad):** ¿el paper aporta algo que no se sabía? ¿El gap está articulado y la contribución es explícita y real, o es un resultado obvio/ya conocido revestido de novedad? Related work honesto (reconoce lo que ya existe) sube; reinventar la rueda baja.
|
||||
- **rigor:** método reproducible y estadística correcta. Exige: **effect size + intervalos de confianza**, no solo `p<0.05`; **corrección por comparaciones múltiples** (Holm-Bonferroni o similar) si se testean varias hipótesis; N justificado (no insuficiente); ausencia de p-hacking/cherry-picking. Estadística de teatro (p-valor suelto sin tamaño de efecto, "tendencia hacia la significancia", N=3 presentado como concluyente) hunde este eje.
|
||||
- **reproducibility (reproducibilidad):** ¿otra persona puede re-correr el experimento con lo descrito? Exige protocolo, datos accesibles (o su descripción), código en `experiments/`, semillas/versiones. Si tú mismo no podrías reproducirlo con lo que hay, el eje es bajo. Pre-registro presente y seguido sube; ausente baja.
|
||||
- **validity (validez):** las cuatro validez de Shadish/Cook/Campbell — **interna** (¿la causa es realmente la causa, o hay confusores?), **externa** (¿generaliza fuera de esta muestra?), **de constructo** (¿se mide lo que se dice medir?), **estadística** (¿las inferencias estadísticas son legítimas?). El paper debe DECLARAR sus amenazas a la validez. Amenazas no declaradas que tú detectas → bajan el eje y van a `gaps`.
|
||||
|
||||
### 5. Chequea coherencia con el pre-registro (HARKing)
|
||||
|
||||
Compara los análisis REPORTADOS en Results contra los PRE-REGISTRADOS en `preregistration.md`:
|
||||
|
||||
- ¿Los análisis confirmatorios presentados son exactamente los pre-registrados? Si aparecen análisis NO declarados presentados como si fueran confirmatorios → **HARKing** (Hypothesizing After Results are Known). Marca `harking_detected: true`.
|
||||
- ¿Hay análisis pre-registrados que desaparecieron del paper (resultados incómodos enterrados)? Eso es cherry-picking — anótalo en `gaps`.
|
||||
- Análisis exploratorios son legítimos SOLO si el paper los etiqueta honestamente como exploratorios (generan hipótesis, no las confirman). Presentar exploratorio como confirmatorio = HARKing.
|
||||
- Si no hay `preregistration.md`, no puedes verificar esto: anótalo como amenaza grave y trata todos los resultados como potencialmente exploratorios.
|
||||
|
||||
### 6. Verifica honestidad: limitaciones y overclaiming
|
||||
|
||||
- ¿Hay una sección de **limitaciones / amenazas a la validez** declarada honestamente? Su ausencia es una bandera roja: ningún estudio real está libre de limitaciones.
|
||||
- ¿Las **claims ≤ evidencia**? Compara el lenguaje de las conclusiones con lo que los datos permiten. "demostramos que X causa Y" sobre un diseño correlacional = **overclaiming**. "el método es superior" sobre un solo dataset = overclaiming. Lista cada overclaim en `gaps`.
|
||||
|
||||
### 7. Emite el veredicto
|
||||
|
||||
Default conservador. Reglas de decisión:
|
||||
|
||||
- **reject** si: hay claims no soportadas centrales al paper, O HARKing detectado, O rigor ≤ 2, O validez ≤ 2, O no hay riesgo real de refutación (la hipótesis no podía fallar).
|
||||
- **major_revision** si: el núcleo es salvable pero hay gaps serios (evidencia incompleta, estadística mejorable, amenazas no declaradas, pre-registro ausente) — el caso por defecto cuando algo falta pero no es fraude.
|
||||
- **accept** SOLO si: los 4 ejes ≥ 3, cero claims no soportadas centrales, sin HARKing, limitaciones declaradas, claims ≤ evidencia, reproducible. Es raro y hay que ganárselo.
|
||||
|
||||
Ante la duda, baja, no subas. Es preferible un major_revision injusto que dejar pasar un paper-mill.
|
||||
|
||||
---
|
||||
|
||||
## Output (formato obligatorio)
|
||||
|
||||
Devuelve un bloque JSON con EXACTAMENTE esta forma, seguido de un párrafo corto de justificación en prosa (crítico y específico, sin elogios de relleno):
|
||||
|
||||
```json
|
||||
{
|
||||
"scores": {
|
||||
"novelty": 0,
|
||||
"rigor": 0,
|
||||
"reproducibility": 0,
|
||||
"validity": 0
|
||||
},
|
||||
"claims_unsupported": [
|
||||
"Claim '<texto>': <por qué no está soportada — evidencia ausente / número no cuadra / inferencia inválida>"
|
||||
],
|
||||
"harking_detected": false,
|
||||
"gaps": [
|
||||
"<amenaza a la validez no declarada / overclaim / estadística faltante / dato no reproducible>"
|
||||
],
|
||||
"verdict": "reject"
|
||||
}
|
||||
```
|
||||
|
||||
Reglas del output:
|
||||
|
||||
- `scores`: enteros 0-5. Tacaño por defecto.
|
||||
- `claims_unsupported`: una entrada por claim que no superó la refutación, con el motivo concreto. Lista vacía solo si TODAS las claims se sostuvieron contra la evidencia.
|
||||
- `harking_detected`: `true` en cuanto detectes un análisis confirmatorio no pre-registrado, o si la ausencia de pre-registro impide descartarlo (en ese caso explícalo en `gaps`).
|
||||
- `gaps`: amenazas a la validez no declaradas, overclaims, estadística de teatro, datos no reproducibles. Concreto y accionable.
|
||||
- `verdict`: `accept` | `major_revision` | `reject`. Default conservador según las reglas de la sección 7.
|
||||
|
||||
El párrafo de prosa que sigue al JSON resume el veredicto en lenguaje directo: qué hunde el paper o qué falta para subir de nivel. Sin "buen trabajo", sin "interesante contribución" de relleno — solo señal.
|
||||
|
||||
---
|
||||
|
||||
## Tono y anti-patrones
|
||||
|
||||
- **Crítico y específico.** "La tabla 2 reporta p=0.03 pero no da tamaño de efecto ni CI; con N=4 esto no sostiene el claim de la sección 4.2" — no "la estadística podría mejorarse".
|
||||
- **Cita evidencia.** Siempre `archivo:línea` o `tabla/figura X`. Una crítica sin cita es ruido.
|
||||
- **No inventes mérito.** Si el paper no aporta novedad, dilo. El sesgo de complacencia es el que alimenta los paper-mills.
|
||||
- **No arregles el paper.** No es tu trabajo (no tienes Write). Tu trabajo es el veredicto. Sugiere QUÉ falta, no escribas el fix.
|
||||
- **Default a fallar.** Evidencia ausente = claim no soportada. Pre-registro ausente = no se puede descartar HARKing. Duda = baja la nota.
|
||||
|
||||
## Relación con el ecosistema
|
||||
|
||||
- Es la materialización del **paso 9 (peer review)** del proceso de 10 pasos del subsistema `papers/` (ver `reports/0001-2026-06-30-papers-system-design.md`), heredando el patrón de **verificador adversarial** del modo orquestador (`.claude/rules/orchestration.md`): un juez independiente que por defecto refuta y solo aprueba con evidencia.
|
||||
- Sus outputs se guardan en `papers/<slug>/reviews/` para trazar la evolución del paper entre revisiones.
|
||||
- Complementa el `preregister_hypothesis` (rigor experimental, congela la hipótesis antes de los datos) y `render_paper_pdf` (entrega): este agente es el control de calidad que decide si el paper merece convertirse en PDF entregable o volver a revisión.
|
||||
@@ -72,8 +72,10 @@ from .profile_datetime import profile_datetime
|
||||
from .resample_timeseries import resample_timeseries
|
||||
from .add_pdf_internal_links import add_pdf_internal_links
|
||||
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
|
||||
from .render_paper_pdf import render_paper_pdf
|
||||
|
||||
__all__ = [
|
||||
"render_paper_pdf",
|
||||
"suggest_intratable_fk_candidates",
|
||||
"detect_time_column",
|
||||
"extract_timeseries_raw",
|
||||
|
||||
@@ -31,7 +31,7 @@ import math
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.1.0"
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "correlacion"
|
||||
CHAPTER_TITLE = "Correlación"
|
||||
|
||||
@@ -47,13 +47,6 @@ _MAX_MATRIX_LABELS = 16
|
||||
# How many pairs to show in each of the top-positive / top-negative tables.
|
||||
_TOP_N = 10
|
||||
|
||||
# How many of the strongest numeric-numeric pairs to draw as scatter plots on
|
||||
# each sign (positive / negative). A scatter per pair carries a fitted line/curve
|
||||
# and a relationship-type label; keeping the count small keeps the chapter
|
||||
# readable on a phone / a slide. Only signed (Pearson/Spearman) pairs qualify —
|
||||
# Cramér's V / correlation ratio pairs are not numeric-numeric, so no scatter.
|
||||
_SCATTER_TOP_N = 3
|
||||
|
||||
# Glossary terms this chapter explains. Each is registered in the shared
|
||||
# collector (ctx['glossary']) and marked clickable on its first appearance in the
|
||||
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
|
||||
@@ -321,139 +314,6 @@ def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _is_seq(values) -> bool:
|
||||
"""True for a non-empty list/tuple of values (a raw numeric column)."""
|
||||
return isinstance(values, (list, tuple)) and len(values) > 0
|
||||
|
||||
|
||||
def _select_scatter_pairs(pairs: list, top_n: int = _SCATTER_TOP_N):
|
||||
"""Pick the strongest numeric-numeric pairs to draw as scatters.
|
||||
|
||||
Only signed (Pearson/Spearman) pairs are numeric-numeric and thus eligible
|
||||
for a scatter with a fitted curve. Returns up to ``top_n`` of the strongest
|
||||
positive pairs followed by up to ``top_n`` of the strongest negative ones,
|
||||
each ranked by magnitude. Mixed-type metrics (Cramér's V, correlation ratio,
|
||||
mutual information) are excluded — they have no x/y scatter interpretation.
|
||||
"""
|
||||
positive = []
|
||||
negative = []
|
||||
for pair in pairs:
|
||||
if not isinstance(pair, dict) or not _is_signed(pair):
|
||||
continue
|
||||
value = pair.get("value")
|
||||
if not _is_num(value):
|
||||
continue
|
||||
if value > 0:
|
||||
positive.append(pair)
|
||||
elif value < 0:
|
||||
negative.append(pair)
|
||||
positive.sort(key=lambda p: abs(float(p.get("value", 0.0))), reverse=True)
|
||||
negative.sort(key=lambda p: abs(float(p.get("value", 0.0))), reverse=True)
|
||||
return positive[:top_n] + negative[:top_n]
|
||||
|
||||
|
||||
def _classification_note(a: str, b: str, cls: dict) -> str:
|
||||
"""Human-readable sentence describing the relationship of a pair.
|
||||
|
||||
Plain text (not baked into the figure image) so the type label is selectable
|
||||
in the PDF / extractable by pdftotext, and sits right next to its scatter
|
||||
inside the keep-together Group.
|
||||
"""
|
||||
tipo = model._safe_str(cls.get("tipo")) or "sin forma clara"
|
||||
bits = []
|
||||
pearson = cls.get("pearson")
|
||||
spearman = cls.get("spearman")
|
||||
r2_lin = cls.get("r2_linear")
|
||||
r2_poly = None
|
||||
for key in ("r2_poly2", "r2_poly3"):
|
||||
v = cls.get(key)
|
||||
if _is_num(v) and (r2_poly is None or float(v) > r2_poly):
|
||||
r2_poly = float(v)
|
||||
if _is_num(pearson):
|
||||
bits.append(f"Pearson r={float(pearson):+.2f}")
|
||||
if _is_num(spearman):
|
||||
bits.append(f"Spearman ρ={float(spearman):+.2f}")
|
||||
if _is_num(r2_lin):
|
||||
bits.append(f"R² lineal={float(r2_lin):.2f}")
|
||||
if r2_poly is not None:
|
||||
bits.append(f"R² polinómico={r2_poly:.2f}")
|
||||
metrics = "; ".join(bits)
|
||||
text = (f"Relación **{tipo}** entre «{a}» y «{b}»."
|
||||
+ (f" {metrics}." if metrics else ""))
|
||||
return text
|
||||
|
||||
|
||||
def _scatter_blocks(pairs: list, raw_numeric):
|
||||
"""Build keep-together scatter Groups for the strongest num-num pairs.
|
||||
|
||||
Returns a list of blocks (a Heading plus one Group per pair), or an empty
|
||||
list when there is no raw numeric data (e.g. the lite profile drops
|
||||
``ctx['raw_numeric']`` to skip live recomputation) or the relationship
|
||||
helpers are unavailable. Never raises: any failure degrades to no scatters,
|
||||
leaving the matrix + tables intact.
|
||||
"""
|
||||
if not isinstance(raw_numeric, dict) or not raw_numeric:
|
||||
return []
|
||||
selected = _select_scatter_pairs(pairs)
|
||||
if not selected:
|
||||
return []
|
||||
|
||||
# The relationship helpers live in the datascience package. Import lazily so
|
||||
# the chapter still builds (matrix + tables) when they are absent.
|
||||
try:
|
||||
from datascience.classify_relationship_type import (
|
||||
classify_relationship_type,
|
||||
)
|
||||
from datascience.relationship_scatter_figure import (
|
||||
relationship_scatter_figure,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — degrade, never break the chapter.
|
||||
return []
|
||||
|
||||
groups = []
|
||||
for pair in selected:
|
||||
a = pair.get("a")
|
||||
b = pair.get("b")
|
||||
xs = raw_numeric.get(a)
|
||||
ys = raw_numeric.get(b)
|
||||
# Edge: a selected pair has no raw column (aggregated profile, renamed
|
||||
# column, …) — skip just that pair, keep the rest.
|
||||
if not _is_seq(xs) or not _is_seq(ys):
|
||||
continue
|
||||
try:
|
||||
cls = classify_relationship_type(list(xs), list(ys)) or {}
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
a_lbl = model._safe_str(a)
|
||||
b_lbl = model._safe_str(b)
|
||||
|
||||
def _make(xs=xs, ys=ys, a_lbl=a_lbl, b_lbl=b_lbl, cls=cls):
|
||||
return relationship_scatter_figure(
|
||||
list(xs), list(ys), x_label=a_lbl, y_label=b_lbl,
|
||||
classification=cls)
|
||||
|
||||
groups.append(model.Group(blocks=[
|
||||
model.Heading(text=f"{a_lbl} ↔ {b_lbl}", level=2),
|
||||
model.Figure(
|
||||
make=_make,
|
||||
caption=(f"Dispersión de «{a_lbl}» frente a «{b_lbl}» con la "
|
||||
"curva de ajuste del mejor modelo.")),
|
||||
model.Markdown(text=_classification_note(a_lbl, b_lbl, cls)),
|
||||
]))
|
||||
|
||||
if not groups:
|
||||
return []
|
||||
intro = model.Markdown(text=(
|
||||
"Para los pares numéricos más fuertes (positivos y negativos) se dibuja "
|
||||
"la nube de puntos con su ajuste y se clasifica el **tipo de relación**: "
|
||||
"**lineal** (una recta basta), **polinómica** (curva de grado 2/3 que "
|
||||
"mejora claramente el ajuste lineal), **monótona no-lineal** (crece o "
|
||||
"decrece siempre pero no en línea recta; Spearman ≫ Pearson) o "
|
||||
"**débil/sin forma**."))
|
||||
return [model.Heading(text="Relaciones más fuertes (scatter)", level=2),
|
||||
intro] + groups
|
||||
|
||||
|
||||
def build_correlacion(profile: dict, ctx: dict):
|
||||
"""Build the Correlation Chapter, or None if there are no pairs to show.
|
||||
|
||||
@@ -532,18 +392,6 @@ def build_correlacion(profile: dict, ctx: dict):
|
||||
"No se han hallado correlaciones negativas significativas entre "
|
||||
"columnas numéricas.")))
|
||||
|
||||
# 2.5) Scatter plots of the strongest numeric-numeric pairs, each with its
|
||||
# fitted curve and a relationship-type label (lineal / polinómica / monótona
|
||||
# / débil). Needs the raw numeric sample (ctx['raw_numeric'], row-aligned);
|
||||
# when it is absent (aggregated/lite profile) the scatters are simply omitted
|
||||
# and the matrix + tables above stand on their own.
|
||||
raw_numeric = None
|
||||
if isinstance(ctx, dict):
|
||||
raw_numeric = ctx.get("raw_numeric") or profile.get("raw_numeric")
|
||||
else:
|
||||
raw_numeric = profile.get("raw_numeric")
|
||||
blocks.extend(_scatter_blocks(pairs, raw_numeric))
|
||||
|
||||
# 3) Spuriousness caveat for level-based correlations (Granger–Newbold).
|
||||
caveat = corr.get("levels_caveat")
|
||||
if isinstance(caveat, str) and caveat.strip():
|
||||
|
||||
@@ -175,105 +175,6 @@ def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
|
||||
assert "azufre" in _pdf_text(pdf)
|
||||
|
||||
|
||||
def _raw_numeric_for_profile(n: int = 80) -> dict:
|
||||
"""Row-aligned raw numeric sample matching the signed pairs of _profile().
|
||||
|
||||
Builds columns with a clear, deterministic shape so the relationship-type
|
||||
classifier has something unambiguous to label:
|
||||
- density vs alcohol: strong negative linear (the top-negative pair).
|
||||
- alcohol vs quality: positive linear.
|
||||
- ph, fixed_acidity, sulphates: filler columns for the remaining pairs.
|
||||
"""
|
||||
import math as _m
|
||||
|
||||
alcohol = [8.0 + 0.05 * i for i in range(n)]
|
||||
density = [1.0 - 0.002 * a for a in alcohol] # neg linear vs alcohol
|
||||
quality = [3.0 + 0.4 * a + (0.1 if i % 2 else -0.1) # pos linear vs alcohol
|
||||
for i, a in enumerate(alcohol)]
|
||||
ph = [3.0 + 0.3 * _m.sin(i / 5.0) for i in range(n)]
|
||||
fixed_acidity = [7.0 - 0.5 * p for p in ph] # neg linear vs ph
|
||||
sulphates = [0.5 + 0.01 * (i % 7) for i in range(n)]
|
||||
return {
|
||||
"alcohol": alcohol, "density": density, "quality": quality,
|
||||
"ph": ph, "fixed_acidity": fixed_acidity, "sulphates": sulphates,
|
||||
}
|
||||
|
||||
|
||||
def test_golden_scatters_de_pares_num_num_con_tipo_de_relacion():
|
||||
"""Con ctx['raw_numeric'], el capítulo añade scatters (Figure dentro de Group)
|
||||
de los pares num-num más fuertes, cada uno con su etiqueta de tipo en texto."""
|
||||
from datascience.automatic_eda.model import Group
|
||||
|
||||
ctx = {"raw_numeric": _raw_numeric_for_profile()}
|
||||
ch = build_correlacion(_profile(), ctx)
|
||||
assert ch is not None
|
||||
groups = [b for b in ch.blocks if isinstance(b, Group)]
|
||||
assert groups, "debe emitir al menos un Group con scatter"
|
||||
# Cada Group lleva su figura (lazy) y una nota de texto con el tipo.
|
||||
for g in groups:
|
||||
gkinds = [b.kind for b in g.blocks]
|
||||
assert "figure" in gkinds and "markdown" in gkinds
|
||||
# La sección y la etiqueta de tipo aparecen como texto plano (extraíble).
|
||||
headings = " ".join(b.text for b in ch.blocks if b.kind == "heading")
|
||||
assert "Relaciones más fuertes" in headings
|
||||
body = " ".join(b.text for g in groups for b in g.blocks
|
||||
if b.kind == "markdown")
|
||||
assert any(t in body for t in
|
||||
("lineal", "polinómica", "monótona", "sin forma"))
|
||||
# El par num-num más fuerte (density ↔ alcohol) tiene scatter; el par cat-cat
|
||||
# (region ↔ type) NO — no es numérico.
|
||||
assert "density" in body or "alcohol" in body
|
||||
assert "region" not in body and "type" not in body
|
||||
|
||||
|
||||
def test_golden_pdf_muestra_scatters_con_etiqueta_de_tipo():
|
||||
"""En el PDF, el capítulo Correlación incluye los scatters y su etiqueta de
|
||||
tipo en texto seleccionable (pdftotext la encuentra)."""
|
||||
prof = _profile()
|
||||
ctx = {"raw_numeric": _raw_numeric_for_profile()}
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
pdf = os.path.join(d, "corr_scatter.pdf")
|
||||
rp = render_automatic_eda_pdf(prof, pdf, {"title": "EDA — wine",
|
||||
"ctx": ctx})
|
||||
assert rp["path"] == pdf and rp["n_pages"] >= 1
|
||||
txt = _pdf_text(pdf)
|
||||
assert "Relaciones" in txt and "scatter" in txt.lower()
|
||||
# Alguna etiqueta de tipo de relación, en texto.
|
||||
assert any(t in txt for t in
|
||||
("lineal", "polin", "monóton", "monoton", "sin forma"))
|
||||
|
||||
|
||||
def test_edge_sin_raw_numeric_omite_scatters_sin_lanzar():
|
||||
"""profile lite / ctx None: sin raw_numeric el capítulo omite los scatters
|
||||
pero sigue emitiendo matriz + tablas (no lanza)."""
|
||||
from datascience.automatic_eda.model import Group
|
||||
|
||||
for ctx in (None, {}, {"raw_numeric": None}, {"raw_numeric": {}}):
|
||||
ch = build_correlacion(_profile(), ctx)
|
||||
assert ch is not None
|
||||
assert not [b for b in ch.blocks if isinstance(b, Group)]
|
||||
# La matriz y al menos una tabla top siguen presentes.
|
||||
assert any(b.kind == "figure" for b in ch.blocks)
|
||||
assert any(b.kind == "data_table" for b in ch.blocks)
|
||||
|
||||
|
||||
def test_edge_par_sin_columna_cruda_se_omite_sin_lanzar():
|
||||
"""Si un par seleccionado no tiene su columna en raw_numeric, se omite ese
|
||||
par (no lanza); los demás scatters se construyen igual."""
|
||||
from datascience.automatic_eda.model import Group
|
||||
|
||||
raw = _raw_numeric_for_profile()
|
||||
raw.pop("density", None) # rompe el par density ↔ alcohol
|
||||
ch = build_correlacion(_profile(), {"raw_numeric": raw})
|
||||
assert ch is not None
|
||||
groups = [b for b in ch.blocks if isinstance(b, Group)]
|
||||
body = " ".join(b.text for g in groups for b in g.blocks
|
||||
if b.kind == "markdown")
|
||||
# density desaparece de los scatters; otros pares (p.ej. ph↔fixed_acidity,
|
||||
# alcohol↔quality) pueden seguir presentes sin error.
|
||||
assert "density" not in body
|
||||
|
||||
|
||||
def test_glosario_engancha_metodos_y_fdr():
|
||||
"""Mejora 4b: los métodos de correlación (Pearson, Spearman, Cramér's V,
|
||||
razón de correlación) y la corrección por comparaciones múltiples (FDR) se
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,187 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,174 +0,0 @@
|
||||
"""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
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
id: relationship_scatter_figure_py_datascience
|
||||
name: relationship_scatter_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def relationship_scatter_figure(xs: list, ys: list, x_label: str = \"\", y_label: str = \"\", classification: dict = None, max_points: int = 2000) -> \"matplotlib.figure.Figure\""
|
||||
description: "Construye una figura matplotlib scatter de un par de variables numéricas con su curva/recta de ajuste y una anotación del tipo de relación (lineal, polinómica grado 2/3, monótona no-lineal, etc.) más sus métricas (r, ρ, R²lin, R²poly). Consume el dict de classify_relationship_type; si es None lo calcula internamente reusando esa función. Devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA (PDF/PPTX). Backend Agg sin pyplot global; downsample determinista de los puntos dibujados; defensivo ante vacío/None."
|
||||
tags: [eda, correlation, scatter, relationship, matplotlib, figure, visualization, datascience, impure]
|
||||
uses_functions: [classify_relationship_type_py_datascience]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib, numpy]
|
||||
example: |
|
||||
from relationship_scatter_figure import relationship_scatter_figure
|
||||
xs = [float(i) for i in range(100)]
|
||||
ys = [0.5 * x * x - x + 3 for x in xs]
|
||||
classification = {
|
||||
"tipo": "polinómica (grado 2)", "pearson": 0.97, "spearman": 0.99,
|
||||
"r2_linear": 0.92, "r2_poly2": 0.999, "r2_poly3": 0.999,
|
||||
"best_degree": 2, "coeffs": [0.5, -1.0, 3.0],
|
||||
}
|
||||
fig = relationship_scatter_figure(xs, ys, x_label="dosis", y_label="efecto", classification=classification)
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure"
|
||||
- "test_downsample_determinista"
|
||||
- "test_empty_no_lanza"
|
||||
- "test_classification_none"
|
||||
test_file_path: "python/functions/datascience/relationship_scatter_figure_test.py"
|
||||
file_path: "python/functions/datascience/relationship_scatter_figure.py"
|
||||
params:
|
||||
- name: xs
|
||||
desc: "Lista (o tupla) de valores x. Se emparejan por índice con ys. Valores None, bool, NaN o inf descartan ese par (lectura defensiva)."
|
||||
- name: ys
|
||||
desc: "Lista (o tupla) de valores y, paralela a xs. Mismas reglas defensivas que xs."
|
||||
- name: x_label
|
||||
desc: "Etiqueta del eje/título para la variable x. Default \"\" (en el título cae a \"x\")."
|
||||
- name: y_label
|
||||
desc: "Etiqueta del eje/título para la variable y. Default \"\" (en el título cae a \"y\")."
|
||||
- name: classification
|
||||
desc: "Opcional. Dict de classify_relationship_type con claves tipo, pearson, r2_linear, spearman, r2_poly2, r2_poly3, best_degree, coeffs. Si es None se calcula internamente importando y llamando a classify_relationship_type sobre los pares limpios (self-contained). Si el módulo hermano no está disponible, se dibuja el scatter sin curva de ajuste ni anotación. Default None."
|
||||
- name: max_points
|
||||
desc: "Tope del nº de puntos DIBUJADOS. Si los pares limpios superan el tope, la nube se submuestrea por paso fijo ceil(n/max_points) tomando pairs[::step] — DETERMINISTA, no aleatorio, reproducible. La clasificación/ajuste usa SIEMPRE todos los pares limpios; el downsample solo adelgaza el dibujo. Valor no-positivo o no-int desactiva el downsample. Default 2000."
|
||||
output: "Un matplotlib.figure.Figure (figsize 6.4x4.0, dpi 150) con un Axes scatter (puntos semitransparentes alpha 0.5, color #4C72B0), la curva/recta de ajuste (numpy.polyval sobre coeffs, color #C44E52) cuando hay un ajuste polinómico disponible, título \"{x_label} ↔ {y_label}\", labels de ejes y una caja de anotación en la esquina superior izquierda con el tipo de relación y las métricas disponibles (r, ρ, R²lin, R²poly; se omiten las None). Si tras la limpieza hay menos de 2 pares válidos, devuelve igualmente una Figure con un texto centrado \"Sin datos suficientes para el scatter\" (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from relationship_scatter_figure import relationship_scatter_figure
|
||||
|
||||
# Par numérico con relación cuadrática y su clasificación (de
|
||||
# classify_relationship_type). Pasándola explícita evitas recomputarla.
|
||||
xs = [float(i) for i in range(100)]
|
||||
ys = [0.5 * x * x - x + 3 for x in xs]
|
||||
classification = {
|
||||
"tipo": "polinómica (grado 2)",
|
||||
"pearson": 0.97,
|
||||
"spearman": 0.99,
|
||||
"r2_linear": 0.92,
|
||||
"r2_poly2": 0.999,
|
||||
"r2_poly3": 0.999,
|
||||
"best_degree": 2,
|
||||
"coeffs": [0.5, -1.0, 3.0],
|
||||
}
|
||||
|
||||
fig = relationship_scatter_figure(
|
||||
xs, ys, x_label="dosis", y_label="efecto", classification=classification
|
||||
)
|
||||
|
||||
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||
fig.savefig("/tmp/scatter_dosis_efecto.png")
|
||||
|
||||
# Con classification=None la función la calcula internamente (self-contained):
|
||||
fig2 = relationship_scatter_figure(xs, ys, x_label="dosis", y_label="efecto")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala dentro del informe EDA automático cuando quieras visualizar de un vistazo
|
||||
la relación entre dos variables numéricas: la nube de puntos, la curva que mejor
|
||||
la ajusta y una etiqueta legible del tipo de relación con sus métricas. Es la
|
||||
pareja "vista humana" de `classify_relationship_type`: esa función decide el
|
||||
tipo y los coeficientes; esta los pinta en una `Figure` que el renderer del
|
||||
informe rasteriza a PDF/PPTX. Pásale el dict de clasificación si ya lo tienes
|
||||
calculado (evitas recomputar el ajuste); si no, déjalo en `None` y la función lo
|
||||
resuelve sola sobre los pares limpios. Pensada para móvil: anotación pequeña
|
||||
(fontsize 8) y nube adelgazada por `max_points` para que el PDF no pese.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||
es thread-safe; esta función lo evita construyendo el `Figure` directamente,
|
||||
así que es segura de llamar en bucle desde el renderer.
|
||||
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
|
||||
guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes de
|
||||
pares de columnas.
|
||||
- **Downsample determinista, solo del dibujo.** Cuando los pares limpios superan
|
||||
`max_points`, la nube DIBUJADA se adelgaza por paso fijo `pairs[::step]`
|
||||
(reproducible, no aleatorio). La clasificación y el ajuste usan SIEMPRE todos
|
||||
los pares limpios; el downsample no altera las métricas ni la curva.
|
||||
- **`classification=None` ⇒ se calcula sola.** Importa y llama a
|
||||
`classify_relationship_type` sobre los pares limpios. Si ese módulo hermano no
|
||||
está disponible (entorno incompleto), NO lanza: dibuja el scatter sin curva de
|
||||
ajuste ni anotación. Pasar la clasificación explícita es más barato (no
|
||||
recomputa el ajuste).
|
||||
- **Sin curva para `monótona no-lineal`.** Cuando `coeffs` es `None` o
|
||||
`best_degree` es `None` (p.ej. tipo "monótona no-lineal"), no se pinta recta
|
||||
polinómica — solo la nube y la anotación. Tampoco se dibuja la curva si el
|
||||
rango de x es nulo (todos los x iguales). Nunca falla por esto.
|
||||
- **Defensiva, nunca lanza.** `xs=[]`, `ys=[]`, menos de 2 pares válidos, ends
|
||||
`None`/`bool`/`NaN`/`inf` o `coeffs` malformado se manejan sin error: en el
|
||||
peor caso devuelve una `Figure` con "Sin datos suficientes para el scatter".
|
||||
No envuelvas la llamada en try/except por miedo a un raise — no lo hay.
|
||||
@@ -1,322 +0,0 @@
|
||||
"""Impure EDA helper: scatter figure of a numeric pair with its fit (`eda` group).
|
||||
|
||||
Builds a matplotlib scatter of two numeric variables, overlays the fitted
|
||||
curve/line implied by the relationship classification (linear, polynomial of
|
||||
degree 2/3, etc.) and annotates the relationship type with its available
|
||||
metrics. Returns a ready-to-rasterize ``matplotlib.figure.Figure``; it never
|
||||
shows nor saves it.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||
global state and is safe to call repeatedly from a report renderer.
|
||||
|
||||
To keep the rendered PDF/PPTX light on phones, when the number of valid pairs
|
||||
exceeds ``max_points`` the *plotted* points are down-sampled DETERMINISTICALLY by
|
||||
a fixed step (``pairs[::step]``), never randomly, so the output is reproducible.
|
||||
The classification/fit always uses every clean pair; the down-sample only thins
|
||||
the drawn cloud.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
# Sober blue for the scatter cloud and red for the fitted curve (Tufte: the
|
||||
# data points are the primary ink, the fit is the secondary highlight).
|
||||
_POINT_COLOR = "#4C72B0"
|
||||
_FIT_COLOR = "#C44E52"
|
||||
# Muted gray for the no-data fallback message.
|
||||
_MUTED_TEXT = "#5f6b7a"
|
||||
|
||||
|
||||
def _finite(value):
|
||||
"""Coerce ``value`` to a finite float, or return None when not usable.
|
||||
|
||||
bool is a subclass of int, but a real numeric measurement is never a bool,
|
||||
so True/False are treated as missing instead of coercing to 1.0/0.0. NaN and
|
||||
+/-infinity are never valid either.
|
||||
"""
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
return None
|
||||
return f
|
||||
|
||||
|
||||
def _clean_pairs(xs, ys):
|
||||
"""Pair ``xs[i], ys[i]`` by index, dropping any pair with a non-finite end."""
|
||||
pairs = []
|
||||
if isinstance(xs, (list, tuple)) and isinstance(ys, (list, tuple)):
|
||||
n = min(len(xs), len(ys))
|
||||
for i in range(n):
|
||||
x = _finite(xs[i])
|
||||
y = _finite(ys[i])
|
||||
if x is None or y is None:
|
||||
continue
|
||||
pairs.append((x, y))
|
||||
return pairs
|
||||
|
||||
|
||||
def _ordered_trend(xs_clean, ys_clean, n_bins: int = 12):
|
||||
"""Return (x_trend, y_trend): the ordered trend of y over x for a monotonic
|
||||
relationship that has no polynomial fit.
|
||||
|
||||
When x has few distinct values (an ordinal/discrete scale) the trend is the
|
||||
mean of y per distinct x value. Otherwise x is split into ``n_bins`` ordered
|
||||
quantile bins and each point is (mean x, mean y) of the bin. Returns
|
||||
``(None, None)`` when there is nothing meaningful to draw.
|
||||
"""
|
||||
x_arr = np.asarray(xs_clean, dtype=float)
|
||||
y_arr = np.asarray(ys_clean, dtype=float)
|
||||
if x_arr.size < 2:
|
||||
return None, None
|
||||
uniq = np.unique(x_arr)
|
||||
if uniq.size <= max(2, n_bins):
|
||||
# Discrete x: one trend point per distinct value (mean y).
|
||||
xt = uniq
|
||||
yt = np.array([float(np.mean(y_arr[x_arr == ux])) for ux in uniq])
|
||||
return xt, yt
|
||||
# Continuous x: ordered quantile bins, (mean x, mean y) per bin.
|
||||
order = np.argsort(x_arr, kind="stable")
|
||||
x_sorted = x_arr[order]
|
||||
y_sorted = y_arr[order]
|
||||
chunks_x = np.array_split(x_sorted, n_bins)
|
||||
chunks_y = np.array_split(y_sorted, n_bins)
|
||||
xt = np.array([float(np.mean(cx)) for cx in chunks_x if cx.size])
|
||||
yt = np.array([float(np.mean(cy)) for cy in chunks_y if cy.size])
|
||||
return xt, yt
|
||||
|
||||
|
||||
def _no_data_figure(message: str) -> "matplotlib.figure.Figure":
|
||||
"""A bare Figure carrying a centered muted message (defensive fallback)."""
|
||||
fig = Figure(figsize=(6.4, 4.0), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color=_MUTED_TEXT,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def _metrics_caption(classification: dict) -> str:
|
||||
"""Format the available metrics of a classification dict into one line.
|
||||
|
||||
Omits the metrics that are None. Keys consumed (any may be absent/None):
|
||||
``pearson`` (r), ``spearman`` (rho), ``r2_linear`` (R²lin) and the best
|
||||
polynomial R² (``r2_poly3`` if a cubic was the best fit, else ``r2_poly2``).
|
||||
"""
|
||||
parts = []
|
||||
r = _finite(classification.get("pearson"))
|
||||
if r is not None:
|
||||
parts.append(f"r={r:.2f}")
|
||||
rho = _finite(classification.get("spearman"))
|
||||
if rho is not None:
|
||||
parts.append(f"ρ={rho:.2f}")
|
||||
r2_lin = _finite(classification.get("r2_linear"))
|
||||
if r2_lin is not None:
|
||||
parts.append(f"R²lin={r2_lin:.2f}")
|
||||
# Prefer the R² of the best polynomial degree when it is a poly fit.
|
||||
best_degree = classification.get("best_degree")
|
||||
r2_poly = None
|
||||
if best_degree == 3:
|
||||
r2_poly = _finite(classification.get("r2_poly3"))
|
||||
elif best_degree == 2:
|
||||
r2_poly = _finite(classification.get("r2_poly2"))
|
||||
if r2_poly is None:
|
||||
# Fall back to whichever poly R² is present (cubic first).
|
||||
r2_poly = _finite(classification.get("r2_poly3"))
|
||||
if r2_poly is None:
|
||||
r2_poly = _finite(classification.get("r2_poly2"))
|
||||
if r2_poly is not None:
|
||||
parts.append(f"R²poly={r2_poly:.2f}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def relationship_scatter_figure(
|
||||
xs: list,
|
||||
ys: list,
|
||||
x_label: str = "",
|
||||
y_label: str = "",
|
||||
classification: dict = None,
|
||||
max_points: int = 2000,
|
||||
) -> "matplotlib.figure.Figure":
|
||||
"""Build a scatter figure of a numeric pair with its fit and a type label.
|
||||
|
||||
Cleans the pairs defensively (drops any pair with a None/bool/NaN/inf end),
|
||||
plots a semi-transparent scatter cloud (down-sampled deterministically when
|
||||
it exceeds ``max_points``), overlays the polynomial fit implied by
|
||||
``classification`` and annotates the relationship type plus its available
|
||||
metrics in a corner box.
|
||||
|
||||
The fit and classification always use every clean pair; only the drawn cloud
|
||||
is thinned by the down-sample. When ``classification`` is None it is computed
|
||||
internally by reusing ``classify_relationship_type`` over the clean pairs, so
|
||||
the function is self-contained.
|
||||
|
||||
The function is fully defensive: empty input, fewer than 2 clean pairs, a
|
||||
missing/None ``coeffs`` or a missing sibling classifier never raise. When
|
||||
there is nothing valid to draw it still returns a ``Figure`` carrying a
|
||||
centered "Sin datos suficientes para el scatter" message.
|
||||
|
||||
Args:
|
||||
xs: List (or tuple) of x values. Paired by index with ``ys``. Values that
|
||||
are None, bool, NaN or infinite discard that pair. Read defensively.
|
||||
ys: List (or tuple) of y values, parallel to ``xs``. Same defensive rules.
|
||||
x_label: Axis/title label for the x variable. Default "" (falls back to
|
||||
"x" in the title).
|
||||
y_label: Axis/title label for the y variable. Default "" (falls back to
|
||||
"y" in the title).
|
||||
classification: Optional dict from ``classify_relationship_type`` with
|
||||
keys ``tipo, pearson, r2_linear, spearman, r2_poly2, r2_poly3,
|
||||
best_degree, coeffs``. When None, it is computed internally by
|
||||
importing and calling ``classify_relationship_type`` over the clean
|
||||
pairs. When that sibling module is unavailable, the scatter is still
|
||||
drawn (no fit curve, no annotation).
|
||||
max_points: Cap on the number of *plotted* points. When the number of
|
||||
clean pairs exceeds this cap, the drawn cloud is down-sampled by a
|
||||
fixed step ``ceil(n/max_points)`` taking ``pairs[::step]`` —
|
||||
DETERMINISTIC, not random, so the figure is reproducible. A
|
||||
non-positive or non-int value disables down-sampling. Default 2000.
|
||||
|
||||
Returns:
|
||||
A ``matplotlib.figure.Figure`` (figsize 6.4x4.0, dpi 150) with a single
|
||||
scatter Axes, the fitted curve (when a polynomial fit is available) and a
|
||||
corner annotation with the relationship type and metrics. When there are
|
||||
fewer than 2 clean pairs it returns a Figure with a centered "Sin datos
|
||||
suficientes para el scatter" message. The caller rasterizes/closes it.
|
||||
"""
|
||||
pairs = _clean_pairs(xs, ys)
|
||||
if len(pairs) < 2:
|
||||
return _no_data_figure("Sin datos suficientes para el scatter")
|
||||
|
||||
# Full clean coordinates feed the classification/fit; the plotted cloud is
|
||||
# what gets thinned.
|
||||
xs_clean = [p[0] for p in pairs]
|
||||
ys_clean = [p[1] for p in pairs]
|
||||
|
||||
# Resolve the classification. If not provided, reuse the sibling classifier
|
||||
# over ALL clean pairs (self-contained). Missing module => no fit/annotation.
|
||||
cls = classification
|
||||
if cls is None:
|
||||
try:
|
||||
from classify_relationship_type import classify_relationship_type
|
||||
|
||||
cls = classify_relationship_type(xs_clean, ys_clean)
|
||||
except Exception:
|
||||
cls = None
|
||||
if not isinstance(cls, dict):
|
||||
cls = {}
|
||||
|
||||
# --- Deterministic down-sampling of the DRAWN points only.
|
||||
n_total = len(pairs)
|
||||
if (
|
||||
isinstance(max_points, int)
|
||||
and not isinstance(max_points, bool)
|
||||
and max_points > 0
|
||||
and n_total > max_points
|
||||
):
|
||||
step = math.ceil(n_total / max_points)
|
||||
sampled = pairs[::step]
|
||||
else:
|
||||
sampled = pairs
|
||||
|
||||
x_plot = [p[0] for p in sampled]
|
||||
y_plot = [p[1] for p in sampled]
|
||||
|
||||
fig = Figure(figsize=(6.4, 4.0), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
|
||||
ax.scatter(
|
||||
x_plot,
|
||||
y_plot,
|
||||
s=12,
|
||||
alpha=0.5,
|
||||
color=_POINT_COLOR,
|
||||
edgecolors="none",
|
||||
rasterized=True,
|
||||
)
|
||||
|
||||
# --- Fitted curve/line over the full clean x range.
|
||||
coeffs = cls.get("coeffs")
|
||||
best_degree = cls.get("best_degree")
|
||||
tipo = cls.get("tipo")
|
||||
x_min, x_max = min(xs_clean), max(xs_clean)
|
||||
drew_fit = False
|
||||
if coeffs is not None and best_degree is not None and x_max > x_min:
|
||||
try:
|
||||
coeff_arr = np.asarray(coeffs, dtype=float)
|
||||
if coeff_arr.ndim == 1 and coeff_arr.size > 0 and np.all(np.isfinite(coeff_arr)):
|
||||
x_line = np.linspace(x_min, x_max, 200)
|
||||
y_line = np.polyval(coeff_arr, x_line)
|
||||
if np.all(np.isfinite(y_line)):
|
||||
ax.plot(x_line, y_line, color=_FIT_COLOR, linewidth=2)
|
||||
drew_fit = True
|
||||
except Exception:
|
||||
# Never fail the figure because of a malformed coeffs array.
|
||||
pass
|
||||
|
||||
# A monotonic non-linear relationship has no fitted polynomial (coeffs is
|
||||
# None by design — a low-degree polynomial would mislead). Draw instead the
|
||||
# ordered trend of y over x so the reader still sees the shape: y averaged
|
||||
# within ordered x-bins (or per distinct x value when x is discrete with few
|
||||
# levels, e.g. an ordinal scale). Defensive: any failure leaves the cloud.
|
||||
if (not drew_fit and isinstance(tipo, str) and "monóton" in tipo.lower()
|
||||
and x_max > x_min):
|
||||
try:
|
||||
xt, yt = _ordered_trend(xs_clean, ys_clean)
|
||||
if xt is not None and len(xt) >= 2:
|
||||
ax.plot(xt, yt, color=_FIT_COLOR, linewidth=2, marker="o",
|
||||
markersize=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Labels and title.
|
||||
tx = x_label if x_label else "x"
|
||||
ty = y_label if y_label else "y"
|
||||
ax.set_title(f"{tx} ↔ {ty}", fontsize=12, loc="left", pad=8)
|
||||
ax.set_xlabel(x_label)
|
||||
ax.set_ylabel(y_label)
|
||||
|
||||
# --- Corner annotation: relationship type + available metrics.
|
||||
caption_lines = []
|
||||
if tipo:
|
||||
caption_lines.append(str(tipo))
|
||||
metrics_line = _metrics_caption(cls)
|
||||
if metrics_line:
|
||||
caption_lines.append(metrics_line)
|
||||
if caption_lines:
|
||||
ax.text(
|
||||
0.03,
|
||||
0.97,
|
||||
"\n".join(caption_lines),
|
||||
transform=ax.transAxes,
|
||||
ha="left",
|
||||
va="top",
|
||||
fontsize=8,
|
||||
bbox=dict(
|
||||
boxstyle="round,pad=0.35",
|
||||
facecolor="white",
|
||||
edgecolor="#cccccc",
|
||||
alpha=0.85,
|
||||
),
|
||||
)
|
||||
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Tests para relationship_scatter_figure (scatter de un par numérico, grupo eda).
|
||||
|
||||
Usa el backend Agg sin pyplot global; no muestra ni guarda figuras. Cada test
|
||||
cierra explícitamente la Figure construida (matplotlib.pyplot.close) para no
|
||||
acumular estado entre tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.collections import PathCollection # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from relationship_scatter_figure import relationship_scatter_figure
|
||||
|
||||
|
||||
def _scatter_offsets(fig):
|
||||
"""Return the plotted points of the first PathCollection (scatter) found."""
|
||||
for ax in fig.axes:
|
||||
for coll in ax.collections:
|
||||
if isinstance(coll, PathCollection):
|
||||
return coll.get_offsets()
|
||||
return None
|
||||
|
||||
|
||||
def test_returns_figure():
|
||||
xs = [float(i) for i in range(20)]
|
||||
ys = [2.0 * x + 1.0 for x in xs] # y = 2x + 1
|
||||
classification = {
|
||||
"tipo": "lineal",
|
||||
"pearson": 1.0,
|
||||
"r2_linear": 1.0,
|
||||
"spearman": 1.0,
|
||||
"r2_poly2": 1.0,
|
||||
"r2_poly3": 1.0,
|
||||
"best_degree": 1,
|
||||
"coeffs": [2.0, 1.0],
|
||||
}
|
||||
fig = relationship_scatter_figure(
|
||||
xs, ys, x_label="a", y_label="b", classification=classification
|
||||
)
|
||||
assert hasattr(fig, "savefig")
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_downsample_determinista():
|
||||
n = 5000
|
||||
xs = [float(i) for i in range(n)]
|
||||
ys = [0.5 * x for x in xs]
|
||||
classification = {
|
||||
"tipo": "lineal",
|
||||
"pearson": 1.0,
|
||||
"r2_linear": 1.0,
|
||||
"spearman": 1.0,
|
||||
"r2_poly2": 1.0,
|
||||
"r2_poly3": 1.0,
|
||||
"best_degree": 1,
|
||||
"coeffs": [0.5, 0.0],
|
||||
}
|
||||
fig = relationship_scatter_figure(
|
||||
xs, ys, x_label="x", y_label="y", classification=classification, max_points=1000
|
||||
)
|
||||
assert isinstance(fig, Figure)
|
||||
offsets = _scatter_offsets(fig)
|
||||
assert offsets is not None
|
||||
# El nº de puntos dibujados no debe exceder el cap.
|
||||
assert len(offsets) <= 1000
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_no_lanza():
|
||||
fig = relationship_scatter_figure([], [], x_label="x", y_label="y")
|
||||
assert isinstance(fig, Figure)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_classification_none():
|
||||
# Solo se ejecuta si el módulo hermano classify_relationship_type existe.
|
||||
try:
|
||||
import classify_relationship_type # noqa: F401
|
||||
except Exception:
|
||||
import pytest
|
||||
|
||||
pytest.skip("classify_relationship_type aún no disponible")
|
||||
xs = [float(i) for i in range(30)]
|
||||
ys = [3.0 * x - 2.0 for x in xs]
|
||||
fig = relationship_scatter_figure(
|
||||
xs, ys, x_label="a", y_label="b", classification=None
|
||||
)
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: render_paper_pdf
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def render_paper_pdf(paper_dir: str) -> dict"
|
||||
description: "Convierte un paper académico IMRaD escrito en Markdown (papers/<slug>/paper.md, con frontmatter YAML opcional title/authors/date/abstract + cuerpo) en un PDF papers/<slug>/out/paper.pdf. REUTILIZA el paginador de flujo del paquete automatic_eda (el mismo motor del PDF móvil A5 de los informes EDA): no reimplementa paginación ni toca matplotlib. Cada sección IMRaD (encabezado de nivel 1, p.ej. # Introduction, # Methods) se mapea a un Chapter que empieza en página nueva; el motor parsea por sí mismo headings, listas, tablas pipe, párrafos y **negrita** dentro del texto. Como el motor NO entiende la sintaxis de imagen Markdown , esta función detecta esas líneas y las parte en bloques Image separados, resolviendo el src relativo a base_dir y base_dir/figures/. La portada (si hay título) lista autores y fecha (DD/MM/AAAA si parseable) más el abstract. dict-no-throw: nunca lanza, devuelve {status, pdf_path, n_pages, note}."
|
||||
tags: [papers, pdf, academic, render, report, imrad, mobile, automatic-eda, markdown, no-cut, matplotlib, datascience, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, re, datetime, yaml, "datascience.automatic_eda"]
|
||||
params:
|
||||
- name: paper_dir
|
||||
desc: "ruta al directorio del paper (papers/<slug>/, del que se lee paper.md) O directamente la ruta a un archivo paper.md (cualquier ruta terminada en .md). El directorio base para resolver figuras y escribir el PDF es el dirname del paper.md. Si el paper.md no existe (incluida una ruta totalmente inexistente) devuelve status='error' sin crash."
|
||||
output: "dict (nunca lanza): {status: 'ok'|'error', pdf_path: str|None, n_pages: int, note: str}. En éxito status='ok', pdf_path es la ruta del PDF escrito (<base_dir>/out/paper.pdf) y n_pages el total de páginas. En error status='error', pdf_path=None, n_pages=0 y note explica la causa (paper.md no encontrado, fallo del motor, o excepción inesperada)."
|
||||
tested: true
|
||||
tests: ["test_golden_genera_pdf_con_portada_y_secciones", "test_edge_sin_frontmatter_ni_figuras", "test_edge_path_inexistente_no_revienta", "test_edge_figura_inexistente_degrada", "test_acepta_ruta_directa_al_md"]
|
||||
test_file_path: "python/functions/datascience/render_paper_pdf_test.py"
|
||||
file_path: "python/functions/datascience/render_paper_pdf.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import render_paper_pdf
|
||||
|
||||
# Estructura del paper:
|
||||
# papers/zz-demo/paper.md (frontmatter YAML + cuerpo IMRaD)
|
||||
# papers/zz-demo/figures/fig1.png (figuras referenciadas con )
|
||||
#
|
||||
# paper.md:
|
||||
# ---
|
||||
# title: A Minimal IMRaD Paper
|
||||
# authors: [Ada Lovelace, Alan Turing]
|
||||
# date: 2026-06-30
|
||||
# abstract: Demostramos que el motor pagina un paper sin cortar nada.
|
||||
# ---
|
||||
# # Introduction
|
||||
# Texto con **negrita** y una lista:
|
||||
# - Punto uno.
|
||||
# 
|
||||
# # Methods
|
||||
# | Métrica | Valor |
|
||||
# | --- | --- |
|
||||
# | Precisión | 0.91 |
|
||||
|
||||
res = render_paper_pdf("papers/zz-demo")
|
||||
print(res["status"], res["n_pages"], res["pdf_path"])
|
||||
# -> ok 3 papers/zz-demo/out/paper.pdf
|
||||
|
||||
# También acepta la ruta directa al .md:
|
||||
render_paper_pdf("papers/zz-demo/paper.md")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas un paper académico (o cualquier documento IMRaD) escrito en
|
||||
Markdown y quieras un **PDF móvil A5 listo para leer**, sin montar LaTeX ni
|
||||
configurar un pipeline de pandoc. Úsala después de redactar `paper.md` con su
|
||||
frontmatter (título, autores, fecha, abstract) y secciones de nivel 1; obtienes
|
||||
`out/paper.pdf` con portada, una página nueva por sección IMRaD, tablas que se
|
||||
parten repitiendo la cabecera y figuras escaladas para caber enteras —
|
||||
garantía de no-corte heredada del motor `automatic_eda`. Es la capa de
|
||||
presentación PDF del grupo `papers`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe `out/paper.pdf` (y crea el directorio `out/`) junto al
|
||||
`paper.md`. Necesita **matplotlib** instalado en el venv (lo usa el motor
|
||||
`automatic_eda.render_pdf` con backend headless `Agg`; corre en agentes/CI sin
|
||||
display). `pyyaml` es opcional: si falta, el frontmatter se parsea con un
|
||||
parser line-based `clave: valor` degradado.
|
||||
- **Reutiliza el motor `automatic_eda.render_pdf`**: NO reimplementa paginación
|
||||
ni toca matplotlib. `render_pdf` no tiene ID propio en el registry (es parte
|
||||
del paquete de soporte `automatic_eda`), por eso `uses_functions` queda vacío;
|
||||
la dependencia real es ese motor del paquete.
|
||||
- **Nunca lanza** (dict-no-throw): `paper.md` inexistente → `{status:"error",
|
||||
pdf_path:None, note:"paper.md no encontrado: ..."}`; cualquier excepción
|
||||
inesperada → `{status:"error", note:"fallo: ..."}`. Frontmatter ausente o
|
||||
incompleto degrada limpio (sin portada, el cuerpo entero se pagina).
|
||||
- **Figuras relativas a `figures/`**: el `src` de `` se resuelve
|
||||
probando `<base_dir>/<src>` y `<base_dir>/figures/<basename>`; usa el primero
|
||||
que exista. Si ninguno existe, el motor **degrada** dibujando
|
||||
"(imagen no encontrada: ...)" — el PDF se genera igual, no crashea. Las URLs
|
||||
`http(s)` se dejan como texto Markdown, no se descargan.
|
||||
- **Solo imágenes en línea propia**: el motor `_place_markdown` NO entiende
|
||||
``; esta función solo convierte a `Image` las líneas cuyo único
|
||||
contenido es la imagen. Una imagen embebida a mitad de un párrafo se quedaría
|
||||
como texto crudo.
|
||||
- **A5 portrait mobile-first**: el formato (tamaño de página, tipografía, pie
|
||||
`Capítulo · vX.Y.Z`) lo fija el motor EDA y no es configurable desde aquí.
|
||||
@@ -0,0 +1,297 @@
|
||||
"""render_paper_pdf — convierte un paper académico IMRaD en Markdown a un PDF.
|
||||
|
||||
Toma un paper escrito en Markdown con frontmatter YAML opcional (título,
|
||||
autores, fecha, abstract) más un cuerpo dividido en secciones IMRaD por
|
||||
encabezados de nivel 1 (``# Introduction``, ``# Methods``, ...) y produce un PDF
|
||||
``out/paper.pdf`` junto al paper.
|
||||
|
||||
REUTILIZA el paginador de flujo del paquete ``automatic_eda`` (el mismo motor
|
||||
que rinde los informes EDA en PDF móvil A5): no reimplementa paginación ni toca
|
||||
matplotlib directamente. Cada sección IMRaD se mapea a un ``Chapter`` (empieza
|
||||
en página nueva). El motor ``_place_markdown`` parsea por sí mismo headings,
|
||||
listas, tablas pipe, párrafos y ``**negrita**`` dentro del texto, pero NO
|
||||
entiende la sintaxis de imagen Markdown ````; por eso esta función
|
||||
detecta esas líneas y las convierte en bloques ``Image`` separados, partiendo el
|
||||
texto Markdown alrededor de cada imagen.
|
||||
|
||||
dict-no-throw (estilo del grupo eda): NUNCA lanza. Devuelve
|
||||
``{status, pdf_path, n_pages, note}``; ante cualquier fallo devuelve
|
||||
``status="error"`` con ``pdf_path=None`` y la causa en ``note``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import os
|
||||
import re
|
||||
|
||||
from datascience.automatic_eda import Chapter, Heading, Image, Markdown, render_pdf
|
||||
|
||||
# Una línea cuyo único contenido es una imagen Markdown: 
|
||||
_IMG_LINE = re.compile(r"^\s*!\[([^\]]*)\]\(\s*([^)\s]+)\s*\)\s*$")
|
||||
# Un encabezado de nivel 1 al inicio de línea (un solo '#' seguido de espacio).
|
||||
_H1_LINE = re.compile(r"^#[ \t]+(.+?)\s*$")
|
||||
|
||||
|
||||
def render_paper_pdf(paper_dir: str) -> dict:
|
||||
"""Renderiza un paper académico Markdown IMRaD en un PDF.
|
||||
|
||||
Args:
|
||||
paper_dir: ruta al directorio del paper (``papers/<slug>/``, del que se
|
||||
lee ``paper.md``) o directamente la ruta a un archivo ``paper.md``.
|
||||
|
||||
Returns:
|
||||
dict (nunca lanza): ``{status: "ok"|"error", pdf_path: str|None,
|
||||
n_pages: int, note: str}``. En éxito ``pdf_path`` es la ruta escrita y
|
||||
``n_pages`` el total de páginas; en error ``pdf_path`` es None y
|
||||
``note`` explica la causa.
|
||||
"""
|
||||
try:
|
||||
# 1) Resolver el path del paper.md y el directorio base.
|
||||
arg = str(paper_dir)
|
||||
md_path = arg if arg.endswith(".md") else os.path.join(arg, "paper.md")
|
||||
|
||||
# 2) Si el paper.md no existe, degradar sin crash.
|
||||
if not os.path.isfile(md_path):
|
||||
return {"status": "error", "pdf_path": None, "n_pages": 0,
|
||||
"note": f"paper.md no encontrado: {md_path}"}
|
||||
|
||||
base_dir = os.path.dirname(os.path.abspath(md_path))
|
||||
|
||||
# 3) Leer el archivo y separar frontmatter del cuerpo.
|
||||
with open(md_path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
fm_text, body = _split_frontmatter(text)
|
||||
fm = _parse_frontmatter(fm_text)
|
||||
|
||||
title = _safe_str(fm.get("title")).strip()
|
||||
authors = fm.get("authors")
|
||||
date_raw = fm.get("date")
|
||||
abstract = _safe_str(fm.get("abstract")).strip()
|
||||
|
||||
# 4) Construir los capítulos: portada (si hay título) + cuerpo IMRaD.
|
||||
chapters: list = []
|
||||
if title:
|
||||
cover_md = _portada_markdown(authors, date_raw, abstract)
|
||||
cover_blocks: list = [Heading(text=title, level=1)]
|
||||
if cover_md.strip():
|
||||
cover_blocks.append(Markdown(text=cover_md))
|
||||
chapters.append(Chapter(id="portada", title=title, version="1.0.0",
|
||||
blocks=cover_blocks))
|
||||
|
||||
preamble, sections = _split_body_sections(body)
|
||||
|
||||
if not sections:
|
||||
# Sin encabezados H1: todo el cuerpo en un único capítulo.
|
||||
chapters.append(Chapter(
|
||||
id="cuerpo", title="Cuerpo", version="1.0.0",
|
||||
blocks=_markdown_to_blocks(body, base_dir)))
|
||||
else:
|
||||
# Texto antes del primer H1 (si lo hay) como capítulo previo.
|
||||
if preamble.strip():
|
||||
chapters.append(Chapter(
|
||||
id="cuerpo", title="Cuerpo", version="1.0.0",
|
||||
blocks=_markdown_to_blocks(preamble, base_dir)))
|
||||
for idx, (sec_title, sec_body) in enumerate(sections):
|
||||
blocks: list = [Heading(text=sec_title, level=1)]
|
||||
blocks.extend(_markdown_to_blocks(sec_body, base_dir))
|
||||
chapters.append(Chapter(
|
||||
id=_slugify(sec_title) or f"sec{idx}",
|
||||
title=sec_title, version="1.0.0", blocks=blocks))
|
||||
|
||||
# 5) Renderizar con el motor de automatic_eda.
|
||||
out_path = os.path.join(base_dir, "out", "paper.pdf")
|
||||
res = render_pdf(chapters, out_path, meta={"title": title or "paper"})
|
||||
|
||||
# 6) Mapear el retorno del motor a la forma de esta función.
|
||||
path = res.get("path")
|
||||
return {
|
||||
"status": "ok" if path else "error",
|
||||
"pdf_path": path,
|
||||
"n_pages": int(res.get("n_pages") or 0),
|
||||
"note": res.get("note"),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — dict-no-throw estricto.
|
||||
return {"status": "error", "pdf_path": None, "n_pages": 0,
|
||||
"note": f"fallo: {e}"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Frontmatter
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _split_frontmatter(text: str):
|
||||
"""Separa el bloque frontmatter YAML inicial del cuerpo.
|
||||
|
||||
Devuelve ``(fm_text|None, body)``. Si el archivo no empieza con una valla
|
||||
``---`` o no se cierra, no hay frontmatter y el cuerpo es el texto entero.
|
||||
"""
|
||||
if text.startswith(""):
|
||||
text = text.lstrip("")
|
||||
lines = text.split("\n")
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return None, text
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:])
|
||||
# Valla de apertura sin cierre: tratar todo como cuerpo.
|
||||
return None, text
|
||||
|
||||
|
||||
def _parse_frontmatter(fm_text) -> dict:
|
||||
"""Parsea el frontmatter. Intenta YAML; si no, parser line-based simple."""
|
||||
if not fm_text:
|
||||
return {}
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
data = yaml.safe_load(fm_text)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception: # noqa: BLE001 — yaml ausente o frontmatter inválido.
|
||||
pass
|
||||
# Fallback degradado: 'clave: valor' por línea.
|
||||
out: dict = {}
|
||||
for line in fm_text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
||||
continue
|
||||
k, _, v = stripped.partition(":")
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Portada
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _portada_markdown(authors, date_raw, abstract) -> str:
|
||||
"""Markdown de la portada: autores, fecha y, si hay, el abstract."""
|
||||
parts: list = []
|
||||
authors_str = _fmt_authors(authors)
|
||||
if authors_str:
|
||||
parts.append(f"**Autores:** {authors_str}")
|
||||
if date_raw not in (None, ""):
|
||||
parts.append(f"**Fecha:** {_fmt_date(date_raw)}")
|
||||
md = "\n\n".join(parts)
|
||||
abstract = _safe_str(abstract).strip()
|
||||
if abstract:
|
||||
md = (md + "\n\n" if md else "") + "## Abstract\n\n" + abstract
|
||||
return md
|
||||
|
||||
|
||||
def _fmt_authors(authors) -> str:
|
||||
"""Lista o string de autores → string separado por comas."""
|
||||
if authors in (None, ""):
|
||||
return ""
|
||||
if isinstance(authors, (list, tuple)):
|
||||
return ", ".join(_safe_str(a).strip() for a in authors
|
||||
if _safe_str(a).strip())
|
||||
return _safe_str(authors).strip()
|
||||
|
||||
|
||||
def _fmt_date(raw) -> str:
|
||||
"""Fecha → ``DD/MM/AAAA`` si es parseable; si no, el valor crudo."""
|
||||
if isinstance(raw, _dt.datetime):
|
||||
return raw.strftime("%d/%m/%Y")
|
||||
if isinstance(raw, _dt.date):
|
||||
return raw.strftime("%d/%m/%Y")
|
||||
s = _safe_str(raw).strip()
|
||||
if not s:
|
||||
return s
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"):
|
||||
try:
|
||||
return _dt.datetime.strptime(s, fmt).strftime("%d/%m/%Y")
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return _dt.datetime.fromisoformat(s).strftime("%d/%m/%Y")
|
||||
except Exception: # noqa: BLE001
|
||||
return s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cuerpo y figuras
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _split_body_sections(body: str):
|
||||
"""Divide el cuerpo en (preámbulo, [(título_H1, contenido)...]) por H1."""
|
||||
preamble_lines: list = []
|
||||
sections: list = []
|
||||
current = None # (titulo, [lineas])
|
||||
for line in body.split("\n"):
|
||||
m = _H1_LINE.match(line)
|
||||
if m and not line.startswith("##"):
|
||||
if current is not None:
|
||||
sections.append((current[0], "\n".join(current[1])))
|
||||
current = (m.group(1).strip(), [])
|
||||
elif current is None:
|
||||
preamble_lines.append(line)
|
||||
else:
|
||||
current[1].append(line)
|
||||
if current is not None:
|
||||
sections.append((current[0], "\n".join(current[1])))
|
||||
return "\n".join(preamble_lines), sections
|
||||
|
||||
|
||||
def _markdown_to_blocks(text: str, base_dir: str) -> list:
|
||||
"""Parte un Markdown en bloques Markdown/Image alrededor de cada figura.
|
||||
|
||||
Las líneas ```` con ``src`` local se convierten en ``Image``; las
|
||||
que apuntan a URLs http(s) se dejan como texto Markdown.
|
||||
"""
|
||||
blocks: list = []
|
||||
buf: list = []
|
||||
|
||||
def _flush():
|
||||
chunk = "\n".join(buf).strip("\n")
|
||||
if chunk.strip():
|
||||
blocks.append(Markdown(text=chunk))
|
||||
buf.clear()
|
||||
|
||||
for line in text.split("\n"):
|
||||
m = _IMG_LINE.match(line)
|
||||
if m:
|
||||
alt, src = m.group(1), m.group(2)
|
||||
if src.lower().startswith(("http://", "https://")):
|
||||
buf.append(line) # URL remota: se mantiene como texto.
|
||||
continue
|
||||
_flush()
|
||||
blocks.append(Image(path=_resolve_src(src, base_dir),
|
||||
caption=(alt or None)))
|
||||
else:
|
||||
buf.append(line)
|
||||
_flush()
|
||||
return blocks
|
||||
|
||||
|
||||
def _resolve_src(src: str, base_dir: str) -> str:
|
||||
"""Resuelve la ruta de una figura relativa al paper.
|
||||
|
||||
Absoluta → tal cual. Relativa → prueba ``base_dir/src`` y
|
||||
``base_dir/figures/<basename>``; usa la primera que exista, o el join con
|
||||
``base_dir`` si ninguna (el motor degrada dibujando el aviso de no-encontrada).
|
||||
"""
|
||||
if os.path.isabs(src):
|
||||
return src
|
||||
cand1 = os.path.join(base_dir, src)
|
||||
cand2 = os.path.join(base_dir, "figures", os.path.basename(src))
|
||||
for c in (cand1, cand2):
|
||||
if os.path.exists(c):
|
||||
return c
|
||||
return cand1
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Slug ASCII corto para el id del capítulo."""
|
||||
s = re.sub(r"[^a-z0-9]+", "_", _safe_str(text).lower()).strip("_")
|
||||
return s[:40]
|
||||
|
||||
|
||||
def _safe_str(v) -> str:
|
||||
"""str() que nunca lanza y mapea None a ''."""
|
||||
if v is None:
|
||||
return ""
|
||||
try:
|
||||
return str(v)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests para render_paper_pdf — DoD: golden + edges + error path.
|
||||
|
||||
Autocontenido y sin red: escribe papers Markdown sintéticos en directorios
|
||||
temporales y verifica que el PDF se genera (estado, nº de páginas, archivo
|
||||
no vacío) reutilizando el motor de paginación de ``automatic_eda``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from datascience.render_paper_pdf import render_paper_pdf
|
||||
|
||||
|
||||
_GOLDEN_PAPER = """---
|
||||
title: A Minimal IMRaD Paper
|
||||
authors:
|
||||
- Ada Lovelace
|
||||
- Alan Turing
|
||||
date: 2026-06-30
|
||||
abstract: >
|
||||
Demostramos que el motor de paginación rinde un paper IMRaD completo en PDF
|
||||
móvil sin cortar texto ni tablas.
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Este es el cuerpo de la introducción con **texto en negrita** y una lista:
|
||||
|
||||
- Primer punto.
|
||||
- Segundo punto.
|
||||
|
||||
# Methods
|
||||
|
||||
Resultados resumidos en una tabla pipe:
|
||||
|
||||
| Métrica | Valor |
|
||||
| --- | --- |
|
||||
| Precisión | 0.91 |
|
||||
| Recall | 0.88 |
|
||||
|
||||
Texto final de la sección de métodos.
|
||||
"""
|
||||
|
||||
|
||||
def test_golden_genera_pdf_con_portada_y_secciones(tmp_path):
|
||||
"""Golden: paper IMRaD con frontmatter + 2 secciones + tabla → PDF válido."""
|
||||
paper_dir = tmp_path / "zz-demo"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(_GOLDEN_PAPER, encoding="utf-8")
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
pdf_path = res["pdf_path"]
|
||||
assert pdf_path is not None
|
||||
assert os.path.exists(pdf_path)
|
||||
assert os.path.getsize(pdf_path) > 0
|
||||
|
||||
|
||||
def test_edge_sin_frontmatter_ni_figuras(tmp_path):
|
||||
"""Edge 1: cuerpo plano sin frontmatter ni figuras → genera PDF igual."""
|
||||
paper_dir = tmp_path / "plano"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(
|
||||
"Solo un cuerpo plano, sin frontmatter ni encabezados de nivel 1.\n"
|
||||
"Un par de líneas de texto corrido para que el motor lo pagine.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
|
||||
|
||||
def test_edge_path_inexistente_no_revienta():
|
||||
"""Edge 2: directorio inexistente → status error, sin crash, pdf_path None."""
|
||||
res = render_paper_pdf("/tmp/no_existe_xyz_123")
|
||||
|
||||
assert res["status"] == "error"
|
||||
assert res["pdf_path"] is None
|
||||
assert res["n_pages"] == 0
|
||||
assert "no encontrado" in (res["note"] or "")
|
||||
|
||||
|
||||
def test_edge_figura_inexistente_degrada(tmp_path):
|
||||
"""Edge 3: referencia a figura inexistente → el PDF se genera igual."""
|
||||
paper_dir = tmp_path / "con-figura"
|
||||
paper_dir.mkdir()
|
||||
(paper_dir / "paper.md").write_text(
|
||||
"---\n"
|
||||
"title: Paper Con Figura Rota\n"
|
||||
"---\n\n"
|
||||
"# Results\n\n"
|
||||
"Texto antes de la figura.\n\n"
|
||||
"\n\n"
|
||||
"Texto después de la figura.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
res = render_paper_pdf(str(paper_dir))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["n_pages"] >= 1
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
|
||||
|
||||
def test_acepta_ruta_directa_al_md(tmp_path):
|
||||
"""Acepta también la ruta directa a un paper.md (no solo el directorio)."""
|
||||
md = tmp_path / "paper.md"
|
||||
md.write_text("# Discussion\n\nCuerpo de la discusión.\n", encoding="utf-8")
|
||||
|
||||
res = render_paper_pdf(str(md))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert os.path.exists(res["pdf_path"])
|
||||
Reference in New Issue
Block a user