Files
fn_registry/python/functions/datascience/summarize_outlier_dims.py
T
egutierrez 6f88f184f1 feat(eda): capítulo OUTLIERS — valores atípicos univariantes + multivariantes
Nuevo capítulo dedicado `outliers` para el motor AutomaticEDA que reúne y
profundiza en un solo sitio el análisis de valores atípicos, hoy disperso entre
`num_distr` (conteo por columna) y `modelos` (IsolationForest). Se registra en
`chapters_registry.py` entre `missingness` y `correlacion` (bloque de calidad de
datos: calidad → missingness → outliers).

Contenido del capítulo:
- Resumen univariante por columna: nº y % de atípicos por Tukey (1.5·IQR) y por
  z-score (|z| > 3), con vallas inferior/superior y valores extremos. Ordenado
  por contaminación y marcando las columnas más afectadas. Reusa las funciones
  del registry `build_boxplot_stats` (vallas desde los percentiles del profile)
  y `detect_outliers` (regla z-score sobre la muestra cruda de `ctx`).
- Boxplots de Tukey de las columnas más contaminadas (caja, bigotes y puntos
  atípicos), delegados a la función nueva `build_boxplots_figure`.
- Multivariante: filas anómalas considerando todas las columnas a la vez con
  `isolation_forest_outliers` — nº y % de filas, las más anómalas con su score y
  las dimensiones que las hacen raras (top columnas por |z|, vía la función nueva
  `summarize_outlier_dims`). El detector se corre en vivo sobre `raw_numeric`
  para que el indexado de filas coincida exactamente con el de las dimensiones;
  cae al bloque precomputado del perfil cuando no hay muestra cruda (preset lite).
- Interpretación exploratoria: un atípico no es necesariamente un error
  (distingue error de dato vs dato real extremo) y recomendaciones (revisar,
  winsorizar o re-expresar, enlazando con la re-expresión de Tukey del perfil).

Términos clicables registrados en el glosario compartido: `outlier`,
`tukey_fence`, `zscore`, `isolation_forest`.

Funciones nuevas del registry (dominio datascience, grupo eda):
- `build_boxplots_figure_py_datascience` (figure helper, impura)
- `summarize_outlier_dims_py_datascience` (pura)

El capítulo se activa con ≥1 columna numérica y devuelve None en su ausencia;
lee todo defensivo y nunca lanza. Tests: capítulo (golden + edges + error path +
render PDF/PPTX) y ambas funciones nuevas. Suite de no-regresión de AutomaticEDA
verde. Verificado end-to-end con el dataset Titanic (Fare/Parch/SibSp como las
columnas más contaminadas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:12:40 +02:00

145 lines
5.3 KiB
Python

"""Explica que dimensiones (columnas) hacen rara cada fila anomala.
Toma la salida multivariante de `isolation_forest_outliers` (lista de
`{row_index, score}`) y, para cada outlier, devuelve las columnas con mayor
|z-score| respecto a la distribucion de las filas validas. Es la capa de
"explicabilidad" del paso de outliers multivariante en la fase EDA: el
Isolation Forest dice QUE filas son raras, esta funcion dice POR QUE (en que
columnas se desvian mas).
Pura y determinista: reconstruye EXACTAMENTE las mismas "filas validas" que usa
`isolation_forest_outliers` (mismo filtro de columnas numericas y mismo descarte
de filas con None), de modo que el `row_index` apunta a la misma fila en ambas
funciones. No hace I/O ni depende de estado.
"""
import math
import numpy as np
def _is_finite_number(v) -> bool:
"""True si v es int/float finito. bool NO cuenta; NaN/Inf tampoco."""
if isinstance(v, bool):
return False
if not isinstance(v, (int, float)):
return False
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
return False
return True
def summarize_outlier_dims(
raw_numeric: dict,
outlier_rows: list,
top_k: int = 3,
) -> list:
"""Resume las dimensiones que mas desvian a cada fila anomala.
Args:
raw_numeric: dict {nombre_columna: [valores]} alineado por fila (como
ctx['raw_numeric'] del motor AutomaticEDA). Solo se usan columnas
cuyos valores sean todos numericos (None permitido por fila; bool,
str, NaN e Inf descartan la columna entera) — filtro identico al de
isolation_forest_outliers.
outlier_rows: lista de {row_index, score} tal como la devuelve
isolation_forest_outliers. row_index cuenta SOLO las filas validas
(sin None) en orden de aparicion, empezando en 0.
top_k: numero de columnas (las de mayor |z-score|) a reportar por cada
outlier. Default 3. Valores invalidos caen a 3.
Returns:
Lista paralela a outlier_rows (mismo orden) de dicts
{row_index, score, dims}, donde dims es la lista de hasta top_k columnas
ordenadas por |z| descendente: [{col, value, z}, ...] con z redondeado a
3 decimales. Las entradas de outlier_rows fuera de rango o malformadas se
omiten (defensivo). Ante raw_numeric vacio/no-dict, outlier_rows
no-lista, 0 columnas numericas o 0 filas validas devuelve [].
"""
# Validacion defensiva de los argumentos principales.
if not isinstance(raw_numeric, dict) or not isinstance(outlier_rows, list):
return []
if not isinstance(top_k, int) or isinstance(top_k, bool) or top_k < 1:
top_k = 3
# Seleccion de columnas numericas: identica a isolation_forest_outliers.
# Una columna entra solo si todos sus valores son numericos (None permitido
# por fila); cualquier bool/str/NaN/Inf descarta la columna completa.
numeric_cols: dict[str, list] = {}
for name, values in raw_numeric.items():
if not isinstance(values, (list, tuple)):
continue
ok = True
for v in values:
if v is None:
continue
if not _is_finite_number(v):
ok = False
break
if ok:
numeric_cols[name] = list(values)
if len(numeric_cols) < 1:
return []
col_names = list(numeric_cols.keys())
try:
n_rows_total = min(len(numeric_cols[c]) for c in col_names)
except ValueError:
return []
# Reconstruye las filas validas con el MISMO criterio que el detector: la
# fila i toma un valor por columna; si cualquier valor es None, la fila se
# descarta y NO incrementa el indice valido. Asi row_index de outlier_rows
# apunta a esta misma secuencia (base 0, orden de aparicion).
valid_rows: list[list[float]] = []
for i in range(n_rows_total):
row = [numeric_cols[c][i] for c in col_names]
if any(v is None for v in row):
continue
valid_rows.append([float(v) for v in row])
if not valid_rows:
return []
matrix = np.asarray(valid_rows, dtype=float)
n_valid = matrix.shape[0]
means = matrix.mean(axis=0)
stds = matrix.std(axis=0, ddof=0) # poblacional (ddof=0)
out: list = []
for entry in outlier_rows:
if not isinstance(entry, dict):
continue
ri = entry.get("row_index")
# bool es subclase de int: lo excluimos explicitamente.
if not isinstance(ri, int) or isinstance(ri, bool):
continue
if ri < 0 or ri >= n_valid:
continue
try:
score = float(entry.get("score"))
except (TypeError, ValueError):
score = 0.0
row = matrix[ri]
dims = []
for j, name in enumerate(col_names):
std = stds[j]
if std == 0.0:
z = 0.0
else:
z = float((row[j] - means[j]) / std)
dims.append({"col": name, "value": float(row[j]), "z": z})
# Mayor |z| primero; sort estable, empates por orden de columna.
dims.sort(key=lambda d: abs(d["z"]), reverse=True)
dims = dims[:top_k]
for d in dims:
d["z"] = round(d["z"], 3)
out.append({"row_index": int(ri), "score": score, "dims": dims})
return out