6f88f184f1
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>
94 lines
3.6 KiB
Python
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
|
|
) == []
|