refactor(eda): quitar definiciones inline redundantes con el glosario en 5 capítulos

Ahora que el AutomaticEDA tiene un capítulo GLOSARIO con las definiciones de los
términos técnicos (enganchados como links clicables desde el cuerpo), los
capítulos calidad/correlacion/modelos/agregacion/relaciones ya no repiten inline
esas explicaciones largas: se deja el TÉRMINO marcado (clicable, sigue saltando
al glosario) y se elimina el párrafo/oración de definición redundante. Los
HALLAZGOS y datos concretos del análisis se mantienen intactos; solo se quitan
las definiciones generales que el glosario ya cubre.

- calidad: _criteria_intro pasa de un bullet-list con las definiciones de
  completitud/validez/unicidad/calidad + fórmula renormalizada + párrafo de
  outliers a una frase que nombra las dimensiones, sus pesos (60/40) y el
  principio de outliers; los 4 términos siguen marcados.
- modelos: la nota de normalización deja de explicar la fórmula del z-score; la
  intro de PCA ya no define "componentes ortogonales ordenados por varianza"; la
  de KMeans quita "rango −1 a 1: cuanto más alto..." (silhouette); la sección de
  Isolation Forest quita la descripción de árboles/cortes/umbral. Términos
  marcados intactos.
- correlacion: la intro deja de describir cada método y consolida la duplicación
  signo/dirección; los 4 métodos + FDR siguen marcados.
