Files
fn_registry/python/functions/datascience/summarize_outlier_dims_test.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

94 lines
3.6 KiB
Python

"""Tests para summarize_outlier_dims."""
from isolation_forest_outliers import isolation_forest_outliers
from summarize_outlier_dims import summarize_outlier_dims
# Dataset compartido: 3 columnas, 13 filas. La fila ORIGINAL 6 tiene None en "a"
# (se descarta), de modo que la fila ORIGINAL 10 -- con un valor extremo en "c"
# -- queda en el indice VALIDO 9 (no 10). Esto verifica el salto de None.
A = [0.1, 0.2, -0.1, 0.0, 0.3, -0.2, None, 0.15, -0.05, 0.25, 0.2, -0.3, 0.1]
B = [1.0, 1.1, 0.9, 1.2, 0.8, 1.0, 1.3, 1.1, 0.95, 1.05, 0.9, 1.15, 1.0]
C = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.3, 4.95, 5.05, 4.9, 500.0, 5.1, 5.0]
RAW = {"a": A, "b": B, "c": C}
# Mapa original -> valido (saltando original 6):
# orig: 0 1 2 3 4 5 7 8 9 10 11 12
# valid: 0 1 2 3 4 5 6 7 8 9 10 11
# => el extremo en "c" (original 10) esta en el indice valido 9.
EXTREME_VALID_INDEX = 9
def test_row_index_skips_none_rows():
# Mapeo directo (sin depender de la aleatoriedad de IsolationForest): el
# indice valido 9 debe corresponder a la fila con c == 500 -> el None de la
# fila original 6 se salto correctamente.
summary = summarize_outlier_dims(
RAW, [{"row_index": EXTREME_VALID_INDEX, "score": -0.5}], top_k=3
)
assert len(summary) == 1
entry = summary[0]
assert entry["row_index"] == EXTREME_VALID_INDEX
assert entry["score"] == -0.5
# La dimension dominante es "c", con su valor extremo y |z| alto.
top = entry["dims"][0]
assert top["col"] == "c"
assert top["value"] == 500.0
assert abs(top["z"]) > 2.0
# top_k respetado: como mucho 3 dims.
assert len(entry["dims"]) <= 3
def test_extreme_row_flagged_via_isolation():
# Integracion real: detectar outliers y explicarlos.
result = isolation_forest_outliers(RAW, contamination=0.1)
assert "note" not in result
outlier_rows = result["outlier_rows"]
assert outlier_rows # al menos un outlier
summary = summarize_outlier_dims(RAW, outlier_rows, top_k=3)
# Paralela a outlier_rows (todos los indices estan en rango).
assert len(summary) == len(outlier_rows)
by_index = {e["row_index"]: e for e in summary}
# El punto extremo debe estar entre los outliers detectados...
assert EXTREME_VALID_INDEX in by_index
# ...y su dimension top debe ser "c" (donde se desvia ~muchas sigmas).
extreme = by_index[EXTREME_VALID_INDEX]
assert extreme["dims"][0]["col"] == "c"
assert abs(extreme["dims"][0]["z"]) > 2.0
def test_out_of_range_row_index_is_ignored():
# Indices fuera de rango se omiten en lugar de petar.
summary = summarize_outlier_dims(
RAW,
[
{"row_index": 999, "score": -1.0},
{"row_index": -1, "score": -1.0},
{"row_index": EXTREME_VALID_INDEX, "score": -0.5},
],
top_k=2,
)
# Solo sobrevive el indice valido; los otros dos se descartan.
assert len(summary) == 1
assert summary[0]["row_index"] == EXTREME_VALID_INDEX
assert len(summary[0]["dims"]) <= 2
def test_degrades_to_empty_on_invalid_inputs():
# raw_numeric vacio + outlier_rows vacio.
assert summarize_outlier_dims({}, [], 3) == []
# raw_numeric no es dict.
assert summarize_outlier_dims("not a dict", [{"row_index": 0}], 3) == []
# outlier_rows no es lista.
assert summarize_outlier_dims(RAW, "not a list", 3) == []
# Sin columnas numericas (todas con strings) -> [].
assert summarize_outlier_dims(
{"s": ["x", "y", "z"]}, [{"row_index": 0, "score": -1.0}], 3
) == []
# Entradas malformadas dentro de outlier_rows se ignoran (no petan).
assert summarize_outlier_dims(
RAW, ["nope", 42, {"no_row_index": 1}], 3
) == []