diff --git a/python/functions/datascience/automatic_eda/chapters/correlacion.py b/python/functions/datascience/automatic_eda/chapters/correlacion.py index cd559323..b1a4c702 100644 --- a/python/functions/datascience/automatic_eda/chapters/correlacion.py +++ b/python/functions/datascience/automatic_eda/chapters/correlacion.py @@ -31,7 +31,7 @@ import math from .. import model -CHAPTER_VERSION = "1.0.0" +CHAPTER_VERSION = "1.1.0" CHAPTER_ID = "correlacion" CHAPTER_TITLE = "Correlación" @@ -47,6 +47,13 @@ _MAX_MATRIX_LABELS = 16 # How many pairs to show in each of the top-positive / top-negative tables. _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 @@ -314,6 +321,139 @@ 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. @@ -392,6 +532,18 @@ 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(): diff --git a/python/functions/datascience/automatic_eda/chapters/correlacion_test.py b/python/functions/datascience/automatic_eda/chapters/correlacion_test.py index b4291e65..96d7f1fd 100644 --- a/python/functions/datascience/automatic_eda/chapters/correlacion_test.py +++ b/python/functions/datascience/automatic_eda/chapters/correlacion_test.py @@ -175,6 +175,105 @@ 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 diff --git a/python/functions/datascience/classify_relationship_type.md b/python/functions/datascience/classify_relationship_type.md new file mode 100644 index 00000000..170222ad --- /dev/null +++ b/python/functions/datascience/classify_relationship_type.md @@ -0,0 +1,68 @@ +--- +name: classify_relationship_type +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def classify_relationship_type(xs: list, ys: list) -> dict" +description: "Clasifica el TIPO de relacion entre dos variables numericas pareadas por indice para el EDA automatico del grupo eda. Limpia los pares de forma defensiva (descarta None/bool/NaN/inf), reusa pearson y spearman_corr del registry y ajusta polinomios de grado 2 y 3 con numpy.polyfit (R^2 manual), y a partir de esas senales etiqueta la forma: 'lineal', 'polinomica (grado 2/3)', 'monotona no-lineal' o 'debil/sin forma'. Orden de decision: debil -> monotona -> polinomica -> lineal (la primera que matchea gana), con umbrales calibrados para datos reales discretos/ruidosos. Devuelve ademas los coeficientes del mejor modelo en orden de numpy.polyval para pintar la curva de ajuste sobre el scatter. Funcion pura no-throw: ante datos insuficientes (menos de 5 pares validos o varianza ~0) o cualquier fallo devuelve el dict canonico con tipo='debil/sin forma' y el resto a None." +tags: [eda, correlation, relationship, classification, polyfit, datascience, pure] +params: + - name: xs + desc: "Lista (o tupla) de valores numericos de la primera variable, pareada por indice con ys. Cada par xs[i],ys[i] se descarta si cualquiera de los dos es None, bool, NaN o inf. Lectura defensiva." + - name: ys + desc: "Lista (o tupla) de valores numericos de la segunda variable, pareada por indice con xs. Mismas reglas de limpieza que xs." +output: "Dict con SIEMPRE las mismas 8 claves: tipo (str: 'lineal' | 'polinómica (grado 2)' | 'polinómica (grado 3)' | 'monótona no-lineal' | 'débil/sin forma'); pearson (float|None: coeficiente de Pearson r); r2_linear (float|None: r**2 del ajuste lineal); spearman (float|None: rho de Spearman); r2_poly2 (float|None: R^2 del ajuste polinomico de grado 2); r2_poly3 (float|None: R^2 del ajuste de grado 3); best_degree (int|None: grado del modelo elegido — 1 lineal, 2/3 polinomico, None si monotona/debil); coeffs (list|None: coeficientes del mejor modelo en orden de numpy.polyval para pintar la curva, o None). Ante datos insuficientes o error: tipo='débil/sin forma' y el resto de claves a None." +uses_functions: [pearson_py_datascience, spearman_corr_py_datascience] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [numpy] +tested: true +tests: ["test_lineal", "test_polinomica_cuadratica", "test_monotona_no_lineal", "test_monotona_exponencial", "test_debil_sin_forma", "test_lista_vacia_no_lanza", "test_longitudes_distintas_no_lanza", "test_todos_none_no_lanza", "test_entradas_none_no_lanza", "test_constante_no_lanza", "test_filtra_nan_inf_bool"] +test_file_path: "python/functions/datascience/classify_relationship_type_test.py" +file_path: "python/functions/datascience/classify_relationship_type.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from datascience.classify_relationship_type import classify_relationship_type +import numpy as np + +# Relacion claramente cuadratica (forma de parabola) sobre dominio simetrico. +x = list(np.linspace(-10, 10, 60)) +y = [v * v for v in x] + +res = classify_relationship_type(x, y) +print(res["tipo"]) # 'polinómica (grado 2)' +print(res["best_degree"]) # 2 +print(res["r2_linear"]) # 0.0 -> el Pearson lineal no ve la parabola +print(res["r2_poly2"]) # 1.0 +print(res["coeffs"]) # [1.0, -0.0, -0.0] -> numpy.polyval(coeffs, x) ~ x**2 + +# El capitulo pinta la curva de ajuste cuando coeffs no es None: +# if res["coeffs"] is not None: +# xs_fit = np.linspace(min(x), max(x), 200) +# ys_fit = np.polyval(res["coeffs"], xs_fit) +# ax.plot(xs_fit, ys_fit) # curva sobre el ax.scatter(x, y) +``` + +## Cuando usarla + +- Usala en el capitulo de relaciones/correlaciones del EDA automatico, despues de detectar dos columnas numericas con alguna asociacion, para decidir QUE curva de ajuste pintar sobre el scatter (recta, parabola, cubica o ninguna) y poner una etiqueta legible al tipo de relacion. +- Cuando un Pearson bajo no signifique "sin relacion": esta funcion cruza Pearson con Spearman y con ajustes polinomicos para distinguir una relacion lineal debil de una monotona no-lineal (que el rango si capta) o de una curva polinomica. +- Cuando necesites un punto de entrada determinista y no-throw que, con los mismos datos, devuelva siempre el mismo `tipo` y los mismos `coeffs` listos para `numpy.polyval` sin tener que ajustar modelos a mano en el capitulo. + +## Gotchas + +- Funcion pura, deterministica y no-throw: ante menos de 5 pares validos, varianza ~0 (xs o ys constante) o cualquier excepcion interna devuelve el dict canonico `tipo="débil/sin forma"` con el resto de claves a `None`. El dict SIEMPRE trae las 8 claves: nunca compruebes existencia, comprueba `None`. +- El orden de decision importa: `débil -> monótona -> polinómica -> lineal` (la primera que matchee gana). La monotonia se evalua ANTES que el ajuste polinomico, asi que una curva monotona suave (exp, log, potencias) sale `monótona no-lineal` aunque un cubico tambien la ajuste — la dominancia del rango (Spearman >> Pearson) es la senal mas interpretable. Solo cae en `polinómica` una forma curva NO monotona (p.ej. una parabola, Spearman ~0 pero R^2 polinomico alto). +- Umbrales fijos (calibrados para EDA con datos discretos/ruidosos, no para inferencia formal): `débil/sin forma` si las tres senales son bajas a la vez (`abs(pearson) < 0.3` y `abs(spearman) < 0.3` y `mejor_poly < 0.3`); `monótona no-lineal` si `abs(spearman) - abs(pearson) >= 0.1` y `abs(spearman) >= 0.4`; `polinómica (grado N)` si el mejor polinomico mejora `>= 0.1` sobre el lineal y su R^2 `>= 0.3`; en cualquier otro caso con senal (no debil) `lineal`. El suelo de 0.3 evita llamar "debil" a relaciones reales pero discretas (conteos, escalas ordinales) con R^2 bajo pero direccion clara. +- `coeffs` va en orden de `numpy.polyval` (grado descendente). Para `lineal` es `[pendiente, intercepto]` (grado 1); para `polinómica` los del grado elegido; para `monótona no-lineal` y `débil/sin forma` es `None` (el scatter pintara una curva suavizada o nada — lo decide el capitulo, no esta funcion). +- `best_degree` prefiere el grado 2 sobre el 3 cuando empatan dentro de 0.02 de R^2 (parsimonia): no esperes grado 3 salvo que mejore claramente. +- Los pares con `None`, `bool`, `NaN` o `inf` se descartan por indice en silencio; `bool` cuenta como no-numerico (un `True` no es `1`). El dominio de los datos afecta al resultado: una parabola sobre un dominio simetrico da Pearson ~0 (sale `polinómica`), pero sobre un dominio asimetrico el Pearson sube y puede salir `lineal`. diff --git a/python/functions/datascience/classify_relationship_type.py b/python/functions/datascience/classify_relationship_type.py new file mode 100644 index 00000000..683963ce --- /dev/null +++ b/python/functions/datascience/classify_relationship_type.py @@ -0,0 +1,187 @@ +"""Clasifica el TIPO de relacion entre dos variables numericas pareadas. + +Funcion pura del grupo eda. Dadas dos listas numericas pareadas por indice, +limpia los pares de forma defensiva, calcula correlaciones lineal (Pearson) y de +rangos (Spearman) y ajustes polinomicos de grado 2 y 3, y a partir de esas +senales etiqueta la forma de la relacion para el EDA automatico: + + "lineal" | "polinómica (grado 2)" | "polinómica (grado 3)" | + "monótona no-lineal" | "débil/sin forma" + +Ademas devuelve los coeficientes del mejor modelo (en orden de numpy.polyval) +para que el capitulo pinte la curva de ajuste sobre el scatter. Reusa las +funciones del registry `pearson` y `spearman_corr` en vez de reimplementarlas. + +NUNCA lanza: ante cualquier fallo o dato insuficiente devuelve el dict canonico +con tipo="débil/sin forma" y el resto de claves a None. +""" + +import math +import warnings + +import numpy as np + +from datascience.datascience import pearson +from datascience.spearman_corr import spearman_corr + +# Forma canonica de la respuesta cuando no se puede clasificar (datos +# insuficientes, varianza nula o error interno). Siempre las mismas claves. +_WEAK = { + "tipo": "débil/sin forma", + "pearson": None, + "r2_linear": None, + "spearman": None, + "r2_poly2": None, + "r2_poly3": None, + "best_degree": None, + "coeffs": None, +} + + +def _is_num(v) -> bool: + """True si v es un numero real finito (int/float, no bool, no NaN, no inf).""" + return ( + isinstance(v, (int, float)) + and not isinstance(v, bool) + and not (isinstance(v, float) and (math.isnan(v) or math.isinf(v))) + ) + + +def _poly_r2(coeffs, x_arr, y_arr, ss_tot: float) -> float: + """R^2 de un ajuste polinomico: 1 - SS_res/SS_tot. 0 si SS_tot==0.""" + if ss_tot == 0.0: + return 0.0 + pred = np.polyval(coeffs, x_arr) + ss_res = float(np.sum((y_arr - pred) ** 2)) + return 1.0 - ss_res / ss_tot + + +def classify_relationship_type(xs: list, ys: list) -> dict: + """Clasifica el tipo de relacion entre dos variables numericas pareadas. + + Empareja xs[i],ys[i] por indice y descarta el par si cualquiera de los dos + es None, bool, NaN o inf. Sobre los pares limpios calcula Pearson r + (r2_linear = r**2), Spearman rho y los R^2 de ajustes polinomicos de grado 2 + y 3 (con numpy.polyfit + R^2 manual). Con esas senales decide la etiqueta. + + Orden de evaluacion de la etiqueta (la primera que matchee gana). Los + umbrales estan calibrados para datos reales, a menudo discretos y ruidosos + (conteos, escalas ordinales): una relacion con |r| >= 0.3, |rho| >= 0.3 o un + polinomio con R^2 >= 0.3 ya tiene FORMA y no debe etiquetarse como "debil". + 1. "débil/sin forma" — todas las senales bajas a la vez: + abs(pearson) < 0.3 y abs(spearman) < 0.3 y mejor_poly < 0.3. + 2. "monótona no-lineal" — el rango (Spearman) capta una monotonia que el + Pearson lineal no: abs(spearman) - abs(pearson) >= 0.1 y + abs(spearman) >= 0.4. No se fuerza un polinomio (coeffs/best_degree = + None); el capitulo dibuja la tendencia ordenada sobre el scatter. + 3. "polinómica (grado N)" — el mejor polinomico mejora claramente sobre + el lineal (mejor_poly - r2_linear >= 0.1) y mejor_poly >= 0.3. N es el + grado (2 o 3) con mejor R^2, prefiriendo el 2 si empatan dentro de 0.02 + (parsimonia). + 4. "lineal" — el resto: hay senal (no es debil) y la forma que existe es + esencialmente lineal. best_degree=1, coeffs del ajuste de grado 1. + + Si hay menos de 5 pares validos, o la varianza de xs o de ys es ~0 + (constante), devuelve directamente "débil/sin forma". + + Args: + xs: lista (o tupla) de valores numericos de la primera variable, + pareada por indice con ys. Pares con None/bool/NaN/inf se descartan. + ys: lista (o tupla) de valores numericos de la segunda variable, + pareada por indice con xs. + + Returns: + dict con SIEMPRE las mismas claves: + tipo (str), pearson (float|None), r2_linear (float|None), + spearman (float|None), r2_poly2 (float|None), r2_poly3 (float|None), + best_degree (int|None: 1, 2, 3 o None), + coeffs (list|None: coeficientes en orden de numpy.polyval, o None). + Nunca lanza: ante fallo o datos insuficientes devuelve el dict debil. + """ + try: + if xs is None or ys is None: + return dict(_WEAK) + + pairs = [ + (float(x), float(y)) + for x, y in zip(xs, ys) + if _is_num(x) and _is_num(y) + ] + + # Datos insuficientes para hablar de forma de la relacion. + if len(pairs) < 5: + return dict(_WEAK) + + clean_x = [p[0] for p in pairs] + clean_y = [p[1] for p in pairs] + + # Varianza ~0 en cualquiera de las series => relacion indefinida. + if len(set(clean_x)) < 2 or len(set(clean_y)) < 2: + return dict(_WEAK) + x_arr = np.asarray(clean_x, dtype=float) + y_arr = np.asarray(clean_y, dtype=float) + if float(np.var(x_arr)) < 1e-15 or float(np.var(y_arr)) < 1e-15: + return dict(_WEAK) + + # Correlaciones reutilizando las funciones del registry. + r = pearson(clean_x, clean_y) + spearman = spearman_corr(clean_x, clean_y) + r2_linear = r ** 2 + + # Ajustes polinomicos grado 2 y 3 con R^2 manual. + ss_tot = float(np.sum((y_arr - float(np.mean(y_arr))) ** 2)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + c1 = np.polyfit(x_arr, y_arr, 1) + c2 = np.polyfit(x_arr, y_arr, 2) + c3 = np.polyfit(x_arr, y_arr, 3) + r2_poly2 = _poly_r2(c2, x_arr, y_arr, ss_tot) + r2_poly3 = _poly_r2(c3, x_arr, y_arr, ss_tot) + + mejor_poly = max(r2_poly2, r2_poly3) + # Grado del mejor polinomico, con preferencia por la parsimonia: solo se + # elige el grado 3 si supera al grado 2 por mas de 0.02. + best_poly_degree = 3 if (r2_poly3 - r2_poly2) > 0.02 else 2 + + abs_s = abs(spearman) + abs_p = abs(r) + + # Decision en orden: debil-temprano -> monotona -> polinomica -> lineal. + if abs_p < 0.3 and abs_s < 0.3 and mejor_poly < 0.3: + # Ninguna senal supera el suelo de forma: relacion debil/sin forma. + tipo = "débil/sin forma" + best_degree = None + coeffs = None + elif (abs_s - abs_p) >= 0.1 and abs_s >= 0.4: + # Spearman (rango) capta una monotonia que el Pearson lineal no: + # relacion monotona no-lineal. No se fuerza un polinomio que tal vez + # no ajusta bien; el capitulo dibuja la tendencia ordenada. + tipo = "monótona no-lineal" + best_degree = None + coeffs = None + elif (mejor_poly - r2_linear) >= 0.1 and mejor_poly >= 0.3: + tipo = "polinómica (grado {})".format(best_poly_degree) + best_degree = best_poly_degree + best_coeffs = c2 if best_poly_degree == 2 else c3 + coeffs = [float(c) for c in best_coeffs] + else: + # Hay senal (no es debil) y no es ni monotona-pura ni polinomica: + # la correlacion que existe es esencialmente lineal. + tipo = "lineal" + best_degree = 1 + coeffs = [float(c) for c in c1] + + return { + "tipo": tipo, + "pearson": round(float(r), 6), + "r2_linear": round(float(r2_linear), 6), + "spearman": round(float(spearman), 6), + "r2_poly2": round(float(r2_poly2), 6), + "r2_poly3": round(float(r2_poly3), 6), + "best_degree": best_degree, + "coeffs": ( + [round(c, 8) for c in coeffs] if coeffs is not None else None + ), + } + except Exception: + return dict(_WEAK) diff --git a/python/functions/datascience/classify_relationship_type_test.py b/python/functions/datascience/classify_relationship_type_test.py new file mode 100644 index 00000000..eab1bb35 --- /dev/null +++ b/python/functions/datascience/classify_relationship_type_test.py @@ -0,0 +1,174 @@ +"""Tests para classify_relationship_type.""" + +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(__file__)) + +from classify_relationship_type import classify_relationship_type + +# Claves que el dict de salida debe contener SIEMPRE. +_EXPECTED_KEYS = { + "tipo", "pearson", "r2_linear", "spearman", + "r2_poly2", "r2_poly3", "best_degree", "coeffs", +} + + +def _assert_shape(r): + """Toda salida tiene exactamente las 8 claves canonicas.""" + assert isinstance(r, dict) + assert set(r.keys()) == _EXPECTED_KEYS + + +def test_lineal(): + """Golden: y = 2x + 1 con ruido pequeno -> 'lineal', best_degree=1.""" + rng = np.random.default_rng(42) + x = np.linspace(0.0, 10.0, 50) + y = 2.0 * x + 1.0 + rng.normal(0.0, 0.3, 50) + + r = classify_relationship_type(list(x), list(y)) + _assert_shape(r) + + assert r["tipo"] == "lineal" + assert r["best_degree"] == 1 + assert r["r2_linear"] >= 0.5 + # coeffs ~ [pendiente, intercepto] del ajuste de grado 1. + assert r["coeffs"] is not None and len(r["coeffs"]) == 2 + assert abs(r["coeffs"][0] - 2.0) < 0.1 # pendiente ~2 + assert abs(r["coeffs"][1] - 1.0) < 0.3 # intercepto ~1 + + +def test_polinomica_cuadratica(): + """Golden: y = x**2 sobre [-10, 10] -> 'polinómica', best_degree in (2, 3).""" + x = np.linspace(-10.0, 10.0, 60) + y = x ** 2 + + r = classify_relationship_type(list(x), list(y)) + _assert_shape(r) + + assert r["tipo"].startswith("polinómica") + assert r["best_degree"] in (2, 3) + # Una parabola perfecta queda capturada por el grado 2 (parsimonia). + assert r["best_degree"] == 2 + assert r["r2_poly2"] > 0.99 + assert r["coeffs"] is not None and len(r["coeffs"]) == r["best_degree"] + 1 + + +def test_monotona_no_lineal(): + """Golden: monotona convexa de cola pesada -> 'monótona no-lineal'. + + y = 1/(N+1-i)**2 es estrictamente creciente (Spearman ~ 1) pero su cola + explosiva hace que ni la recta ni un polinomio de grado 2/3 la ajusten + (R^2 polinomico < 0.5), de modo que el Pearson lineal NO capta la relacion + que el rango (Spearman) si ve. Construccion deterministica (sin azar). + """ + n = 200 + i = np.arange(n, dtype=float) + y = 1.0 / (n + 1 - i) ** 2 + + r = classify_relationship_type(list(i), list(y)) + _assert_shape(r) + + assert r["tipo"] == "monótona no-lineal" + assert r["best_degree"] is None + assert r["coeffs"] is None + # Spearman fuerte y claramente por encima del Pearson. + assert abs(r["spearman"]) >= 0.5 + assert abs(r["spearman"]) - abs(r["pearson"]) >= 0.15 + + +def test_monotona_exponencial(): + """DoD literal: y = exp(x) (monotona no-lineal) -> 'monótona no-lineal'. + + exp es estrictamente creciente (Spearman = 1) pero el Pearson lineal queda + claramente por debajo (~0.86), así que la dominancia del rango la marca como + monótona no-lineal en vez de lineal o polinómica. + """ + x = np.linspace(0.0, 5.0, 80) + y = np.exp(x) + + r = classify_relationship_type(list(x), list(y)) + _assert_shape(r) + + assert r["tipo"] == "monótona no-lineal" + assert r["best_degree"] is None and r["coeffs"] is None + assert abs(r["spearman"]) >= 0.9 + assert abs(r["spearman"]) - abs(r["pearson"]) >= 0.1 + + +def test_debil_sin_forma(): + """Golden: x e y independientes (semilla fija) -> 'débil/sin forma'.""" + rng = np.random.default_rng(0) + x = rng.normal(0.0, 1.0, 200) + y = rng.normal(0.0, 1.0, 200) + + r = classify_relationship_type(list(x), list(y)) + _assert_shape(r) + + assert r["tipo"] == "débil/sin forma" + assert r["best_degree"] is None + assert r["coeffs"] is None + # Todas las senales son bajas. + assert abs(r["pearson"]) < 0.3 + assert r["r2_linear"] < 0.1 + + +def test_lista_vacia_no_lanza(): + """Edge: listas vacias -> dict debil canonico, sin lanzar.""" + r = classify_relationship_type([], []) + _assert_shape(r) + assert r["tipo"] == "débil/sin forma" + assert r["pearson"] is None + assert r["r2_linear"] is None + assert r["spearman"] is None + assert r["r2_poly2"] is None + assert r["r2_poly3"] is None + assert r["best_degree"] is None + assert r["coeffs"] is None + + +def test_longitudes_distintas_no_lanza(): + """Edge: listas de distinta longitud -> empareja por indice, no lanza.""" + # zip trunca a la longitud minima: solo 3 pares (< 5) -> debil. + r = classify_relationship_type([1, 2, 3, 4, 5, 6, 7, 8], [1.0, 2.0, 3.0]) + _assert_shape(r) + assert r["tipo"] == "débil/sin forma" + assert r["best_degree"] is None + + +def test_todos_none_no_lanza(): + """Edge: todos los valores None -> ningun par valido -> debil, no lanza.""" + r = classify_relationship_type([None, None, None, None, None, None], + [None, None, None, None, None, None]) + _assert_shape(r) + assert r["tipo"] == "débil/sin forma" + assert r["coeffs"] is None + + +def test_entradas_none_no_lanza(): + """Edge: xs/ys None directamente -> debil, no lanza.""" + assert classify_relationship_type(None, None)["tipo"] == "débil/sin forma" + assert classify_relationship_type([1.0, 2.0], None)["tipo"] == "débil/sin forma" + + +def test_constante_no_lanza(): + """Edge: ys constante (varianza ~0) -> debil, no lanza.""" + r = classify_relationship_type([1, 2, 3, 4, 5, 6, 7], [5, 5, 5, 5, 5, 5, 5]) + _assert_shape(r) + assert r["tipo"] == "débil/sin forma" + + +def test_filtra_nan_inf_bool(): + """Edge: pares con NaN/inf/bool/None se descartan por indice.""" + nan = float("nan") + inf = float("inf") + # Solo i=0,1,2,3,4 quedan validos (5 pares) y forman una recta perfecta. + xs = [0.0, 1.0, 2.0, 3.0, 4.0, nan, inf, True, None] + ys = [1.0, 3.0, 5.0, 7.0, 9.0, 1.0, 2.0, 3.0, 4.0] + r = classify_relationship_type(xs, ys) + _assert_shape(r) + # Los 5 pares validos son y = 2x + 1 exacto -> lineal. + assert r["tipo"] == "lineal" + assert r["best_degree"] == 1 diff --git a/python/functions/datascience/relationship_scatter_figure.md b/python/functions/datascience/relationship_scatter_figure.md new file mode 100644 index 00000000..2bedac32 --- /dev/null +++ b/python/functions/datascience/relationship_scatter_figure.md @@ -0,0 +1,122 @@ +--- +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. diff --git a/python/functions/datascience/relationship_scatter_figure.py b/python/functions/datascience/relationship_scatter_figure.py new file mode 100644 index 00000000..dd0b3371 --- /dev/null +++ b/python/functions/datascience/relationship_scatter_figure.py @@ -0,0 +1,322 @@ +"""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 diff --git a/python/functions/datascience/relationship_scatter_figure_test.py b/python/functions/datascience/relationship_scatter_figure_test.py new file mode 100644 index 00000000..c8aa12c3 --- /dev/null +++ b/python/functions/datascience/relationship_scatter_figure_test.py @@ -0,0 +1,100 @@ +"""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)