7fa19d65db
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>
102 lines
4.5 KiB
Python
102 lines
4.5 KiB
Python
"""extract_null_mask — extrae la mascara de nulos (1=falta / 0=presente) de una tabla.
|
|
|
|
Lector read-only inyectado: recibe `query_fn(sql) -> dict` con el mismo contrato
|
|
que duckdb_query_readonly / pg_query (y que el `_q` de profile_table):
|
|
`{"status": "ok", "rows": [{col: val, ...}, ...]}`. Esta funcion NO abre ninguna
|
|
conexion por su cuenta — solo usa `query_fn`. Construye UNA sola query que, por
|
|
cada columna pedida, evalua `CASE WHEN "col" IS NULL THEN 1 ELSE 0 END` y devuelve
|
|
una muestra de filas con esos bits. El resultado es un dict `mask` con una lista
|
|
0/1 por columna, alineada por fila (1 = el valor falta / IS NULL, 0 = presente),
|
|
listo para alimentar el capitulo de calidad / patron de nulos de AutomaticEDA sin
|
|
que el capitulo toque la base de datos.
|
|
|
|
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier excepcion y
|
|
degrada a `{"status": "error", "error": str, ...}`.
|
|
"""
|
|
|
|
|
|
def _to_bit(value):
|
|
"""Coacciona el valor 0/1 del CASE a int de forma defensiva.
|
|
|
|
El SQL ya devuelve 0 (presente) o 1 (falta). Por si una celda llega como None
|
|
(el CASE no se aplico o el backend la nulifico), se cuenta como 1 (falta). El
|
|
resto se reduce a int: un entero distinto de 0 cuenta como 1 (falta), 0 como
|
|
presente. Un valor no convertible se trata como presente (0) — nunca lanza.
|
|
"""
|
|
if value is None:
|
|
return 1
|
|
try:
|
|
return 1 if int(value) != 0 else 0
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def extract_null_mask(query_fn, table, columns, max_rows=5000):
|
|
"""Extrae la mascara de nulos (1=falta / 0=presente) de una muestra de la tabla.
|
|
|
|
Args:
|
|
query_fn: callable lector read-only del backend activo. Recibe un string
|
|
SQL y devuelve un dict {"status": "ok", "rows": [{col: val, ...}]}
|
|
(mismo contrato que duckdb_query_readonly / el `_q` de profile_table).
|
|
No se abre ninguna conexion aqui: toda la lectura pasa por query_fn.
|
|
table: nombre de la tabla. Se escapa con comillas dobles en la query.
|
|
columns: lista de nombres de columna a evaluar. Cada una produce una
|
|
entrada en `mask` con una lista 0/1 paralela por fila. Vacia o None ->
|
|
status error.
|
|
max_rows: limite de filas a muestrear (clausula LIMIT). Default 5000.
|
|
|
|
Returns:
|
|
dict (nunca lanza):
|
|
{
|
|
"status": "ok" | "error",
|
|
"error": str, # solo si status == "error"
|
|
"table": str,
|
|
"columns": [str, ...], # columnas efectivamente leidas, en orden
|
|
"mask": {col: [int 0/1, ...], ...}, # alineada por fila, 1=falta, 0=presente
|
|
"n": int # nº de filas muestreadas
|
|
}
|
|
Todas las listas de `mask` tienen la misma longitud (= n).
|
|
"""
|
|
base = {"status": "ok", "table": table, "columns": [], "mask": {}, "n": 0}
|
|
try:
|
|
if query_fn is None:
|
|
return {**base, "status": "error", "error": "query_fn es None"}
|
|
if not table:
|
|
return {**base, "status": "error", "error": "table es obligatorio"}
|
|
if not columns:
|
|
return {**base, "status": "error", "error": "columns vacío"}
|
|
|
|
# Identificadores escapados con comillas dobles (como hace profile_table)
|
|
# para tolerar nombres con mayusculas/espacios/palabras reservadas. Cada
|
|
# columna se proyecta como su propio bit IS NULL conservando el alias.
|
|
select_sql = ", ".join(
|
|
f'(CASE WHEN "{c}" IS NULL THEN 1 ELSE 0 END) AS "{c}"' for c in columns
|
|
)
|
|
sql = f'SELECT {select_sql} FROM "{table}" LIMIT {int(max_rows)}'
|
|
|
|
q = query_fn(sql)
|
|
if not isinstance(q, dict) or q.get("status") != "ok":
|
|
err = (
|
|
q.get("error", "query_fn fallo")
|
|
if isinstance(q, dict)
|
|
else "query_fn no devolvio un dict"
|
|
)
|
|
return {**base, "status": "error", "error": err}
|
|
|
|
rows = q.get("rows", []) or []
|
|
mask = {c: [] for c in columns}
|
|
for row in rows:
|
|
for c in columns:
|
|
# row.get tolera filas que no traigan la columna (None -> falta).
|
|
mask[c].append(_to_bit(row.get(c) if isinstance(row, dict) else None))
|
|
|
|
return {
|
|
"status": "ok",
|
|
"table": table,
|
|
"columns": list(columns),
|
|
"mask": mask,
|
|
"n": len(rows),
|
|
}
|
|
except Exception as e: # noqa: BLE001 - dict-no-throw: degradar, nunca lanzar
|
|
return {**base, "status": "error", "error": str(e)}
|