"""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, }