Files
fn_registry/python/functions/datascience/extract_null_mask_test.py
T
egutierrez 7fa19d65db feat(eda): capítulo MISSINGNESS — patrones de datos faltantes (co-ocurrencia + MCAR/MAR)
Añade el capítulo `missingness` al motor AutomaticEDA, complemento natural de
`calidad`: donde calidad reporta cuánto falta por columna, este capítulo analiza
el PATRÓN de los nulos — dónde faltan y si las columnas faltan juntas
(co-ocurrencia de ausencias), la señal que distingue MCAR de MAR antes de imputar.

Capítulo (`chapters/missingness.py`), registrado en `chapters_registry.py` justo
tras `calidad`:
- Resumen global: % de celdas faltantes, columnas con nulos, filas completas vs
  incompletas.
- Ranking por columna (tabla + barras horizontales).
- Co-ocurrencia: correlación de las máscaras is-null entre columnas (heatmap +
  tabla de los pares que co-faltan, con co-faltantes y Jaccard).
- Patrones de fila más frecuentes (estilo matriz de missingno).
- Lectura MCAR/MAR exploratoria (heurística por correlación/solape de ausencias,
  no confirmatoria), que cita la evidencia concreta.
- Términos de glosario clicables: missingness, MCAR, MAR.

La máscara is-null por fila de TODAS las columnas (numéricas y categóricas) se
construye con un push-down DuckDB sobre ctx['db_path']/table (mismo patrón que el
capítulo agregación), con fallback a ctx['raw_numeric'] cuando no hay BD. Activa
solo si la tabla tiene nulos; si no, devuelve None.

Funciones nuevas del grupo `eda` (dominio datascience):
- extract_null_mask (impura): máscara is-null por fila vía query_fn.
- missingness_overview (pura): resumen global + filas completas/incompletas.
- missingness_correlation (pura): correlación de ausencias + pares + Jaccard,
  reutiliza pearson.
- missingness_row_patterns (pura): patrones de fila más comunes.
- missingness_corr_heatmap_figure / missingness_rank_bar_figure (impuras): figuras.

Verificado: EDA de titanic genera el capítulo en PDF + PPTX + MD con Cabin 77.1%,
Age 19.9% y la co-ocurrencia Age↔Cabin (158 filas). Suite completa de AutomaticEDA
+ render_automatic_eda en verde (125 passed); tests por función y por capítulo;
fn index sin error.

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

117 lines
3.7 KiB
Python

"""Tests para extract_null_mask.
No usa DuckDB real: inyecta un query_fn FAKE (closure) que devuelve filas
predefinidas (simulando el SELECT de bits 0/1) y, opcionalmente, captura el SQL
recibido para verificar la query generada (CASE WHEN ... IS NULL + LIMIT). Asi el
test es autocontenido y no depende de ningun backend.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from extract_null_mask import extract_null_mask
def _fake_query(rows, captured=None, status="ok", error=None):
"""Crea un query_fn FAKE.
`captured` (lista opcional) recibe el SQL ejecutado para poder inspeccionarlo.
`status`/`error` permiten simular un fallo del backend.
"""
def _q(sql):
if captured is not None:
captured.append(sql)
if status != "ok":
return {"status": "error", "error": error or "boom"}
return {"status": "ok", "rows": rows}
return _q
def test_golden_mask_alineada():
"""Golden: mask 0/1 por columna alineada por fila, n correcto, status ok."""
# Cada fila simula el SELECT (CASE WHEN col IS NULL THEN 1 ELSE 0 END) AS col.
rows = [
{"email": 0, "telefono": 1, "edad": 0},
{"email": 0, "telefono": 0, "edad": 1},
{"email": 1, "telefono": 1, "edad": 0},
]
res = extract_null_mask(_fake_query(rows), "clientes", ["email", "telefono", "edad"])
assert res["status"] == "ok"
assert res["table"] == "clientes"
assert res["columns"] == ["email", "telefono", "edad"]
assert res["n"] == 3
assert res["mask"]["email"] == [0, 0, 1]
assert res["mask"]["telefono"] == [1, 0, 1]
assert res["mask"]["edad"] == [0, 1, 0]
# Todas las listas con la misma longitud.
assert all(len(v) == res["n"] for v in res["mask"].values())
def test_celda_none_cuenta_como_falta():
"""Una celda None se cuenta defensivamente como 1 (falta)."""
rows = [
{"email": 0, "telefono": None},
{"email": None, "telefono": 1},
{"email": 1, "telefono": 0},
]
res = extract_null_mask(_fake_query(rows), "clientes", ["email", "telefono"])
assert res["status"] == "ok"
assert res["mask"]["email"] == [0, 1, 1]
assert res["mask"]["telefono"] == [1, 1, 0]
assert res["n"] == 3
def test_columns_vacia_status_error():
"""columns vacia -> status error con columns/mask/n vacios."""
res = extract_null_mask(_fake_query([]), "clientes", [])
assert res["status"] == "error"
assert "columns" in res["error"]
assert res["table"] == "clientes"
assert res["columns"] == []
assert res["mask"] == {}
assert res["n"] == 0
def test_query_fn_status_error_propaga():
"""query_fn que devuelve status != ok -> se propaga como error, mask {}."""
res = extract_null_mask(
_fake_query([], status="error", error="db locked"),
"clientes",
["email"],
)
assert res["status"] == "error"
assert "db locked" in res["error"]
assert res["mask"] == {}
assert res["n"] == 0
def test_query_fn_none_da_error_sin_reventar():
"""query_fn None -> error degradado, sin excepcion."""
res = extract_null_mask(None, "clientes", ["email"])
assert res["status"] == "error"
assert res["columns"] == []
assert res["mask"] == {}
assert res["n"] == 0
def test_sql_contiene_case_y_limit():
"""La query genera un CASE WHEN IS NULL por columna escapada + LIMIT sobre la tabla."""
captured = []
rows = [{"email": 0}]
extract_null_mask(
_fake_query(rows, captured),
"clientes_tbl",
["email"],
max_rows=123,
)
assert len(captured) == 1
sql = captured[0]
assert 'CASE WHEN "email" IS NULL THEN 1 ELSE 0 END' in sql
assert 'AS "email"' in sql
assert 'FROM "clientes_tbl"' in sql
assert "LIMIT 123" in sql