- agregacion: la intro quita la definición de pivot ("cruzan dos categóricas
  sobre una medida") y abrevia la selección de claves; groupby y pivot marcados.
- relaciones: la intro y la sección de candidatas/inter-tabla quitan las
  definiciones de PK ("identifica cada fila"), FK ("referencian a otra tabla") y
  containment ("valores contenidos en la clave de otra"); pk/fk/cardinalidad/
  containment siguen marcados.

Verificado sobre el EDA de titanic (run_models + run_llm, 48 págs): los 23 link
annotations término→glosario se conservan (PyMuPDF), el glosario mantiene las 20
definiciones, y el texto visible de los 5 capítulos baja un 34.7% en conjunto
(calidad −67%, modelos −33%, relaciones −19%, agregacion −15%, correlacion −8%).
Tests actualizados (calidad_test asertaba el texto viejo). Suite EDA + pipeline
verde (118 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:15:24 +02:00
parent ab21e5d90b
commit fd63261444
6 changed files with 69 additions and 86 deletions
@@ -561,13 +561,11 @@ def _intro_blocks(gloss=None, mark_term: bool = False) -> list:
t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)") t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)")
t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)") t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)")
text = ( text = (
f"Este capítulo analiza la tabla {t_groupby}: " f"Este capítulo analiza la tabla {t_groupby}: elige las columnas "
"elige las columnas categóricas más informativas por su cardinalidad " "categóricas más informativas (por cardinalidad y relevancia, no todas "
"y relevancia, no todas contra todas, para no inflar comparaciones " "contra todas) y resume las variables numéricas dentro de cada grupo "
"espurias — y resume las variables numéricas dentro de cada grupo " f"(conteo, media, mediana, desviación). Se añaden {t_pivot} y "
f"(conteo, media, mediana, desviación). Las {t_pivot} " "**gráficos de barras** (siempre desde cero) para comparar los grupos."
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
"(siempre desde cero) comparan los grupos de un vistazo."
) )
return [model.Heading(text=CHAPTER_TITLE, level=1), return [model.Heading(text=CHAPTER_TITLE, level=1),
model.Markdown(text=text)] model.Markdown(text=text)]
@@ -3,12 +3,13 @@
Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The
chapter implements the quality model of report 2046: chapter implements the quality model of report 2046:
1. **En qué se basa la calidad** — an intro paragraph explaining the two scored 1. **En qué se basa la calidad** — a concise intro naming the two scored
dimensions and their weights (completitud 60%, validez 40%) plus the dimensions and their weights (completitud 60%, validez 40%) plus the
table-level row uniqueness, BEFORE any number, and stating explicitly that table-level row uniqueness, BEFORE any number, and stating that outliers are
outliers are reported as observations and do **not** lower the score. The reported as observations and do **not** lower the score. The criteria terms
criteria terms (calidad de datos, completitud, validez, unicidad de registro) (calidad de datos, completitud, validez, unicidad de registro) are hooked
are hooked into the shared glossary as clickable jumps. into the shared glossary as clickable jumps; their full definitions live in
the GLOSARIO chapter, not inline here.
2. **Scores por columna** — a table with, per column, the total quality score and 2. **Scores por columna** — a table with, per column, the total quality score and
its breakdown into completeness / validity (no consistency dimension). its breakdown into completeness / validity (no consistency dimension).
3. **Problemas de calidad** — a table listing ONLY real quality defects 3. **Problemas de calidad** — a table listing ONLY real quality defects
@@ -309,30 +310,22 @@ def _term(key: str, label: str, mark: bool) -> str:
def _criteria_intro(mark: bool) -> str: def _criteria_intro(mark: bool) -> str:
"""Intro paragraph explaining the two scored dimensions and the principle.""" """Intro: how the score is composed, with every term marked clickable.
Concise on purpose: the definitions of each term (calidad de datos,
completitud, validez, unicidad de registro) now live in the GLOSARIO
chapter, so the body no longer repeats them — it only states how the score
is composed and keeps each term marked so it stays a clickable jump.
"""
calidad = _term("calidad_datos", "calidad de datos", mark) calidad = _term("calidad_datos", "calidad de datos", mark)
completitud = _term("completitud", "Completitud (peso 60%)", mark) completitud = _term("completitud", "completitud", mark)
validez = _term("validez", "Validez (peso 40%, cuando es medible)", mark) validez = _term("validez", "validez", mark)
unicidad = _term("unicidad_registro", "unicidad de registro", mark) unicidad = _term("unicidad_registro", "unicidad de registro", mark)
return ( return (
f"La {calidad} de cada columna es un score de 0 a 100 que combina solo " f"La {calidad} de cada columna es un score de 0 a 100 que combina "
"dimensiones medibles desde el perfil de la tabla, sin fuente externa " f"{completitud} (peso 60%) y {validez} (peso 40%, cuando es medible); "
"de verdad:\n\n" f"a nivel de tabla se añade la {unicidad}. Los valores atípicos no "
f"- {completitud}: proporción de valores presentes (1 % de nulos; en " "bajan el score: se listan aparte como **observaciones analíticas**."
"texto, las celdas vacías cuentan como faltantes). Los nulos y vacíos "
"bajan el score.\n"
f"- {validez}: proporción de valores que encajan con su tipo o formato "
"(un número que parsea, una fecha legible, un email con forma de email). "
"Si una columna es texto libre sin formato esperado, la validez no se "
"mide y el score se basa solo en la completitud.\n\n"
f"Score de columna = 100 × (0,6·completitud + 0,4·validez), "
"renormalizado cuando la validez no aplica. A nivel de tabla se añade "
f"la {unicidad} (1 % de filas duplicadas).\n\n"
"**Los valores atípicos (outliers) NO bajan la calidad.** Un valor "
"extremo puede ser real y correcto; detectar atípicos es parte del "
"análisis de la distribución, no un juicio de corrección. Por eso, junto "
"con las columnas constantes y los identificadores, se listan aparte "
"como **observaciones analíticas** que no afectan al score."
) )
@@ -72,14 +72,16 @@ def test_golden_chapter_estructura_y_version():
assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds
def test_golden_intro_explica_dos_dimensiones_y_pesos(): def test_golden_intro_nombra_dos_dimensiones_y_pesos():
# La intro nombra las dos dimensiones, sus pesos y la unicidad, pero ya NO
# repite sus definiciones largas: estas viven ahora en el capítulo GLOSARIO.
ch = build_calidad(_profile(), {}) ch = build_calidad(_profile(), {})
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
for needle in ("Completitud", "Validez", "60%", "40%", for needle in ("completitud", "validez", "60%", "40%",
"unicidad de registro"): "unicidad de registro"):
assert needle in intro, f"falta {needle!r} en la intro de criterios" assert needle in intro, f"falta {needle!r} en la intro de criterios"
# El principio: los outliers NO bajan la calidad. # El principio: los outliers NO bajan la calidad.
assert "atípicos" in intro and "NO bajan" in intro assert "atípicos" in intro and "no bajan" in intro
# Ya no se menciona la dimensión consistencia eliminada. # Ya no se menciona la dimensión consistencia eliminada.
assert "20%" not in intro assert "20%" not in intro
@@ -356,12 +356,11 @@ def build_correlacion(profile: dict, ctx: dict):
t_cramers = _term(mark_term, "cramers_v", "Cramér's V") t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación") t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
blocks.append(model.Markdown(text=( blocks.append(model.Markdown(text=(
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a " "Asociación entre columnas. Cada par se evalúa con la métrica adecuada "
f"sus tipos ({t_pearson}/{t_spearman} entre numéricas — con **signo**; " f"a sus tipos: {t_pearson}/{t_spearman} (numéricas), {t_cramers} "
f"{t_cramers} entre categóricas; {t_corr_ratio} num-categórica; " f"(categóricas), {t_corr_ratio} (num-categórica) e información mutua. "
"información mutua como medida común no lineal). Sólo las correlaciones " "Sólo las correlaciones **num-num** llevan **signo** (dirección): por "
"**num-num** tienen dirección: por eso los pares **negativos** son siempre " "eso los pares **negativos** son siempre num-num.")))
"num-num.")))
# 1) Association matrix (heatmap). # 1) Association matrix (heatmap).
labels, trimmed = _ordered_labels(pairs) labels, trimmed = _ordered_labels(pairs)
@@ -6,15 +6,16 @@ normality}``). It renders, as structured markdown/tables/figures that the core
paginator never cuts: paginator never cuts:
1. **Normalization note** — every multivariate model below standardizes the 1. **Normalization note** — every multivariate model below standardizes the
columns with z-score first; the chapter explains why (different scales would columns with z-score first (the term is marked clickable; its definition
otherwise dominate distance/variance). lives in the GLOSARIO chapter, not inline).
2. **PCA** — a scree plot (explained + cumulative variance, single Y axis) plus 2. **PCA** — a scree plot (explained + cumulative variance, single Y axis) plus
variance and top-loadings tables. variance and top-loadings tables.
3. **KMeans segments** — a PCA scatter **coloured by cluster** (its own 3. **KMeans segments** — a PCA scatter **coloured by cluster** (its own
page/slide), the cluster-size table, and a per-cluster LLM micro-analysis page/slide), the cluster-size table, and a per-cluster LLM micro-analysis
with a title for each segment. with a title for each segment.
4. **Isolation Forest outliers** — a short explanation of how anomalous rows are 4. **Isolation Forest outliers** — the multivariate anomaly counts and decision
isolated multivariately and how the threshold is chosen, plus the counts. threshold (the method is marked clickable; its definition lives in the
GLOSARIO chapter, not inline).
5. **Normality** — per-column Jarque-Bera / D'Agostino / Shapiro verdicts. 5. **Normality** — per-column Jarque-Bera / D'Agostino / Shapiro verdicts.
The raw numeric data needed to colour the cluster scatter is **not** in the The raw numeric data needed to colour the cluster scatter is **not** in the
@@ -314,12 +315,8 @@ def _normalization_intro(gloss=None, mark_term: bool = False) -> list:
text = ( text = (
"Estos modelos son **no supervisados**: buscan estructura latente sin " "Estos modelos son **no supervisados**: buscan estructura latente sin "
"una variable objetivo. Antes de aplicarlos, todas las columnas " "una variable objetivo. Antes de aplicarlos, todas las columnas "
f"numéricas se {zscore} (cada valor menos la media, dividido por la " f"numéricas se {zscore}, para que todas pesen por igual con "
"desviación típica). Sin esta normalización, una variable con escala " "independencia de su escala."
"grande (p.ej. ingresos en euros) dominaría las distancias y la varianza "
"frente a otra de escala pequeña (p.ej. un ratio entre 0 y 1), sesgando "
"tanto el PCA como el KMeans. Tras la estandarización todas las variables "
"pesan por igual."
) )
return [model.Heading(text="Modelos no supervisados", level=1), return [model.Heading(text="Modelos no supervisados", level=1),
model.Markdown(text=text)] model.Markdown(text=text)]
@@ -334,11 +331,11 @@ def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
n_used = pca.get("n_rows_used") n_used = pca.get("n_rows_used")
n_feat = pca.get("n_features") n_feat = pca.get("n_features")
intro = ( intro = (
f"El {_term(mark_term, 'pca', 'PCA')} resume {_fmt_num(n_feat)} variables " f"El {_term(mark_term, 'pca', 'PCA')} se aplica sobre "
"numéricas en componentes ortogonales ordenados por la varianza que " f"{_fmt_num(n_feat)} variables numéricas ({_fmt_num(n_used)} filas "
f"capturan ({_fmt_num(n_used)} filas usadas tras eliminar nulos). El " "usadas tras eliminar nulos). El gráfico de sedimentación (scree) "
"gráfico de sedimentación (scree) muestra cuánta varianza aporta cada " "muestra cuánta varianza aporta cada componente y su acumulado: un "
"componente y su acumulado: un codo marca cuántos componentes bastan." "codo marca cuántos componentes bastan."
) )
blocks.append(model.Markdown(text=intro)) blocks.append(model.Markdown(text=intro))
@@ -403,9 +400,8 @@ def _kmeans_section(kmeans: dict, projection: dict, titles,
t_sil = _term(mark_term, "silhouette", "*silhouette*") t_sil = _term(mark_term, "silhouette", "*silhouette*")
intro = ( intro = (
f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** " f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** "
f"elegidos automáticamente maximizando el coeficiente de {t_sil} " f"elegidos automáticamente por el coeficiente de {t_sil} "
f"(**{_fmt_num(sil)}**, rango 1 a 1: cuanto más alto, segmentos más " f"(**{_fmt_num(sil)}**). Los segmentos se proyectan sobre el plano de "
"compactos y separados). Los segmentos se proyectan sobre el plano de "
"los dos primeros componentes principales para visualizarlos." "los dos primeros componentes principales para visualizarlos."
) )
blocks.append(model.Markdown(text=intro)) blocks.append(model.Markdown(text=intro))
@@ -469,14 +465,10 @@ def _outliers_section(outliers: dict, gloss=None, mark_term: bool = False) -> li
level=2)] level=2)]
isof = _term(mark_term, "isolation_forest", "**Isolation Forest**") isof = _term(mark_term, "isolation_forest", "**Isolation Forest**")
explain = ( explain = (
f"{isof} detecta filas anómalas de forma *multivariante*: " f"{isof} marca filas anómalas de forma *multivariante*: combinaciones "
"construye árboles que parten el espacio con cortes aleatorios y mide " "de valores poco frecuentes considerando **todas las columnas a la "
"cuántos cortes hacen falta para aislar cada fila. Las filas raras " "vez**, no una sola. La tabla resume cuántas se detectaron y el umbral "
"(combinaciones de valores poco frecuentes considerando **todas las " "de decisión empleado."
"columnas a la vez**, no una sola) se aíslan con muy pocos cortes y "
"obtienen un score bajo. El **umbral** de decisión separa las filas "
"normales de las anómalas según la contaminación esperada del modelo: "
"una fila es outlier cuando su score queda por debajo de ese umbral."
) )
blocks.append(model.Markdown(text=explain)) blocks.append(model.Markdown(text=explain))
blocks.append(model.KVTable(rows=[ blocks.append(model.KVTable(rows=[
@@ -256,14 +256,14 @@ def _pk_candidates_section(profile: dict, mark: bool) -> list:
pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark
else "**clave primaria**") else "**clave primaria**")
intro = ( intro = (
f"Estas columnas son **candidatas a {pk}**: su " f"Columnas **candidatas a {pk}**: su "
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y no " "[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y "
"tienen nulos, así que cada valor identifica una fila distinta. Son " "no tienen nulos. Son candidatas, no una clave declarada: la base no "
"candidatas, no una clave declarada: la base no las marca como tal." "las marca como tal."
if mark else if mark else
"Estas columnas son **candidatas a clave primaria**: su cardinalidad " "Columnas **candidatas a clave primaria**: su cardinalidad iguala al "
"iguala al número de filas y no tienen nulos, así que cada valor " "número de filas y no tienen nulos. Son candidatas, no una clave "
"identifica una fila distinta.") "declarada.")
rows = [] rows = []
for name in keys: for name in keys:
@@ -320,10 +320,10 @@ def _inter_table_section(db_path: str, tables: list, mark: bool) -> list:
blocks = [ blocks = [
model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2), model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2),
model.Markdown(text=( model.Markdown(text=(
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se infieren " f"La fuente tiene varias tablas. Estas {fk_term} candidatas se "
f"por señal de nombre y por {containment}: una columna de una tabla cuyos " f"infieren por señal de nombre y por {containment}. No están "
"valores están contenidos en la clave de otra. No están declaradas por " "declaradas por la base; son la relación más probable según los "
"la base; son la relación más probable según los datos.")), "datos.")),
] ]
shown = candidates[:MAX_FK_ROWS] shown = candidates[:MAX_FK_ROWS]
@@ -441,13 +441,12 @@ def _intro_blocks(mark: bool) -> list:
pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria" pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria"
fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea" fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea"
text = ( text = (
f"Este capítulo analiza las **relaciones de clave** de la tabla: qué columna " f"Este capítulo analiza las **relaciones de clave** de la tabla: cuál es "
f"identifica cada fila (la {pk}) y qué columnas referencian a otra tabla (las " f"la {pk} y cuáles son las {fk}. Cuando la base las **declara** como "
f"{fk}). Cuando la base las **declara** como restricciones del esquema, se " "restricciones del esquema, se muestran tal cual; cuando no, se proponen "
"muestran tal cual; cuando no, se proponen las más probables a partir de los " "las más probables a partir de los datos —por containment entre tablas o, "
"datos —por inclusión de valores entre tablas (containment) o, en una sola " "en una sola tabla, por una heurística de nombre y cardinalidad— siempre "
"tabla, por una heurística de nombre y cardinalidad— siempre marcadas como " "marcadas como candidatas, nunca como hechos.")
"candidatas, nunca como hechos.")
return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)] return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)]