feat(eda): engancha glosario clicable en correlacion/modelos/agregacion
Fase 4b — extiende el glosario clicable de AutomaticEDA (mecanismo ya probado end-to-end con `entropia` en cat_distr) a tres capítulos más, siguiendo el contrato sección 11 (glossary.add(key,label,def) + span [[term:KEY]]texto[[/term]]): - correlacion: Pearson, Spearman, Cramér's V, razón de correlación (η) y la corrección por comparaciones múltiples (FDR). Los métodos se marcan en el intro (siempre presente); FDR se registra y marca solo cuando se emite su resumen, para no dejar entradas de glosario sin aparición que las referencie. - modelos: PCA, KMeans, coeficiente de silueta (silhouette), Isolation Forest y la estandarización z-score. Cada término se registra dentro de la sección que lo usa (tras su early-return), de modo que un término solo entra al glosario cuando su sección realmente se renderiza. - agregacion: agrupación (split-apply-combine / groupby) y tabla dinámica (pivot), ambos en el intro siempre presente. Solo se añaden los enganches de glosario: ningún cambio en la lógica de datos. El texto visible es idéntico con o sin marcador (los renderers lo eliminan), así que el layout de línea no cambia. Sin colector en ctx (render suelto) los capítulos degradan y no marcan nada. Tests: un test de glosario por capítulo verifica registro + marcado y la degradación sin colector. Suite AutomaticEDA + render pipeline: 87 passed. Golden titanic (run_models+series+llm): los 12 términos aparecen como entradas del glosario en PDF (16 link annotations GOTO) y PPTX (15 saltos hlinksldjump). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,35 @@ _DEF_MAX_CARD = 20
|
|||||||
_DEF_MAX_MEASURES = 4
|
_DEF_MAX_MEASURES = 4
|
||||||
_DEF_TOP_N = 12
|
_DEF_TOP_N = 12
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains. Both appear in the always-rendered intro,
|
||||||
|
# so they are registered and marked clickable whenever a collector is in ctx —
|
||||||
|
# the canonical two-step pattern (see ``cat_distr``): ``glossary.add(key, label,
|
||||||
|
# definition)`` + the inline span ``[[term:KEY]]texto[[/term]]`` in a Markdown
|
||||||
|
# block. Mapping key -> (label, definition).
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"groupby": (
|
||||||
|
"Agrupación (split-apply-combine)",
|
||||||
|
"Operación de agrupación (group by): parte la tabla en grupos según los "
|
||||||
|
"valores de una columna categórica, aplica un cálculo (conteo, media, "
|
||||||
|
"mediana…) dentro de cada grupo y combina los resultados en una tabla "
|
||||||
|
"resumen. Es el patrón split-apply-combine."),
|
||||||
|
"pivot_table": (
|
||||||
|
"Tabla dinámica (pivot)",
|
||||||
|
"Tabla dinámica que cruza dos variables categóricas — una en las filas y "
|
||||||
|
"otra en las columnas — y rellena cada celda con un agregado (media, "
|
||||||
|
"suma…) de una medida numérica. Resume de un vistazo cómo interactúan las "
|
||||||
|
"dos categóricas sobre esa medida."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
|
||||||
|
|
||||||
|
The visible text is identical with or without the marker (the renderers strip
|
||||||
|
it), so wrapping never changes line layout — it only adds the link.
|
||||||
|
"""
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Formatting helpers (mirror the other chapters' defensive style).
|
# Formatting helpers (mirror the other chapters' defensive style).
|
||||||
@@ -525,13 +554,18 @@ def _sections_live(profile: dict, ctx: dict, candidates: dict) -> list:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Entry point.
|
# Entry point.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _intro_blocks() -> list:
|
def _intro_blocks(gloss=None, mark_term: bool = False) -> list:
|
||||||
|
if gloss is not None:
|
||||||
|
for key, (label, definition) in _TERM_DEFS.items():
|
||||||
|
gloss.add(key, label, definition)
|
||||||
|
t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)")
|
||||||
|
t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)")
|
||||||
text = (
|
text = (
|
||||||
"Este capítulo analiza la tabla **por grupos** (split-apply-combine): "
|
f"Este capítulo analiza la tabla {t_groupby}: "
|
||||||
"elige las columnas categóricas más informativas — por su cardinalidad "
|
"elige las columnas categóricas más informativas — por su cardinalidad "
|
||||||
"y relevancia, no todas contra todas, para no inflar comparaciones "
|
"y relevancia, no todas contra todas, para no inflar comparaciones "
|
||||||
"espurias — y resume las variables numéricas dentro de cada grupo "
|
"espurias — y resume las variables numéricas dentro de cada grupo "
|
||||||
"(conteo, media, mediana, desviación). Las **tablas dinámicas** (pivot) "
|
f"(conteo, media, mediana, desviación). Las {t_pivot} "
|
||||||
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
|
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
|
||||||
"(siempre desde cero) comparan los grupos de un vistazo."
|
"(siempre desde cero) comparan los grupos de un vistazo."
|
||||||
)
|
)
|
||||||
@@ -556,13 +590,21 @@ def build_agregacion(profile: dict, ctx: dict):
|
|||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Shared glossary collector: groupby + pivot_table live in the always-present
|
||||||
|
# intro, so they are registered + marked there. Degrades silently (mark_term
|
||||||
|
# False) when no collector is in ctx (standalone render).
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
|
||||||
|
mark_term = gloss is not None
|
||||||
|
|
||||||
# Pre-computed results take precedence (offline / tests / forward-compat).
|
# Pre-computed results take precedence (offline / tests / forward-compat).
|
||||||
pre = ctx.get("aggregations")
|
pre = ctx.get("aggregations")
|
||||||
if _is_dict(pre) and (pre.get("groupby") or pre.get("pivots")):
|
if _is_dict(pre) and (pre.get("groupby") or pre.get("pivots")):
|
||||||
sections = _sections_from_precomputed(pre)
|
sections = _sections_from_precomputed(pre)
|
||||||
if not sections:
|
if not sections:
|
||||||
return None
|
return None
|
||||||
blocks = _intro_blocks() + sections + _insights_section(ctx)
|
blocks = (_intro_blocks(gloss, mark_term) + sections
|
||||||
|
+ _insights_section(ctx))
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|
||||||
@@ -583,10 +625,11 @@ def build_agregacion(profile: dict, ctx: dict):
|
|||||||
"crudos. Pasa ctx['db_path'] + ctx['table'] (para el cálculo "
|
"crudos. Pasa ctx['db_path'] + ctx['table'] (para el cálculo "
|
||||||
"push-down en DuckDB) o ctx['aggregations'] ya precalculado. "
|
"push-down en DuckDB) o ctx['aggregations'] ya precalculado. "
|
||||||
f"Columnas categóricas candidatas: {keys or '—'}.")
|
f"Columnas categóricas candidatas: {keys or '—'}.")
|
||||||
blocks = _intro_blocks() + [note] + _insights_section(ctx)
|
blocks = (_intro_blocks(gloss, mark_term) + [note]
|
||||||
|
+ _insights_section(ctx))
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|
||||||
blocks = _intro_blocks() + sections + _insights_section(ctx)
|
blocks = _intro_blocks(gloss, mark_term) + sections + _insights_section(ctx)
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|||||||
@@ -254,3 +254,25 @@ def test_anti_corte_muchos_grupos_y_texto_largo():
|
|||||||
# First, middle and last words of the long paragraph all present.
|
# First, middle and last words of the long paragraph all present.
|
||||||
for i in (0, 60, 119):
|
for i in (0, 60, 119):
|
||||||
assert f"palabra{i}" in txt
|
assert f"palabra{i}" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_glosario_engancha_groupby_y_pivot():
|
||||||
|
"""Mejora 4b: la agrupación (split-apply-combine) y la tabla dinámica (pivot)
|
||||||
|
se registran en el colector compartido y se marcan clicables en el cuerpo.
|
||||||
|
Sin colector en ctx, el capítulo degrada y no marca nada."""
|
||||||
|
from datascience.automatic_eda.model import GlossaryCollector
|
||||||
|
|
||||||
|
g = GlossaryCollector()
|
||||||
|
ctx = dict(_ctx_precomputed())
|
||||||
|
ctx["glossary"] = g
|
||||||
|
ch = build_agregacion(_profile(), ctx)
|
||||||
|
assert ch is not None
|
||||||
|
keys = {t["key"] for t in g.terms()}
|
||||||
|
assert {"groupby", "pivot_table"} <= keys
|
||||||
|
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:groupby]]" in body and "[[term:pivot_table]]" in body
|
||||||
|
|
||||||
|
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||||
|
ch2 = build_agregacion(_profile(), _ctx_precomputed())
|
||||||
|
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:" not in body2
|
||||||
|
|||||||
@@ -47,6 +47,53 @@ _MAX_MATRIX_LABELS = 16
|
|||||||
# How many pairs to show in each of the top-positive / top-negative tables.
|
# How many pairs to show in each of the top-positive / top-negative tables.
|
||||||
_TOP_N = 10
|
_TOP_N = 10
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains. Each is registered in the shared
|
||||||
|
# collector (ctx['glossary']) and marked clickable on its first appearance in the
|
||||||
|
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
|
||||||
|
# implementation): ``glossary.add(key, label, definition)`` + the inline span
|
||||||
|
# ``[[term:KEY]]texto visible[[/term]]`` in a Markdown block. Mapping key ->
|
||||||
|
# (label, definition). ``fdr`` is only registered when the FDR summary is present.
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"pearson": (
|
||||||
|
"Pearson (coeficiente r)",
|
||||||
|
"Coeficiente de correlación lineal de Pearson (r) entre dos variables "
|
||||||
|
"numéricas. Va de −1 (relación lineal inversa perfecta) a +1 (directa "
|
||||||
|
"perfecta); 0 indica ausencia de relación lineal. Sólo capta relaciones "
|
||||||
|
"lineales, por eso lleva signo."),
|
||||||
|
"spearman": (
|
||||||
|
"Spearman (correlación de rangos)",
|
||||||
|
"Correlación de rangos de Spearman: el coeficiente de Pearson calculado "
|
||||||
|
"sobre los puestos (rangos) de los valores en vez de sus magnitudes. Mide "
|
||||||
|
"relaciones monótonas (no necesariamente lineales), va de −1 a +1 y es "
|
||||||
|
"robusta frente a valores atípicos."),
|
||||||
|
"cramers_v": (
|
||||||
|
"Cramér's V",
|
||||||
|
"Medida de asociación entre dos variables categóricas, derivada del "
|
||||||
|
"estadístico chi-cuadrado y normalizada al rango 0–1 (0 = independientes, "
|
||||||
|
"1 = asociación total). No tiene signo: sólo mide la intensidad."),
|
||||||
|
"correlation_ratio": (
|
||||||
|
"Razón de correlación (η)",
|
||||||
|
"Razón de correlación (eta) entre una variable numérica y una "
|
||||||
|
"categórica: la fracción de la varianza de la numérica explicada por los "
|
||||||
|
"grupos de la categórica. Va de 0 (los grupos no explican nada) a 1 (la "
|
||||||
|
"explican toda); no tiene signo."),
|
||||||
|
"fdr": (
|
||||||
|
"Comparaciones múltiples (FDR)",
|
||||||
|
"Al evaluar muchos pares a la vez, algunos parecen significativos por "
|
||||||
|
"puro azar. La corrección por tasa de falsos descubrimientos (FDR, "
|
||||||
|
"Benjamini-Hochberg) ajusta los p-valores para controlar la proporción "
|
||||||
|
"esperada de falsos positivos entre los pares declarados significativos."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
|
||||||
|
|
||||||
|
The visible text is identical with or without the marker (the renderers strip
|
||||||
|
the marker), so wrapping never changes line layout — it only adds the link.
|
||||||
|
"""
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
def _is_num(v) -> bool:
|
def _is_num(v) -> bool:
|
||||||
"""True for a real, finite int/float (not bool, not NaN/inf)."""
|
"""True for a real, finite int/float (not bool, not NaN/inf)."""
|
||||||
@@ -245,7 +292,7 @@ def _methods_block(corr: dict):
|
|||||||
return model.KVTable(rows=rows, title="Métodos de asociación")
|
return model.KVTable(rows=rows, title="Métodos de asociación")
|
||||||
|
|
||||||
|
|
||||||
def _fdr_text(corr: dict) -> str | None:
|
def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
|
||||||
"""One-line summary of the multiple-testing (FDR) correction, or None."""
|
"""One-line summary of the multiple-testing (FDR) correction, or None."""
|
||||||
mt = corr.get("multiple_testing")
|
mt = corr.get("multiple_testing")
|
||||||
if not isinstance(mt, dict) or not mt:
|
if not isinstance(mt, dict) or not mt:
|
||||||
@@ -254,7 +301,8 @@ def _fdr_text(corr: dict) -> str | None:
|
|||||||
alpha = mt.get("alpha")
|
alpha = mt.get("alpha")
|
||||||
n_tests = mt.get("n_tests")
|
n_tests = mt.get("n_tests")
|
||||||
n_rej = mt.get("n_rejected")
|
n_rej = mt.get("n_rejected")
|
||||||
parts = [f"Corrección por comparaciones múltiples ({method}"]
|
multi = _term(mark_term, "fdr", "comparaciones múltiples")
|
||||||
|
parts = [f"Corrección por {multi} ({method}"]
|
||||||
if _is_num(alpha):
|
if _is_num(alpha):
|
||||||
parts[0] += f", α={float(alpha):g}"
|
parts[0] += f", α={float(alpha):g}"
|
||||||
parts[0] += ")."
|
parts[0] += ")."
|
||||||
@@ -289,13 +337,31 @@ def build_correlacion(profile: dict, ctx: dict):
|
|||||||
|
|
||||||
blocks: list = []
|
blocks: list = []
|
||||||
|
|
||||||
# Intro: what this chapter shows and how to read the sign.
|
# Register the always-present method terms in the shared glossary and mark
|
||||||
|
# their first appearance clickable (the FDR term is registered lazily below,
|
||||||
|
# only when the FDR summary is actually emitted). Degrades silently when no
|
||||||
|
# collector is in ctx (standalone render) — mark_term stays False.
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
|
||||||
|
mark_term = gloss is not None
|
||||||
|
if gloss is not None:
|
||||||
|
for key in ("pearson", "spearman", "cramers_v", "correlation_ratio"):
|
||||||
|
label, definition = _TERM_DEFS[key]
|
||||||
|
gloss.add(key, label, definition)
|
||||||
|
|
||||||
|
# Intro: what this chapter shows and how to read the sign. Build the marked
|
||||||
|
# method names as locals first (avoids backslash-in-f-string for "Cramér's V").
|
||||||
|
t_pearson = _term(mark_term, "pearson", "Pearson")
|
||||||
|
t_spearman = _term(mark_term, "spearman", "Spearman")
|
||||||
|
t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
|
||||||
|
t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
|
||||||
blocks.append(model.Markdown(text=(
|
blocks.append(model.Markdown(text=(
|
||||||
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
|
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
|
||||||
"sus tipos (Pearson/Spearman entre numéricas — con **signo**; Cramér's V "
|
f"sus tipos ({t_pearson}/{t_spearman} entre numéricas — con **signo**; "
|
||||||
"entre categóricas; razón de correlación num-categórica; información mutua "
|
f"{t_cramers} entre categóricas; {t_corr_ratio} num-categórica; "
|
||||||
"como medida común no lineal). Sólo las correlaciones **num-num** tienen "
|
"información mutua como medida común no lineal). Sólo las correlaciones "
|
||||||
"dirección: por eso los pares **negativos** son siempre num-num.")))
|
"**num-num** tienen dirección: por eso los pares **negativos** son siempre "
|
||||||
|
"num-num.")))
|
||||||
|
|
||||||
# 1) Association matrix (heatmap).
|
# 1) Association matrix (heatmap).
|
||||||
labels, trimmed = _ordered_labels(pairs)
|
labels, trimmed = _ordered_labels(pairs)
|
||||||
@@ -337,9 +403,13 @@ def build_correlacion(profile: dict, ctx: dict):
|
|||||||
"no estacionarias y pueden ser espurias (Granger–Newbold). Compáralas "
|
"no estacionarias y pueden ser espurias (Granger–Newbold). Compáralas "
|
||||||
"sobre los retornos/diferencias antes de interpretarlas.")))
|
"sobre los retornos/diferencias antes de interpretarlas.")))
|
||||||
|
|
||||||
# 4) FDR summary + methods legend.
|
# 4) FDR summary + methods legend. Register the FDR term only when its
|
||||||
fdr_text = _fdr_text(corr)
|
# summary is emitted, so the glossary never lists an unreferenced entry.
|
||||||
|
fdr_text = _fdr_text(corr, mark_term=mark_term)
|
||||||
if fdr_text:
|
if fdr_text:
|
||||||
|
if gloss is not None:
|
||||||
|
label, definition = _TERM_DEFS["fdr"]
|
||||||
|
gloss.add("fdr", label, definition)
|
||||||
blocks.append(model.Markdown(text=fdr_text))
|
blocks.append(model.Markdown(text=fdr_text))
|
||||||
methods = _methods_block(corr)
|
methods = _methods_block(corr)
|
||||||
if methods is not None:
|
if methods is not None:
|
||||||
|
|||||||
@@ -173,3 +173,25 @@ def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
|
|||||||
assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
|
assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
|
||||||
# A short, unbreakable fragment of the long label survives the wrap.
|
# A short, unbreakable fragment of the long label survives the wrap.
|
||||||
assert "azufre" in _pdf_text(pdf)
|
assert "azufre" in _pdf_text(pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_glosario_engancha_metodos_y_fdr():
|
||||||
|
"""Mejora 4b: los métodos de correlación (Pearson, Spearman, Cramér's V,
|
||||||
|
razón de correlación) y la corrección por comparaciones múltiples (FDR) se
|
||||||
|
registran en el colector compartido y se marcan clicables en el cuerpo. Sin
|
||||||
|
colector en ctx, el capítulo degrada y no marca nada."""
|
||||||
|
from datascience.automatic_eda.model import GlossaryCollector
|
||||||
|
|
||||||
|
g = GlossaryCollector()
|
||||||
|
ch = build_correlacion(_profile(), {"glossary": g})
|
||||||
|
assert ch is not None
|
||||||
|
keys = {t["key"] for t in g.terms()}
|
||||||
|
assert {"pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"} <= keys
|
||||||
|
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||||
|
for k in ("pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"):
|
||||||
|
assert f"[[term:{k}]]" in body, k
|
||||||
|
|
||||||
|
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||||
|
ch2 = build_correlacion(_profile(), {})
|
||||||
|
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:" not in body2
|
||||||
|
|||||||
@@ -55,6 +55,62 @@ _CLUSTER_COLORS = [
|
|||||||
"#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac",
|
"#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Glossary terms this chapter explains. Each is registered in the shared
|
||||||
|
# collector (ctx['glossary']) and marked clickable on its first appearance — the
|
||||||
|
# canonical two-step pattern (see ``cat_distr``): ``glossary.add(key, label,
|
||||||
|
# definition)`` + the inline span ``[[term:KEY]]texto[[/term]]`` in a Markdown
|
||||||
|
# block. A term is registered only when its section is actually rendered, so the
|
||||||
|
# glossary never lists an entry no in-text appearance points to.
|
||||||
|
_TERM_DEFS = {
|
||||||
|
"zscore": (
|
||||||
|
"Estandarización z-score",
|
||||||
|
"Transformación que lleva cada columna numérica a media 0 y desviación "
|
||||||
|
"típica 1: a cada valor le resta la media de su columna y lo divide por "
|
||||||
|
"la desviación típica. Así variables con escalas muy distintas (euros "
|
||||||
|
"frente a un ratio 0–1) pesan por igual en las distancias y la varianza."),
|
||||||
|
"pca": (
|
||||||
|
"PCA (componentes principales)",
|
||||||
|
"El análisis de componentes principales resume muchas variables "
|
||||||
|
"numéricas correlacionadas en pocos ejes nuevos (componentes), "
|
||||||
|
"ortogonales entre sí y ordenados por la cantidad de varianza que "
|
||||||
|
"capturan. Permite ver la estructura de los datos en 2D y saber cuántas "
|
||||||
|
"dimensiones bastan para explicarlos."),
|
||||||
|
"kmeans": (
|
||||||
|
"KMeans (segmentación)",
|
||||||
|
"Algoritmo de agrupamiento no supervisado que reparte las filas en k "
|
||||||
|
"segmentos: asigna cada fila al centro (centroide) más cercano y recoloca "
|
||||||
|
"los centroides de forma iterativa hasta minimizar la distancia interna "
|
||||||
|
"de cada grupo. Aquí k se elige automáticamente."),
|
||||||
|
"silhouette": (
|
||||||
|
"Coeficiente de silueta (silhouette)",
|
||||||
|
"Métrica de calidad de un agrupamiento, en el rango −1 a 1: para cada "
|
||||||
|
"fila compara cómo de cerca está de su propio segmento frente al segmento "
|
||||||
|
"vecino más próximo. Cuanto más alto el promedio, más compactos y "
|
||||||
|
"separados están los segmentos."),
|
||||||
|
"isolation_forest": (
|
||||||
|
"Isolation Forest (anomalías)",
|
||||||
|
"Algoritmo de detección de anomalías multivariante: construye árboles que "
|
||||||
|
"parten el espacio con cortes aleatorios y mide cuántos cortes hacen "
|
||||||
|
"falta para aislar cada fila. Las filas raras se aíslan con muy pocos "
|
||||||
|
"cortes y se marcan como outliers según un umbral de contaminación."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _term(mark: bool, key: str, text: str) -> str:
|
||||||
|
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
|
||||||
|
|
||||||
|
The visible text is identical with or without the marker (the renderers strip
|
||||||
|
it), so wrapping never changes line layout — it only adds the link.
|
||||||
|
"""
|
||||||
|
return f"[[term:{key}]]{text}[[/term]]" if mark else text
|
||||||
|
|
||||||
|
|
||||||
|
def _register(gloss, key: str) -> None:
|
||||||
|
"""Register term ``key`` in the collector (idempotent); no-op if gloss None."""
|
||||||
|
if gloss is not None:
|
||||||
|
label, definition = _TERM_DEFS[key]
|
||||||
|
gloss.add(key, label, definition)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Formatting helpers (mirror the overview chapter's defensive style).
|
# Formatting helpers (mirror the overview chapter's defensive style).
|
||||||
@@ -252,34 +308,37 @@ def _make_cluster_scatter(projection: dict):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Section builders. Each returns a list of blocks (possibly empty).
|
# Section builders. Each returns a list of blocks (possibly empty).
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _normalization_intro() -> list:
|
def _normalization_intro(gloss=None, mark_term: bool = False) -> list:
|
||||||
|
_register(gloss, "zscore")
|
||||||
|
zscore = _term(mark_term, "zscore", "**estandarizan con z-score**")
|
||||||
text = (
|
text = (
|
||||||
"Estos modelos son **no supervisados**: buscan estructura latente sin "
|
"Estos modelos son **no supervisados**: buscan estructura latente sin "
|
||||||
"una variable objetivo. Antes de aplicarlos, todas las columnas "
|
"una variable objetivo. Antes de aplicarlos, todas las columnas "
|
||||||
"numéricas se **estandarizan con z-score** (cada valor menos la media, "
|
f"numéricas se {zscore} (cada valor menos la media, dividido por la "
|
||||||
"dividido por la desviación típica). Sin esta normalización, una "
|
"desviación típica). Sin esta normalización, una variable con escala "
|
||||||
"variable con escala grande (p.ej. ingresos en euros) dominaría las "
|
"grande (p.ej. ingresos en euros) dominaría las distancias y la varianza "
|
||||||
"distancias y la varianza frente a otra de escala pequeña (p.ej. un "
|
"frente a otra de escala pequeña (p.ej. un ratio entre 0 y 1), sesgando "
|
||||||
"ratio entre 0 y 1), sesgando tanto el PCA como el KMeans. Tras la "
|
"tanto el PCA como el KMeans. Tras la estandarización todas las variables "
|
||||||
"estandarización todas las variables pesan por igual."
|
"pesan por igual."
|
||||||
)
|
)
|
||||||
return [model.Heading(text="Modelos no supervisados", level=1),
|
return [model.Heading(text="Modelos no supervisados", level=1),
|
||||||
model.Markdown(text=text)]
|
model.Markdown(text=text)]
|
||||||
|
|
||||||
|
|
||||||
def _pca_section(pca: dict) -> list:
|
def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
|
||||||
if not _is_dict(pca) or not pca.get("explained_variance_ratio"):
|
if not _is_dict(pca) or not pca.get("explained_variance_ratio"):
|
||||||
return []
|
return []
|
||||||
|
_register(gloss, "pca")
|
||||||
blocks = [model.Heading(text="PCA — varianza explicada", level=2)]
|
blocks = [model.Heading(text="PCA — varianza explicada", level=2)]
|
||||||
|
|
||||||
n_used = pca.get("n_rows_used")
|
n_used = pca.get("n_rows_used")
|
||||||
n_feat = pca.get("n_features")
|
n_feat = pca.get("n_features")
|
||||||
intro = (
|
intro = (
|
||||||
f"El PCA resume {_fmt_num(n_feat)} variables numéricas en componentes "
|
f"El {_term(mark_term, 'pca', 'PCA')} resume {_fmt_num(n_feat)} variables "
|
||||||
f"ortogonales ordenados por la varianza que capturan "
|
"numéricas en componentes ortogonales ordenados por la varianza que "
|
||||||
f"({_fmt_num(n_used)} filas usadas tras eliminar nulos). El gráfico de "
|
f"capturan ({_fmt_num(n_used)} filas usadas tras eliminar nulos). El "
|
||||||
"sedimentación (scree) muestra cuánta varianza aporta cada componente y "
|
"gráfico de sedimentación (scree) muestra cuánta varianza aporta cada "
|
||||||
"su acumulado: un codo marca cuántos componentes bastan."
|
"componente y su acumulado: un codo marca cuántos componentes bastan."
|
||||||
)
|
)
|
||||||
blocks.append(model.Markdown(text=intro))
|
blocks.append(model.Markdown(text=intro))
|
||||||
|
|
||||||
@@ -325,11 +384,14 @@ def _pca_section(pca: dict) -> list:
|
|||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
|
def _kmeans_section(kmeans: dict, projection: dict, titles,
|
||||||
|
gloss=None, mark_term: bool = False) -> list:
|
||||||
has_km = _is_dict(kmeans) and kmeans.get("best_k")
|
has_km = _is_dict(kmeans) and kmeans.get("best_k")
|
||||||
has_proj = _is_dict(projection) and projection.get("points")
|
has_proj = _is_dict(projection) and projection.get("points")
|
||||||
if not has_km and not has_proj:
|
if not has_km and not has_proj:
|
||||||
return []
|
return []
|
||||||
|
_register(gloss, "kmeans")
|
||||||
|
_register(gloss, "silhouette")
|
||||||
|
|
||||||
blocks = [model.Heading(text="Segmentación (KMeans)", level=2)]
|
blocks = [model.Heading(text="Segmentación (KMeans)", level=2)]
|
||||||
|
|
||||||
@@ -337,9 +399,11 @@ def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
|
|||||||
sil = (projection or {}).get("silhouette")
|
sil = (projection or {}).get("silhouette")
|
||||||
if sil is None:
|
if sil is None:
|
||||||
sil = (kmeans or {}).get("silhouette")
|
sil = (kmeans or {}).get("silhouette")
|
||||||
|
t_kmeans = _term(mark_term, "kmeans", "KMeans")
|
||||||
|
t_sil = _term(mark_term, "silhouette", "*silhouette*")
|
||||||
intro = (
|
intro = (
|
||||||
f"KMeans agrupa las filas en **{_fmt_num(best_k)} segmentos** elegidos "
|
f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** "
|
||||||
"automáticamente maximizando el coeficiente de *silhouette* "
|
f"elegidos automáticamente maximizando el coeficiente de {t_sil} "
|
||||||
f"(**{_fmt_num(sil)}**, rango −1 a 1: cuanto más alto, segmentos más "
|
f"(**{_fmt_num(sil)}**, rango −1 a 1: cuanto más alto, segmentos más "
|
||||||
"compactos y separados). 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."
|
||||||
@@ -394,16 +458,18 @@ def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
|
|||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def _outliers_section(outliers: dict) -> list:
|
def _outliers_section(outliers: dict, gloss=None, mark_term: bool = False) -> list:
|
||||||
if not _is_dict(outliers) or outliers.get("n_outliers") is None:
|
if not _is_dict(outliers) or outliers.get("n_outliers") is None:
|
||||||
return []
|
return []
|
||||||
if outliers.get("note") and not outliers.get("n_rows_used"):
|
if outliers.get("note") and not outliers.get("n_rows_used"):
|
||||||
# insufficient data — nothing meaningful to show.
|
# insufficient data — nothing meaningful to show.
|
||||||
return []
|
return []
|
||||||
|
_register(gloss, "isolation_forest")
|
||||||
blocks = [model.Heading(text="Detección de anomalías (Isolation Forest)",
|
blocks = [model.Heading(text="Detección de anomalías (Isolation Forest)",
|
||||||
level=2)]
|
level=2)]
|
||||||
|
isof = _term(mark_term, "isolation_forest", "**Isolation Forest**")
|
||||||
explain = (
|
explain = (
|
||||||
"**Isolation Forest** detecta filas anómalas de forma *multivariante*: "
|
f"{isof} detecta filas anómalas de forma *multivariante*: "
|
||||||
"construye árboles que parten el espacio con cortes aleatorios y mide "
|
"construye árboles que parten el espacio con cortes aleatorios y mide "
|
||||||
"cuántos cortes hacen falta para aislar cada fila. Las filas raras "
|
"cuántos cortes hacen falta para aislar cada fila. Las filas raras "
|
||||||
"(combinaciones de valores poco frecuentes considerando **todas las "
|
"(combinaciones de valores poco frecuentes considerando **todas las "
|
||||||
@@ -484,15 +550,21 @@ def build_modelos(profile: dict, ctx: dict):
|
|||||||
(kmeans and kmeans.get("best_k")) or (projection and projection.get("points"))
|
(kmeans and kmeans.get("best_k")) or (projection and projection.get("points"))
|
||||||
) else None
|
) else None
|
||||||
|
|
||||||
|
# Shared glossary collector: terms are registered + marked clickable inside
|
||||||
|
# each section, only when that section actually renders (no orphan entries).
|
||||||
|
glossary = ctx.get("glossary")
|
||||||
|
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
|
||||||
|
mark_term = gloss is not None
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
sections += _pca_section(pca) if pca else []
|
sections += _pca_section(pca, gloss, mark_term) if pca else []
|
||||||
sections += _kmeans_section(kmeans, projection, titles)
|
sections += _kmeans_section(kmeans, projection, titles, gloss, mark_term)
|
||||||
sections += _outliers_section(outliers) if outliers else []
|
sections += _outliers_section(outliers, gloss, mark_term) if outliers else []
|
||||||
sections += _normality_section(normality) if normality else []
|
sections += _normality_section(normality) if normality else []
|
||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
return None # models block present but nothing renderable.
|
return None # models block present but nothing renderable.
|
||||||
|
|
||||||
blocks = _normalization_intro() + sections
|
blocks = _normalization_intro(gloss, mark_term) + sections
|
||||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||||
version=CHAPTER_VERSION, blocks=blocks)
|
version=CHAPTER_VERSION, blocks=blocks)
|
||||||
|
|||||||
@@ -257,3 +257,26 @@ def test_anticortes_tabla_normalidad_larga_no_corta():
|
|||||||
# Every column name survives (wrapped/split, never truncated).
|
# Every column name survives (wrapped/split, never truncated).
|
||||||
for i in (0, 19, 39):
|
for i in (0, 19, 39):
|
||||||
assert f"col_{i}" in txt
|
assert f"col_{i}" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_glosario_engancha_terminos_modelos():
|
||||||
|
"""Mejora 4b: PCA, KMeans, silhouette, Isolation Forest y la estandarización
|
||||||
|
z-score se registran en el colector compartido y se marcan clicables en el
|
||||||
|
cuerpo. Sin colector en ctx, el capítulo degrada y no marca nada."""
|
||||||
|
from datascience.automatic_eda.model import GlossaryCollector
|
||||||
|
|
||||||
|
g = GlossaryCollector()
|
||||||
|
ctx = dict(_ctx_full())
|
||||||
|
ctx["glossary"] = g
|
||||||
|
ch = build_modelos(_profile(), ctx)
|
||||||
|
assert ch is not None
|
||||||
|
keys = {t["key"] for t in g.terms()}
|
||||||
|
assert {"zscore", "pca", "kmeans", "silhouette", "isolation_forest"} <= keys
|
||||||
|
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
|
||||||
|
for k in ("zscore", "pca", "kmeans", "silhouette", "isolation_forest"):
|
||||||
|
assert f"[[term:{k}]]" in body, k
|
||||||
|
|
||||||
|
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
|
||||||
|
ch2 = build_modelos(_profile(), _ctx_full())
|
||||||
|
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
|
||||||
|
assert "[[term:" not in body2
|
||||||
|
|||||||
Reference in New Issue
Block a user