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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 "",
|
||||
"sí" 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)",
|
||||
"sí" 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.
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Generated
+47
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user