7ac69ab4fb
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>
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""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,
|
|
}
|