Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13c82be780 |
@@ -42,11 +42,7 @@ from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
# 1.1.0: drop the duplicated section labels — the dictionary and PII DataTables
|
||||
# no longer carry a ``title`` (the section Heading labels them once, per the
|
||||
# OVERVIEW pattern in the contract). The data-dictionary column already reads
|
||||
# "Significado de negocio".
|
||||
CHAPTER_VERSION = "1.1.0"
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "analisis_llm"
|
||||
CHAPTER_TITLE = "Análisis LLM"
|
||||
|
||||
@@ -122,11 +118,6 @@ def _dictionary_block(llm: dict):
|
||||
Columns: Columna / Descripción / Significado de negocio / Unidad. The
|
||||
paginator splits this by rows repeating the header and wraps long cells, so a
|
||||
long dictionary (many columns) never gets cut.
|
||||
|
||||
The block carries **no** ``title``: the section is labelled once by the
|
||||
``Heading`` that ``build_analisis_llm`` appends right before it (the canonical
|
||||
OVERVIEW pattern, contract §8). Giving the table its own ``title`` too would
|
||||
print "Diccionario de datos" twice in a row.
|
||||
"""
|
||||
entries = llm.get("dictionary")
|
||||
if not isinstance(entries, (list, tuple)) or not entries:
|
||||
@@ -146,7 +137,7 @@ def _dictionary_block(llm: dict):
|
||||
])
|
||||
if not rows:
|
||||
return None
|
||||
return model.DataTable(header=header, rows=rows)
|
||||
return model.DataTable(header=header, rows=rows, title="Diccionario de datos")
|
||||
|
||||
|
||||
def _analyses_blocks(llm: dict) -> list:
|
||||
@@ -168,12 +159,7 @@ def _cleaning_blocks(llm: dict) -> list:
|
||||
|
||||
|
||||
def _pii_block(llm: dict):
|
||||
"""DataTable for PII/GDPR findings, or None if absent/empty.
|
||||
|
||||
Like the dictionary block, it carries **no** ``title`` (the ``Heading`` in
|
||||
``build_analisis_llm`` labels the section once); it keeps its ``note`` with
|
||||
the orientative-detection caveat, which the renderers print under the table.
|
||||
"""
|
||||
"""DataTable for PII/GDPR findings, or None if absent/empty."""
|
||||
entries = llm.get("pii")
|
||||
if not isinstance(entries, (list, tuple)) or not entries:
|
||||
return None
|
||||
@@ -190,7 +176,7 @@ def _pii_block(llm: dict):
|
||||
if not rows:
|
||||
return None
|
||||
return model.DataTable(
|
||||
header=header, rows=rows,
|
||||
header=header, rows=rows, title="Datos personales (PII / RGPD)",
|
||||
note="detección automática orientativa — revisar antes de tratar los datos")
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from pptx import Presentation
|
||||
from datascience.automatic_eda.chapters.analisis_llm import (
|
||||
build_analisis_llm, CHAPTER_VERSION)
|
||||
from datascience.automatic_eda.chapters_registry import build_document
|
||||
from datascience.automatic_eda.model import Chapter, DataTable, Heading
|
||||
from datascience.automatic_eda.model import Chapter, DataTable
|
||||
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
|
||||
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||
|
||||
@@ -117,45 +117,6 @@ def test_golden_build_y_render_pdf_pptx():
|
||||
assert "DESCTOKEN" in ptx
|
||||
|
||||
|
||||
def test_sin_rotulos_duplicados_y_significado_de_negocio():
|
||||
"""The dictionary / PII sections must be labelled ONCE.
|
||||
|
||||
Regression for the duplicated 'Diccionario de datos' and 'Datos personales
|
||||
(PII / RGPD)' headings (each section used to print its label twice: a Heading
|
||||
plus the DataTable's own title). The fix drops the DataTable title and keeps
|
||||
a single Heading — the OVERVIEW pattern. The data-dictionary column header is
|
||||
also pinned to the exact text 'Significado de negocio'.
|
||||
"""
|
||||
ch = build_analisis_llm(_profile(), {})
|
||||
assert ch is not None
|
||||
|
||||
# Structure: section labels come from Headings; tables carry no title.
|
||||
headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
|
||||
assert headings.count("Diccionario de datos") == 1
|
||||
assert headings.count("Datos personales (PII / RGPD)") == 1
|
||||
for b in ch.blocks:
|
||||
if isinstance(b, DataTable):
|
||||
assert not b.title, f"DataTable should not duplicate the label: {b.title!r}"
|
||||
|
||||
# The data dictionary's third column reads exactly 'Significado de negocio'.
|
||||
dicts = [b for b in ch.blocks if isinstance(b, DataTable) and "Descripción" in b.header]
|
||||
assert dicts, "expected the data-dictionary DataTable"
|
||||
assert dicts[0].header == ["Columna", "Descripción", "Significado de negocio", "Unidad"]
|
||||
|
||||
# The PII table keeps its orientative-detection note.
|
||||
pii = [b for b in ch.blocks if isinstance(b, DataTable) and b.header == ["Columna", "Tipo", "Severidad"]]
|
||||
assert pii and pii[0].note and "orientativa" in pii[0].note
|
||||
|
||||
# Render: each label appears exactly once across the whole document (the only
|
||||
# 'Diccionario de datos' / 'Datos personales' producer is this chapter).
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out_pdf = os.path.join(d, "eda.pdf")
|
||||
render_automatic_eda_pdf(_profile(), out_pdf, {"title": "EDA — ventas"})
|
||||
txt = _pdf_text(out_pdf)
|
||||
assert txt.count("Diccionario de datos") == 1
|
||||
assert txt.count("Datos personales") == 1
|
||||
|
||||
|
||||
def test_orden_capitulo_junto_a_overview():
|
||||
chapters = build_document(_profile(), {})
|
||||
ids = [c.id for c in chapters]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Numeric distributions chapter (NUM DISTR) for AutomaticEDA.
|
||||
|
||||
For every numeric column the chapter draws, as a single indivisible figure, a
|
||||
histogram with the **mean, median and ±1σ band drawn as reference lines** and a
|
||||
**Tukey boxplot right below it** sharing the same X axis — exactly the user
|
||||
requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
|
||||
histogram with the **mean, median and ±1σ band drawn as reference lines** (the
|
||||
legend reports the numeric value of the mean, the median **and the standard
|
||||
deviation σ**) and a **Tukey boxplot right below it** sharing the same X axis —
|
||||
exactly the user requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
|
||||
so the renderers rasterize and scale it to fit a whole page/slide and nothing is
|
||||
ever cut; columns with many numerics simply flow across pages as small
|
||||
multiples.
|
||||
@@ -34,7 +35,7 @@ try:
|
||||
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
|
||||
build_boxplot_stats = None # type: ignore[assignment]
|
||||
|
||||
CHAPTER_VERSION = "1.1.0"
|
||||
CHAPTER_VERSION = "1.2.0"
|
||||
CHAPTER_ID = "num_distr"
|
||||
CHAPTER_TITLE = "Distribuciones numéricas"
|
||||
|
||||
@@ -140,9 +141,11 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
|
||||
std = numeric.get("std")
|
||||
|
||||
# ±1σ band first (behind the lines), then median (solid) and mean (dashed).
|
||||
# The band's legend entry also reports the numeric value of the standard
|
||||
# deviation, so the reader sees mean, median AND σ at a glance.
|
||||
if mean is not None and std is not None and std > 0:
|
||||
ax_h.axvspan(mean - std, mean + std, color="#f0c27b", alpha=0.22,
|
||||
zorder=1, label="±1σ")
|
||||
zorder=1, label=f"±1σ (σ = {_fmt_num(std)})")
|
||||
if median is not None:
|
||||
ax_h.axvline(median, color="#2e8b57", linestyle="-", linewidth=1.6,
|
||||
zorder=4, label=f"mediana = {_fmt_num(median)}")
|
||||
@@ -152,7 +155,19 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
|
||||
|
||||
ax_h.set_ylabel("frecuencia", fontsize=8)
|
||||
ax_h.tick_params(labelsize=7)
|
||||
ax_h.legend(fontsize=6.5, loc="upper right", framealpha=0.85)
|
||||
# Always surface σ in the legend: if the ±1σ band could not be drawn (no mean
|
||||
# or std<=0) but σ is still known, add a label-only proxy handle so the value
|
||||
# of the standard deviation is reported regardless of the band.
|
||||
handles, labels = ax_h.get_legend_handles_labels()
|
||||
if std is not None and not any("σ =" in lbl for lbl in labels):
|
||||
from matplotlib.lines import Line2D
|
||||
proxy = Line2D([], [], linestyle="none", marker="",
|
||||
label=f"σ = {_fmt_num(std)}")
|
||||
handles.append(proxy)
|
||||
labels.append(f"σ = {_fmt_num(std)}")
|
||||
if handles:
|
||||
ax_h.legend(handles, labels, fontsize=6.5, loc="upper right",
|
||||
framealpha=0.85)
|
||||
for spine in ("top", "right"):
|
||||
ax_h.spines[spine].set_visible(False)
|
||||
|
||||
|
||||
@@ -159,6 +159,50 @@ def test_anti_corte_muchas_columnas_pdf_y_pptx():
|
||||
assert res_pptx["n_slides"] >= 8 # at least one slide per column figure.
|
||||
|
||||
|
||||
def _hist_legend_texts(numeric, box=None):
|
||||
"""Build the per-column figure and return its histogram-legend label texts."""
|
||||
from datascience.automatic_eda.chapters.num_distr import _make_hist_box
|
||||
import matplotlib.pyplot as plt
|
||||
fig = _make_hist_box("col", numeric, box or {})
|
||||
ax_h = fig.axes[0] # the histogram is the top axis.
|
||||
leg = ax_h.get_legend()
|
||||
texts = [t.get_text() for t in leg.get_texts()] if leg else []
|
||||
plt.close(fig)
|
||||
return texts
|
||||
|
||||
|
||||
def test_golden_leyenda_histograma_reporta_valor_std():
|
||||
# The histogram legend must report the numeric value of the standard
|
||||
# deviation σ next to mean and median.
|
||||
numeric = _numeric_block(42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5)
|
||||
texts = _hist_legend_texts(numeric)
|
||||
joined = " ".join(texts)
|
||||
assert any("σ =" in t for t in texts), f"σ value missing in legend: {texts}"
|
||||
assert "12.3" in joined, f"std value 12.3 not in legend: {texts}"
|
||||
assert any("media =" in t for t in texts)
|
||||
assert any("mediana =" in t for t in texts)
|
||||
|
||||
|
||||
def test_edge_std_en_leyenda_aunque_no_haya_banda():
|
||||
# When the ±1σ band cannot be drawn (no mean) but σ is known, the legend
|
||||
# still surfaces the σ value via a label-only proxy handle.
|
||||
numeric = _numeric_block(42.5, 40.0, 7.5, 1.0, 100.0, "right-skewed", 0)
|
||||
numeric["mean"] = None # forces the band off; σ must still appear.
|
||||
texts = _hist_legend_texts(numeric)
|
||||
assert any("σ = 7.5" in t for t in texts), f"σ proxy missing: {texts}"
|
||||
|
||||
|
||||
def test_edge_sin_std_no_revienta_la_figura():
|
||||
# A numeric block without σ must not raise and simply omits the σ entry.
|
||||
import matplotlib.pyplot as plt
|
||||
numeric = _numeric_block(42.5, 40.0, 0.0, 1.0, 100.0, "discrete", 0)
|
||||
numeric["std"] = None
|
||||
texts = _hist_legend_texts(numeric)
|
||||
assert not any("σ =" in t for t in texts)
|
||||
# mean/median lines still produce their own legend entries.
|
||||
assert any("media =" in t for t in texts)
|
||||
|
||||
|
||||
def test_distribution_gloss_cubre_todas_las_etiquetas():
|
||||
# Every label detect_distribution_type can emit has a Spanish gloss.
|
||||
for label in ("normal-ish", "right-skewed", "left-skewed", "heavy-tail",
|
||||
|
||||
Reference in New Issue
Block a user