feat(eda): series temporales + rigor anti-data-mining + PDF movil + /eda + benchmark issues

Bloque del grupo eda (sesion ausente EDA-benchmark):
- 8 funciones nuevas: adf_kpss_stationarity, acf_pacf, stl_decompose, to_returns,
  fdr_correction, suggest_reexpression, exploratory_caveats, render_eda_pdf
- integracion: profile_table (run_series, emit_pdf), association_matrix (FDR Benjamini-Hochberg),
  render_eda_markdown (secciones series/reexpresion/caveats)
- slash commands /eda y /capitulos
- issues 0173-0177: mejoras del /eda derivadas del benchmark sobre 12 datasets reales
  (outlier_pct x100, periodo estacional, FK inference, render models, tipos id-like)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-06-29 03:34:01 +02:00
parent 02301aaed3
commit 7ac69ab4fb
33 changed files with 3995 additions and 51 deletions
+16
View File
@@ -45,9 +45,25 @@ from .run_eda_models import run_eda_models
from .eda_llm_insights import eda_llm_insights
from .build_eda_notebook import build_eda_notebook
from .decode_qr_image import decode_qr_image
from .adf_kpss_stationarity import adf_kpss_stationarity
from .acf_pacf import acf_pacf
from .stl_decompose import stl_decompose
from .to_returns import to_returns
from .fdr_correction import fdr_correction
from .suggest_reexpression import suggest_reexpression
from .exploratory_caveats import exploratory_caveats
from .render_eda_pdf import render_eda_pdf
__all__ = [
"decode_qr_image",
"adf_kpss_stationarity",
"acf_pacf",
"stl_decompose",
"to_returns",
"fdr_correction",
"suggest_reexpression",
"exploratory_caveats",
"render_eda_pdf",
"summarize_table_duckdb",
"summarize_table_pg",
"spearman_corr",
+73
View File
@@ -0,0 +1,73 @@
---
name: acf_pacf
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict"
description: "Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie temporal con sus bandas de confianza (statsmodels), mas el test Ljung-Box de autocorrelacion global. Devuelve listas acf/pacf, sus intervalos, los lags significativos y un flag is_autocorrelated. Clave: una serie autocorrelacionada viola IID, asi que los p-valores de una regresion OLS estandar sobre ella estan inflados (Lopez de Prado). Descarta None/NaN; <8 puntos validos -> nota."
tags: [statistics, timeseries, autocorrelation, acf, pacf, ljung-box, arima, eda, forecasting, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, numpy, statsmodels]
params:
- name: values
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del calculo."
- name: nlags
desc: "numero maximo de retardos a calcular (default 40). Se recorta a los limites de statsmodels: n-1 para ACF, (n//2)-1 para PACF."
- name: alpha
desc: "nivel de significancia para las bandas de confianza y el test de Ljung-Box (default 0.05)."
output: "dict con 'acf' y 'pacf' (listas, indice 0 = lag 0), 'acf_confint'/'pacf_confint' (banda por lag), 'significant_acf_lags'/'significant_pacf_lags' (lags >=1 fuera de banda), 'ljung_box' (stat, p_value, lags) e 'is_autocorrelated' (bool: Ljung-Box rechaza independencia). Con <8 puntos: {'n', 'note', 'is_autocorrelated': None}. Nunca lanza excepcion."
tested: true
tests: ["test_ruido_blanco_no_autocorrelado", "test_ar1_es_autocorrelado", "test_lag1_significativo_en_ar1", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_recorta_nlags_a_limites", "test_acf_lag0_es_uno"]
test_file_path: "python/functions/datascience/acf_pacf_test.py"
file_path: "python/functions/datascience/acf_pacf.py"
---
## Ejemplo
```python
from datascience import acf_pacf
import numpy as np
# Ruido blanco: sin autocorrelacion (Ljung-Box no rechaza independencia)
rng = np.random.default_rng(0)
ruido = rng.normal(0, 1, 500).tolist()
acf_pacf(ruido)["is_autocorrelated"] # -> False
# Proceso AR(1) fuerte: autocorrelado, lag 1 significativo en PACF
ar = [0.0]
for _ in range(500):
ar.append(0.8 * ar[-1] + rng.normal(0, 1))
res = acf_pacf(ar)
res["is_autocorrelated"] # -> True
res["significant_pacf_lags"][:1] # -> [1]
```
## Cuando usarla
Para diagnosticar la estructura de dependencia temporal de una serie: identificar
el orden de un modelo ARIMA (PACF corta en el orden AR, ACF corta en el orden MA),
o detectar estacionalidad (picos en lags estacionales). Y, critico para EDA: antes
de meter una variable temporal en una regresion, comprueba `is_autocorrelated`. Si
es `True`, la serie no es IID y los p-valores de OLS estandar estan inflados — hay
que usar errores estandar robustos (Newey-West) o modelar la dinamica
explicitamente (Lopez de Prado).
## Gotchas
- Es pura pero importa `statsmodels` y `numpy` (ambos en `python/.venv`).
- `acf[0]` y `pacf[0]` valen siempre 1.0 (autocorrelacion de la serie consigo
misma en lag 0). Los lags interesantes empiezan en el indice 1.
- `nlags` se recorta automaticamente: PACF exige `nlags < n/2`. Si pides 40 lags
sobre una serie de 30 puntos, `nlags` efectivo baja — mira el campo `nlags`
del resultado para saber cuantos se calcularon.
- Las bandas de confianza asumen ruido blanco bajo H0; en una serie con
tendencia muchos lags saldran "significativos" por la propia tendencia, no por
estructura ARMA. Estaciona primero (ver adf_kpss_stationarity / to_returns).
- Ljung-Box es un test global (todos los lags juntos); los lags individuales
significativos te dicen DONDE esta la autocorrelacion.
+134
View File
@@ -0,0 +1,134 @@
"""Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie (grupo eda).
Funcion pura y determinista que calcula la funcion de autocorrelacion y la
parcial con sus bandas de confianza, mas el test de Ljung-Box de autocorrelacion
global. Motivada por Hyndman ("Forecasting") para identificar el orden de un
modelo ARIMA, y por Lopez de Prado ("Advances in Financial ML"): una serie
autocorrelacionada viola el supuesto IID, de modo que los p-valores de una
regresion OLS estandar sobre ella estan inflados (falsos positivos).
"""
from __future__ import annotations
import math
import numpy as np
from statsmodels.stats.diagnostic import acorr_ljungbox
from statsmodels.tsa.stattools import acf, pacf
def _clean(values: list) -> list[float]:
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
Los booleanos se excluyen explicitamente (en Python ``bool`` es subclase de
``int``, pero no es un valor de serie temporal valido).
"""
out: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
if not isinstance(v, (int, float)):
continue
x = float(v)
if math.isnan(x) or math.isinf(x):
continue
out.append(x)
return out
def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict:
"""Calcula ACF, PACF y el test Ljung-Box de una serie temporal.
Computa la funcion de autocorrelacion (ACF) y la autocorrelacion parcial
(PACF) hasta ``nlags`` retardos, con sus bandas de confianza al nivel
``1 - alpha``, e identifica que retardos son significativos (cuyo intervalo
de confianza no contiene 0). Ademas corre el test de **Ljung-Box** sobre el
conjunto de retardos: H0 = "los datos son independientes" (sin
autocorrelacion); si ``p < alpha`` se rechaza -> la serie esta
autocorrelacionada.
Funcion pura y determinista: no hace I/O, no muta los inputs.
Args:
values: serie temporal de valores numericos en orden cronologico.
None/NaN/infinitos/no-numericos se descartan antes del calculo.
nlags: numero maximo de retardos a calcular (default 40). Se recorta
automaticamente a ``n // 2`` para PACF (statsmodels exige
``nlags < n/2``) y a ``n - 1`` para ACF.
alpha: nivel de significancia para las bandas de confianza y para el
test de Ljung-Box (default 0.05).
Returns:
Con menos de 8 puntos validos devuelve
``{"n": n, "note": "datos insuficientes", "is_autocorrelated": None}``.
En otro caso un dict con::
{
"n": int,
"nlags": int, # retardos efectivamente calculados
"acf": [float, ...], # incluye lag 0 (=1.0) en el indice 0
"pacf": [float, ...],
"acf_confint": [[low, high], ...], # banda por lag
"pacf_confint": [[low, high], ...],
"significant_acf_lags": [int, ...], # lags (>=1) significativos
"significant_pacf_lags": [int, ...],
"ljung_box": {"stat": float, "p_value": float, "lags": int},
"is_autocorrelated": bool, # Ljung-Box rechaza independencia
}
``is_autocorrelated = True`` significa que la serie NO es ruido blanco:
cuidado al aplicarle inferencia OLS clasica (p-valores inflados).
"""
clean = _clean(values)
n = len(clean)
if n < 8:
return {"n": n, "note": "datos insuficientes", "is_autocorrelated": None}
arr = np.asarray(clean, dtype=float)
# Recorta nlags a los limites de statsmodels: ACF admite hasta n-1, PACF < n/2.
eff_lags = min(nlags, n - 1, (n // 2) - 1)
eff_lags = max(eff_lags, 1)
acf_vals, acf_confint = acf(arr, nlags=eff_lags, alpha=alpha, fft=False)
pacf_vals, pacf_confint = pacf(arr, nlags=eff_lags, alpha=alpha)
# Un lag es significativo si su banda de confianza (centrada en el valor) no
# contiene 0. statsmodels devuelve confint como intervalos centrados en el
# estimador, asi que comparamos el intervalo desplazado al origen.
def _significant(vals, confint) -> list[int]:
out: list[int] = []
for lag in range(1, len(vals)):
low = confint[lag][0] - vals[lag]
high = confint[lag][1] - vals[lag]
if vals[lag] < low or vals[lag] > high:
out.append(lag)
return out
significant_acf = _significant(acf_vals, acf_confint)
significant_pacf = _significant(pacf_vals, pacf_confint)
# Ljung-Box sobre el maximo retardo calculado.
lb = acorr_ljungbox(arr, lags=[eff_lags], return_df=True)
lb_stat = float(lb["lb_stat"].iloc[0])
lb_p = float(lb["lb_pvalue"].iloc[0])
is_autocorrelated = bool(lb_p < alpha)
return {
"n": n,
"nlags": int(eff_lags),
"acf": [float(v) for v in acf_vals],
"pacf": [float(v) for v in pacf_vals],
"acf_confint": [[float(lo), float(hi)] for lo, hi in acf_confint],
"pacf_confint": [[float(lo), float(hi)] for lo, hi in pacf_confint],
"significant_acf_lags": significant_acf,
"significant_pacf_lags": significant_pacf,
"ljung_box": {
"stat": lb_stat,
"p_value": lb_p,
"lags": int(eff_lags),
},
"is_autocorrelated": is_autocorrelated,
}
@@ -0,0 +1,69 @@
---
name: adf_kpss_stationarity
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict"
description: "Test de estacionariedad de una serie temporal combinando ADF (H0=raiz unitaria/no estacionaria) y KPSS (H0=estacionaria) de statsmodels. Devuelve por test estadistico, p_value, lags y conclusion, mas un veredicto de consenso ('stationary'|'non_stationary'|'inconclusive'). Avisa de correlacion espuria (Granger-Newbold) cuando la serie no es estacionaria. Descarta None/NaN/infinitos; <8 puntos validos -> nota 'datos insuficientes'."
tags: [statistics, timeseries, stationarity, adf, kpss, unit-root, eda, forecasting, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, warnings, statsmodels]
params:
- name: values
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del test."
- name: alpha
desc: "nivel de significancia para ambos contrastes (default 0.05). p<alpha rechaza la hipotesis nula del test correspondiente."
output: "dict con 'adf' y 'kpss' (cada uno: stat, p_value, lags, stationary bool, conclusion), un 'verdict' de consenso ('stationary'|'non_stationary'|'inconclusive'), y 'warning' (texto sobre correlacion espuria si el veredicto no es stationary, si no None). Con <8 puntos validos: {'n', 'note': 'datos insuficientes', 'verdict': None}. Nunca lanza excepcion."
tested: true
tests: ["test_random_walk_es_no_estacionario", "test_ruido_blanco_es_estacionario", "test_serie_con_tendencia_no_es_estacionaria", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_warning_presente_si_no_estacionaria", "test_estructura_basica_del_dict"]
test_file_path: "python/functions/datascience/adf_kpss_stationarity_test.py"
file_path: "python/functions/datascience/adf_kpss_stationarity.py"
---
## Ejemplo
```python
from datascience import adf_kpss_stationarity
# Ruido blanco: estacionario (ADF rechaza raiz unitaria, KPSS no rechaza estacionariedad)
import numpy as np
rng = np.random.default_rng(0)
ruido = rng.normal(0, 1, 300).tolist()
adf_kpss_stationarity(ruido)["verdict"] # -> "stationary"
# Random walk (suma acumulada): NO estacionario
paseo = np.cumsum(rng.normal(0, 1, 300)).tolist()
res = adf_kpss_stationarity(paseo)
res["verdict"] # -> "non_stationary"
res["warning"] # -> aviso de correlacion espuria
```
## Cuando usarla
Antes de correlacionar, regresionar o modelar (ARIMA, VAR) una serie temporal,
para saber si es estacionaria. Es el primer paso obligatorio del analisis de
series: una serie no estacionaria (con tendencia o raiz unitaria) rompe los
supuestos de la regresion OLS clasica y, si la correlacionas con otra serie no
estacionaria, obtienes una correlacion alta pero **espuria** (Granger-Newbold).
Si el veredicto no es `"stationary"`, diferencia la serie o pasala a retornos
(`to_returns`) y vuelve a testear.
## Gotchas
- Es pura pero importa `statsmodels.tsa.stattools` (instalado en `python/.venv`).
- ADF y KPSS tienen hipotesis nulas OPUESTAS: en ADF `p<alpha` significa
estacionaria; en KPSS `p<alpha` significa NO estacionaria. La funcion ya
normaliza ambos a un campo `stationary` coherente — no inviertas tu la logica.
- KPSS interpola el p-valor sobre una tabla acotada `[0.01, 0.10]`: si el
estadistico cae fuera, statsmodels recorta el p-valor al extremo y lo marca en
`kpss.p_value_clipped = True`. Un p recortado a 0.01 o 0.10 es un limite, no un
valor exacto.
- El veredicto `"inconclusive"` suele indicar serie estacionaria-en-tendencia o
que necesita diferenciacion; no es un fallo, es informacion.
- Necesita al menos 8 puntos validos tras limpiar; con menos devuelve una nota.
@@ -0,0 +1,162 @@
"""Tests de estacionariedad de una serie temporal: ADF + KPSS (grupo eda).
Funcion pura y determinista que combina dos contrastes de estacionariedad con
hipotesis nulas opuestas y emite un veredicto de consenso. Motivada por la
necesidad (Hyndman "Forecasting", Hamilton "Time Series Analysis") de saber si
una serie es estacionaria ANTES de correlacionarla o modelarla: correlacionar
niveles no estacionarios produce correlacion espuria (Granger-Newbold 1974).
"""
from __future__ import annotations
import math
import warnings
from statsmodels.tsa.stattools import adfuller, kpss
def _clean(values: list) -> list[float]:
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
Los booleanos se excluyen explicitamente: en Python ``bool`` es subclase de
``int``, pero tratar True/False como numeros en una serie temporal es casi
siempre un error de tipado.
"""
out: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
if not isinstance(v, (int, float)):
continue
x = float(v)
if math.isnan(x) or math.isinf(x):
continue
out.append(x)
return out
def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict:
"""Evalua la estacionariedad de una serie combinando ADF y KPSS.
Aplica dos contrastes con hipotesis nulas opuestas:
- **ADF** (Augmented Dickey-Fuller): H0 = "la serie tiene raiz unitaria"
(es NO estacionaria). Si ``p < alpha`` se rechaza H0 -> evidencia de
estacionariedad.
- **KPSS** (Kwiatkowski-Phillips-Schmidt-Shin): H0 = "la serie es
estacionaria (en torno a una tendencia)". Si ``p < alpha`` se rechaza H0
-> evidencia de NO estacionariedad.
Combinar ambos da mas robustez que cualquiera por separado, porque sus
hipotesis nulas son contrarias. El veredicto de consenso sigue la
interpretacion estandar (Hyndman, "Forecasting: Principles and Practice"):
- ADF rechaza H0 **y** KPSS no rechaza H0 -> ``"stationary"``.
- ADF no rechaza H0 **y** KPSS rechaza H0 -> ``"non_stationary"``.
- Ambos coinciden en lo contrario o se contradicen -> ``"inconclusive"``
(a menudo indica serie diferenciable o estacionaria en tendencia).
Funcion pura y determinista: no hace I/O, no muta los inputs.
Args:
values: serie temporal de valores numericos en orden cronologico.
None/NaN/infinitos/no-numericos se descartan antes del test.
alpha: nivel de significancia para ambos contrastes (default 0.05).
Returns:
Con menos de 8 puntos validos (muestra insuficiente para un test de
raiz unitaria fiable) devuelve
``{"n": n, "note": "datos insuficientes", "verdict": None}``.
En otro caso un dict con::
{
"n": int,
"alpha": float,
"adf": {"stat": float, "p_value": float, "lags": int,
"stationary": bool, # rechaza H0 de raiz unitaria
"conclusion": str},
"kpss": {"stat": float, "p_value": float, "lags": int,
"stationary": bool, # NO rechaza H0 de estacionariedad
"conclusion": str,
"p_value_clipped": bool}, # p en limite de tabla KPSS
"verdict": "stationary" | "non_stationary" | "inconclusive",
"warning": str | None, # aviso de correlacion espuria si procede
}
``warning`` se rellena cuando el veredicto NO es ``"stationary"`` para
recordar que correlacionar/regresionar niveles no estacionarios produce
relaciones espurias; conviene pasar a retornos o diferencias.
"""
clean = _clean(values)
n = len(clean)
if n < 8:
return {"n": n, "note": "datos insuficientes", "verdict": None}
# ADF: H0 = raiz unitaria (no estacionaria). p < alpha => estacionaria.
adf_stat, adf_p, adf_lags, _adf_nobs, _adf_crit, _adf_icbest = adfuller(
clean, autolag="AIC"
)
adf_stationary = bool(adf_p < alpha)
adf = {
"stat": float(adf_stat),
"p_value": float(adf_p),
"lags": int(adf_lags),
"stationary": adf_stationary,
"conclusion": (
"rechaza H0 de raiz unitaria: evidencia de estacionariedad"
if adf_stationary
else "no rechaza H0 de raiz unitaria: posible no estacionaria"
),
}
# KPSS: H0 = estacionaria en torno a tendencia. p < alpha => NO estacionaria.
# statsmodels emite InterpolationWarning cuando el p-valor cae fuera de la
# tabla [0.01, 0.10]; lo capturamos para saber si quedo recortado.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
kpss_stat, kpss_p, kpss_lags, _kpss_crit = kpss(
clean, regression="c", nlags="auto"
)
p_clipped = any("InterpolationWarning" in str(w.category) for w in caught) or any(
"p-value" in str(w.message).lower() for w in caught
)
kpss_stationary = bool(kpss_p >= alpha) # NO rechaza H0 => estacionaria
kpss_result = {
"stat": float(kpss_stat),
"p_value": float(kpss_p),
"lags": int(kpss_lags),
"stationary": kpss_stationary,
"conclusion": (
"no rechaza H0 de estacionariedad: evidencia de estacionariedad"
if kpss_stationary
else "rechaza H0 de estacionariedad: posible no estacionaria"
),
"p_value_clipped": bool(p_clipped),
}
# Consenso de los dos contrastes.
if adf_stationary and kpss_stationary:
verdict = "stationary"
elif (not adf_stationary) and (not kpss_stationary):
verdict = "non_stationary"
else:
verdict = "inconclusive"
warning: str | None = None
if verdict != "stationary":
warning = (
"serie no claramente estacionaria: correlacionar o regresionar sus "
"niveles puede dar relaciones espurias (Granger-Newbold). Considera "
"trabajar sobre retornos o diferencias (ver to_returns)."
)
return {
"n": n,
"alpha": float(alpha),
"adf": adf,
"kpss": kpss_result,
"verdict": verdict,
"warning": warning,
}
@@ -3,19 +3,23 @@ name: association_matrix
kind: function
lang: py
domain: datascience
version: "1.0.0"
version: "1.1.0"
purity: pure
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
tags: [eda, correlation, association, statistics, mixed-types, mutual-information]
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20, alpha: float = 0.05, fdr_method: str = \"bh\") -> dict"
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Cada par lleva su p-valor (test de correlacion / chi-cuadrado / ANOVA) y se corrige por comparaciones multiples (FDR) para combatir el sesgo de mineria de datos: el subconjunto fuerte se basa en la significancia corregida, no solo en superar el umbral de magnitud."
tags: [eda, correlation, association, statistics, mixed-types, mutual-information, multiple-testing, p-value, fdr]
params:
- name: columns
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
- name: strong_threshold
desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
desc: "Umbral de magnitud en [0, 1]. Condicion necesaria (ya no suficiente) para ser fuerte: abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
- name: top_n
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
- name: alpha
desc: "Nivel de significancia tras la correccion FDR (default 0.05). Un par con p-valor disponible solo es fuerte si ademas su p-valor ajustado <= alpha."
- name: fdr_method
desc: "Metodo de correccion de comparaciones multiples: 'bh' (Benjamini-Hochberg, FDR; default) o 'bonferroni' (FWER, mas conservador)."
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra, p_value, p_value_adjusted, significant}; strong: subconjunto con magnitud >= umbral Y significativo tras FDR (pares sin test se admiten por magnitud), ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion; n_tests: nº total de pares evaluados (== len(pairs)); multiple_testing: {method, alpha, n_tests, n_rejected}}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
uses_functions:
- pearson_py_datascience
- spearman_corr_py_datascience
@@ -23,13 +27,14 @@ uses_functions:
- theils_u_py_datascience
- correlation_ratio_py_datascience
- mutual_info_columns_py_datascience
- fdr_correction_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
imports: [scipy]
tested: true
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"]
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty", "test_pairs_carry_significance_fields", "test_result_reports_multiple_testing_summary", "test_strong_requires_corrected_significance", "test_bonferroni_method_is_accepted"]
test_file_path: "python/functions/datascience/association_matrix_test.py"
file_path: "python/functions/datascience/association_matrix.py"
---
@@ -84,3 +89,36 @@ no-lineal a todos los pares.
categorica como primer argumento y la numerica como segundo.
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
## Gotchas
- **Ahora corrige multiple-testing (v1.1.0).** El subconjunto `strong` ya no
depende solo de la magnitud: un par con magnitud alta pero p-valor ajustado
> `alpha` NO entra en `strong`. Esto combate el sesgo de mineria de datos
(data-mining bias, Aronson cap. 6): al evaluar todos los pares a la vez, el
azar produce correlaciones espurias que el umbral de magnitud por si solo
dejaria pasar.
- Cada par lleva `p_value` (test del metodo principal: correlacion de
Pearson/Spearman, chi-cuadrado de independencia para Cramer's V, ANOVA de una
via para correlation ratio) y `p_value_adjusted` (tras `fdr_correction`). La
informacion mutua no tiene test asociado, por lo que un par cuyo metodo
principal sea degenerado puede tener `p_value = None`; esos pares se admiten en
`strong` por magnitud (no hay p-valor que corregir).
- `n_tests` (top-level) es el numero total de pares evaluados (`len(pairs)`),
mientras que `multiple_testing.n_tests` es el numero de p-valores **validos**
que entraron en la correccion (puede ser menor si algun par no tiene test).
- Sigue siendo pura, pero ahora importa `scipy.stats` (`pearsonr`, `spearmanr`,
`chi2_contingency`, `f_oneway`) para los p-valores; scipy ya vive en
`python/.venv`.
- Sube `alpha` o usa `fdr_method="bonferroni"` segun lo costoso que sea un falso
positivo: BH controla la tasa de falsos descubrimientos (mas potencia),
Bonferroni la probabilidad de cualquier falso positivo (mas cautela).
## Capability growth log
- v1.1.0 (28/06/2026) — anade p-valor por par (Pearson/Spearman, chi-cuadrado,
ANOVA) + correccion de comparaciones multiples via `fdr_correction` (BH /
Bonferroni). `strong` pasa a basarse en la significancia corregida, no solo en
el umbral de magnitud. Nuevos parametros `alpha` y `fdr_method`; nuevas claves
`p_value`/`p_value_adjusted`/`significant` por par y `n_tests`/
`multiple_testing` en el resultado. Retrocompatible: no quita claves previas.
@@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
"""
import math
from collections import Counter, defaultdict
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
from datascience import (
correlation_ratio,
@@ -19,6 +22,10 @@ from datascience import (
theils_u,
)
# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su
# __init__ (lo integra el orquestador al cerrar el grupo eda).
from datascience.fdr_correction import fdr_correction
# Tipos que, para efectos de asociacion, se tratan como categoricos.
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
@@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
return cx, cy
def _safe_pvalue(value) -> float | None:
"""Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido."""
if value is None:
return None
try:
pv = float(value)
except (TypeError, ValueError):
return None
if math.isnan(pv) or math.isinf(pv):
return None
return pv
def _pearson_pvalue(cx: list, cy: list) -> float | None:
"""p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado."""
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
return None
try:
return _safe_pvalue(pearsonr(cx, cy).pvalue)
except Exception:
return None
def _spearman_pvalue(cx: list, cy: list) -> float | None:
"""p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado."""
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
return None
try:
return _safe_pvalue(spearmanr(cx, cy).pvalue)
except Exception:
return None
def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None:
"""p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado."""
pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None]
if len(pairs) < 2:
return None
rows = sorted({x for x, _ in pairs}, key=repr)
cols = sorted({y for _, y in pairs}, key=repr)
if len(rows) < 2 or len(cols) < 2:
return None
row_idx = {v: i for i, v in enumerate(rows)}
col_idx = {v: j for j, v in enumerate(cols)}
counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
table = [
[counts.get((i, j), 0) for j in range(len(cols))]
for i in range(len(rows))
]
try:
return _safe_pvalue(chi2_contingency(table).pvalue)
except Exception:
return None
def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None:
"""p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado."""
groups: dict = defaultdict(list)
for c, x in zip(cat_vals, num_vals):
if c is None or not _is_num(x):
continue
groups[c].append(float(x))
valid = [g for g in groups.values() if len(g) >= 2]
if len(valid) < 2:
return None
try:
return _safe_pvalue(f_oneway(*valid).pvalue)
except Exception:
return None
def association_matrix(
columns: dict,
strong_threshold: float = 0.5,
top_n: int = 20,
alpha: float = 0.05,
fdr_method: str = "bh",
) -> dict:
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
@@ -81,22 +161,48 @@ def association_matrix(
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
columna (devuelve `pairs=[]`, `strong=[]`).
Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor
del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de
correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio:
ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan
todos los pares a la vez, esos p-valores se corrigen por comparaciones
multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el
subconjunto `strong` se basa en la **significancia corregida**, no solo en
superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado
> alpha NO entra en `strong`.
Args:
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
es uno de "numeric", "categorical", "datetime", "boolean", "text".
Los tipos datetime/boolean/text se tratan como categoricos.
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
abs(value) >= umbral o extra["mi"] >= umbral.
strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser
"fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero
ya no suficiente (ver alpha).
top_n: numero maximo de pares fuertes a devolver, ordenados por
relevancia (max(abs(value), mi)) descendente.
alpha: nivel de significancia tras la correccion FDR (default 0.05). Un
par con p-valor disponible solo es fuerte si ademas su p-valor
ajustado <= alpha.
fdr_method: metodo de correccion de comparaciones multiples,
"bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER).
Returns:
dict con claves:
pairs: lista de todos los pares evaluados, cada uno
{a, b, a_type, b_type, method, value, extra}.
strong: subconjunto de pairs por encima del umbral, ordenado por
relevancia descendente y truncado a top_n.
{a, b, a_type, b_type, method, value, extra, p_value,
p_value_adjusted, significant}. `p_value` es el del test del
metodo principal (None si no aplica / degenerado);
`p_value_adjusted` el p-valor tras FDR; `significant` True si
p_value_adjusted <= alpha.
strong: subconjunto de pairs que cumplen magnitud >= umbral Y son
significativos tras la correccion (los pares sin test disponible
se admiten por magnitud), ordenado por relevancia descendente y
truncado a top_n.
methods_legend: dict {metodo: descripcion}.
n_tests: numero total de pares evaluados (== len(pairs)).
multiple_testing: dict {method, alpha, n_tests, n_rejected} con el
resumen de la correccion (n_tests aqui = p-valores validos
corregidos, puede ser < len(pairs) si algun par no tiene test).
"""
legend = {
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
@@ -168,20 +274,32 @@ def association_matrix(
s = spearman_corr(a_vals, b_vals)
extra["pearson"] = p
extra["spearman"] = s
value = p if abs(p) >= abs(s) else s
pearson_p = _pearson_pvalue(cx, cy)
spearman_p = _spearman_pvalue(cx, cy)
extra["pearson_p"] = pearson_p
extra["spearman_p"] = spearman_p
if abs(p) >= abs(s):
value = p
p_value = pearson_p
else:
value = s
p_value = spearman_p
elif (not a_numeric) and (not b_numeric):
method = "cramers_v"
value = cramers_v(a_vals, b_vals)
extra["u_ab"] = theils_u(a_vals, b_vals)
extra["u_ba"] = theils_u(b_vals, a_vals)
p_value = _chi2_pvalue(a_vals, b_vals)
else:
method = "correlation_ratio"
if a_numeric:
# a numerica, b categorica.
value = correlation_ratio(b_vals, a_vals)
p_value = _anova_pvalue(b_vals, a_vals)
else:
# a categorica, b numerica.
value = correlation_ratio(a_vals, b_vals)
p_value = _anova_pvalue(a_vals, b_vals)
pairs.append(
{
@@ -192,19 +310,55 @@ def association_matrix(
"method": method,
"value": value,
"extra": extra,
"p_value": p_value,
}
)
# Correccion de comparaciones multiples sobre los p-valores disponibles.
# Se pasa la lista completa (incluidos los None de pares sin test): la
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
fdr = fdr_correction(
[pair["p_value"] for pair in pairs],
alpha=alpha,
method=fdr_method,
)
for pair, padj, rej in zip(
pairs, fdr["p_values_adjusted"], fdr["reject"]
):
pair["p_value_adjusted"] = padj
pair["significant"] = bool(rej)
def _relevance(pair: dict) -> float:
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
strong = [
pair
for pair in pairs
if abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
]
def _is_strong(pair: dict) -> bool:
# Condicion 1: magnitud por encima del umbral (necesaria).
magnitude_ok = (
abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
)
if not magnitude_ok:
return False
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
# admiten por magnitud, ya que no hay p-valor que corregir.
if pair["p_value"] is None:
return True
return pair["significant"]
strong = [pair for pair in pairs if _is_strong(pair)]
strong.sort(key=_relevance, reverse=True)
strong = strong[:top_n]
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
return {
"pairs": pairs,
"strong": strong,
"methods_legend": legend,
"n_tests": len(pairs),
"multiple_testing": {
"method": fdr_method,
"alpha": alpha,
"n_tests": fdr["n_tests"],
"n_rejected": fdr["n_rejected"],
},
}
@@ -80,3 +80,79 @@ def test_single_column_returns_empty():
result = association_matrix(columns)
assert result["pairs"] == []
assert result["strong"] == []
def test_pairs_carry_significance_fields():
# Tras la correccion FDR cada par evaluado lleva p_value, p_value_adjusted y
# significant. Un par num-num fuertemente correlado es significativo.
columns = {
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
"price": {
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
"type": "numeric",
},
}
result = association_matrix(columns, strong_threshold=0.5)
pair = _find_pair(result["pairs"], "size", "price")
assert "p_value" in pair and "p_value_adjusted" in pair and "significant" in pair
assert pair["p_value"] is not None and pair["p_value"] < 0.05
assert pair["significant"] is True
# p ajustado nunca por debajo del crudo.
assert pair["p_value_adjusted"] >= pair["p_value"] - 1e-12
def test_result_reports_multiple_testing_summary():
columns = {
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
"price": {
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
"type": "numeric",
},
}
result = association_matrix(columns)
# n_tests = total de pares evaluados.
assert result["n_tests"] == len(result["pairs"])
mt = result["multiple_testing"]
assert mt["method"] == "bh"
assert mt["alpha"] == 0.05
assert mt["n_rejected"] >= 1
assert mt["n_tests"] >= 1
def test_strong_requires_corrected_significance():
# Par num-num con magnitud alta pero p-valor no diminuto. Con alpha normal es
# fuerte; con un alpha mas estricto que su p-valor, deja de ser significativo
# y sale de strong AUNQUE la magnitud siga por encima del umbral. Esto prueba
# que strong se basa en la significancia corregida, no solo en el umbral.
columns = {
"a": {"values": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "type": "numeric"},
"b": {"values": [2, 1, 3, 4, 6, 5, 7, 8, 10, 9, 11, 12], "type": "numeric"},
}
relaxed = association_matrix(columns, strong_threshold=0.5, alpha=0.05)
pair = _find_pair(relaxed["pairs"], "a", "b")
assert pair["p_value"] is not None and pair["p_value"] < 0.05
assert abs(pair["value"]) >= 0.5
assert _find_pair(relaxed["strong"], "a", "b") is not None
# alpha mas estricto que el p-valor del par -> ya no significativo.
strict = association_matrix(
columns, strong_threshold=0.5, alpha=pair["p_value"] / 10.0
)
sp = _find_pair(strict["pairs"], "a", "b")
assert abs(sp["value"]) >= 0.5 # magnitud intacta
assert sp["significant"] is False
assert _find_pair(strict["strong"], "a", "b") is None
def test_bonferroni_method_is_accepted():
columns = {
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
"price": {
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
"type": "numeric",
},
}
result = association_matrix(columns, fdr_method="bonferroni")
assert result["multiple_testing"]["method"] == "bonferroni"
pair = _find_pair(result["pairs"], "size", "price")
assert pair["p_value_adjusted"] is not None
@@ -0,0 +1,77 @@
---
name: exploratory_caveats
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def exploratory_caveats(profile: dict) -> dict"
description: "Genera las advertencias que recuerdan que un EDA es EXPLORATORIO (genera hipotesis), no confirmatorio. Inspecciona un TableProfile del grupo eda y devuelve solo los caveats que aplican a lo calculado: correlacion!=causalidad, overfitting in-sample, p-values no son confirmacion, comparaciones multiples, outliers!=errores, muestra pequena, datos faltantes. El caveat general va siempre. Pura."
tags: [eda, exploratory, caveats, hypotheses, overfitting, correlation-causation, p-values, tukey, lopez-de-prado, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: profile
desc: "TableProfile dict del grupo eda. Se leen defensivamente `correlations` (pares), `models` (pca/kmeans/outliers/normality), `columns` (sub-bloques `numeric` con n_outliers/outlier_pct y `trend` con p_value), `n_rows`, `null_cell_pct` y `all_null_cols`. Cualquier clave puede faltar."
output: "dict con `n` (numero de caveats), `caveats` (lista de {id, topic, message, reference} empezando por el general `exploratory_nature`) y `note` (vacio en caso normal; mensaje si el perfil esta vacio y solo se devuelve el caveat general). Nunca lanza excepcion."
tested: true
tests: ["test_perfil_vacio_solo_caveat_general", "test_none_no_lanza_y_da_general", "test_caveat_general_siempre_primero", "test_correlaciones_disparan_causalidad_y_overfitting", "test_dos_o_mas_pares_disparan_comparaciones_multiples", "test_modelos_disparan_overfitting_y_pvalues", "test_outliers_por_columna_disparan_caveat", "test_outliers_multivariantes_disparan_caveat", "test_trend_pvalue_dispara_caveat_pvalues", "test_muestra_pequena_dispara_caveat", "test_muestra_grande_no_dispara_small_sample", "test_muchos_faltantes_disparan_missing_data", "test_columnas_all_null_disparan_missing_data", "test_pocos_faltantes_no_disparan_missing_data", "test_estructura_de_cada_caveat"]
test_file_path: "python/functions/datascience/exploratory_caveats_test.py"
file_path: "python/functions/datascience/exploratory_caveats.py"
---
## Ejemplo
```python
from datascience import exploratory_caveats
profile = {
"n_rows": 5000,
"correlations": {"pairs": [
{"a": "precio", "b": "ventas", "value": 0.82},
{"a": "precio", "b": "margen", "value": -0.61},
]},
"models": {"pca": {"explained": [0.6, 0.3]}, "normality": {"precio": {"is_normal": False}}},
"columns": [{"name": "precio", "numeric": {"n_outliers": 4, "outlier_pct": 0.8}}],
}
out = exploratory_caveats(profile)
out["n"] # -> 6
[c["id"] for c in out["caveats"]]
# -> ['exploratory_nature', 'correlation_not_causation', 'in_sample_overfitting',
# 'p_values_not_confirmation', 'multiple_comparisons', 'outliers_not_errors']
# Perfil vacio -> solo la advertencia general.
exploratory_caveats({})["caveats"][0]["id"] # -> "exploratory_nature"
```
## Cuando usarla
Al cerrar un EDA, antes de entregar el reporte o de tomar decisiones sobre lo que
muestra. Convierte la disciplina exploratoria (Tukey: el EDA da hipotesis, no
conclusiones) en una lista accionable de advertencias adaptada a lo que realmente se
calculo en ese perfil. Pensada para inyectar una seccion "Advertencias / esto es
exploratorio" en el markdown de un reporte EDA, o para que un agente recuerde no
tratar una correlacion o una "significancia" como confirmacion. NO la uses para
calcular estadisticos: solo razona sobre el contenido de un TableProfile ya hecho.
## Gotchas
- Es **pura**: no recalcula nada, solo decide que advertencias aplican a partir de
las claves presentes en el `profile`. Si una fase del EDA no se corrio (p.ej. sin
`models`), su caveat no aparece — es deliberado.
- El caveat `exploratory_nature` (general) va SIEMPRE, incluso con perfil vacio o
`None` (en ese caso `note` lo avisa). No lanza excepcion ante entradas raras.
- `correlations` se tolera como lista de pares o como dict con `pairs`/`strongest`
(mismo shape que consume `render_eda_markdown`). Un solo par dispara
`correlation_not_causation` + `in_sample_overfitting`; >=2 anaden ademas
`multiple_comparisons`.
- Umbrales: muestra pequena si `n_rows < 30`; faltantes notables si
`null_cell_pct > 0.2` (fraccion) o si hay `all_null_cols`. Son convenciones
prudentes, ajustables si el caller lo necesita (recomputando sobre el mismo
profile).
- `null_cell_pct` se asume fraccion 0-1 (como en el resto del grupo eda). Si tu
pipeline lo guarda como porcentaje 0-100, el umbral se dispara casi siempre.
@@ -0,0 +1,246 @@
"""Genera las advertencias que recuerdan que un EDA es EXPLORATORIO, no confirmatorio.
Funcion pura y determinista: dict (TableProfile del grupo ``eda``) entra, dict con
una lista de caveats sale. No hace I/O, no muta el input, no lanza excepciones.
Doctrina (Tukey, *EDA* 1977; Aronson; López de Prado 2018): el análisis exploratorio
sirve para GENERAR hipótesis, no para confirmarlas. Lo que se ve mirando todo el
dataset a la vez —correlaciones, clusters, "significancias", outliers— es un punto de
partida, no una conclusión: hay que validarlo fuera de muestra con un análisis dirigido.
Esta función inspecciona qué contiene el perfil y devuelve solo las advertencias que
aplican a lo que realmente se ha calculado (si hay correlaciones → caveat de
causalidad; si hay modelos → caveat de overfitting; etc.), además de una advertencia
general que siempre acompaña a un EDA.
"""
from __future__ import annotations
# Umbrales para disparar caveats dependientes de magnitud.
_SMALL_SAMPLE_ROWS = 30 # n_rows por debajo de esto -> baja potencia.
_HIGH_MISSING_FRACTION = 0.2 # null_cell_pct (fracción) por encima -> sesgo MNAR.
def _to_float(v):
"""Parsea a float; None si es None/bool/no parseable (NaN incluido)."""
if v is None or isinstance(v, bool):
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
if f != f: # NaN
return None
return f
def _correlation_pairs(profile: dict) -> list:
"""Extrae la lista de pares de correlación del perfil, tolerando varios shapes.
``correlations`` puede ser una lista de pares o un dict con ``pairs`` /
``strongest``. Devuelve siempre una lista (vacía si no hay nada usable).
"""
correlations = profile.get("correlations")
if not correlations:
return []
if isinstance(correlations, dict):
pairs = correlations.get("pairs") or correlations.get("strongest") or []
else:
pairs = correlations
return list(pairs) if isinstance(pairs, (list, tuple)) else []
def _has_models(profile: dict) -> bool:
"""True si el perfil contiene un bloque de modelos multivariantes ajustados."""
models = profile.get("models")
if not isinstance(models, dict):
return False
return any(models.get(k) for k in ("pca", "kmeans", "outliers"))
def _has_pvalues(profile: dict) -> bool:
"""True si el perfil contiene p-values (tests de normalidad o de tendencia)."""
models = profile.get("models")
if isinstance(models, dict) and models.get("normality"):
return True
# Tests de tendencia adjuntados por columna (trend_slope) también traen p-value.
for col in profile.get("columns") or []:
if isinstance(col, dict) and isinstance(col.get("trend"), dict):
if col["trend"].get("p_value") is not None:
return True
return False
def _has_outliers(profile: dict) -> bool:
"""True si se han detectado outliers (multivariantes o por columna numérica)."""
models = profile.get("models")
if isinstance(models, dict) and models.get("outliers"):
return True
for col in profile.get("columns") or []:
if not isinstance(col, dict):
continue
num = col.get("numeric")
if isinstance(num, dict):
n_out = _to_float(num.get("n_outliers"))
opct = _to_float(num.get("outlier_pct"))
if (n_out is not None and n_out > 0) or (opct is not None and opct > 0):
return True
return False
def exploratory_caveats(profile: dict) -> dict:
"""Devuelve las advertencias de que el EDA es exploratorio según lo que contiene.
Inspecciona un TableProfile (dict del grupo ``eda``) y arma la lista de caveats
relevantes. Una advertencia general (la naturaleza exploratoria del EDA) se
incluye SIEMPRE; el resto solo se añaden cuando el perfil contiene aquello a lo
que aplican:
- correlaciones presentes -> correlación ≠ causalidad.
- modelos / correlaciones -> riesgo de overfitting in-sample (validar OOS).
- p-values (normalidad/tendencia) -> no son confirmación sin corregir / IID.
- ≥2 pares de correlación -> comparaciones múltiples (falsos positivos).
- outliers detectados -> no implican errores.
- n_rows pequeño -> baja potencia, estimaciones inestables.
- muchos faltantes -> posible sesgo si no son aleatorios (MNAR).
Es pura, determinista y no lanza excepciones. Un perfil vacío o ``None`` devuelve
solo el caveat general con una nota.
Args:
profile: TableProfile dict del grupo ``eda``. Se lee todo defensivamente con
``.get(...)`` porque casi cualquier fase puede faltar.
Returns:
dict con:
- ``n``: número de caveats devueltos (int).
- ``caveats``: lista de dicts ``{"id", "topic", "message", "reference"}``,
empezando por el general ``exploratory_nature``.
- ``note``: cadena vacía en el caso normal; mensaje cuando el perfil está
vacío y solo se devuelve la advertencia general.
"""
if not isinstance(profile, dict):
profile = {}
caveats: list = []
# Caveat general: SIEMPRE presente. El EDA genera hipótesis, no conclusiones.
caveats.append({
"id": "exploratory_nature",
"topic": "naturaleza exploratoria",
"message": (
"El EDA genera HIPÓTESIS, no conclusiones. Cada patrón que veas aquí es un "
"punto de partida para confirmarlo con un análisis dirigido sobre datos "
"nuevos, no una verdad ya establecida."
),
"reference": "Tukey (1977), Exploratory Data Analysis; Aronson",
})
if not profile:
return {
"n": len(caveats),
"caveats": caveats,
"note": "perfil vacío: solo se devuelve la advertencia general",
}
corr_pairs = _correlation_pairs(profile)
has_corr = len(corr_pairs) > 0
has_models = _has_models(profile)
# Correlación ≠ causalidad.
if has_corr:
caveats.append({
"id": "correlation_not_causation",
"topic": "correlación vs causalidad",
"message": (
"Las correlaciones son asociaciones, no relaciones causales. Una "
"correlación fuerte puede venir de una variable de confusión o del "
"azar; valídala out-of-sample o con un diseño experimental antes de "
"actuar sobre ella."
),
"reference": "Tukey (1977), EDA",
})
# Overfitting in-sample: cualquier patrón ajustado sobre todo el dataset.
if has_models or has_corr:
caveats.append({
"id": "in_sample_overfitting",
"topic": "overfitting in-sample",
"message": (
"Los patrones (modelos, clusters, correlaciones) se han extraído sobre "
"TODO el dataset. Lo aprendido in-sample puede no replicar fuera de "
"muestra (overfitting / selección por backtest). Valida con holdout o "
"walk-forward antes de confiar en ellos."
),
"reference": "López de Prado (2018), Advances in Financial Machine Learning",
})
# p-values: no son confirmación sin corregir multiplicidad / sobre datos no-IID.
if _has_pvalues(profile):
caveats.append({
"id": "p_values_not_confirmation",
"topic": "p-values",
"message": (
"Los p-values sin corregir por comparaciones múltiples, o calculados "
"sobre datos no-IID (series temporales, datos agrupados), no son "
"confirmación. Trata cualquier 'significancia' vista en exploración "
"como provisional."
),
"reference": "Tukey (1977), EDA",
})
# Comparaciones múltiples: cuantos más pares/columnas miras, más falsos positivos.
if len(corr_pairs) >= 2:
caveats.append({
"id": "multiple_comparisons",
"topic": "comparaciones múltiples",
"message": (
"Al examinar muchos pares/columnas a la vez, algunos parecerán "
"'significativos' solo por azar (problema de comparaciones múltiples). "
"Cuantas más combinaciones miras, más falsos positivos esperas."
),
"reference": "López de Prado (2018), AFML",
})
# Outliers detectados no implican errores.
if _has_outliers(profile):
caveats.append({
"id": "outliers_not_errors",
"topic": "outliers",
"message": (
"Los outliers detectados son puntos estadísticamente atípicos, NO "
"necesariamente errores. Pueden ser el dato más interesante (fraude, "
"evento raro). Investígalos antes de eliminarlos."
),
"reference": "Tukey (1977), EDA",
})
# Muestra pequeña: baja potencia, estimaciones inestables.
n_rows = _to_float(profile.get("n_rows"))
if n_rows is not None and n_rows < _SMALL_SAMPLE_ROWS:
caveats.append({
"id": "small_sample",
"topic": "muestra pequeña",
"message": (
f"Pocas filas (n={int(n_rows)}): la potencia estadística es baja y las "
"estimaciones (media, correlación, forma de la distribución) son "
"inestables. Los patrones pueden cambiar con más datos."
),
"reference": "Tukey (1977), EDA",
})
# Datos faltantes: posible sesgo si no son aleatorios (MNAR).
null_frac = _to_float(profile.get("null_cell_pct"))
all_null_cols = profile.get("all_null_cols") or []
if (null_frac is not None and null_frac > _HIGH_MISSING_FRACTION) or all_null_cols:
caveats.append({
"id": "missing_data_bias",
"topic": "datos faltantes",
"message": (
"Hay un volumen notable de datos faltantes. Si los ausentes no son "
"aleatorios (MNAR), los estadísticos calculados sobre lo presente "
"están sesgados; no extrapoles sin entender por qué faltan."
),
"reference": "Tukey (1977), EDA",
})
return {"n": len(caveats), "caveats": caveats, "note": ""}
@@ -0,0 +1,83 @@
---
name: fdr_correction
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict"
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh') o Bonferroni (FWER, 'bonferroni'). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)."
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, p-value, data-mining-bias, python]
params:
- name: pvalues
desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)."
- name: alpha
desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)."
- name: method
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador). Cualquier otro valor devuelve un dict con note."
output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math]
tested: true
tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos"]
test_file_path: "python/functions/datascience/fdr_correction_test.py"
file_path: "python/functions/datascience/fdr_correction.py"
---
## Ejemplo
```python
from datascience import fdr_correction
# Tres pruebas: dos muy significativas, una claramente no.
pvalues = [0.01, 0.02, 0.5]
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
print(bh["reject"]) # -> [True, True, False]
print(bh["n_rejected"]) # -> 2
# Bonferroni es mas conservador: solo sobrevive la mas fuerte.
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
print(bon["reject"]) # -> [True, False, False]
print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0]
# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la
# lista completa de pares y recuperar el mapeo 1:1.
mix = fdr_correction([0.001, None, 0.9])
print(mix["reject"]) # -> [True, False, False]
print(mix["n_tests"]) # -> 2 (el None no cuenta como prueba)
```
## Cuando usarla
Cuando evalues **muchas hipotesis a la vez** y vayas a declarar "significativos"
los resultados por debajo de un umbral de p-valor: matriz de asociacion entre
todas las columnas, barrido de reglas/senales, cualquier busqueda que pruebe N
combinaciones y se quede con las que "pasan". Sin corregir, con N pruebas y
alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas
correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la
familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"`
por defecto (mejor potencia); `"bonferroni"` cuando un falso positivo sea muy
costoso y prefieras maxima cautela.
## Gotchas
- Pura y sin dependencias externas (solo `math` de la stdlib).
- Corrige **dentro de una familia de pruebas**: pasa de una vez todos los
p-valores que compiten, no los corrijas por separado o pierdes el control del
sesgo.
- La salida esta **alineada 1:1** con la entrada. Las posiciones invalidas
(`None`, `NaN`, fuera de `[0, 1]`, no numericas) se devuelven como
`p_values_adjusted=None` y `reject=False`, y no cuentan en `n_tests` (m). Por
eso puedes pasar la lista completa de pares aunque algunos no tengan test.
- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que
`len(pvalues)` si hay `None`.
- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos
descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
con `note`.
@@ -0,0 +1,158 @@
"""Correccion de comparaciones multiples (multiple-testing) para una lista de p-valores.
Funcion pura del grupo eda. Cuando se evaluan muchas hipotesis a la vez (p.ej.
todos los pares de una matriz de asociacion), la probabilidad de obtener al menos
un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria
de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical
Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo
mediante dos metodos clasicos:
- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia
(Family-Wise Error Rate, FWER). Mas conservador.
No usa dependencias externas: aritmetica de la libreria estandar.
"""
from __future__ import annotations
import math
def _is_valid_p(v) -> bool:
"""True si v es un p-valor numerico finito dentro de [0, 1]."""
if v is None or isinstance(v, bool):
return False
if not isinstance(v, (int, float)):
return False
x = float(v)
if math.isnan(x) or math.isinf(x):
return False
return 0.0 <= x <= 1.0
def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict:
"""Corrige una lista de p-valores por comparaciones multiples.
Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y
devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y
si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las
posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de
``[0, 1]`` o no numerico) se conservan en la salida como ``None`` /
``False`` y se excluyen del conteo de pruebas ``m``; asi el llamador puede
pasar la lista completa (incluidos pares sin test disponible) y recuperar un
mapeo 1:1.
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
excepcion ante datos vacios o invalidos; en su lugar devuelve un dict con la
clave ``note`` explicando el caso degenerado.
Args:
pvalues: lista de p-valores (floats en [0, 1]). Se admiten ``None`` u
otros valores no validos en posiciones sin test disponible; se
propagan como ``None`` en la salida y no cuentan como prueba.
alpha: nivel de significancia objetivo tras la correccion (default 0.05).
Para BH es el umbral del FDR; para Bonferroni, del FWER.
method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER).
Returns:
dict con las claves:
p_values_adjusted: lista alineada con ``pvalues``. Cada entrada es el
p-valor ajustado (float en [0, 1]) o ``None`` si la posicion no
era un p-valor valido.
reject: lista de booleanos alineada con ``pvalues``. ``True`` si la
hipotesis se rechaza al nivel ``alpha`` tras la correccion
(es significativa); ``False`` en caso contrario o si la posicion
no era valida.
n_tests: numero de p-valores validos usados en la correccion (m).
n_rejected: numero de hipotesis rechazadas (significativas).
alpha: nivel de significancia aplicado (float).
method: metodo aplicado (``"bh"`` o ``"bonferroni"``).
Casos degenerados (lista vacia, sin p-valores validos o metodo
desconocido) anaden ademas una clave ``note`` y devuelven listas
coherentes (``reject`` todo ``False``, ``p_values_adjusted`` con ``None``
en las posiciones invalidas).
"""
method_norm = (method or "").strip().lower()
if method_norm not in {"bh", "bonferroni"}:
n = len(pvalues)
return {
"p_values_adjusted": [None] * n,
"reject": [False] * n,
"n_tests": 0,
"n_rejected": 0,
"alpha": float(alpha),
"method": method,
"note": (
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) "
"o 'bonferroni'"
),
}
n = len(pvalues)
if n == 0:
return {
"p_values_adjusted": [],
"reject": [],
"n_tests": 0,
"n_rejected": 0,
"alpha": float(alpha),
"method": method_norm,
"note": "lista de p-valores vacia",
}
# Posiciones validas: (indice_original, p). Las invalidas se propagan como None.
valid = [(i, float(p)) for i, p in enumerate(pvalues) if _is_valid_p(p)]
m = len(valid)
adjusted: list = [None] * n
reject: list = [False] * n
if m == 0:
return {
"p_values_adjusted": adjusted,
"reject": reject,
"n_tests": 0,
"n_rejected": 0,
"alpha": float(alpha),
"method": method_norm,
"note": "ningun p-valor valido en la entrada",
}
a = float(alpha)
if method_norm == "bonferroni":
# p ajustado = min(1, p * m); rechaza si p_ajustado <= alpha.
for orig_idx, p in valid:
padj = min(1.0, p * m)
adjusted[orig_idx] = padj
reject[orig_idx] = padj <= a
else:
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
# con la monotonicidad acumulada de derecha a izquierda.
order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc
q_sorted = [0.0] * m
prev = 1.0
for rank in range(m, 0, -1):
orig_idx, p = order[rank - 1]
val = p * m / rank
prev = min(prev, val)
q_sorted[rank - 1] = min(prev, 1.0)
for k in range(m):
orig_idx, _p = order[k]
q = q_sorted[k]
adjusted[orig_idx] = q
reject[orig_idx] = q <= a
n_rejected = sum(1 for r in reject if r)
return {
"p_values_adjusted": adjusted,
"reject": reject,
"n_tests": m,
"n_rejected": n_rejected,
"alpha": a,
"method": method_norm,
}
@@ -264,24 +264,129 @@ def render_eda_markdown(profile: dict) -> str:
parts.append("## Calidad")
parts.append(_md_table(["column", "quality_score", "issues"], rows))
# 7. Correlations (tolerate None for now).
# 7. Correlaciones / asociación. `association_matrix` ya corrige los p-valores
# por comparaciones múltiples (FDR Benjamini-Hochberg / Bonferroni); aquí solo
# se renderizan los campos que produjo (value, p_value_adjusted, significant),
# sin recalcular nada. Se prefieren los pares `strong` (magnitud alta Y
# significativos tras la corrección); si no hay, se muestran todos.
correlations = profile.get("correlations")
if correlations:
pairs = correlations
strong = []
all_pairs = []
multiple_testing = None
if isinstance(correlations, dict):
pairs = correlations.get("pairs") or correlations.get("strongest") or []
strong = correlations.get("strong") or correlations.get("strongest") or []
all_pairs = correlations.get("pairs") or []
multiple_testing = correlations.get("multiple_testing")
else:
all_pairs = correlations
shown = strong or all_pairs
corr_rows = []
for pair in pairs or []:
if isinstance(pair, dict):
corr_rows.append([
pair.get("a") or pair.get("col_a"),
pair.get("b") or pair.get("col_b"),
_fmt_num(pair.get("value") if pair.get("value") is not None
else pair.get("corr")),
])
for pair in shown or []:
if not isinstance(pair, dict):
continue
padj = pair.get("p_value_adjusted")
sig = pair.get("significant")
corr_rows.append([
pair.get("a") or pair.get("col_a"),
pair.get("b") or pair.get("col_b"),
pair.get("method", ""),
_fmt_num(pair.get("value") if pair.get("value") is not None
else pair.get("corr")),
_fmt_num(padj) if padj is not None else "",
"" if sig else ("no" if sig is not None else ""),
])
if corr_rows:
parts.append("## Correlaciones")
parts.append(_md_table(["a", "b", "corr"], corr_rows))
if isinstance(multiple_testing, dict):
parts.append(
"Corrección de comparaciones múltiples: "
f"{multiple_testing.get('method')} "
f"(α={multiple_testing.get('alpha')}); "
f"{multiple_testing.get('n_rejected')} de "
f"{multiple_testing.get('n_tests')} pares significativos tras la "
"corrección. Mostrando "
f"{'solo pares fuertes' if strong else 'todos los pares evaluados'}."
)
parts.append(_md_table(
["a", "b", "method", "value", "p_adj (FDR)", "sig"], corr_rows))
# 7b. Re-expresión sugerida (escalera de potencias de Tukey) por columna
# numérica. `suggest_reexpression` decide la transformación que más simetriza;
# aquí solo se rinde su recomendación y razón.
reexp_rows = []
for col in columns:
if not isinstance(col, dict):
continue
rx = col.get("reexpression")
if not isinstance(rx, dict) or rx.get("recommended") is None:
continue
ladder = rx.get("ladder_power")
reexp_rows.append([
col.get("name"),
_fmt_num(rx.get("skew")),
rx.get("recommended"),
_fmt_num(ladder) if ladder is not None else "",
rx.get("reason", ""),
])
if reexp_rows:
parts.append("## Re-expresión sugerida")
parts.append(_md_table(
["column", "skew", "transform", "ladder_power", "reason"], reexp_rows))
# 7c. Series temporales. Bloque por columna numérica cuando el pipeline corrió
# con run_series: estacionariedad (ADF+KPSS), autocorrelación (ACF/PACF +
# Ljung-Box), descomposición STL y, si es una serie de niveles, sugerencia de
# retornos.
series_blocks = []
for col in columns:
if not isinstance(col, dict):
continue
s = col.get("series")
if not isinstance(s, dict):
continue
name = col.get("name") or "(col)"
block = [f"### {name}"]
rows = []
stat = s.get("stationarity") or {}
if stat.get("verdict") is not None:
rows.append(["estacionariedad (ADF+KPSS)", stat.get("verdict")])
acf = s.get("acf_pacf") or {}
if acf.get("is_autocorrelated") is not None:
rows.append([
"autocorrelada (Ljung-Box)",
"" if acf.get("is_autocorrelated") else "no",
])
sig_lags = acf.get("significant_acf_lags")
if sig_lags:
rows.append([
"lags ACF significativos",
", ".join(str(lag) for lag in sig_lags[:12]),
])
stl = s.get("stl") or {}
if stl.get("trend_strength") is not None:
rows.append(["fuerza de tendencia (STL)", _fmt_num(stl.get("trend_strength"))])
if stl.get("seasonal_strength") is not None:
rows.append(["fuerza estacional (STL)", _fmt_num(stl.get("seasonal_strength"))])
if stl.get("period") is not None:
rows.append(["periodo estacional", stl.get("period")])
elif stl.get("note"):
rows.append(["STL", stl.get("note")])
if s.get("levels_suggested"):
rows.append(["sugerencia", "convertir a retornos (serie de niveles)"])
tr = s.get("to_returns") or {}
if tr.get("mean") is not None:
rows.append(["retorno medio (log)", _fmt_num(tr.get("mean"))])
if tr.get("std") is not None:
rows.append(["volatilidad retornos (σ)", _fmt_num(tr.get("std"))])
if rows:
block.append(_md_table(["aspecto", "valor"], rows))
if stat.get("warning"):
block.append(f"> {stat.get('warning')}")
series_blocks.append("\n\n".join(block))
if series_blocks:
parts.append("## Series temporales")
parts.extend(series_blocks)
# 8. LLM analysis (tolerate None for now).
llm = profile.get("llm")
@@ -299,4 +404,24 @@ def render_eda_markdown(profile: dict) -> str:
else:
parts.append(str(llm))
# 9. Avisos exploratorios. `exploratory_caveats` recuerda que el EDA genera
# hipótesis, no conclusiones; se renderiza la lista de advertencias que aplican
# a lo que realmente se calculó.
caveats = profile.get("caveats")
cav_list = []
if isinstance(caveats, dict):
cav_list = caveats.get("caveats") or []
elif isinstance(caveats, list):
cav_list = caveats
cav_lines = []
for cav in cav_list:
if not isinstance(cav, dict):
continue
topic = cav.get("topic") or cav.get("id") or ""
msg = cav.get("message") or ""
cav_lines.append(f"- **{topic}**: {msg}")
if cav_lines:
parts.append("## Avisos exploratorios")
parts.append("\n".join(cav_lines))
return "\n\n".join(parts) + "\n"
@@ -0,0 +1,114 @@
---
name: render_eda_pdf
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict"
description: "Renderiza un TableProfile del grupo eda en un PDF multipágina portátil pensado para LEER Y EXPLORAR EN EL MÓVIL. Páginas A5 retrato, una columna, tipografía grande; diseño Tufte (alto data-ink ratio, histogramas reales como small multiples, barras top-k, heatmap de asociación, integridad de ejes desde 0). Lee todo el profile defensivamente con .get y sólo renderiza las secciones presentes; bloques nuevos del profile (models, caveats, ...) se vuelcan genéricamente (forward-compatible). dict-no-throw: nunca lanza, devuelve {pdf_path, n_pages, note}. Motor matplotlib PdfPages, cero dependencias nuevas."
tags: [eda, pdf, render, report, mobile, tufte, visualization, matplotlib, profiling, datascience, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, textwrap, datetime, matplotlib, numpy]
params:
- name: profile
desc: "TableProfile dict del grupo de capacidad eda (el dict que profile_table devuelve bajo la clave 'profile'). Puede tener muchas claves ausentes o None; un profile None/vacío genera igualmente un PDF de 1 página. Claves consumidas: table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows/_pct, null_cell_pct, quality_score, type_breakdown, constant_cols, all_null_cols, key_candidates, columns[] (con numeric.histogram [{lo,hi,count}], categorical.top [{value,count,pct}], quality_score, flags/issues), correlations.pairs [{a,b,value}], llm. Cualquier otra clave de nivel superior se vuelca en una página forward-compat."
- name: out_path
desc: "ruta del archivo PDF de salida. Los directorios padre se crean si faltan."
- name: title
desc: "título opcional para la portada. Por defecto 'EDA — <table>'."
output: "dict (nunca lanza): {pdf_path: str, n_pages: int, note: str}. En éxito pdf_path es la ruta escrita, n_pages el número de páginas generadas y note un resumen ('N páginas', con detalle de las secciones omitidas si alguna falló). En error fatal de escritura pdf_path es None y note explica la causa."
tested: true
tests: ["test_golden_genera_pdf_multipagina", "test_edge_profile_vacio_no_revienta", "test_edge_profile_none_no_revienta", "test_edge_solo_numericas", "test_forward_compat_seccion_desconocida"]
test_file_path: "python/functions/datascience/render_eda_pdf_test.py"
file_path: "python/functions/datascience/render_eda_pdf.py"
---
## Ejemplo
```python
from datascience import render_eda_pdf
# TableProfile mínimo (en la práctica viene de profile_table(...)["profile"]).
profile = {
"table": "ventas",
"source": "data/ventas.csv",
"n_rows": 1000,
"n_cols": 2,
"null_cell_pct": 0.02,
"quality_score": 92.5,
"type_breakdown": {"numeric": 1, "categorical": 1},
"columns": [
{
"name": "precio",
"inferred_type": "numeric",
"quality_score": 95.0,
"numeric": {
"min": 1.0, "max": 100.0, "median": 40.0, "mean": 42.5,
"std": 12.3, "outlier_pct": 1.2,
"histogram": [
{"lo": 0.0, "hi": 25.0, "count": 100},
{"lo": 25.0, "hi": 50.0, "count": 500},
{"lo": 50.0, "hi": 75.0, "count": 300},
{"lo": 75.0, "hi": 100.0, "count": 50},
],
},
},
{
"name": "categoria",
"inferred_type": "categorical",
"quality_score": 99.0,
"categorical": {
"entropy": 1.05,
"top": [
{"value": "neumaticos", "count": 500, "pct": 0.5},
{"value": "aceite", "count": 300, "pct": 0.3},
{"value": "filtros", "count": 200, "pct": 0.2},
],
},
},
],
}
res = render_eda_pdf(profile, "reports/eda_ventas.pdf", title="EDA — ventas")
print(res) # -> {'pdf_path': 'reports/eda_ventas.pdf', 'n_pages': 5, 'note': '5 páginas'}
```
## Cuando usarla
Cuando quieras una **4ª salida portátil del EDA para revisar en el teléfono**:
después de `profile_table(...)`, pásale el `profile` resultante para emitir un PDF
que el usuario recibe y explora desde el móvil, sin abrir notebooks ni markdown.
Úsala como capa de presentación del grupo `eda` (junto al report markdown, el JSON
sidecar y el notebook Jupyter): histogramas reales en small multiples, barras top-k
de las categóricas, heatmap de correlaciones y una portada con el score de calidad,
todo maquetado para pantalla pequeña con criterios de Tufte (alto data-ink ratio,
ejes honestos desde 0). No recalcula nada del perfil — sólo lo dibuja.
## Gotchas
- **Impura**: escribe un archivo en `out_path` (crea los directorios padre). Usa el
backend headless `Agg` de matplotlib, así que corre en agentes/CI sin display.
- **Nunca lanza** (dict-no-throw): cada sección se construye aislada; si una falla,
se omite y se anota en `note`, pero el PDF se genera igual. Un profile `None`/`{}`
produce un PDF de 1 página válido.
- **Forward-compatible**: sólo conoce un conjunto fijo de claves de nivel superior;
cualquier bloque nuevo del profile (p.ej. `models`, `caveats`, series temporales
que añadan otras funciones del grupo) se vuelca en una página genérica "Otras
secciones" en vez de perderse o romper. No asume claves que quizá no existan.
- **Registro en el package**: el `## Ejemplo` usa `from datascience import render_eda_pdf`,
que requiere que la función esté añadida al `__init__.py` del paquete (lo hace `fn
index` + la integración del orquestador). El test importa el módulo directo
(`from render_eda_pdf import render_eda_pdf`) para no depender de ese registro.
- **Histograma real, no ASCII**: necesita `numeric.histogram` como lista de bins
`{lo, hi, count}` (el formato que emite `describe_numeric`). Si una columna numérica
no trae histograma, esa columna se salta en la página de distribuciones.
- **Heatmap de correlaciones**: reconstruye la matriz simétrica desde
`correlations.pairs` (`{a, b, value}`); anota los valores en celda sólo si hay ≤8
columnas para no saturar la pantalla del móvil.
- **PDF con texto seleccionable** (`pdf.fonttype=42`, TrueType embebido), legible y
buscable en visores móviles.
@@ -0,0 +1,626 @@
"""render_eda_pdf — Portable, mobile-readable PDF report of a TableProfile (eda group).
Impure function (writes a file): takes a TableProfile dict from the `eda`
capability group and renders a MULTI-PAGE PDF designed to be read and explored
on a phone screen. It is the 4th output of the eda workflow, next to the
markdown report, the JSON sidecar and the executed Jupyter notebook.
Design follows Edward Tufte, "The Visual Display of Quantitative Information":
high data-ink ratio (no chartjunk, despined axes, light grids), small multiples
for per-column histograms, and graphical integrity (y-axes start at 0, no
misleading truncation). Pages are A5 portrait, single column, with a large,
legible typeface so the report stays readable on a small display.
Every key of the profile is read defensively with ``.get(...)`` and only the
sections actually present are rendered. The function is forward-compatible: if
the profile carries blocks this renderer does not know about (e.g. ``models``,
time series, ``caveats`` added by sibling functions), they are dumped generically
on a final page instead of being ignored or crashing the render.
dict-no-throw contract of the eda group: it NEVER raises. Any failure of a single
section is caught and noted; the function always returns a dict with the path,
the page count and a human note.
Engine: matplotlib ``PdfPages`` (already in ``python/.venv``) — zero new deps.
"""
import os
import textwrap
from datetime import datetime, timezone
import matplotlib
# Headless backend: this runs in agents/CI without a display.
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
from matplotlib.backends.backend_pdf import PdfPages # noqa: E402
# A5 portrait in inches (148 x 210 mm). Single column, tall, phone-friendly.
_A5_PORTRAIT = (5.83, 8.27)
# Number of per-column small multiples stacked vertically on one page.
_NUMERIC_PER_PAGE = 3
_CATEGORICAL_PER_PAGE = 3
# Top-of-profile keys this renderer handles explicitly. Anything else found at
# the top level of the profile is dumped on the forward-compat "Otros" page so
# new sections added by sibling functions still reach the reader.
_KNOWN_TOP_KEYS = {
"table", "source", "profiled_at", "n_rows", "n_cols", "size_bytes",
"duplicate_rows", "duplicate_pct", "null_cell_pct", "constant_cols",
"all_null_cols", "quality_score", "type_breakdown", "key_candidates",
"columns", "correlations", "llm",
}
# Restrained, high-contrast palette: a single accent reads cleanly on a phone.
_INK = "#1b1b1b"
_ACCENT = "#2a6f97"
_MUTED = "#8a8a8a"
# --------------------------------------------------------------------------- #
# Small formatting + Tufte helpers
# --------------------------------------------------------------------------- #
def _fmt_num(value, decimals: int = 3) -> str:
"""Format a number compactly; fall back to str for non-numerics/None."""
if value is None:
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return f"{value:,}"
if isinstance(value, float):
if value != value: # NaN
return "NaN"
if value in (float("inf"), float("-inf")):
return str(value)
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
return text if text else "0"
return str(value)
def _fmt_pct(value, decimals: int = 1) -> str:
"""Format a fraction (0-1) as 'NN.N%'. Returns '' for None."""
if value is None:
return ""
try:
num = float(value)
except (TypeError, ValueError):
return str(value)
return f"{num * 100:.{decimals}f}%"
def _despine(ax) -> None:
"""Strip top/right spines and soften the rest — raise the data-ink ratio."""
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(_MUTED)
ax.spines[side].set_linewidth(0.6)
ax.tick_params(colors=_MUTED, labelsize=7, length=2)
ax.title.set_color(_INK)
def _truncate(text, width: int = 22) -> str:
"""Clip an arbitrary value to a short label for tight phone layouts."""
s = str(text) if text is not None else ""
return s if len(s) <= width else s[: width - 1] + ""
def _text_page(pdf, title: str, lines: list, subtitle: str = None) -> int:
"""Render one text page (monospace body) and return 1 (pages written)."""
fig = plt.figure(figsize=_A5_PORTRAIT)
fig.text(0.08, 0.94, title, fontsize=16, fontweight="bold", color=_INK)
if subtitle:
fig.text(0.08, 0.905, subtitle, fontsize=9, color=_MUTED)
body = "\n".join(lines)
fig.text(
0.08, 0.88, body, fontsize=9.5, color=_INK, family="monospace",
va="top", ha="left", linespacing=1.5,
)
pdf.savefig(fig)
plt.close(fig)
return 1
def _kv_lines(rows: list, key_width: int = 18) -> list:
"""Format [label, value] rows as aligned 'label : value' monospace lines."""
out = []
for label, value in rows:
out.append(f"{str(label):<{key_width}}: {value}")
return out
# --------------------------------------------------------------------------- #
# Page builders (each fully defensive, each returns the number of pages it made)
# --------------------------------------------------------------------------- #
def _cover_page(pdf, profile: dict, title: str) -> int:
"""Cover: table name, date, shape and an oversized quality score."""
fig = plt.figure(figsize=_A5_PORTRAIT)
table = profile.get("table") or "(tabla sin nombre)"
heading = title or f"EDA — {table}"
fig.text(0.08, 0.82, heading, fontsize=22, fontweight="bold", color=_INK,
wrap=True)
sub = []
src = profile.get("source")
if src:
sub.append(f"fuente: {_truncate(src, 40)}")
when = profile.get("profiled_at") or datetime.now(timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC"
)
sub.append(f"generado: {when}")
fig.text(0.08, 0.76, "\n".join(sub), fontsize=10, color=_MUTED, va="top")
n_rows = profile.get("n_rows")
n_cols = profile.get("n_cols")
shape = (f"{_fmt_num(n_rows)} filas × {_fmt_num(n_cols)} columnas")
fig.text(0.08, 0.60, shape, fontsize=15, color=_ACCENT, fontweight="bold")
score = profile.get("quality_score")
if score is not None:
fig.text(0.08, 0.42, "calidad", fontsize=12, color=_MUTED)
fig.text(0.08, 0.31, _fmt_num(score), fontsize=60, fontweight="bold",
color=_INK)
fig.text(0.08, 0.25, "sobre 100", fontsize=12, color=_MUTED)
fig.text(0.08, 0.06, "Tufte · alta densidad de datos · lectura en móvil",
fontsize=8, color=_MUTED, style="italic")
pdf.savefig(fig)
plt.close(fig)
return 1
def _overview_page(pdf, profile: dict) -> int:
"""Overview key/value page: types, duplicates, nulls, constants, keys."""
rows = []
if profile.get("n_rows") is not None:
rows.append(["Filas", _fmt_num(profile.get("n_rows"))])
if profile.get("n_cols") is not None:
rows.append(["Columnas", _fmt_num(profile.get("n_cols"))])
if profile.get("size_bytes") is not None:
rows.append(["Tamaño (bytes)", _fmt_num(profile.get("size_bytes"))])
if profile.get("duplicate_rows") is not None:
dup = _fmt_num(profile.get("duplicate_rows"))
if profile.get("duplicate_pct") is not None:
dup += f" ({_fmt_pct(profile.get('duplicate_pct'))})"
rows.append(["Filas duplicadas", dup])
if profile.get("null_cell_pct") is not None:
rows.append(["Celdas nulas", _fmt_pct(profile.get("null_cell_pct"))])
if profile.get("quality_score") is not None:
rows.append(["Calidad", _fmt_num(profile.get("quality_score"))])
type_breakdown = profile.get("type_breakdown") or {}
tb = ", ".join(
f"{k}: {v}" for k, v in type_breakdown.items() if v
)
if tb:
rows.append(["Tipos", tb])
constant_cols = profile.get("constant_cols") or []
if constant_cols:
rows.append(["Columnas constantes", _truncate(", ".join(constant_cols), 40)])
all_null_cols = profile.get("all_null_cols") or []
if all_null_cols:
rows.append(["Columnas all-null", _truncate(", ".join(all_null_cols), 40)])
key_candidates = profile.get("key_candidates") or []
if key_candidates:
rows.append(["Candidatos a clave", _truncate(", ".join(key_candidates), 40)])
if not rows:
rows.append(["(sin métricas de overview)", ""])
return _text_page(pdf, "Overview", _kv_lines(rows, key_width=20))
def _numeric_pages(pdf, columns: list) -> int:
"""Small multiples: a real histogram per numeric column, several per page."""
numeric_cols = [
c for c in columns
if isinstance(c, dict) and c.get("numeric") and c["numeric"].get("histogram")
]
if not numeric_cols:
return 0
pages = 0
for start in range(0, len(numeric_cols), _NUMERIC_PER_PAGE):
chunk = numeric_cols[start:start + _NUMERIC_PER_PAGE]
fig, axes = plt.subplots(
len(chunk), 1, figsize=_A5_PORTRAIT, squeeze=False,
)
fig.suptitle("Distribuciones numéricas", fontsize=14, fontweight="bold",
color=_INK, x=0.08, ha="left", y=0.98)
for ax, col in zip(axes[:, 0], chunk):
_draw_histogram(ax, col)
# Hide unused axes if the chunk is short (keeps spacing even).
for ax in axes[len(chunk):, 0]:
ax.axis("off")
fig.tight_layout(rect=[0, 0, 1, 0.95])
pdf.savefig(fig)
plt.close(fig)
pages += 1
return pages
def _draw_histogram(ax, col: dict) -> None:
"""Draw one column's real histogram from its {lo, hi, count} bins."""
num = col.get("numeric") or {}
hist = num.get("histogram") or []
lefts, widths, counts = [], [], []
for b in hist:
if not isinstance(b, dict):
continue
lo = b.get("lo")
hi = b.get("hi")
cnt = b.get("count") or 0
if lo is None or hi is None:
continue
w = hi - lo
if w <= 0:
w = max(abs(lo) * 1e-6, 1e-6)
lefts.append(lo)
widths.append(w)
counts.append(cnt)
name = col.get("name") or "(col)"
if not counts:
ax.axis("off")
ax.text(0.5, 0.5, f"{name}: sin datos numéricos", ha="center",
va="center", fontsize=8, color=_MUTED, transform=ax.transAxes)
return
ax.bar(lefts, counts, width=widths, align="edge", color=_ACCENT,
edgecolor="white", linewidth=0.3)
# Graphical integrity: count axis starts at 0, never truncated.
ax.set_ylim(bottom=0)
_despine(ax)
ax.set_title(_truncate(name, 28), fontsize=10, loc="left", pad=4)
ax.grid(axis="y", color=_MUTED, alpha=0.15, linewidth=0.5)
ax.set_axisbelow(True)
# Median reference line (a single light marker, no chartjunk).
median = num.get("median")
if isinstance(median, (int, float)) and not isinstance(median, bool):
ax.axvline(median, color=_INK, linewidth=0.8, alpha=0.5)
# One compact annotation line: mean / std / outliers.
bits = []
if num.get("mean") is not None:
bits.append(f"μ={_fmt_num(num.get('mean'))}")
if num.get("std") is not None:
bits.append(f"σ={_fmt_num(num.get('std'))}")
if num.get("outlier_pct") is not None:
bits.append(f"outliers={_fmt_num(num.get('outlier_pct'), 1)}%")
if bits:
ax.text(0.99, 0.92, " ".join(bits), transform=ax.transAxes,
ha="right", va="top", fontsize=7, color=_MUTED)
def _categorical_pages(pdf, columns: list) -> int:
"""Top-k horizontal bars per categorical column, several per page."""
cat_cols = [
c for c in columns
if isinstance(c, dict) and c.get("categorical")
and (c["categorical"].get("top"))
]
if not cat_cols:
return 0
pages = 0
for start in range(0, len(cat_cols), _CATEGORICAL_PER_PAGE):
chunk = cat_cols[start:start + _CATEGORICAL_PER_PAGE]
fig, axes = plt.subplots(
len(chunk), 1, figsize=_A5_PORTRAIT, squeeze=False,
)
fig.suptitle("Categóricas (top-k)", fontsize=14, fontweight="bold",
color=_INK, x=0.08, ha="left", y=0.98)
for ax, col in zip(axes[:, 0], chunk):
_draw_topk_bars(ax, col)
for ax in axes[len(chunk):, 0]:
ax.axis("off")
fig.tight_layout(rect=[0, 0, 1, 0.95])
pdf.savefig(fig)
plt.close(fig)
pages += 1
return pages
def _draw_topk_bars(ax, col: dict) -> None:
"""Draw top-k counts for one categorical column as horizontal bars."""
cat = col.get("categorical") or {}
top = cat.get("top") or []
labels, values = [], []
for item in top[:10]:
if not isinstance(item, dict):
continue
labels.append(_truncate(item.get("value"), 20))
values.append(item.get("count") or 0)
name = col.get("name") or "(col)"
if not values:
ax.axis("off")
ax.text(0.5, 0.5, f"{name}: sin categorías", ha="center", va="center",
fontsize=8, color=_MUTED, transform=ax.transAxes)
return
# Largest on top: reverse so barh reads naturally top-to-bottom.
labels = labels[::-1]
values = values[::-1]
y = np.arange(len(values))
ax.barh(y, values, color=_ACCENT, edgecolor="white", linewidth=0.3)
ax.set_yticks(y)
ax.set_yticklabels(labels, fontsize=7)
ax.set_xlim(left=0) # bars start at 0 — honest length encoding.
_despine(ax)
ax.set_title(_truncate(name, 28), fontsize=10, loc="left", pad=4)
ax.grid(axis="x", color=_MUTED, alpha=0.15, linewidth=0.5)
ax.set_axisbelow(True)
if cat.get("entropy") is not None:
ax.text(0.99, 1.02, f"entropía={_fmt_num(cat.get('entropy'))}",
transform=ax.transAxes, ha="right", va="bottom", fontsize=7,
color=_MUTED)
def _quality_page(pdf, columns: list) -> int:
"""Worst-quality columns first, with their issues/flags."""
scored = [
c for c in columns
if isinstance(c, dict) and c.get("quality_score") is not None
]
if not scored:
return 0
scored = sorted(scored, key=lambda c: c.get("quality_score"))
lines = [f"{'columna':<20} {'score':>6} problemas", "-" * 52]
for col in scored:
issues = col.get("issues") or col.get("flags") or []
issues_s = ", ".join(issues) if isinstance(issues, list) else str(issues)
lines.append(
f"{_truncate(col.get('name'), 20):<20} "
f"{_fmt_num(col.get('quality_score'), 1):>6} {_truncate(issues_s, 24)}"
)
return _text_page(pdf, "Calidad", lines,
subtitle="ordenado de peor a mejor calidad")
def _correlations_page(pdf, correlations) -> int:
"""Heatmap of the association matrix reconstructed from the pairs list."""
if not correlations:
return 0
pairs = correlations
if isinstance(correlations, dict):
pairs = correlations.get("pairs") or correlations.get("strong") or []
if not pairs:
return 0
# Build the symmetric label set and a value matrix from the pairs.
labels = []
for p in pairs:
if not isinstance(p, dict):
continue
for key in ("a", "col_a", "b", "col_b"):
v = p.get(key)
if v is not None and v not in labels:
labels.append(v)
if len(labels) < 2:
return 0
idx = {lab: i for i, lab in enumerate(labels)}
n = len(labels)
mat = np.full((n, n), np.nan)
for i in range(n):
mat[i, i] = 1.0
for p in pairs:
if not isinstance(p, dict):
continue
a = p.get("a") or p.get("col_a")
b = p.get("b") or p.get("col_b")
val = p.get("value")
if val is None:
val = p.get("corr")
if a in idx and b in idx and val is not None:
try:
fv = float(val)
except (TypeError, ValueError):
continue
mat[idx[a], idx[b]] = fv
mat[idx[b], idx[a]] = fv
fig, ax = plt.subplots(figsize=_A5_PORTRAIT)
fig.suptitle("Correlaciones / asociación", fontsize=14, fontweight="bold",
color=_INK, x=0.08, ha="left", y=0.97)
im = ax.imshow(mat, cmap="RdBu_r", vmin=-1, vmax=1, aspect="auto")
ax.set_xticks(np.arange(n))
ax.set_yticks(np.arange(n))
ax.set_xticklabels([_truncate(lab, 12) for lab in labels], rotation=60,
ha="right", fontsize=7, color=_INK)
ax.set_yticklabels([_truncate(lab, 14) for lab in labels], fontsize=7,
color=_INK)
ax.tick_params(length=0)
for side in ("top", "right", "left", "bottom"):
ax.spines[side].set_visible(False)
# Annotate cells only when few columns (keeps it legible on a phone).
if n <= 8:
for i in range(n):
for j in range(n):
if not np.isnan(mat[i, j]):
ax.text(j, i, _fmt_num(mat[i, j], 2), ha="center",
va="center", fontsize=6,
color=_INK if abs(mat[i, j]) < 0.6 else "white")
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
cbar.ax.tick_params(labelsize=7)
fig.tight_layout(rect=[0, 0, 1, 0.94])
pdf.savefig(fig)
plt.close(fig)
return 1
def _llm_pages(pdf, llm) -> int:
"""Render the LLM block (data dictionary / summary) as wrapped text pages."""
if not llm:
return 0
lines = []
if isinstance(llm, dict):
for key, value in llm.items():
if value is None:
continue
lines.append(f"## {key}")
lines.extend(_wrap_value(value))
lines.append("")
else:
lines.extend(_wrap_value(llm))
if not lines:
return 0
return _paginate_text(pdf, "Análisis LLM", lines)
def _generic_pages(pdf, profile: dict) -> int:
"""Forward-compat: dump unknown top-level sections so they still reach the reader."""
extras = {
k: v for k, v in profile.items()
if k not in _KNOWN_TOP_KEYS and v is not None
}
if not extras:
return 0
lines = []
for key, value in extras.items():
lines.append(f"## {key}")
lines.extend(_wrap_value(value))
lines.append("")
if not lines:
return 0
return _paginate_text(pdf, "Otras secciones", lines,
subtitle="bloques nuevos del profile (forward-compat)")
def _wrap_value(value, width: int = 78) -> list:
"""Flatten an arbitrary value into wrapped, readable text lines."""
out = []
if isinstance(value, dict):
for k, v in value.items():
out.append(f"- {k}: {_truncate(_scalar(v), 64)}")
elif isinstance(value, (list, tuple)):
for item in value:
if isinstance(item, dict):
out.append("- " + _truncate(
", ".join(f"{k}={_scalar(v)}" for k, v in item.items()), 70))
else:
out.append(f"- {_truncate(_scalar(item), 72)}")
else:
for line in textwrap.wrap(str(value), width=width) or [""]:
out.append(line)
return out
def _scalar(v) -> str:
"""Compact one-line representation of a scalar/nested value."""
if isinstance(v, float):
return _fmt_num(v)
if isinstance(v, (dict, list, tuple)):
return _truncate(str(v), 60)
return str(v)
def _paginate_text(pdf, title: str, lines: list, subtitle: str = None,
per_page: int = 34) -> int:
"""Split a long list of text lines across several text pages."""
pages = 0
for start in range(0, len(lines), per_page):
chunk = lines[start:start + per_page]
page_title = title if pages == 0 else f"{title} (cont.)"
pages += _text_page(pdf, page_title, chunk,
subtitle=subtitle if pages == 0 else None)
return pages
# --------------------------------------------------------------------------- #
# Public entry point
# --------------------------------------------------------------------------- #
def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict:
"""Render a TableProfile dict into a portable, mobile-readable multi-page PDF.
The report is laid out for reading on a phone: A5 portrait pages, single
column, large type, Tufte-style high data-ink charts (real histograms as
small multiples, top-k bars, an association heatmap). Every profile key is
read defensively and only present sections are rendered; unknown top-level
blocks are dumped on a forward-compat page rather than dropped.
Args:
profile: TableProfile dict from the `eda` capability group (the dict
returned by ``profile_table`` under ``profile``). May have many keys
absent or None; a None/empty profile still yields a 1-page PDF.
out_path: filesystem path where the PDF is written. Parent directories
are created if missing.
title: optional report title for the cover. Defaults to
``"EDA — <table>"``.
Returns:
dict (never raises): {"pdf_path": str, "n_pages": int, "note": str}.
On a fatal write error, ``pdf_path`` is None and ``note`` explains why.
"""
if profile is None:
profile = {}
if not isinstance(profile, dict):
return {"pdf_path": None, "n_pages": 0,
"note": f"profile no es dict: {type(profile).__name__}"}
columns = profile.get("columns") or []
if not isinstance(columns, list):
columns = []
notes = []
n_pages = 0
try:
parent = os.path.dirname(os.path.abspath(out_path))
os.makedirs(parent, exist_ok=True)
except OSError as e:
return {"pdf_path": None, "n_pages": 0,
"note": f"no se pudo crear el directorio destino: {e}"}
# Tufte-ish defaults scoped to this render only.
rc = {
"font.size": 10,
"font.family": "sans-serif",
"axes.titlesize": 11,
"axes.edgecolor": _MUTED,
"figure.facecolor": "white",
"savefig.facecolor": "white",
"pdf.fonttype": 42, # embed TrueType so text stays selectable on mobile.
}
# Each section is isolated: a failure in one never aborts the whole PDF.
builders = [
("cover", lambda p: _cover_page(p, profile, title)),
("overview", lambda p: _overview_page(p, profile)),
("numeric", lambda p: _numeric_pages(p, columns)),
("categorical", lambda p: _categorical_pages(p, columns)),
("quality", lambda p: _quality_page(p, columns)),
("correlations", lambda p: _correlations_page(p, profile.get("correlations"))),
("llm", lambda p: _llm_pages(p, profile.get("llm"))),
("generic", lambda p: _generic_pages(p, profile)),
]
try:
with plt.rc_context(rc):
with PdfPages(out_path) as pdf:
for name, build in builders:
try:
n_pages += build(pdf) or 0
except Exception as e: # noqa: BLE001 — one bad section never aborts.
notes.append(f"sección '{name}' omitida: {e}")
# Guarantee at least one page so the PDF is always valid.
if n_pages == 0:
n_pages += _text_page(
pdf, title or "EDA", ["(perfil vacío — sin secciones)"]
)
except Exception as e: # noqa: BLE001
return {"pdf_path": None, "n_pages": 0,
"note": f"fallo al escribir el PDF: {e}"}
note = f"{n_pages} páginas"
if notes:
note += " · " + "; ".join(notes)
return {"pdf_path": out_path, "n_pages": n_pages, "note": note}
@@ -0,0 +1,72 @@
---
name: stl_decompose
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict"
description: "Descomposicion STL (Seasonal-Trend using Loess, statsmodels) de una serie temporal en tendencia, estacional y resto. Si period es None lo infiere por autocorrelacion. Devuelve las 3 componentes (o estadisticos si son largas), mas la fuerza de tendencia y de estacionalidad de Hyndman (1 - Var(resto)/Var(resto+componente)). Descarta None/NaN; serie corta (<2*period) -> nota."
tags: [statistics, timeseries, decomposition, stl, seasonality, trend, eda, forecasting, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, numpy, statsmodels]
params:
- name: values
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes de descomponer."
- name: period
desc: "periodo estacional (observaciones por ciclo, p.ej. 12 para mensual con estacionalidad anual). Si None se infiere por autocorrelacion; si no hay periodo claro devuelve nota."
- name: robust
desc: "si True (default) usa el ajuste robusto de STL, que reduce el efecto de outliers sobre tendencia y estacionalidad."
output: "dict con 'period' usado, 'period_inferred' (bool), 'trend'/'seasonal'/'resid' (cada uno min/max/mean/std + values si la serie es corta, si no None), 'trend_strength' y 'seasonal_strength' (medidas de Hyndman en [0,1]). Serie insuficiente o sin periodo inferible: dict con 'note' y strengths en None. Nunca lanza excepcion."
tested: true
tests: ["test_serie_con_tendencia_y_estacionalidad", "test_fuerza_estacional_alta_con_estacionalidad_fuerte", "test_infiere_periodo_si_none", "test_serie_corta_devuelve_nota", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_serie_larga_resume_sin_values"]
test_file_path: "python/functions/datascience/stl_decompose_test.py"
file_path: "python/functions/datascience/stl_decompose.py"
---
## Ejemplo
```python
from datascience import stl_decompose
import numpy as np
# Serie mensual = tendencia lineal + ciclo estacional anual (periodo 12) + ruido
rng = np.random.default_rng(0)
n = 120
serie = [0.3 * i + 10 * np.sin(2 * np.pi * i / 12) + rng.normal(0, 1) for i in range(n)]
res = stl_decompose(serie, period=12)
res["trend_strength"] # -> ~0.99 (tendencia clara)
res["seasonal_strength"] # -> ~0.98 (estacionalidad clara)
res["seasonal"]["values"][:3] # primeras 3 muestras de la componente estacional
# Sin pasar periodo: lo infiere por autocorrelacion
stl_decompose(serie)["period_inferred"] # -> True
```
## Cuando usarla
Cuando quieres separar una serie temporal en sus partes para entenderla o
prepararla para modelar: cuanta de su variacion es tendencia de fondo, cuanta es
ciclo estacional repetitivo y cuanta es ruido. Util en EDA para decidir si merece
la pena desestacionalizar antes de comparar periodos, para detectar un cambio de
tendencia, o para extraer features (las fuerzas de tendencia/estacionalidad de
Hyndman resumen la serie en dos numeros comparables entre series).
## Gotchas
- Es pura pero importa `statsmodels.tsa.seasonal.STL` y `numpy` (en `python/.venv`).
- STL exige al menos **dos ciclos completos**: con `n < 2*period` devuelve una
nota en vez de descomponer. Para datos mensuales con estacionalidad anual
(period=12) necesitas >= 24 meses.
- La inferencia automatica de `period` busca el pico de autocorrelacion; es
heuristica. Si conoces el periodo real (12 mensual, 7 diario-semanal, 24
horario-diario), pasalo explicito: es mas fiable.
- Las componentes largas (> 200 puntos) se resumen en estadisticos y `values`
queda en `None` para no inflar el payload; las cortas vienen completas.
- Las fuerzas estan en `[0,1]` por construccion (se recortan a 0 si la varianza
del resto supera la de resto+componente, lo que indica componente inexistente).
@@ -0,0 +1,195 @@
"""Descomposicion STL de una serie temporal en tendencia/estacional/resto (grupo eda).
Funcion pura y determinista que aplica STL (Seasonal-Trend decomposition using
Loess, Cleveland et al. 1990) via statsmodels y reporta las tres componentes mas
las medidas de fuerza de tendencia y de estacionalidad de Hyndman ("Forecasting:
Principles and Practice", seccion de feature extraction). Util en EDA para
entender que parte de la variacion de una serie es tendencia, ciclo estacional o
ruido antes de modelar o desestacionalizar.
"""
from __future__ import annotations
import math
import numpy as np
from statsmodels.tsa.seasonal import STL
def _clean(values: list) -> list[float]:
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos."""
out: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
if not isinstance(v, (int, float)):
continue
x = float(v)
if math.isnan(x) or math.isinf(x):
continue
out.append(x)
return out
def _infer_period(arr: np.ndarray, max_period: int) -> int | None:
"""Infiere el periodo estacional dominante via autocorrelacion.
Busca el retardo (entre 2 y ``max_period``) que maximiza la autocorrelacion
de la serie. Devuelve None si no encuentra un pico claro (autocorrelacion
maxima por debajo de un umbral pequeno).
"""
n = len(arr)
if n < 6:
return None
x = arr - arr.mean()
denom = float(np.dot(x, x))
if denom == 0.0:
return None
best_lag = None
best_corr = 0.0
upper = min(max_period, n // 2)
for lag in range(2, upper + 1):
corr = float(np.dot(x[:-lag], x[lag:]) / denom)
if corr > best_corr:
best_corr = corr
best_lag = lag
if best_lag is None or best_corr < 0.2:
return None
return best_lag
def _summarize(component: list[float], max_inline: int = 200) -> dict:
"""Resume una componente: la incluye entera si es corta, si no estadisticos."""
arr = np.asarray(component, dtype=float)
summary = {
"min": float(arr.min()),
"max": float(arr.max()),
"mean": float(arr.mean()),
"std": float(arr.std(ddof=0)),
}
if len(component) <= max_inline:
summary["values"] = [float(v) for v in component]
else:
summary["values"] = None
summary["note"] = f"serie larga ({len(component)} puntos): solo estadisticos"
return summary
def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict:
"""Descompone una serie temporal en tendencia, estacional y resto via STL.
Aplica STL (Seasonal-Trend decomposition using Loess) sobre ``values`` y
devuelve las tres componentes (resumidas si la serie es larga) junto a la
fuerza de tendencia y la fuerza estacional de Hyndman::
F_trend = max(0, 1 - Var(resto) / Var(resto + tendencia))
F_seasonal = max(0, 1 - Var(resto) / Var(resto + estacional))
Ambas en ``[0, 1]``: cercano a 1 indica una componente fuerte y bien
definida; cercano a 0 indica que esa componente apenas existe.
Funcion pura y determinista: no hace I/O, no muta los inputs.
Args:
values: serie temporal de valores numericos en orden cronologico.
None/NaN/infinitos/no-numericos se descartan antes de descomponer.
period: periodo estacional (numero de observaciones por ciclo, p.ej. 12
para datos mensuales con estacionalidad anual). Si es ``None`` se
intenta inferir por autocorrelacion; si no se halla un periodo
claro, se devuelve una nota.
robust: si ``True`` (default) usa el ajuste robusto de STL, que reduce el
efecto de outliers sobre tendencia y estacionalidad.
Returns:
Con menos de ``2 * period`` puntos (o <8 si no hay periodo) devuelve un
dict con ``note`` explicando por que no se pudo descomponer y
``trend_strength``/``seasonal_strength`` en ``None``.
En otro caso un dict con::
{
"n": int,
"period": int, # periodo usado (inferido o dado)
"period_inferred": bool, # True si se infirio automaticamente
"robust": bool,
"trend": {min,max,mean,std, values|note},
"seasonal": {...},
"resid": {...},
"trend_strength": float, # F_trend de Hyndman en [0,1]
"seasonal_strength": float, # F_seasonal de Hyndman en [0,1]
}
"""
clean = _clean(values)
n = len(clean)
if n < 8:
return {
"n": n,
"note": "datos insuficientes",
"trend_strength": None,
"seasonal_strength": None,
}
arr = np.asarray(clean, dtype=float)
inferred = False
if period is None:
period = _infer_period(arr, max_period=max(2, n // 2))
inferred = True
if period is None:
return {
"n": n,
"note": "no se pudo inferir un periodo estacional; pasa period explicito",
"trend_strength": None,
"seasonal_strength": None,
}
period = int(period)
if period < 2:
return {
"n": n,
"note": "period debe ser >= 2",
"trend_strength": None,
"seasonal_strength": None,
}
# STL exige al menos dos ciclos completos.
if n < 2 * period:
return {
"n": n,
"period": period,
"note": f"serie corta: STL necesita >= 2*period ({2 * period}) puntos",
"trend_strength": None,
"seasonal_strength": None,
}
result = STL(arr, period=period, robust=robust).fit()
trend = np.asarray(result.trend, dtype=float)
seasonal = np.asarray(result.seasonal, dtype=float)
resid = np.asarray(result.resid, dtype=float)
# Fuerza de tendencia y estacional (Hyndman). Var con ddof=0.
var_resid = float(np.var(resid, ddof=0))
var_resid_trend = float(np.var(resid + trend, ddof=0))
var_resid_seasonal = float(np.var(resid + seasonal, ddof=0))
trend_strength = (
max(0.0, 1.0 - var_resid / var_resid_trend) if var_resid_trend > 0 else 0.0
)
seasonal_strength = (
max(0.0, 1.0 - var_resid / var_resid_seasonal)
if var_resid_seasonal > 0
else 0.0
)
return {
"n": n,
"period": period,
"period_inferred": bool(inferred),
"robust": bool(robust),
"trend": _summarize(trend.tolist()),
"seasonal": _summarize(seasonal.tolist()),
"resid": _summarize(resid.tolist()),
"trend_strength": float(trend_strength),
"seasonal_strength": float(seasonal_strength),
}
@@ -0,0 +1,73 @@
---
name: suggest_reexpression
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def suggest_reexpression(stats: dict) -> dict"
description: "Sugiere la re-expresion de la escalera de potencias de Tukey (none/log/log1p/sqrt/square/cube/box-cox/yeo-johnson) que mas simetriza una columna numerica, a partir de su skew y su dominio (ceros/negativos). Pura: razona por reglas, NO ejecuta la transformacion. Devuelve recomendacion + razon legible + alternativas ordenadas."
tags: [statistics, eda, reexpression, transform, skew, tukey, ladder-of-powers, box-cox, yeo-johnson, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: stats
desc: "dict con los estadisticos de una columna numerica (sub-bloque `numeric` de un ColumnProfile del grupo eda, o el ColumnProfile completo). Usa `skew` (obligatorio), y `min`/`zero_pct`/`negative_pct` cuando esten para determinar el dominio. Si recibe un ColumnProfile entero, baja a su clave `numeric`."
output: "dict con `recommended` (nombre de la transformacion o None si falta skew), `ladder_power` (exponente conceptual de la escalera de Tukey: 1.0 raw, 0.5 sqrt, 0.0 log, None para data-driven), `reason` (explicacion legible), `alternatives` (lista ordenada de {transform, ladder_power, reason}), `skew` (el usado) y `note` (vacio en caso normal; mensaje si la entrada es incompleta o el dominio es desconocido). Nunca lanza excepcion."
tested: true
tests: ["test_aproximadamente_simetrica_recomienda_none", "test_positiva_fuerte_todo_positivo_recomienda_log", "test_positiva_moderada_todo_positivo_recomienda_sqrt", "test_positiva_con_ceros_fuerte_recomienda_log1p", "test_positiva_con_negativos_recomienda_yeo_johnson", "test_negativa_fuerte_todo_positivo_recomienda_cube", "test_negativa_moderada_todo_positivo_recomienda_square", "test_dominio_desconocido_recomienda_yeo_johnson_con_nota", "test_acepta_columnprofile_completo_con_numeric_anidado", "test_skew_ausente_devuelve_nota", "test_stats_vacio_devuelve_nota", "test_no_dict_no_lanza", "test_skew_no_numerico_devuelve_nota"]
test_file_path: "python/functions/datascience/suggest_reexpression_test.py"
file_path: "python/functions/datascience/suggest_reexpression.py"
---
## Ejemplo
```python
from datascience import suggest_reexpression
# Columna estrictamente positiva con cola derecha larga -> log.
stats = {"skew": 2.3, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0}
out = suggest_reexpression(stats)
out["recommended"] # -> "log"
out["ladder_power"] # -> 0.0 (escalon p=0 de la escalera de Tukey)
out["reason"] # -> "skew = 2.3 (cola derecha..., fuerte) y todos los valores > 0: log comprime..."
[a["transform"] for a in out["alternatives"]] # -> ["box-cox", "sqrt"]
# Con valores negativos, log/Box-Cox no valen -> Yeo-Johnson.
suggest_reexpression({"skew": 1.8, "min": -4.0, "negative_pct": 20.0})["recommended"] # -> "yeo-johnson"
# Funciona directo sobre el sub-bloque `numeric` de describe_numeric:
# col["numeric"] = {"skew": ..., "min": ..., "zero_pct": ..., "negative_pct": ...}
suggest_reexpression(col["numeric"])
```
## Cuando usarla
Cuando un EDA ya detecto que una columna numerica esta sesgada (|skew| alto en el
bloque `numeric` de `describe_numeric` / `detect_distribution_type`) y quieres el
siguiente paso de Tukey: que transformacion la simetriza. Cierra el gap entre
"detecto skew" y "sugiere la re-expresion". Util antes de modelar (muchos modelos
asumen ~normalidad o varianza estable) y para enriquecer un reporte EDA con una
recomendacion accionable por columna. NO la uses si solo quieres el valor del skew
(eso ya lo da `describe_numeric`).
## Gotchas
- Es **pura**: NO ejecuta la transformacion, solo decide cual sugerir. Aplicarla es
trabajo del caller (numpy/scipy/sklearn) si decide seguir la recomendacion.
- Necesita `skew`. Sin el devuelve `recommended=None` + `note` (no lanza).
- El dominio (ceros/negativos) se infiere de `min`, `zero_pct` y `negative_pct`. Si
ninguno esta presente, el dominio es desconocido y sugiere `yeo-johnson` (opcion
segura para cualquier rango) con una nota; pasale al menos `min` para una decision
mas fina (log vs sqrt vs Box-Cox).
- `zero_pct`/`negative_pct` se interpretan como ">0 = hay ceros/negativos"; la escala
(fraccion 0-1 o porcentaje 0-100) es indiferente para la decision.
- Umbrales: |skew|<0.5 -> `none`; 0.5-1.0 -> moderada; >=1.0 -> fuerte. Son la
convencion habitual, no una verdad absoluta — un caller puede recomputar con el
`skew` que se devuelve.
- `log`/`Box-Cox` exigen datos estrictamente positivos; con ceros usa `log1p`; con
negativos o ceros, `Yeo-Johnson`. La funcion ya aplica estas reglas por ti.
@@ -0,0 +1,267 @@
"""Sugiere la re-expresión (escalera de potencias de Tukey) que más simetriza una columna.
Funcion pura y determinista: no hace I/O, no ejecuta la transformación, no muta el
input. Solo razona por reglas sobre un bloque de estadísticos de una columna numérica
(el sub-bloque ``numeric`` de un ColumnProfile del grupo ``eda``: ``describe_numeric``)
y devuelve la transformación de la "escalera de potencias" de Tukey que se espera que
reduzca mejor la asimetría, junto a su razón legible y alternativas ordenadas.
Trasfondo (Tukey, *EDA* 1977, cap. 3-4 "re-expression"): la escalera de potencias
ordena las transformaciones por su exponente ``p``::
... x^3 x^2 x sqrt(x) log(x) -1/sqrt(x) -1/x ...
p=3 p=2 p=1 p=0.5 p=0 p=-0.5 p=-1
Bajar por la escalera (``p`` menor) comprime la cola derecha → corrige asimetría
POSITIVA. Subir por la escalera (``p`` mayor) corrige asimetría NEGATIVA. El log
(``p=0``) es el escalón más usado para colas derechas largas, pero exige datos
estrictamente positivos. Con ceros se usa ``log1p`` (= ``log(1+x)``); con negativos
o ceros, la generalización moderna es ``Yeo-Johnson`` (y ``Box-Cox`` para datos
estrictamente positivos), que estiman el exponente óptimo a partir de los datos.
Esta función NO ejecuta la transformación: decide cuál sugerir. Es el caller quien la
aplica (p.ej. con ``numpy``/``scipy``/``sklearn``) si decide seguir la recomendación.
"""
from __future__ import annotations
# Umbrales sobre |skew| (convención habitual en EDA):
# |skew| < 0.5 -> aproximadamente simétrica, no hace falta re-expresar.
# 0.5 <= |skew| < 1.0 -> asimetría moderada.
# |skew| >= 1.0 -> asimetría fuerte (cola larga).
_SYMMETRIC_THRESHOLD = 0.5
_STRONG_THRESHOLD = 1.0
# Exponente conceptual de la escalera de Tukey por transformación (didáctico).
_LADDER_POWER = {
"cube": 3.0,
"square": 2.0,
"none": 1.0,
"sqrt": 0.5,
"log": 0.0,
"log1p": 0.0,
"reciprocal": -1.0,
"box-cox": None, # data-driven (lambda estimado)
"yeo-johnson": None, # data-driven (lambda estimado)
}
def _to_float(v):
"""Parsea a float; None si es None/bool/no parseable (NaN incluido)."""
if v is None or isinstance(v, bool):
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
if f != f: # NaN
return None
return f
def _alt(name: str, reason: str) -> dict:
"""Construye una entrada de alternativa con su exponente de la escalera."""
return {"transform": name, "ladder_power": _LADDER_POWER.get(name), "reason": reason}
def suggest_reexpression(stats: dict) -> dict:
"""Sugiere la transformación de la escalera de potencias de Tukey que más simetriza.
Razona por reglas (no ejecuta la transformación) a partir de un bloque de
estadísticos de una columna numérica. Acepta tanto el sub-bloque ``numeric`` de
un ColumnProfile (claves ``skew``, ``min``, ``kurtosis``, ``zero_pct``,
``negative_pct``...) como el ColumnProfile completo (en cuyo caso usa su clave
``numeric``). La decisión combina la magnitud y el signo de ``skew`` con el
dominio de los datos (si hay ceros y/o negativos), porque ``log``/``Box-Cox``
solo admiten valores estrictamente positivos.
Reglas:
- ``|skew| < 0.5`` -> ``none`` (ya es ~simétrica).
- ``skew`` positivo (cola derecha):
- hay negativos -> ``yeo-johnson``.
- hay ceros (sin negativos) -> ``log1p`` (fuerte) / ``sqrt`` (moderado).
- estrictamente positivos -> ``log`` (fuerte) / ``sqrt`` (moderado).
- ``skew`` negativo (cola izquierda):
- hay negativos o ceros -> ``yeo-johnson``.
- estrictamente positivos -> ``cube`` (fuerte) / ``square`` (moderado).
- dominio desconocido (sin ``min``/``zero_pct``/``negative_pct``) y
``skew`` apreciable -> ``yeo-johnson`` (opción segura que admite cualquier
dominio) más una nota.
Es pura, determinista y no lanza excepciones: entradas vacías o sin ``skew``
devuelven ``recommended = None`` y una ``note`` explicativa.
Args:
stats: dict con los estadísticos de la columna. Espera al menos ``skew``.
Usa además ``min``, ``zero_pct`` y ``negative_pct`` (cuando estén) para
determinar el dominio. Si recibe un ColumnProfile completo, lee su
sub-bloque ``numeric``.
Returns:
dict con:
- ``recommended``: nombre de la transformación sugerida (``"none"``,
``"log"``, ``"log1p"``, ``"sqrt"``, ``"square"``, ``"cube"``,
``"reciprocal"``, ``"box-cox"``, ``"yeo-johnson"``) o ``None`` si no
se puede decidir (falta ``skew``).
- ``ladder_power``: exponente conceptual de la escalera de Tukey de la
transformación recomendada (``1.0`` raw, ``0.5`` sqrt, ``0.0`` log,
``None`` para las data-driven), o ``None`` si no hay recomendación.
- ``reason``: explicación legible de por qué se sugiere.
- ``alternatives``: lista ordenada de otras transformaciones razonables,
cada una ``{"transform", "ladder_power", "reason"}``.
- ``skew``: el skew usado en la decisión (float) o ``None``.
- ``note``: cadena vacía en el caso normal; mensaje cuando la entrada es
incompleta (sin ``skew``, dominio desconocido, etc.).
"""
if not isinstance(stats, dict) or not stats:
return {
"recommended": None,
"ladder_power": None,
"reason": "",
"alternatives": [],
"skew": None,
"note": "stats vacío o no es un dict: nada que sugerir",
}
# Aceptar un ColumnProfile completo: bajar a su sub-bloque numeric.
if "skew" not in stats and isinstance(stats.get("numeric"), dict):
stats = stats["numeric"]
skew = _to_float(stats.get("skew"))
if skew is None:
return {
"recommended": None,
"ladder_power": None,
"reason": "",
"alternatives": [],
"skew": None,
"note": "skew ausente o no numérico: no se puede sugerir re-expresión",
}
minimum = _to_float(stats.get("min"))
zero_pct = _to_float(stats.get("zero_pct"))
negative_pct = _to_float(stats.get("negative_pct"))
# Determinar el dominio de los datos a partir de lo disponible.
domain_known = (
minimum is not None or zero_pct is not None or negative_pct is not None
)
has_negative = (negative_pct is not None and negative_pct > 0) or (
minimum is not None and minimum < 0
)
has_zero = (zero_pct is not None and zero_pct > 0) or (
minimum is not None and minimum == 0
)
strictly_positive = domain_known and not has_negative and not has_zero
abs_skew = abs(skew)
strong = abs_skew >= _STRONG_THRESHOLD
magnitude = "fuerte" if strong else "moderada"
side = "cola derecha (asimetría positiva)" if skew > 0 else "cola izquierda (asimetría negativa)"
note = ""
# 1. Aproximadamente simétrica -> no re-expresar.
if abs_skew < _SYMMETRIC_THRESHOLD:
return {
"recommended": "none",
"ladder_power": _LADDER_POWER["none"],
"reason": (
f"skew = {skew:.3g} (|skew| < {_SYMMETRIC_THRESHOLD}): la columna ya es "
"aproximadamente simétrica, no necesita re-expresión"
),
"alternatives": [],
"skew": skew,
"note": "",
}
alternatives: list = []
# 2. Asimetría positiva (cola derecha): bajar por la escalera de Tukey.
if skew > 0:
if has_negative:
recommended = "yeo-johnson"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) y hay valores negativos: "
"Yeo-Johnson estima el exponente óptimo y admite negativos y ceros "
"(log/Box-Cox no)"
)
elif has_zero:
recommended = "log1p" if strong else "sqrt"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) con ceros presentes: "
+ ("log1p = log(1+x) comprime la cola sin romper en x=0"
if strong else
"sqrt simetriza una cola moderada y admite el cero")
)
alternatives.append(_alt(
"yeo-johnson",
"estima el exponente óptimo y admite ceros; alternativa data-driven",
))
alternatives.append(_alt(
"sqrt" if strong else "log1p",
"otro escalón cercano de la escalera para ceros",
))
elif strictly_positive:
recommended = "log" if strong else "sqrt"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: "
+ ("log comprime con fuerza la cola derecha larga (escalón p=0)"
if strong else
"sqrt corrige una cola derecha moderada (escalón p=0.5)")
)
alternatives.append(_alt(
"box-cox",
"estima el exponente óptimo sobre datos estrictamente positivos",
))
alternatives.append(_alt(
"sqrt" if strong else "log",
"escalón vecino de la escalera de Tukey",
))
else:
recommended = "yeo-johnson"
note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: "
"Yeo-Johnson funciona con cualquier rango (positivos, ceros, negativos)"
)
# 3. Asimetría negativa (cola izquierda): subir por la escalera de Tukey.
else:
if has_negative or has_zero:
recommended = "yeo-johnson"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) con ceros/negativos: "
"Yeo-Johnson sube por la escalera y admite cualquier dominio"
)
elif strictly_positive:
recommended = "cube" if strong else "square"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: "
+ ("x^3 alarga la cola izquierda corta (escalón p=3)"
if strong else
"x^2 corrige una cola izquierda moderada (escalón p=2)")
)
alternatives.append(_alt(
"box-cox",
"estima un exponente > 1 óptimo sobre datos positivos",
))
alternatives.append(_alt(
"square" if strong else "cube",
"escalón vecino hacia arriba de la escalera de Tukey",
))
else:
recommended = "yeo-johnson"
note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura"
reason = (
f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: "
"Yeo-Johnson funciona con cualquier rango"
)
return {
"recommended": recommended,
"ladder_power": _LADDER_POWER.get(recommended),
"reason": reason,
"alternatives": alternatives,
"skew": skew,
"note": note,
}
@@ -0,0 +1,70 @@
---
name: to_returns
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def to_returns(values: list, method: str = 'log') -> dict"
description: "Convierte una serie de niveles (precios) a retornos: 'log' (ln(p_t/p_{t-1})) o 'simple' (p_t/p_{t-1}-1). Para correlacionar/modelar series financieras sobre retornos (aprox.) estacionarios en vez de niveles no estacionarios, evitando la regresion espuria (Granger-Newbold, Lopez de Prado). Devuelve la serie de retornos mas stats basicas. Maneja ceros/negativos en log marcando el paso invalido. Descarta None/NaN; <2 puntos validos -> nota."
tags: [timeseries, returns, finance, stationarity, log-returns, eda, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math]
params:
- name: values
desc: "serie de niveles (precios) en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes de calcular."
- name: method
desc: "'log' (default) para retornos logaritmicos ln(p_t/p_{t-1}), o 'simple' para retornos aritmeticos p_t/p_{t-1}-1."
output: "dict con 'returns' (lista, un retorno por par consecutivo; None si el paso es invalido), 'method', 'n_levels', 'n_returns', 'n_skipped', y stats 'mean'/'std'/'min'/'max' de los retornos validos (None si todos invalidos). method invalido o <2 puntos: dict con 'note' y 'returns': []. Nunca lanza excepcion."
tested: true
tests: ["test_log_returns_valores_conocidos", "test_simple_returns_valores_conocidos", "test_log_marca_no_positivo_como_invalido", "test_simple_admite_negativos", "test_method_invalido_devuelve_nota", "test_un_solo_punto_devuelve_nota", "test_descarta_none_y_nan", "test_stats_de_retornos"]
test_file_path: "python/functions/datascience/to_returns_test.py"
file_path: "python/functions/datascience/to_returns.py"
---
## Ejemplo
```python
from datascience import to_returns
# Retornos logaritmicos de una serie de precios
precios = [100.0, 105.0, 103.0, 108.0]
res = to_returns(precios, method="log")
res["returns"] # -> [0.0488, -0.0192, 0.0474] (ln(105/100), ln(103/105), ...)
res["n_returns"] # -> 3
# Retornos simples (porcentuales)
to_returns(precios, method="simple")["returns"] # -> [0.05, -0.0190, 0.0485]
# Un precio <= 0 invalida ese paso en log (no peta)
to_returns([100.0, 0.0, 50.0], method="log")["n_skipped"] # -> 2
```
## Cuando usarla
Antes de correlacionar, medir volatilidad o modelar una serie financiera de
precios. Los precios son no estacionarios (tienen raiz unitaria): correlacionar
dos series de precios da correlaciones altas pero espurias. Los retornos son
(aproximadamente) estacionarios, asi que son la unidad correcta. Encadena con
`adf_kpss_stationarity` para confirmar que los retornos ya son estacionarios, y
luego con `spearman_corr`/`pearson` o un modelo. Usa `log` para modelar (aditivo
en el tiempo) y `simple` cuando necesites interpretar el retorno como porcentaje.
## Gotchas
- Es pura (solo `math`, sin dependencias externas).
- `method="log"` exige precios estrictamente positivos: un valor <= 0 invalida
ese paso (queda `None` en `returns` y suma a `n_skipped`) en lugar de lanzar
`ValueError`. Revisa `n_skipped` si tu serie puede tener ceros/negativos.
- La serie de retornos tiene **un elemento menos** que la de niveles (no hay
retorno para el primer punto).
- Los huecos (None/NaN) se eliminan ANTES de emparejar, asi que el retorno se
calcula entre puntos validos consecutivos en el tiempo-indice original, no
rellenando el hueco. Si necesitas tratar huecos como saltos reales, limpia tu
la serie antes.
- `simple` solo invalida el paso cuando el precio previo es exactamente 0
(division por cero); admite precios y retornos negativos.
+127
View File
@@ -0,0 +1,127 @@
"""Convierte una serie de niveles (precios) a retornos (grupo eda).
Funcion pura y determinista que transforma una serie de niveles en una serie de
retornos, simples o logaritmicos. Motivada por Lopez de Prado ("Advances in
Financial ML") y Hamilton ("Time Series Analysis"): las series de precios son no
estacionarias (raiz unitaria), de modo que correlacionarlas o modelarlas sobre
sus niveles produce regresion espuria (Granger-Newbold). Los retornos son
(aproximadamente) estacionarios y son la unidad correcta para correlacionar,
medir volatilidad o ajustar modelos.
"""
from __future__ import annotations
import math
def _clean(values: list) -> list[float]:
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
A diferencia de otras funciones del grupo, aqui el ORDEN importa (es una
serie temporal), pero un hueco intermedio rompe el calculo de retorno
consecutivo; por eso se descartan los no-validos y el retorno se calcula
sobre los puntos validos restantes en su orden original.
"""
out: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
if not isinstance(v, (int, float)):
continue
x = float(v)
if math.isnan(x) or math.isinf(x):
continue
out.append(x)
return out
def to_returns(values: list, method: str = "log") -> dict:
"""Convierte una serie de niveles (precios) a retornos.
Calcula el retorno entre observaciones consecutivas de la serie limpia:
- ``method="log"``: ``r_t = ln(p_t / p_{t-1})`` (retorno logaritmico).
Aditivo en el tiempo y simetrico; es el preferido para modelar. Requiere
precios estrictamente positivos: si aparece un valor <= 0 ese paso se
marca como invalido (``None`` en la serie) y se cuenta en ``n_skipped``.
- ``method="simple"``: ``r_t = p_t / p_{t-1} - 1`` (retorno aritmetico).
Admite valores negativos; solo se invalida el paso si ``p_{t-1} == 0``
(division por cero).
Funcion pura y determinista: no hace I/O, no muta los inputs.
Args:
values: serie de niveles (precios) en orden cronologico. None/NaN/
infinitos/no-numericos se descartan antes de calcular.
method: ``"log"`` (default) para retornos logaritmicos o ``"simple"``
para retornos aritmeticos.
Returns:
Con menos de 2 puntos validos (no hay ningun par consecutivo) devuelve
``{"n": n, "note": "datos insuficientes", "returns": []}``.
Si ``method`` no es ``"log"`` ni ``"simple"`` devuelve
``{"note": "method debe ser 'log' o 'simple'", "returns": []}``.
En otro caso un dict con::
{
"method": str,
"n_levels": int, # niveles validos de entrada
"returns": [float|None],# un retorno por par consecutivo (None si invalido)
"n_returns": int, # retornos validos (no None)
"n_skipped": int, # pasos invalidados (log de no-positivo, div/0)
"mean": float, # media de los retornos validos
"std": float, # desviacion tipica (ddof=0) de los validos
"min": float,
"max": float,
}
Si todos los pasos resultan invalidos, ``mean/std/min/max`` son ``None``.
"""
if method not in ("log", "simple"):
return {"note": "method debe ser 'log' o 'simple'", "returns": []}
clean = _clean(values)
n = len(clean)
if n < 2:
return {"n": n, "note": "datos insuficientes", "returns": []}
returns: list[float | None] = []
n_skipped = 0
for prev, cur in zip(clean[:-1], clean[1:]):
if method == "log":
if prev <= 0.0 or cur <= 0.0:
returns.append(None)
n_skipped += 1
continue
returns.append(math.log(cur / prev))
else: # simple
if prev == 0.0:
returns.append(None)
n_skipped += 1
continue
returns.append(cur / prev - 1.0)
valid = [r for r in returns if r is not None]
if valid:
mean = sum(valid) / len(valid)
var = sum((r - mean) ** 2 for r in valid) / len(valid)
std = math.sqrt(var)
vmin = min(valid)
vmax = max(valid)
else:
mean = std = vmin = vmax = None
return {
"method": method,
"n_levels": n,
"returns": returns,
"n_returns": len(valid),
"n_skipped": n_skipped,
"mean": mean if mean is None else float(mean),
"std": std if std is None else float(std),
"min": vmin if vmin is None else float(vmin),
"max": vmax if vmax is None else float(vmax),
}
+29 -7
View File
@@ -5,17 +5,29 @@ lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def profile_table(db_path: str, table: str, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True) -> dict"
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla DuckDB end-to-end componiendo las 7 funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + render markdown) y emite el TableProfile completo mas (opcional) un report markdown y un JSON sidecar. Es la composicion canonica para hazme un EDA de esta tabla."
tags: [eda, duckdb, profiling, data-quality, pipeline, dataops]
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla (DuckDB o PostgreSQL) end-to-end componiendo las funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + correlaciones con correccion FDR + re-expresion de Tukey + avisos exploratorios) y, opcional, modelos baratos (run_models), interpretacion LLM (run_llm) y analisis de serie temporal por columna (run_series: estacionariedad ADF+KPSS, ACF/PACF, STL, retornos). Emite el TableProfile completo mas (opcional) report markdown + JSON sidecar + PDF movil (emit_pdf). Es la composicion canonica para hazme un EDA de esta tabla."
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
uses_functions:
- summarize_table_duckdb_py_datascience
- summarize_table_pg_py_datascience
- describe_numeric_py_datascience
- summarize_categorical_py_datascience
- infer_semantic_type_py_datascience
- column_quality_score_py_datascience
- association_matrix_py_datascience
- run_eda_models_py_datascience
- eda_llm_insights_py_datascience
- adf_kpss_stationarity_py_datascience
- acf_pacf_py_datascience
- stl_decompose_py_datascience
- to_returns_py_datascience
- suggest_reexpression_py_datascience
- exploratory_caveats_py_datascience
- render_eda_markdown_py_datascience
- render_eda_pdf_py_datascience
- duckdb_query_readonly_py_infra
- pg_query_py_infra
uses_types: []
returns: []
returns_optional: false
@@ -28,16 +40,26 @@ test_file_path: "python/functions/pipelines/profile_table_test.py"
file_path: "python/functions/pipelines/profile_table.py"
params:
- name: db_path
desc: "Ruta al archivo DuckDB (read-only, debe existir; no se crea)."
desc: "Ruta al archivo DuckDB (read-only, debe existir; no se crea) o DSN PostgreSQL si backend='postgres'."
- name: table
desc: "Nombre de la tabla a perfilar."
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado base (summarize) y de muestreo read-only."
- name: sample
desc: "Maximo de valores no nulos muestreados por columna para el enriquecimiento (describe_numeric / summarize_categorical / infer_semantic_type). Default 5000."
- name: run_models
desc: "Si True (default False) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad) y guarda el bloque en prof['models']."
- name: run_llm
desc: "Si True (default False) hace 1 llamada LLM sobre el perfil agregado y guarda el resultado en prof['llm']."
- name: run_series
desc: "Si True (default False) calcula por columna numerica un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF, STL y, si parece de niveles, retornos). Ordena por la primera columna datetime si existe; si no, por el orden fisico. Guardado en col['series'] y agregado en prof['series']."
- name: emit_pdf
desc: "Si True (default False) renderiza un PDF multipagina vertical (legible en movil) del perfil junto al report markdown y devuelve su ruta en pdf_path."
- name: report_dir
desc: "Directorio donde escribir los reports si write_report. Default 'reports'. Se crea si no existe."
desc: "Directorio donde escribir los reports si write_report (y el PDF si emit_pdf). Default 'reports'. Se crea si no existe."
- name: write_report
desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths del retorno son None."
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates y type_breakdown recalculado>, report_md_path:str|None, report_json_path:str|None} o {status:'error', error:str} (dict-no-throw)."
desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths markdown/json del retorno son None (emit_pdf es independiente)."
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates, type_breakdown recalculado, correlaciones con FDR, reexpression por columna numerica, caveats, y (con run_series) series>, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None} o {status:'error', error:str} (dict-no-throw)."
---
## Ejemplo
+159 -4
View File
@@ -29,16 +29,23 @@ import os
from datetime import datetime, timezone
from datascience import (
acf_pacf,
adf_kpss_stationarity,
association_matrix,
column_quality_score,
describe_numeric,
eda_llm_insights,
exploratory_caveats,
infer_semantic_type,
render_eda_markdown,
render_eda_pdf,
run_eda_models,
stl_decompose,
suggest_reexpression,
summarize_categorical,
summarize_table_duckdb,
summarize_table_pg,
to_returns,
)
from infra import duckdb_query_readonly, pg_query
@@ -115,6 +122,83 @@ def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
return q.get("rows", [])
def _sample_series(query_fn, table: str, value_col: str, order_col, sample: int) -> list:
"""Trae hasta `sample` valores no nulos de una columna en orden de serie temporal.
A diferencia de _sample_values, cuando hay una columna de orden temporal
(`order_col`, normalmente la primera columna datetime de la tabla) se ordena
ascendentemente por ella para que la secuencia recuperada respete el orden
cronologico, requisito de los contrastes de serie temporal (ADF/KPSS, ACF/PACF,
STL). Si `order_col` es None se cae al orden fisico de inserciones (columna
numerica secuencial). query_fn es el lector read-only del backend activo.
"""
base = (
f'SELECT "{value_col}" AS v FROM "{table}" '
f'WHERE "{value_col}" IS NOT NULL'
)
if order_col:
base += f' ORDER BY "{order_col}"'
base += f" LIMIT {int(sample)}"
q = query_fn(base)
if q.get("status") != "ok":
return []
return [row.get("v") for row in q.get("rows", [])]
def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int) -> dict:
"""Construye el bloque `series` de una columna numerica (estilo dict-no-throw).
Compone los contrastes de serie temporal del grupo `eda` sobre la secuencia
ordenada de la columna: estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF +
Ljung-Box) y descomposicion STL (tendencia/estacional/resto). Cuando la columna
parece de NIVELES (precios: estrictamente positiva y no claramente estacionaria)
anade ademas la conversion a retornos (`to_returns`) como sugerencia, ya que
correlacionar/modelar niveles no estacionarios produce relaciones espurias
(Granger-Newbold).
Devuelve None si no hay suficientes puntos validos (<8) para ningun contraste.
"""
name = col.get("name")
raw = _sample_series(query_fn, table, name, order_col, sample)
series_vals = [f for f in (_to_float(v) for v in raw) if f is not None]
if len(series_vals) < 8:
return None
block: dict = {
"order_col": order_col,
"ordered": bool(order_col),
"n": len(series_vals),
"stationarity": adf_kpss_stationarity(series_vals),
"acf_pacf": acf_pacf(series_vals),
# stl_decompose auto-infiere el periodo; si no hay estacionalidad detectable
# devuelve una nota y strengths None (se incluye igual, es informativo).
"stl": stl_decompose(series_vals),
}
# Sugerencia de retornos solo si la columna parece de niveles: estrictamente
# positiva y con veredicto de estacionariedad NO confirmado.
nb = col.get("numeric") or {}
minimum = nb.get("min")
verdict = (block["stationarity"] or {}).get("verdict")
if (
isinstance(minimum, (int, float))
and not isinstance(minimum, bool)
and minimum > 0
and verdict in ("non_stationary", "inconclusive")
):
block["to_returns"] = to_returns(series_vals, method="log")
block["levels_suggested"] = True
block["levels_reason"] = (
"columna estrictamente positiva y no claramente estacionaria: parece una "
"serie de niveles (precios); trabajar sobre retornos evita correlacion "
"espuria (Granger-Newbold)."
)
else:
block["levels_suggested"] = False
return block
def profile_table(
db_path: str,
table: str,
@@ -122,6 +206,8 @@ def profile_table(
sample: int = 5000,
run_models: bool = False,
run_llm: bool = False,
run_series: bool = False,
emit_pdf: bool = False,
report_dir: str = "reports",
write_report: bool = True,
) -> dict:
@@ -135,6 +221,20 @@ def profile_table(
sample: maximo de valores no nulos muestreados por columna para el
enriquecimiento (describe_numeric / summarize_categorical /
infer_semantic_type). Default 5000.
run_models: si True (default False) corre los modelos baratos
(PCA/KMeans/IsolationForest/normalidad) sobre las numericas y guarda
el bloque en prof["models"].
run_llm: si True (default False) hace 1 llamada LLM sobre el perfil
agregado y guarda el resultado en prof["llm"].
run_series: si True (default False) calcula, para cada columna numerica,
un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF,
descomposicion STL y, si parece de niveles, conversion a retornos).
Si hay una columna datetime se usa como orden cronologico; si no, se
usa el orden fisico de filas (columna numerica secuencial). Los bloques
se guardan por columna en col["series"] y agregados en prof["series"].
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
(legible en movil) del perfil junto al report markdown y devuelve su
ruta en pdf_path.
report_dir: directorio donde escribir los reports si write_report.
Default "reports". Se crea si no existe.
write_report: si True (default), escribe un report markdown + un JSON
@@ -143,8 +243,8 @@ def profile_table(
Returns:
dict. En exito: {status:'ok', profile: <TableProfile>,
report_md_path: str|None, report_json_path: str|None}. En error (sin
lanzar): {status:'error', error:str}.
report_md_path: str|None, report_json_path: str|None, pdf_path: str|None}.
En error (sin lanzar): {status:'error', error:str}.
"""
try:
# 1) Perfil base por columna (push-down SQL) + lector read-only del
@@ -195,6 +295,9 @@ def profile_table(
if inferred == "numeric":
vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
col["numeric"] = describe_numeric(vals_float)
# Re-expresion sugerida (escalera de Tukey): que transformacion
# simetriza mejor la columna a partir de su skew/dominio.
col["reexpression"] = suggest_reexpression(col["numeric"])
elif inferred in ("categorical", "text"):
col["categorical"] = summarize_categorical(vals)
# Para columnas no promovidas que ya eran categorical/text y no
@@ -299,12 +402,53 @@ def profile_table(
except Exception: # noqa: BLE001
prof["llm"] = None
# 9) Reports opcionales.
# 8.7) Analisis de serie temporal opt-in. Para cada columna numerica se
# calcula estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF) y
# descomposicion STL sobre la secuencia ordenada; si parece de niveles se
# anade la conversion a retornos. Si hay una columna datetime se usa como
# orden cronologico; si no, el orden fisico (columna numerica secuencial).
if run_series:
try:
order_col = next(
(
c.get("name")
for c in cols
if c.get("inferred_type") == "datetime"
),
None,
)
series_map: dict = {}
for col in cols:
if col.get("inferred_type") != "numeric":
continue
try:
sblock = _build_series_block(
_q, table, col, order_col, sample
)
except Exception: # noqa: BLE001
sblock = None
if sblock is not None:
col["series"] = sblock
series_map[col["name"]] = sblock
prof["series"] = series_map or None
except Exception: # noqa: BLE001
prof["series"] = None
# 8.8) Avisos exploratorios: recuerdan que el EDA genera hipotesis, no
# conclusiones. Se calculan sobre el perfil ya completo (correlaciones,
# modelos, outliers, faltantes determinan que advertencias aplican).
try:
prof["caveats"] = exploratory_caveats(prof)
except Exception: # noqa: BLE001
prof["caveats"] = None
# 9) Reports opcionales (markdown + JSON sidecar + PDF movil).
report_md_path = None
report_json_path = None
pdf_path = None
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if write_report:
os.makedirs(report_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
report_json_path = os.path.join(report_dir, f"eda_{table}_{ts}.json")
report_md_path = os.path.join(report_dir, f"eda_{table}_{ts}.md")
with open(report_json_path, "w", encoding="utf-8") as fh:
@@ -312,11 +456,22 @@ def profile_table(
with open(report_md_path, "w", encoding="utf-8") as fh:
fh.write(render_eda_markdown(prof))
# PDF multipagina vertical (legible en movil), junto al report markdown.
if emit_pdf:
try:
os.makedirs(report_dir, exist_ok=True)
pdf_target = os.path.join(report_dir, f"eda_{table}_{ts}.pdf")
pres = render_eda_pdf(prof, pdf_target)
pdf_path = pres.get("pdf_path")
except Exception: # noqa: BLE001
pdf_path = None
return {
"status": "ok",
"profile": prof,
"report_md_path": report_md_path,
"report_json_path": report_json_path,
"pdf_path": pdf_path,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
+1
View File
@@ -37,6 +37,7 @@ dependencies = [
"scipy>=1.17.1",
"seaborn>=0.13.2",
"shapely>=2.1.2",
"statsmodels>=0.14.6",
"trimesh>=4.12.2",
"xlrd>=2.0.2",
]
+47
View File
@@ -918,6 +918,7 @@ dependencies = [
{ name = "scipy" },
{ name = "seaborn" },
{ name = "shapely" },
{ name = "statsmodels" },
{ name = "trimesh" },
{ name = "xlrd" },
]
@@ -977,6 +978,7 @@ requires-dist = [
{ name = "scipy", specifier = ">=1.17.1" },
{ name = "seaborn", specifier = ">=0.13.2" },
{ name = "shapely", specifier = ">=2.1.2" },
{ name = "statsmodels", specifier = ">=0.14.6" },
{ name = "trimesh", specifier = ">=4.12.2" },
{ name = "xlrd", specifier = ">=2.0.2" },
]
@@ -3099,6 +3101,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
]
[[package]]
name = "patsy"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" },
]
[[package]]
name = "pexpect"
version = "4.9.0"
@@ -4863,6 +4877,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
]
[[package]]
name = "statsmodels"
version = "0.14.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "packaging" },
{ name = "pandas" },
{ name = "patsy" },
{ name = "scipy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" },
{ url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" },
{ url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" },
{ url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" },
{ url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" },
{ url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" },
{ url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" },
{ url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" },
{ url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" },
{ url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" },
{ url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" },
{ url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" },
{ url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" },
{ url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" },
{ url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"