Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f1530797e |
@@ -59,6 +59,9 @@ from .acf_pacf import acf_pacf
|
||||
from .stl_decompose import stl_decompose
|
||||
from .to_returns import to_returns
|
||||
from .fdr_correction import fdr_correction
|
||||
from .effect_size_cohens_d import effect_size_cohens_d
|
||||
from .confidence_interval_mean import confidence_interval_mean
|
||||
from .preregister_hypothesis import preregister_hypothesis
|
||||
from .suggest_reexpression import suggest_reexpression
|
||||
from .exploratory_caveats import exploratory_caveats
|
||||
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
||||
@@ -90,6 +93,9 @@ __all__ = [
|
||||
"stl_decompose",
|
||||
"to_returns",
|
||||
"fdr_correction",
|
||||
"effect_size_cohens_d",
|
||||
"confidence_interval_mean",
|
||||
"preregister_hypothesis",
|
||||
"suggest_reexpression",
|
||||
"exploratory_caveats",
|
||||
"render_eda_pdf",
|
||||
|
||||
@@ -1,594 +0,0 @@
|
||||
"""Missingness chapter (MISSINGNESS) — patterns of missing data.
|
||||
|
||||
Complements the CALIDAD chapter: where CALIDAD reports *how much* is missing per
|
||||
column (the null percentage that lowers the completeness score), this chapter
|
||||
reports the **pattern** of the missing data — whether columns tend to be missing
|
||||
*together* (co-occurrence of absences) or independently. That distinction is what
|
||||
separates data that is missing completely at random ([[term:mcar]]MCAR[[/term]])
|
||||
from data missing as a function of another variable ([[term:mar]]MAR[[/term]]),
|
||||
which is the key question to settle before imputing or modelling.
|
||||
|
||||
The chapter activates only when the table actually has missing data (at least one
|
||||
column with a null in the aggregated profile); otherwise it returns ``None`` and
|
||||
disappears from the document.
|
||||
|
||||
Sections, in order:
|
||||
|
||||
1. **Resumen global** — % of missing cells in the dataset, number of columns with
|
||||
nulls, and complete rows (no missing) vs incomplete rows (≥1 missing).
|
||||
2. **Ranking por columna** — columns sorted by their null percentage, with a
|
||||
horizontal bar figure.
|
||||
3. **Co-ocurrencia de ausencias** — the correlation of the binary is-null masks
|
||||
between columns (which columns tend to be missing together): a heatmap plus a
|
||||
table of the top column pairs that co-miss.
|
||||
4. **Patrones de fila** — the most frequent "which columns are missing together"
|
||||
row patterns, in the style of missingno's pattern matrix.
|
||||
5. **Lectura MCAR/MAR** — an interpretive, *exploratory* note (not a confirmatory
|
||||
test such as Little's) reading the absence correlations as a hint of MCAR
|
||||
(independent absences) vs MAR (co-occurring absences).
|
||||
|
||||
The aggregate per-column null counts come from the ``eda`` group ``TableProfile``
|
||||
(``columns[i]['null_count'] / 'null_pct'`` and the table-level ``null_cell_pct``).
|
||||
The per-row is-null mask needed for co-occurrence is built from raw data: a single
|
||||
DuckDB push-down over ``ctx['db_path'] / ctx['table']`` (same pattern as the
|
||||
AGREGACION chapter) covering ALL columns, with a fallback to the numeric-only
|
||||
``ctx['raw_numeric']`` when no database is reachable. All the heavy lifting is
|
||||
delegated to pure registry functions (``missingness_overview``,
|
||||
``missingness_correlation``, ``missingness_row_patterns``) and two figure helpers
|
||||
(``missingness_rank_bar_figure``, ``missingness_corr_heatmap_figure``); every one
|
||||
is imported lazily and degrades to an honest note so this chapter never raises.
|
||||
|
||||
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.0.0"
|
||||
CHAPTER_ID = "missingness"
|
||||
CHAPTER_TITLE = "Datos faltantes"
|
||||
|
||||
# Sample cap for the per-row is-null mask push-down. Co-occurrence and row
|
||||
# patterns are computed on this sample; the global % of missing cells and the
|
||||
# per-column ranking come from the (exact) aggregated profile instead.
|
||||
MASK_SAMPLE = 5000
|
||||
# Thresholds for the MCAR/MAR heuristic note. A pair counts as a *strong*
|
||||
# co-occurrence when the absence correlation alone is high; as a *partial*
|
||||
# co-occurrence when the absences overlap materially (high Jaccard) even if the
|
||||
# Pearson correlation is modest — the usual case when one column is missing far
|
||||
# more often than the other (e.g. Cabin 77% vs Age 20% in Titanic), which dilutes
|
||||
# the correlation while the rows still co-miss in absolute terms.
|
||||
_CORR_STRONG = 0.30
|
||||
_JACCARD_NOTABLE = 0.20
|
||||
# Rows shown in the top-pairs and row-patterns tables (bounded, never silently
|
||||
# truncated: the table note reports the full count).
|
||||
_TOP_PAIRS = 12
|
||||
_TOP_PATTERNS = 12
|
||||
# Truncate long column names in tables (the renderer also wraps).
|
||||
_LABEL_MAX = 28
|
||||
|
||||
# Glossary terms this chapter explains (contract §11.1). Registered in the shared
|
||||
# collector and marked clickable on their first appearance.
|
||||
_TERMS = {
|
||||
"missingness": (
|
||||
"Patrón de datos faltantes (missingness)",
|
||||
"El patrón con el que faltan los datos: cuánto falta, en qué columnas y "
|
||||
"si las ausencias de unas columnas coinciden (co-ocurren) con las de "
|
||||
"otras. Analizarlo —no solo contar nulos— distingue datos que faltan al "
|
||||
"azar (MCAR) de los que faltan en función de otra variable (MAR), lo que "
|
||||
"decide cómo imputar o si descartar filas sin sesgar el análisis.",
|
||||
),
|
||||
"mcar": (
|
||||
"MCAR (Missing Completely At Random)",
|
||||
"Los valores faltan de forma independiente de cualquier dato, observado o "
|
||||
"no: las ausencias de unas columnas no se relacionan entre sí ni con los "
|
||||
"valores. Es el caso más benigno —descartar filas o imputar la media no "
|
||||
"introduce sesgo—, pero rara vez se cumple del todo en datos reales.",
|
||||
),
|
||||
"mar": (
|
||||
"MAR (Missing At Random)",
|
||||
"La probabilidad de que un valor falte depende de OTRAS variables "
|
||||
"observadas (p. ej. una medición que falta más en cierto grupo). Las "
|
||||
"ausencias co-ocurren entre columnas o se relacionan con los valores de "
|
||||
"otras; imputar exige condicionar en esas variables para no sesgar. La "
|
||||
"co-ocurrencia fuerte de ausencias es un indicio (exploratorio) de MAR.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Small defensive formatters (own copy: the chapter never imports siblings).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _fmt_int(value) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{int(round(float(value))):,}".replace(",", ".")
|
||||
except (TypeError, ValueError):
|
||||
return model._safe_str(value)
|
||||
|
||||
|
||||
def _fmt_pct(value, decimals: int = 1) -> str:
|
||||
"""Format an already-0-100 value as a percentage. None -> placeholder."""
|
||||
if value is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(value):.{decimals}f}%"
|
||||
except (TypeError, ValueError):
|
||||
return model._safe_str(value)
|
||||
|
||||
|
||||
def _fmt_num(value, decimals: int = 3) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return model._safe_str(value)
|
||||
if f != f: # NaN
|
||||
return "—"
|
||||
text = f"{f:.{decimals}f}".rstrip("0").rstrip(".")
|
||||
return text if text else "0"
|
||||
|
||||
|
||||
def _truncate(text, limit: int = _LABEL_MAX) -> str:
|
||||
s = model._safe_str(text)
|
||||
if len(s) <= limit:
|
||||
return s
|
||||
return s[: max(1, limit - 1)].rstrip() + "…"
|
||||
|
||||
|
||||
def _term(key: str, label: str, mark: bool) -> str:
|
||||
if mark:
|
||||
return f"[[term:{key}]]**{label}**[[/term]]"
|
||||
return f"**{label}**"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Profile reads (exact, all rows).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _null_count_of(col: dict):
|
||||
"""Best-effort null count of a column: ``null_count`` or null_pct*n_rows."""
|
||||
nc = col.get("null_count")
|
||||
if isinstance(nc, (int, float)) and not isinstance(nc, bool):
|
||||
return int(nc)
|
||||
np_ = col.get("null_pct")
|
||||
nr = col.get("n_rows")
|
||||
if isinstance(np_, (int, float)) and isinstance(nr, (int, float)):
|
||||
return int(round(float(np_) * float(nr)))
|
||||
return 0
|
||||
|
||||
|
||||
def _columns_with_nulls(profile: dict):
|
||||
"""Return ``[(name, null_count, null_pct_0_100)]`` for columns with nulls,
|
||||
sorted by null percentage descending. Reads the aggregated profile (exact)."""
|
||||
cols = profile.get("columns") or []
|
||||
out = []
|
||||
for c in cols:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
nc = _null_count_of(c)
|
||||
if nc <= 0:
|
||||
continue
|
||||
np_ = c.get("null_pct")
|
||||
nr = c.get("n_rows") or profile.get("n_rows")
|
||||
if isinstance(np_, (int, float)) and not isinstance(np_, bool):
|
||||
pct = float(np_) * 100.0 if np_ <= 1.0 else float(np_)
|
||||
elif nr:
|
||||
pct = nc / float(nr) * 100.0
|
||||
else:
|
||||
pct = None
|
||||
out.append((c.get("name") or "(col)", nc, pct))
|
||||
out.sort(key=lambda t: (t[2] if t[2] is not None else -1.0), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _global_missing_pct(profile: dict):
|
||||
"""Table-level % of missing cells (0-100), exact, from the profile."""
|
||||
v = profile.get("null_cell_pct")
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
return float(v) * 100.0 if v <= 1.0 else float(v)
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Per-row is-null mask (sample): DuckDB push-down, fallback to raw_numeric.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _build_query_fn(ctx: dict):
|
||||
"""Return ``(query_fn, table)`` for a DuckDB-backed ctx, or ``(None, None)``.
|
||||
|
||||
Mirrors build_eda_render_ctx: a read-only closure over the registry wrapper.
|
||||
Only DuckDB is supported here; any other backend degrades to raw_numeric."""
|
||||
db_path = ctx.get("db_path")
|
||||
table = ctx.get("table")
|
||||
if not db_path or not table:
|
||||
return None, None
|
||||
try:
|
||||
from infra import duckdb_query_readonly
|
||||
except Exception: # noqa: BLE001 — wrapper unavailable -> degrade.
|
||||
return None, None
|
||||
|
||||
def query_fn(sql):
|
||||
return duckdb_query_readonly(db_path, sql)
|
||||
|
||||
return query_fn, table
|
||||
|
||||
|
||||
def _null_mask(profile: dict, ctx: dict):
|
||||
"""Build the per-row is-null mask ``{col: [0/1, ...]}``.
|
||||
|
||||
Tries a single DuckDB push-down over ALL columns first (so categorical
|
||||
columns like Cabin are covered, not only numeric ones); falls back to the
|
||||
numeric-only ``ctx['raw_numeric']`` (None -> missing); returns ``(None, 0,
|
||||
None)`` when neither is reachable. Never raises.
|
||||
Returns ``(mask, n_sampled, source)`` with source in {"db","raw_numeric"}.
|
||||
"""
|
||||
cols = profile.get("columns") or []
|
||||
names = [c.get("name") for c in cols
|
||||
if isinstance(c, dict) and c.get("name")]
|
||||
# 1) DuckDB push-down over every column (covers categoricals too).
|
||||
query_fn, table = _build_query_fn(ctx)
|
||||
if query_fn is not None and names:
|
||||
try:
|
||||
from datascience.extract_null_mask import extract_null_mask
|
||||
|
||||
res = extract_null_mask(query_fn, table, names, max_rows=MASK_SAMPLE)
|
||||
if isinstance(res, dict) and res.get("status") == "ok":
|
||||
mask = res.get("mask") or {}
|
||||
if mask:
|
||||
return mask, int(res.get("n") or 0), "db"
|
||||
except Exception: # noqa: BLE001 — degrade to raw_numeric.
|
||||
pass
|
||||
# 2) Fallback: numeric-only mask derived from raw_numeric (None -> missing).
|
||||
rn = ctx.get("raw_numeric")
|
||||
if isinstance(rn, dict) and rn:
|
||||
mask = {}
|
||||
for col, vals in rn.items():
|
||||
if isinstance(vals, (list, tuple)):
|
||||
mask[col] = [1 if v is None else 0 for v in vals]
|
||||
if mask:
|
||||
n = max((len(v) for v in mask.values()), default=0)
|
||||
return mask, n, "raw_numeric"
|
||||
return None, 0, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lazy registry delegations (each degrades to None on any failure).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _overview(mask: dict):
|
||||
try:
|
||||
from datascience.missingness_overview import missingness_overview
|
||||
|
||||
out = missingness_overview(mask)
|
||||
return out if isinstance(out, dict) else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _correlation(mask: dict, top_k: int):
|
||||
try:
|
||||
from datascience.missingness_correlation import missingness_correlation
|
||||
|
||||
out = missingness_correlation(mask, top_k=top_k)
|
||||
return out if isinstance(out, dict) else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _row_patterns(mask: dict, top_n: int):
|
||||
try:
|
||||
from datascience.missingness_row_patterns import missingness_row_patterns
|
||||
|
||||
out = missingness_row_patterns(mask, top_n=top_n)
|
||||
return out if isinstance(out, dict) else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _rank_bar_make(names, pcts, title):
|
||||
def make():
|
||||
try:
|
||||
from datascience.missingness_rank_bar_figure import (
|
||||
missingness_rank_bar_figure,
|
||||
)
|
||||
|
||||
return missingness_rank_bar_figure(names, pcts, title=title)
|
||||
except Exception: # noqa: BLE001 — minimal fallback figure.
|
||||
return _fallback_fig("ranking de nulos no disponible")
|
||||
|
||||
return make
|
||||
|
||||
|
||||
def _heatmap_make(matrix, labels, title):
|
||||
def make():
|
||||
try:
|
||||
from datascience.missingness_corr_heatmap_figure import (
|
||||
missingness_corr_heatmap_figure,
|
||||
)
|
||||
|
||||
return missingness_corr_heatmap_figure(matrix, labels, title=title)
|
||||
except Exception: # noqa: BLE001 — minimal fallback figure.
|
||||
return _fallback_fig("heatmap de co-ocurrencia no disponible")
|
||||
|
||||
return make
|
||||
|
||||
|
||||
def _fallback_fig(message: str):
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
fig = Figure(figsize=(5.0, 2.2))
|
||||
ax = fig.add_subplot(111)
|
||||
ax.text(0.5, 0.5, message, ha="center", va="center")
|
||||
ax.axis("off")
|
||||
return fig
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Block builders.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _summary_block(profile: dict, with_nulls: list, overview, sampled, n_total):
|
||||
rows = []
|
||||
gpct = _global_missing_pct(profile)
|
||||
rows.append(("Celdas faltantes (global)", _fmt_pct(gpct)))
|
||||
rows.append(("Columnas con faltantes", str(len(with_nulls))))
|
||||
all_null = profile.get("all_null_cols")
|
||||
if isinstance(all_null, (list, tuple)) and all_null:
|
||||
rows.append(("Columnas 100% faltantes", str(len(all_null))))
|
||||
if isinstance(overview, dict):
|
||||
cr = overview.get("complete_rows")
|
||||
ir = overview.get("incomplete_rows")
|
||||
suffix = ""
|
||||
if (isinstance(sampled, int) and isinstance(n_total, (int, float))
|
||||
and sampled and n_total and sampled < n_total):
|
||||
suffix = f" (sobre muestra de {_fmt_int(sampled)} filas)"
|
||||
if cr is not None:
|
||||
rows.append(("Filas completas (sin faltantes)",
|
||||
f"{_fmt_int(cr)} ({_fmt_pct(overview.get('complete_pct'))})"
|
||||
+ suffix))
|
||||
if ir is not None:
|
||||
rows.append(("Filas con ≥1 faltante",
|
||||
f"{_fmt_int(ir)} "
|
||||
f"({_fmt_pct(overview.get('incomplete_pct'))})" + suffix))
|
||||
return model.KVTable(rows=rows, title="Resumen de datos faltantes")
|
||||
|
||||
|
||||
def _ranking_block(with_nulls: list):
|
||||
header = ["Columna", "Faltantes", "% faltante"]
|
||||
rows = [[_truncate(n), _fmt_int(c), _fmt_pct(p)] for (n, c, p) in with_nulls]
|
||||
if not rows:
|
||||
return None
|
||||
return model.DataTable(
|
||||
header=header, rows=rows, title="Faltantes por columna",
|
||||
note="ordenado de más a menos faltante")
|
||||
|
||||
|
||||
def _ranking_figure(with_nulls: list):
|
||||
names = [n for (n, _, p) in with_nulls if p is not None]
|
||||
pcts = [p for (_, _, p) in with_nulls if p is not None]
|
||||
if not names:
|
||||
return None
|
||||
return model.Figure(
|
||||
make=_rank_bar_make(names, pcts, "% de valores faltantes por columna"),
|
||||
caption="Porcentaje de valores faltantes por columna (barras).")
|
||||
|
||||
|
||||
def _pairs_block(corr: dict):
|
||||
"""Top column pairs whose absences co-occur, as a table, or None."""
|
||||
pairs = (corr or {}).get("pairs") or []
|
||||
header = ["Columna A", "Columna B", "Corr. ausencia", "Co-faltan", "Jaccard"]
|
||||
rows = []
|
||||
for p in pairs[:_TOP_PAIRS]:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
rows.append([
|
||||
_truncate(p.get("a")),
|
||||
_truncate(p.get("b")),
|
||||
_fmt_num(p.get("corr")),
|
||||
_fmt_int(p.get("co_missing")),
|
||||
_fmt_num(p.get("jaccard")),
|
||||
])
|
||||
if not rows:
|
||||
return None
|
||||
shown = len(rows)
|
||||
total = len(pairs)
|
||||
note = ("correlación de las máscaras is-null entre columnas; "
|
||||
"«Co-faltan» = nº de filas en que ambas faltan a la vez")
|
||||
if total > shown:
|
||||
note += f" — top {shown} de {total} pares"
|
||||
return model.DataTable(header=header, rows=rows,
|
||||
title="Pares de columnas que co-faltan", note=note)
|
||||
|
||||
|
||||
def _heatmap_block(corr: dict):
|
||||
cols = (corr or {}).get("columns") or []
|
||||
matrix = (corr or {}).get("matrix") or []
|
||||
if len(cols) < 2 or not matrix:
|
||||
return None
|
||||
labels = [_truncate(c, 16) for c in cols]
|
||||
return model.Figure(
|
||||
make=_heatmap_make(matrix, labels, "Co-ocurrencia de ausencias"),
|
||||
caption=("Correlación de las ausencias entre columnas (azul = faltan "
|
||||
"juntas; rojo = cuando una falta la otra tiende a estar)."))
|
||||
|
||||
|
||||
def _patterns_block(patterns_res: dict):
|
||||
patterns = (patterns_res or {}).get("patterns") or []
|
||||
header = ["Columnas que faltan juntas", "Filas", "%"]
|
||||
rows = []
|
||||
for p in patterns[:_TOP_PATTERNS]:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
cols = p.get("missing_cols") or []
|
||||
if cols:
|
||||
label = ", ".join(_truncate(c, 18) for c in cols)
|
||||
else:
|
||||
label = "(fila completa — sin faltantes)"
|
||||
rows.append([label, _fmt_int(p.get("n_rows")), _fmt_pct(p.get("pct"))])
|
||||
if not rows:
|
||||
return None
|
||||
total = (patterns_res or {}).get("n_patterns")
|
||||
shown = len(rows)
|
||||
note = "cada fila es un patrón de «qué columnas faltan juntas»"
|
||||
if isinstance(total, int) and total > shown:
|
||||
note += f" — top {shown} de {total} patrones distintos"
|
||||
return model.DataTable(header=header, rows=rows,
|
||||
title="Patrones de fila más comunes", note=note)
|
||||
|
||||
|
||||
def _mcar_mar_note(corr: dict, mark: bool):
|
||||
"""Interpretive, exploratory MCAR/MAR note from the absence correlations.
|
||||
|
||||
Reads the absence correlations at two levels so the verdict never contradicts
|
||||
the visible evidence: a *strong* correlation flags a clear non-random (MAR)
|
||||
pattern; a *partial* overlap (many rows co-miss — high Jaccard — even if the
|
||||
correlation is diluted by one column being missing far more often) flags a
|
||||
localized possible-MAR and cites the concrete co-missing pair; only when
|
||||
neither holds does it read the absences as compatible with MCAR."""
|
||||
|
||||
def _pairs_with(attr_ok):
|
||||
out = []
|
||||
for p in (corr or {}).get("pairs") or []:
|
||||
if isinstance(p, dict) and attr_ok(p):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _cf(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
strong = _pairs_with(lambda p: abs(_cf(p.get("corr"))) >= _CORR_STRONG)
|
||||
partial = _pairs_with(
|
||||
lambda p: _cf(p.get("corr")) > 0 and _cf(p.get("jaccard")) >= _JACCARD_NOTABLE)
|
||||
mcar = _term("mcar", "MCAR", mark)
|
||||
mar = _term("mar", "MAR", mark)
|
||||
head = (
|
||||
"**Lectura exploratoria MCAR/MAR.** Esta es una heurística basada en la "
|
||||
"correlación de las ausencias entre columnas, NO un test confirmatorio "
|
||||
"(como el de Little); orienta, no demuestra. ")
|
||||
if strong:
|
||||
top = strong[0]
|
||||
ev = (f"«{model._safe_str(top.get('a'))}» y "
|
||||
f"«{model._safe_str(top.get('b'))}» "
|
||||
f"(corr {_fmt_num(top.get('corr'))})")
|
||||
body = (
|
||||
f"Hay ausencias que co-ocurren con fuerza —{ev}—: las columnas no "
|
||||
f"faltan de forma independiente, lo que es un indicio de un patrón no "
|
||||
f"aleatorio ({mar}). Antes de imputar o descartar filas conviene "
|
||||
f"comprobar si la ausencia depende de otra variable observada; en ese "
|
||||
f"caso la imputación debería condicionar en ella para no sesgar.")
|
||||
elif partial:
|
||||
top = max(partial, key=lambda p: _cf(p.get("jaccard")))
|
||||
ev = (f"«{model._safe_str(top.get('a'))}» y "
|
||||
f"«{model._safe_str(top.get('b'))}» faltan a la vez en "
|
||||
f"{_fmt_int(top.get('co_missing'))} filas "
|
||||
f"(Jaccard {_fmt_num(top.get('jaccard'))})")
|
||||
body = (
|
||||
f"Hay co-ocurrencia parcial de ausencias —{ev}—: algunas columnas "
|
||||
f"tienden a faltar juntas aunque la correlación global sea modesta "
|
||||
f"(habitual cuando una columna falta mucho más que la otra). Es un "
|
||||
f"indicio de un posible patrón localizado no aleatorio ({mar}); "
|
||||
f"conviene revisar si esa ausencia depende de otra variable observada "
|
||||
f"antes de imputar, en lugar de asumir que faltan al azar.")
|
||||
else:
|
||||
body = (
|
||||
f"Las ausencias entre columnas no muestran correlación ni solape "
|
||||
f"relevante: parecen independientes, lo que es compatible con que "
|
||||
f"falten al azar ({mcar}). Aun así, la ausencia podría depender de "
|
||||
f"variables no observadas (la heurística no lo descarta).")
|
||||
return model.Markdown(text=head + body)
|
||||
|
||||
|
||||
def _intro_block(mark: bool, source):
|
||||
missingness = _term("missingness", "missingness", mark)
|
||||
text = (
|
||||
f"Este capítulo analiza el {missingness} de la tabla: no solo cuánto "
|
||||
"falta (eso lo cubre la calidad), sino DÓNDE falta y si las columnas "
|
||||
"faltan juntas. La co-ocurrencia de ausencias se calcula sobre la matriz "
|
||||
"binaria «is-null» por fila.")
|
||||
if source == "raw_numeric":
|
||||
text += (" Nota: no se pudo leer la tabla cruda completa, así que la "
|
||||
"co-ocurrencia se limita a las columnas numéricas disponibles.")
|
||||
return model.Markdown(text=text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Entry point.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_missingness(profile: dict, ctx: dict):
|
||||
"""Build the missingness Chapter, or None if the table has no missing data."""
|
||||
if not isinstance(profile, dict):
|
||||
profile = {}
|
||||
ctx = ctx or {}
|
||||
|
||||
with_nulls = _columns_with_nulls(profile)
|
||||
if not with_nulls:
|
||||
return None # no missing data anywhere -> chapter does not apply.
|
||||
|
||||
# Register glossary terms (if a collector is present) and mark them clickable.
|
||||
glossary = ctx.get("glossary")
|
||||
mark = False
|
||||
if isinstance(glossary, model.GlossaryCollector):
|
||||
for key, (label, definition) in _TERMS.items():
|
||||
glossary.add(key, label, definition)
|
||||
mark = True
|
||||
|
||||
# Per-row is-null mask (sample) for co-occurrence and row patterns.
|
||||
mask, sampled, source = _null_mask(profile, ctx)
|
||||
overview = _overview(mask) if mask else None
|
||||
n_total = profile.get("n_rows")
|
||||
|
||||
blocks = [
|
||||
model.Heading(text="Cuánto y dónde faltan datos", level=2),
|
||||
_intro_block(mark, source),
|
||||
_summary_block(profile, with_nulls, overview, sampled, n_total),
|
||||
model.Heading(text="Faltantes por columna", level=2),
|
||||
]
|
||||
ranking = _ranking_block(with_nulls)
|
||||
if ranking is not None:
|
||||
blocks.append(ranking)
|
||||
rank_fig = _ranking_figure(with_nulls)
|
||||
if rank_fig is not None:
|
||||
blocks.append(rank_fig)
|
||||
|
||||
# Co-occurrence + row patterns need the per-row mask. Without it, say so.
|
||||
if not mask:
|
||||
blocks.append(model.Note(
|
||||
"No se pudo construir la matriz «is-null» por fila (sin acceso a los "
|
||||
"datos crudos), así que no se analiza la co-ocurrencia de ausencias "
|
||||
"ni los patrones de fila en este informe."))
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
|
||||
corr = _correlation(mask, _TOP_PAIRS) or {}
|
||||
co_blocks = [model.Heading(text="Co-ocurrencia de ausencias", level=2)]
|
||||
heatmap = _heatmap_block(corr)
|
||||
if heatmap is not None:
|
||||
co_blocks.append(heatmap)
|
||||
pairs = _pairs_block(corr)
|
||||
if pairs is not None:
|
||||
co_blocks.append(pairs)
|
||||
if heatmap is None and pairs is None:
|
||||
co_blocks.append(model.Note(
|
||||
"Ninguna pareja de columnas comparte ausencias con variación "
|
||||
"suficiente para correlacionarlas (p. ej. una sola columna con "
|
||||
"faltantes), así que no hay co-ocurrencia que mostrar."))
|
||||
# Keep the co-occurrence heading next to its heatmap and table.
|
||||
blocks.append(model.Group(blocks=co_blocks))
|
||||
|
||||
patterns_res = _row_patterns(mask, _TOP_PATTERNS) or {}
|
||||
patterns = _patterns_block(patterns_res)
|
||||
if patterns is not None:
|
||||
blocks.append(model.Heading(text="Patrones de fila", level=2))
|
||||
blocks.append(patterns)
|
||||
|
||||
blocks.append(model.Heading(text="Lectura MCAR / MAR", level=2))
|
||||
blocks.append(_mcar_mar_note(corr, mark))
|
||||
|
||||
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
|
||||
version=CHAPTER_VERSION, blocks=blocks)
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Tests for the MISSINGNESS chapter.
|
||||
|
||||
Covers the Definition of Done for this chapter:
|
||||
* Activates (non-None Chapter with the expected sections) when the profile has
|
||||
missing data, building the co-occurrence from the per-row is-null mask.
|
||||
* Returns None when the table has no missing data at all (edge case).
|
||||
* Registers the MCAR/MAR/missingness glossary terms.
|
||||
* The DuckDB push-down path covers categorical columns (not only numeric),
|
||||
so a categorical column that co-misses with a numeric one is detected.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||
if _FUNCTIONS not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS)
|
||||
|
||||
from datascience.automatic_eda import model # noqa: E402
|
||||
from datascience.automatic_eda.chapters.missingness import ( # noqa: E402
|
||||
build_missingness,
|
||||
)
|
||||
|
||||
|
||||
def _titles(chapter):
|
||||
"""Collect heading texts and table/figure titles for assertions."""
|
||||
out = []
|
||||
for b in chapter.blocks:
|
||||
kind = getattr(b, "kind", None)
|
||||
if kind == "heading":
|
||||
out.append(("heading", getattr(b, "text", "")))
|
||||
elif kind in ("data_table", "kv_table"):
|
||||
out.append((kind, getattr(b, "title", "")))
|
||||
elif kind == "group":
|
||||
for inner in getattr(b, "blocks", []):
|
||||
ik = getattr(inner, "kind", None)
|
||||
if ik == "heading":
|
||||
out.append(("heading", getattr(inner, "text", "")))
|
||||
elif ik in ("data_table", "kv_table"):
|
||||
out.append((ik, getattr(inner, "title", "")))
|
||||
elif ik == "figure":
|
||||
out.append(("figure", getattr(inner, "caption", "")))
|
||||
elif kind == "figure":
|
||||
out.append(("figure", getattr(b, "caption", "")))
|
||||
return out
|
||||
|
||||
|
||||
def _all_text(chapter):
|
||||
parts = []
|
||||
def walk(blocks):
|
||||
for b in blocks:
|
||||
for attr in ("text", "title", "note", "caption"):
|
||||
v = getattr(b, attr, None)
|
||||
if v:
|
||||
parts.append(str(v))
|
||||
if getattr(b, "kind", None) == "group":
|
||||
walk(getattr(b, "blocks", []))
|
||||
walk(chapter.blocks)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def test_returns_none_when_no_missing_data():
|
||||
profile = {
|
||||
"n_rows": 4,
|
||||
"null_cell_pct": 0.0,
|
||||
"columns": [
|
||||
{"name": "a", "null_count": 0, "null_pct": 0.0, "n_rows": 4},
|
||||
{"name": "b", "null_count": 0, "null_pct": 0.0, "n_rows": 4},
|
||||
],
|
||||
}
|
||||
assert build_missingness(profile, {}) is None
|
||||
|
||||
|
||||
def test_activates_with_cooccurrence_via_raw_numeric():
|
||||
# a and b are missing in EXACTLY the same rows (0,1,2) -> perfect absence
|
||||
# correlation. c has no nulls. No db_path -> the chapter falls back to the
|
||||
# numeric raw_numeric mask.
|
||||
profile = {
|
||||
"n_rows": 6,
|
||||
"null_cell_pct": (0.5 + 0.5 + 0.0) / 3.0,
|
||||
"columns": [
|
||||
{"name": "a", "null_count": 3, "null_pct": 0.5, "n_rows": 6},
|
||||
{"name": "b", "null_count": 3, "null_pct": 0.5, "n_rows": 6},
|
||||
{"name": "c", "null_count": 0, "null_pct": 0.0, "n_rows": 6},
|
||||
],
|
||||
}
|
||||
glossary = model.GlossaryCollector()
|
||||
ctx = {
|
||||
"raw_numeric": {
|
||||
"a": [None, None, None, 1.0, 2.0, 3.0],
|
||||
"b": [None, None, None, 4.0, 5.0, 6.0],
|
||||
},
|
||||
"glossary": glossary,
|
||||
}
|
||||
ch = build_missingness(profile, ctx)
|
||||
assert ch is not None
|
||||
assert ch.id == "missingness"
|
||||
assert ch.blocks
|
||||
|
||||
titles = _titles(ch)
|
||||
headings = {t for (k, t) in titles if k == "heading"}
|
||||
# Core sections present.
|
||||
assert any("Cuánto y dónde" in h for h in headings)
|
||||
assert any("Faltantes por columna" in h for h in headings)
|
||||
assert any("Co-ocurrencia" in h for h in headings)
|
||||
assert any("MCAR" in h for h in headings)
|
||||
# A summary KVTable, a ranking DataTable, a co-occurrence figure and the
|
||||
# pairs table all exist.
|
||||
kinds = {k for (k, _) in titles}
|
||||
assert "kv_table" in kinds
|
||||
assert "data_table" in kinds
|
||||
assert "figure" in kinds
|
||||
|
||||
# Glossary terms registered.
|
||||
keys = {t["key"] for t in glossary.terms()}
|
||||
assert {"missingness", "mcar", "mar"} <= keys
|
||||
|
||||
# The MCAR/MAR note reads the co-occurrence; with a perfect overlap it must
|
||||
# flag the non-random (MAR) reading.
|
||||
text = _all_text(ch)
|
||||
assert "MAR" in text
|
||||
|
||||
|
||||
def test_db_pushdown_covers_categorical_column(tmp_path):
|
||||
"""The is-null mask push-down must cover a categorical column, so a
|
||||
categorical that co-misses with a numeric one shows up in the pairs."""
|
||||
import duckdb
|
||||
|
||||
db = str(tmp_path / "miss.duckdb")
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE t (num1 DOUBLE, num2 DOUBLE, cat VARCHAR)")
|
||||
# num1 and cat are NULL together in the first 4 of 10 rows; num2 never null.
|
||||
rows = []
|
||||
for i in range(10):
|
||||
if i < 4:
|
||||
rows.append((None, float(i), None))
|
||||
else:
|
||||
rows.append((float(i), float(i), f"c{i}"))
|
||||
con.executemany("INSERT INTO t VALUES (?,?,?)", rows)
|
||||
con.close()
|
||||
|
||||
profile = {
|
||||
"n_rows": 10,
|
||||
"null_cell_pct": (0.4 + 0.0 + 0.4) / 3.0,
|
||||
"columns": [
|
||||
{"name": "num1", "null_count": 4, "null_pct": 0.4, "n_rows": 10},
|
||||
{"name": "num2", "null_count": 0, "null_pct": 0.0, "n_rows": 10},
|
||||
{"name": "cat", "null_count": 4, "null_pct": 0.4, "n_rows": 10},
|
||||
],
|
||||
}
|
||||
ctx = {"db_path": db, "table": "t", "glossary": model.GlossaryCollector()}
|
||||
ch = build_missingness(profile, ctx)
|
||||
assert ch is not None
|
||||
|
||||
# The pairs table must mention both num1 and cat (they co-miss perfectly),
|
||||
# which is only possible if the mask covered the categorical column.
|
||||
text = _all_text(ch)
|
||||
assert "num1" in text and "cat" in text
|
||||
# Co-occurrence section + a pairs data table exist.
|
||||
titles = _titles(ch)
|
||||
assert any("co-faltan" in (t or "").lower() for (k, t) in titles)
|
||||
@@ -32,7 +32,6 @@ CHAPTER_ORDER = [
|
||||
"num_distr", # numeric distributions
|
||||
"cat_distr", # categorical distributions
|
||||
"calidad", # data quality
|
||||
"missingness", # missing-data patterns (co-occurrence of absences; MCAR/MAR)
|
||||
"correlacion", # correlations / associations
|
||||
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
|
||||
"modelos", # cheap models (PCA/KMeans/outliers)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: confidence_interval_mean
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def confidence_interval_mean(data: list, other: list = None, confidence: float = 0.95) -> dict"
|
||||
description: "Intervalo de confianza (IC) de la media de una muestra con la t de Student, o de la DIFERENCIA de medias de dos muestras independientes con el metodo de Welch (sin asumir varianzas iguales). Una muestra: df=n-1, se=sd_muestral/sqrt(n) (sd con ddof=1), tcrit=t.ppf((1+confidence)/2, df), ci=mean+/-tcrit*se. Dos muestras: IC de mean(data)-mean(other) con se=sqrt(se1^2+se2^2) y grados de libertad de Welch-Satterthwaite. Pura y robusta: nunca lanza; ante casos degenerados (muestra vacia, n<2) devuelve nan + clave note, y con varianza cero el IC colapsa al punto (no es error). Usa scipy.stats y numpy."
|
||||
tags: [papers, statistics, confidence-interval, welch, t-test, python]
|
||||
params:
|
||||
- name: data
|
||||
desc: "muestra de observaciones numericas (lista de numeros). Si other es None, el IC es el de la media de data."
|
||||
- name: other
|
||||
desc: "segunda muestra independiente (lista de numeros) o None (default). Si se da, el IC es el de la diferencia de medias mean(data)-mean(other) calculada con Welch (no asume varianzas iguales)."
|
||||
- name: confidence
|
||||
desc: "nivel de confianza en (0, 1); 0.95 = IC del 95% (default). El cuantil critico es t.ppf((1+confidence)/2, df)."
|
||||
output: "dict {mean, ci_low, ci_high, se, df, confidence, n}. mean = media de data (una muestra) o la diferencia mean(data)-mean(other) (dos muestras). En el caso de dos muestras se anaden ademas n1 y n2 (y n = n1+n2). df son los grados de libertad de la t (Welch-Satterthwaite si dos muestras). Casos degenerados (muestra vacia, n<2) anaden la clave note y dejan ci_low/ci_high/se (y a veces df) en nan; con varianza cero y n>=2 el IC colapsa a [mean, mean] con se=0 (con note, sin nan). Nunca None ni excepcion."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [scipy, numpy]
|
||||
tested: true
|
||||
tests: ["test_one_sample_golden_contra_scipy", "test_one_sample_distinto_nivel_confianza", "test_welch_diferencia_golden_contra_scipy", "test_edge_un_solo_elemento_no_lanza_nan_note", "test_edge_lista_vacia_no_lanza_note", "test_edge_varianza_cero_colapsa_al_punto", "test_edge_welch_muestra_vacia_no_lanza_note", "test_edge_welch_n1_uno_no_lanza_note"]
|
||||
test_file_path: "python/functions/datascience/confidence_interval_mean_test.py"
|
||||
file_path: "python/functions/datascience/confidence_interval_mean.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import confidence_interval_mean
|
||||
|
||||
# IC del 95% de la media de una muestra (t de Student).
|
||||
data = [2, 4, 4, 4, 5, 5, 7, 9]
|
||||
ci = confidence_interval_mean(data, confidence=0.95)
|
||||
print(ci["mean"]) # -> 5.0
|
||||
print(ci["df"]) # -> 7.0 (n - 1)
|
||||
print(round(ci["ci_low"], 5), round(ci["ci_high"], 5))
|
||||
# -> 3.21251 6.78749 (se con sd muestral ddof=1 ~ 2.13809)
|
||||
|
||||
# IC del 95% de la DIFERENCIA de medias (Welch, no asume varianzas iguales).
|
||||
control = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
|
||||
tratado = [18.0, 20.0, 17.0, 19.0, 21.0]
|
||||
diff = confidence_interval_mean(control, tratado, confidence=0.95)
|
||||
print(diff["mean"]) # -> 4.5 (mean(control) - mean(tratado))
|
||||
print(round(diff["ci_low"], 4), round(diff["ci_high"], 4))
|
||||
# Si el intervalo no incluye 0, la diferencia es significativa al 5%.
|
||||
|
||||
# Degenerados: nunca lanza.
|
||||
print(confidence_interval_mean([5])["note"]) # n < 2: ... indefinidos
|
||||
print(confidence_interval_mean([3, 3, 3])["se"]) # -> 0.0 (IC colapsa a [3, 3])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras cuantificar la **incertidumbre de una media estimada** a partir de
|
||||
una muestra: reporta `[ci_low, ci_high]` en vez de un punto suelto para mostrar
|
||||
el rango plausible del valor real al nivel de confianza pedido. Usala tambien
|
||||
para **comparar dos grupos** (A/B test, control vs tratamiento, antes vs
|
||||
despues con grupos independientes): pasa las dos muestras y, si el IC de la
|
||||
diferencia **no incluye el 0**, la diferencia es significativa al nivel
|
||||
`1 - confidence`. Es el complemento del p-valor: ademas de "hay efecto", te dice
|
||||
"de que tamano y con que margen". Para dos muestras usa Welch por defecto, asi
|
||||
que no necesitas comprobar antes si las varianzas son iguales.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Pura y determinista (no hace I/O, no muta las entradas), pero **no** es
|
||||
stdlib-only: depende de `scipy.stats` y `numpy` (ambos en el venv del proyecto).
|
||||
- Con `other` usa **Welch** (df de Welch-Satterthwaite): NO asume varianzas
|
||||
iguales ni tamanos de muestra iguales. Si necesitas el t-test clasico de
|
||||
varianzas agrupadas (pooled), esta funcion no lo hace.
|
||||
- `sd` se calcula con **ddof=1** (sd muestral), que es lo correcto para el IC de
|
||||
una media con la t. Atajos como `sd_poblacional/sqrt(n)` (ddof=0) dan un
|
||||
intervalo demasiado estrecho.
|
||||
- En el caso de dos muestras, `mean` es la **diferencia** `mean(data) - mean(other)`
|
||||
(no la media de data). El orden importa: el signo del IC depende de cual va
|
||||
primero.
|
||||
- Nunca lanza. Casos degenerados devuelven `nan` en `ci_low`/`ci_high`/`se`
|
||||
(y a veces `df`) mas una clave `note`: muestra vacia o `n < 2` en cualquiera de
|
||||
las muestras. **Excepcion**: con varianza cero y `n >= 2` el IC colapsa al
|
||||
punto `[mean, mean]` con `se = 0` (no es un error, no hay `nan`).
|
||||
- Comprueba `"note" in out` antes de usar `ci_low`/`ci_high` si la muestra puede
|
||||
ser degenerada.
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Intervalo de confianza de la media (una muestra) o de la diferencia de medias (Welch).
|
||||
|
||||
Funcion pura del grupo papers. Calcula el intervalo de confianza (IC) de la media
|
||||
de una muestra usando la t de Student, o el IC de la diferencia de medias de dos
|
||||
muestras independientes con el metodo de Welch (sin asumir varianzas iguales).
|
||||
|
||||
- Una muestra: ``df = n - 1``, ``se = sd / sqrt(n)`` (sd con ddof=1),
|
||||
``tcrit = t.ppf((1 + confidence) / 2, df)``, ``ci = mean +/- tcrit * se``.
|
||||
- Dos muestras (Welch): IC de ``mean(data) - mean(other)``, con
|
||||
``se = sqrt(se1^2 + se2^2)`` y grados de libertad de Welch-Satterthwaite.
|
||||
|
||||
No lanza excepciones: ante casos degenerados (muestras vacias, ``n < 2``,
|
||||
varianza cero) devuelve un dict coherente con ``ci_low``/``ci_high``/``se`` en
|
||||
``nan`` (salvo el sub-caso de varianza cero, donde el IC colapsa al punto) y una
|
||||
clave ``note`` explicando el caso. Usa ``scipy.stats`` y ``numpy``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy import stats
|
||||
|
||||
|
||||
def confidence_interval_mean(
|
||||
data: list, other: list = None, confidence: float = 0.95
|
||||
) -> dict:
|
||||
"""Intervalo de confianza de la media o de la diferencia de medias (Welch).
|
||||
|
||||
Si ``other`` es ``None``, calcula el IC de la media de ``data`` con la t de
|
||||
Student. Si se proporciona ``other``, calcula el IC de la diferencia
|
||||
``mean(data) - mean(other)`` con el metodo de Welch (no asume varianzas
|
||||
iguales) y grados de libertad de Welch-Satterthwaite.
|
||||
|
||||
Es una funcion pura y determinista: no hace I/O ni muta las entradas. No
|
||||
lanza excepcion ante datos degenerados; en su lugar devuelve un dict con la
|
||||
clave ``note`` y los campos numericos indefinidos a ``nan``.
|
||||
|
||||
Args:
|
||||
data: muestra de observaciones numericas (lista de numeros).
|
||||
other: segunda muestra independiente. Si se da, el IC es el de la
|
||||
diferencia de medias ``mean(data) - mean(other)`` con Welch. Si es
|
||||
``None`` (default), el IC es el de la media de ``data``.
|
||||
confidence: nivel de confianza en (0, 1), p.ej. 0.95 para el 95%.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
mean: media de ``data`` (una muestra) o la diferencia
|
||||
``mean(data) - mean(other)`` (dos muestras).
|
||||
ci_low: extremo inferior del intervalo de confianza.
|
||||
ci_high: extremo superior del intervalo de confianza.
|
||||
se: error estandar de la media (o de la diferencia).
|
||||
df: grados de libertad de la t (Welch-Satterthwaite si dos muestras).
|
||||
confidence: nivel de confianza aplicado (float).
|
||||
n: tamano de la muestra (una muestra) o tamano total ``n1 + n2``
|
||||
(dos muestras; ademas se incluyen ``n1`` y ``n2``).
|
||||
|
||||
En el caso de dos muestras se incluyen ademas ``n1`` y ``n2``. Casos
|
||||
degenerados (muestra vacia, ``n < 2``, etc.) anaden la clave ``note`` y
|
||||
dejan ``ci_low``/``ci_high``/``se`` (y a veces ``df``) en ``nan``.
|
||||
"""
|
||||
conf = float(confidence)
|
||||
|
||||
if other is None:
|
||||
return _ci_one_sample(data, conf)
|
||||
return _ci_welch(data, other, conf)
|
||||
|
||||
|
||||
def _ci_one_sample(data: list, conf: float) -> dict:
|
||||
"""IC de la media de una sola muestra con la t de Student."""
|
||||
arr = np.asarray(list(data), dtype=float)
|
||||
n = int(arr.size)
|
||||
|
||||
base = {
|
||||
"mean": float("nan"),
|
||||
"ci_low": float("nan"),
|
||||
"ci_high": float("nan"),
|
||||
"se": float("nan"),
|
||||
"df": float("nan"),
|
||||
"confidence": conf,
|
||||
"n": n,
|
||||
}
|
||||
|
||||
if n == 0:
|
||||
base["note"] = "muestra vacia: media e intervalo indefinidos"
|
||||
return base
|
||||
|
||||
mean = float(arr.mean())
|
||||
base["mean"] = mean
|
||||
|
||||
if n < 2:
|
||||
base["note"] = "n < 2: error estandar y grados de libertad indefinidos"
|
||||
return base
|
||||
|
||||
df = n - 1
|
||||
base["df"] = float(df)
|
||||
|
||||
sd = float(arr.std(ddof=1))
|
||||
se = sd / math.sqrt(n)
|
||||
base["se"] = se
|
||||
|
||||
# Varianza cero: el IC colapsa al punto (no es un error).
|
||||
if se == 0.0:
|
||||
base["ci_low"] = mean
|
||||
base["ci_high"] = mean
|
||||
base["note"] = "varianza cero: el intervalo colapsa a la media"
|
||||
return base
|
||||
|
||||
tcrit = float(stats.t.ppf((1.0 + conf) / 2.0, df))
|
||||
margin = tcrit * se
|
||||
base["ci_low"] = mean - margin
|
||||
base["ci_high"] = mean + margin
|
||||
return base
|
||||
|
||||
|
||||
def _ci_welch(data: list, other: list, conf: float) -> dict:
|
||||
"""IC de la diferencia de medias de dos muestras con el metodo de Welch."""
|
||||
a = np.asarray(list(data), dtype=float)
|
||||
b = np.asarray(list(other), dtype=float)
|
||||
n1 = int(a.size)
|
||||
n2 = int(b.size)
|
||||
|
||||
base = {
|
||||
"mean": float("nan"),
|
||||
"ci_low": float("nan"),
|
||||
"ci_high": float("nan"),
|
||||
"se": float("nan"),
|
||||
"df": float("nan"),
|
||||
"confidence": conf,
|
||||
"n": n1 + n2,
|
||||
"n1": n1,
|
||||
"n2": n2,
|
||||
}
|
||||
|
||||
if n1 == 0 or n2 == 0:
|
||||
base["note"] = "alguna muestra esta vacia: diferencia e intervalo indefinidos"
|
||||
return base
|
||||
|
||||
mean1 = float(a.mean())
|
||||
mean2 = float(b.mean())
|
||||
diff = mean1 - mean2
|
||||
base["mean"] = diff
|
||||
|
||||
if n1 < 2 or n2 < 2:
|
||||
base["note"] = (
|
||||
"n < 2 en alguna muestra: error estandar y grados de libertad indefinidos"
|
||||
)
|
||||
return base
|
||||
|
||||
sd1 = float(a.std(ddof=1))
|
||||
sd2 = float(b.std(ddof=1))
|
||||
se1 = sd1 / math.sqrt(n1)
|
||||
se2 = sd2 / math.sqrt(n2)
|
||||
se = math.sqrt(se1 * se1 + se2 * se2)
|
||||
base["se"] = se
|
||||
|
||||
# Ambas varianzas cero: el IC de la diferencia colapsa al punto.
|
||||
if se == 0.0:
|
||||
base["ci_low"] = diff
|
||||
base["ci_high"] = diff
|
||||
base["df"] = float("nan")
|
||||
base["note"] = "varianza cero en ambas muestras: el intervalo colapsa a la diferencia"
|
||||
return base
|
||||
|
||||
# Grados de libertad de Welch-Satterthwaite.
|
||||
df = (se1 * se1 + se2 * se2) ** 2 / (
|
||||
(se1**4) / (n1 - 1) + (se2**4) / (n2 - 1)
|
||||
)
|
||||
base["df"] = float(df)
|
||||
|
||||
tcrit = float(stats.t.ppf((1.0 + conf) / 2.0, df))
|
||||
margin = tcrit * se
|
||||
base["ci_low"] = diff - margin
|
||||
base["ci_high"] = diff + margin
|
||||
return base
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests para confidence_interval_mean (IC de la media / diferencia de medias Welch).
|
||||
|
||||
Importa el modulo hoja directamente (`confidence_interval_mean`) para no depender
|
||||
de que el paquete reexporte la funcion en su __init__ (lo integra el orquestador
|
||||
al cerrar el grupo).
|
||||
|
||||
Los golden se calculan con scipy dentro del propio test para que sean robustos:
|
||||
la funcion bajo prueba debe coincidir con la referencia de scipy a ~1e-9.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy import stats
|
||||
|
||||
from confidence_interval_mean import confidence_interval_mean
|
||||
|
||||
|
||||
def test_one_sample_golden_contra_scipy():
|
||||
# mean=5.0, n=8. Este dataset tiene sd POBLACIONAL (ddof=0) exactamente 2.0,
|
||||
# pero la sd MUESTRAL (ddof=1, la que exige la spec y la que es correcta para
|
||||
# el IC de una media con la t) es sqrt(32/7) ~ 2.13809. El golden robusto se
|
||||
# calcula con scipy usando se con ddof=1, no con el atajo 2.0/sqrt(8).
|
||||
data = [2, 4, 4, 4, 5, 5, 7, 9]
|
||||
out = confidence_interval_mean(data, confidence=0.95)
|
||||
|
||||
n = len(data)
|
||||
mean = float(np.mean(data))
|
||||
sd = float(np.std(data, ddof=1)) # sample sd ~ 2.13809
|
||||
se = sd / math.sqrt(n)
|
||||
lo, hi = stats.t.interval(0.95, df=n - 1, loc=mean, scale=se)
|
||||
|
||||
assert abs(out["mean"] - 5.0) < 1e-9
|
||||
assert abs(out["se"] - se) < 1e-12
|
||||
assert out["df"] == 7.0
|
||||
assert out["n"] == 8
|
||||
assert out["confidence"] == 0.95
|
||||
assert abs(out["ci_low"] - lo) < 1e-9
|
||||
assert abs(out["ci_high"] - hi) < 1e-9
|
||||
# Valores tabulados correctos para ddof=1 (no los 3.32793/6.67207 del
|
||||
# enunciado, que asumian erroneamente sd=2.0 / ddof=0).
|
||||
assert abs(out["ci_low"] - 3.21251) < 1e-3
|
||||
assert abs(out["ci_high"] - 6.78749) < 1e-3
|
||||
assert "note" not in out
|
||||
|
||||
|
||||
def test_one_sample_distinto_nivel_confianza():
|
||||
data = [10.0, 12.0, 11.0, 13.0, 9.0, 14.0]
|
||||
out = confidence_interval_mean(data, confidence=0.99)
|
||||
|
||||
n = len(data)
|
||||
mean = float(np.mean(data))
|
||||
se = float(np.std(data, ddof=1)) / math.sqrt(n)
|
||||
lo, hi = stats.t.interval(0.99, df=n - 1, loc=mean, scale=se)
|
||||
|
||||
assert abs(out["mean"] - mean) < 1e-12
|
||||
assert abs(out["ci_low"] - lo) < 1e-9
|
||||
assert abs(out["ci_high"] - hi) < 1e-9
|
||||
assert out["df"] == float(n - 1)
|
||||
|
||||
|
||||
def test_welch_diferencia_golden_contra_scipy():
|
||||
data = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
|
||||
other = [18.0, 20.0, 17.0, 19.0, 21.0]
|
||||
conf = 0.95
|
||||
out = confidence_interval_mean(data, other, confidence=conf)
|
||||
|
||||
a = np.asarray(data, dtype=float)
|
||||
b = np.asarray(other, dtype=float)
|
||||
n1, n2 = a.size, b.size
|
||||
mean1, mean2 = float(a.mean()), float(b.mean())
|
||||
diff = mean1 - mean2
|
||||
se1 = float(a.std(ddof=1)) / math.sqrt(n1)
|
||||
se2 = float(b.std(ddof=1)) / math.sqrt(n2)
|
||||
se = math.sqrt(se1**2 + se2**2)
|
||||
df = (se1**2 + se2**2) ** 2 / (se1**4 / (n1 - 1) + se2**4 / (n2 - 1))
|
||||
lo, hi = stats.t.interval(conf, df=df, loc=diff, scale=se)
|
||||
|
||||
assert abs(out["mean"] - diff) < 1e-9
|
||||
assert abs(out["mean"] - (mean1 - mean2)) < 1e-9
|
||||
assert abs(out["se"] - se) < 1e-12
|
||||
assert abs(out["df"] - df) < 1e-9
|
||||
assert abs(out["ci_low"] - lo) < 1e-9
|
||||
assert abs(out["ci_high"] - hi) < 1e-9
|
||||
assert out["n1"] == n1
|
||||
assert out["n2"] == n2
|
||||
assert out["n"] == n1 + n2
|
||||
assert "note" not in out
|
||||
|
||||
|
||||
def test_edge_un_solo_elemento_no_lanza_nan_note():
|
||||
out = confidence_interval_mean([5], confidence=0.95)
|
||||
assert out["mean"] == 5.0 # la media si esta definida con n=1
|
||||
assert math.isnan(out["se"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["ci_high"])
|
||||
assert math.isnan(out["df"])
|
||||
assert out["n"] == 1
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_lista_vacia_no_lanza_note():
|
||||
out = confidence_interval_mean([], confidence=0.95)
|
||||
assert math.isnan(out["mean"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["ci_high"])
|
||||
assert math.isnan(out["se"])
|
||||
assert out["n"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_varianza_cero_colapsa_al_punto():
|
||||
out = confidence_interval_mean([3, 3, 3], confidence=0.95)
|
||||
assert out["mean"] == 3.0
|
||||
assert out["se"] == 0.0
|
||||
assert out["ci_low"] == 3.0
|
||||
assert out["ci_high"] == 3.0
|
||||
assert not math.isnan(out["ci_low"])
|
||||
assert out["n"] == 3
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_welch_muestra_vacia_no_lanza_note():
|
||||
out = confidence_interval_mean([1.0, 2.0, 3.0], [], confidence=0.95)
|
||||
assert math.isnan(out["mean"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["se"])
|
||||
assert out["n1"] == 3
|
||||
assert out["n2"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_edge_welch_n1_uno_no_lanza_note():
|
||||
out = confidence_interval_mean([5.0], [1.0, 2.0, 3.0], confidence=0.95)
|
||||
# La diferencia de medias si esta definida.
|
||||
assert abs(out["mean"] - (5.0 - 2.0)) < 1e-9
|
||||
assert math.isnan(out["se"])
|
||||
assert math.isnan(out["ci_low"])
|
||||
assert math.isnan(out["df"])
|
||||
assert "note" in out
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: effect_size_cohens_d
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def effect_size_cohens_d(group_a: list, group_b: list) -> dict"
|
||||
description: "Tamano del efecto (effect size) entre dos grupos numericos: Cohen's d (diferencia de medias estandarizada por la desviacion tipica combinada, varianzas muestrales ddof=1), Hedges' g (d corregido por el sesgo al alza con muestras pequenas via el factor J) e interpretacion cualitativa de la magnitud segun los umbrales clasicos de Cohen (negligible/small/medium/large). El p-valor dice si hay diferencia; el effect size dice como de grande, de forma adimensional e independiente del N. Pura, sin dependencias externas; nunca lanza: los casos degenerados (varianza cero, N<2, listas vacias) devuelven NaN + una clave note."
|
||||
tags: [papers, statistics, effect-size, cohens-d, hedges-g, python]
|
||||
params:
|
||||
- name: group_a
|
||||
desc: "primera muestra (lista de numeros). Necesita >=2 observaciones para que exista la varianza muestral (ddof=1)."
|
||||
- name: group_b
|
||||
desc: "segunda muestra (lista de numeros). Necesita >=2 observaciones. El signo de cohens_d es positivo cuando mean_a > mean_b."
|
||||
output: "dict {cohens_d: float (diferencia de medias estandarizada, puede ser NaN), hedges_g: float (cohens_d * factor de correccion J, puede ser NaN), interpretation: str ('negligible'|'small'|'medium'|'large', o 'undefined' en casos degenerados), n_a: int, n_b: int, mean_a: float, mean_b: float, pooled_sd: float (desviacion tipica combinada)}. Casos degenerados (varianza cero en ambos grupos, N<2 en algun grupo, o listas vacias) anaden clave note. Nunca None ni excepcion."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [math]
|
||||
tested: true
|
||||
tests: ["test_golden_large_effect", "test_hedges_g_menor_en_magnitud_que_cohens_d", "test_interpretation_thresholds", "test_signo_positivo_cuando_a_mayor_que_b", "test_varianza_cero_no_lanza", "test_n_insuficiente_no_lanza", "test_listas_vacias_no_lanza", "test_un_grupo_vacio_no_lanza"]
|
||||
test_file_path: "python/functions/datascience/effect_size_cohens_d_test.py"
|
||||
file_path: "python/functions/datascience/effect_size_cohens_d.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import effect_size_cohens_d
|
||||
|
||||
# Dos grupos desplazados 2 unidades, misma dispersion.
|
||||
a = [1, 2, 3, 4, 5] # media 3, varianza muestral 2.5
|
||||
b = [3, 4, 5, 6, 7] # media 5, varianza muestral 2.5
|
||||
|
||||
out = effect_size_cohens_d(a, b)
|
||||
print(out["cohens_d"]) # -> -1.264911... (a esta 1.26 SD por debajo de b)
|
||||
print(out["hedges_g"]) # -> -1.142500... (|g| < |d|: correccion N pequeno)
|
||||
print(out["interpretation"]) # -> "large" (|d| >= 0.8)
|
||||
print(out["pooled_sd"]) # -> 1.581138...
|
||||
|
||||
# Caso degenerado: varianza cero -> no lanza, NaN + note.
|
||||
deg = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
|
||||
print(deg["interpretation"]) # -> "undefined"
|
||||
print(deg["note"]) # -> "varianza cero, effect size indefinido"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya sepas que dos grupos difieren (o quieras cuantificar su diferencia)
|
||||
y necesites una medida **de magnitud, no de significancia**: comparar el antes
|
||||
y el despues de una intervencion, el grupo control frente al tratamiento, o dos
|
||||
cohortes. Reportala junto al p-valor para responder "¿como de grande es la
|
||||
diferencia?" — un p-valor minusculo con N enorme puede esconder un efecto
|
||||
trivial. Es adimensional (en unidades de desviaciones tipicas), asi que hace
|
||||
comparables resultados entre estudios y alimenta meta-analisis. Usa **Hedges' g**
|
||||
en lugar de Cohen's d cuando los grupos sean pequenos (decenas o menos): d
|
||||
sobreestima el efecto y g lo corrige.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Pura y sin dependencias externas (solo `math` de la stdlib).
|
||||
- Usa **varianza muestral** (ddof=1), no poblacional. Por eso cada grupo
|
||||
necesita al menos 2 observaciones; con N=1 la varianza muestral no existe y la
|
||||
funcion devuelve NaN + `note`.
|
||||
- **Nunca lanza excepcion**. Los casos degenerados devuelven `cohens_d` y
|
||||
`hedges_g` a `float('nan')`, `interpretation="undefined"` y una clave `note`:
|
||||
varianza cero en ambos grupos (`pooled_sd == 0`), N<2 en algun grupo, o listas
|
||||
vacias. Comprueba con `math.isnan(out["cohens_d"])` o la presencia de `note`
|
||||
antes de usar el resultado.
|
||||
- El **signo** de `cohens_d` depende del orden de los argumentos: positivo si
|
||||
`mean_a > mean_b`, negativo en caso contrario. La `interpretation` usa `|d|`,
|
||||
asi que no depende del orden.
|
||||
- `pooled_sd` asume varianzas comparables entre grupos (homogeneidad). Si las
|
||||
dispersiones son muy distintas, Cohen's d clasico pierde precision; considera
|
||||
variantes (Glass's delta) fuera del alcance de esta funcion.
|
||||
- Los umbrales de Cohen (0.2 / 0.5 / 0.8) son convencion, no ley: interpretalos
|
||||
segun el dominio.
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Effect size de dos grupos: Cohen's d, Hedges' g e interpretacion cualitativa.
|
||||
|
||||
Funcion pura del grupo papers. El p-valor responde a "¿hay diferencia?" pero no
|
||||
a "¿como de grande es?". El tamano del efecto (effect size) cuantifica la
|
||||
magnitud de la diferencia entre dos grupos de forma adimensional, independiente
|
||||
del N, y es lo que hace comparables resultados entre estudios (meta-analisis).
|
||||
|
||||
- Cohen's d: diferencia de medias estandarizada por la desviacion tipica
|
||||
combinada (pooled SD), con varianzas muestrales (ddof=1).
|
||||
- Hedges' g: Cohen's d corregido por el sesgo al alza que sufre d con muestras
|
||||
pequenas, multiplicando por el factor de correccion J.
|
||||
- interpretation: etiqueta cualitativa de |d| segun los umbrales clasicos de
|
||||
Cohen (negligible / small / medium / large).
|
||||
|
||||
No usa dependencias externas: aritmetica de la libreria estandar (``math``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def _mean(xs: list) -> float:
|
||||
"""Media aritmetica de una lista no vacia de numeros."""
|
||||
return sum(float(x) for x in xs) / len(xs)
|
||||
|
||||
|
||||
def _sample_variance(xs: list, mean: float) -> float:
|
||||
"""Varianza muestral (ddof=1) de una lista con al menos 2 elementos."""
|
||||
n = len(xs)
|
||||
return sum((float(x) - mean) ** 2 for x in xs) / (n - 1)
|
||||
|
||||
|
||||
def _interpret(abs_d: float) -> str:
|
||||
"""Etiqueta cualitativa del tamano del efecto segun |d| (umbrales de Cohen)."""
|
||||
if abs_d < 0.2:
|
||||
return "negligible"
|
||||
if abs_d < 0.5:
|
||||
return "small"
|
||||
if abs_d < 0.8:
|
||||
return "medium"
|
||||
return "large"
|
||||
|
||||
|
||||
def effect_size_cohens_d(group_a: list, group_b: list) -> dict:
|
||||
"""Calcula el tamano del efecto entre dos grupos numericos.
|
||||
|
||||
Devuelve Cohen's d (diferencia de medias estandarizada por la pooled SD),
|
||||
Hedges' g (d corregido por sesgo de muestra pequena) y una etiqueta
|
||||
cualitativa de la magnitud segun los umbrales de Cohen.
|
||||
|
||||
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
|
||||
excepcion ante datos degenerados; en su lugar devuelve un dict con
|
||||
``cohens_d`` / ``hedges_g`` a ``float('nan')``, ``interpretation`` a
|
||||
``"undefined"`` y una clave ``note`` explicando el caso.
|
||||
|
||||
Definiciones:
|
||||
s_pooled = sqrt(((n1-1)*s1^2 + (n2-1)*s2^2) / (n1+n2-2)), con s1^2, s2^2
|
||||
varianzas muestrales (ddof=1).
|
||||
cohens_d = (mean_a - mean_b) / s_pooled.
|
||||
J = 1 - 3 / (4*(n1+n2) - 9) (factor de correccion de Hedges).
|
||||
hedges_g = cohens_d * J.
|
||||
|
||||
Args:
|
||||
group_a: primera muestra (lista de numeros). Necesita >=2 elementos para
|
||||
que exista la varianza muestral.
|
||||
group_b: segunda muestra (lista de numeros). Necesita >=2 elementos.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
cohens_d: float, diferencia de medias estandarizada (puede ser NaN).
|
||||
hedges_g: float, Cohen's d corregido por sesgo (puede ser NaN).
|
||||
interpretation: str, "negligible" | "small" | "medium" | "large", o
|
||||
"undefined" en casos degenerados.
|
||||
n_a: int, tamano de group_a.
|
||||
n_b: int, tamano de group_b.
|
||||
mean_a: float, media de group_a (NaN si vacio).
|
||||
mean_b: float, media de group_b (NaN si vacio).
|
||||
pooled_sd: float, desviacion tipica combinada (NaN si indefinida).
|
||||
|
||||
Casos degenerados (lista vacia, N<2 en algun grupo, o varianza cero en
|
||||
ambos grupos -> pooled_sd == 0) anaden ademas una clave ``note``.
|
||||
"""
|
||||
nan = float("nan")
|
||||
n_a = len(group_a)
|
||||
n_b = len(group_b)
|
||||
|
||||
# Listas vacias: ni media ni varianza definidas.
|
||||
if n_a == 0 or n_b == 0:
|
||||
return {
|
||||
"cohens_d": nan,
|
||||
"hedges_g": nan,
|
||||
"interpretation": "undefined",
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": _mean(group_a) if n_a else nan,
|
||||
"mean_b": _mean(group_b) if n_b else nan,
|
||||
"pooled_sd": nan,
|
||||
"note": "grupo vacio: media y varianza indefinidas, effect size indefinido",
|
||||
}
|
||||
|
||||
mean_a = _mean(group_a)
|
||||
mean_b = _mean(group_b)
|
||||
|
||||
# N insuficiente: la varianza muestral (ddof=1) no existe con un solo dato,
|
||||
# y la correccion de Hedges no es fiable.
|
||||
if n_a < 2 or n_b < 2:
|
||||
return {
|
||||
"cohens_d": nan,
|
||||
"hedges_g": nan,
|
||||
"interpretation": "undefined",
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": mean_a,
|
||||
"mean_b": mean_b,
|
||||
"pooled_sd": nan,
|
||||
"note": (
|
||||
"N insuficiente: cada grupo necesita >=2 observaciones para la "
|
||||
"varianza muestral; effect size indefinido"
|
||||
),
|
||||
}
|
||||
|
||||
var_a = _sample_variance(group_a, mean_a)
|
||||
var_b = _sample_variance(group_b, mean_b)
|
||||
pooled_sd = math.sqrt(
|
||||
((n_a - 1) * var_a + (n_b - 1) * var_b) / (n_a + n_b - 2)
|
||||
)
|
||||
|
||||
# Varianza cero en ambos grupos: no se puede estandarizar (division por 0).
|
||||
if pooled_sd == 0.0:
|
||||
return {
|
||||
"cohens_d": nan,
|
||||
"hedges_g": nan,
|
||||
"interpretation": "undefined",
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": mean_a,
|
||||
"mean_b": mean_b,
|
||||
"pooled_sd": 0.0,
|
||||
"note": "varianza cero, effect size indefinido",
|
||||
}
|
||||
|
||||
cohens_d = (mean_a - mean_b) / pooled_sd
|
||||
j = 1.0 - 3.0 / (4.0 * (n_a + n_b) - 9.0)
|
||||
hedges_g = cohens_d * j
|
||||
|
||||
return {
|
||||
"cohens_d": cohens_d,
|
||||
"hedges_g": hedges_g,
|
||||
"interpretation": _interpret(abs(cohens_d)),
|
||||
"n_a": n_a,
|
||||
"n_b": n_b,
|
||||
"mean_a": mean_a,
|
||||
"mean_b": mean_b,
|
||||
"pooled_sd": pooled_sd,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests para effect_size_cohens_d (tamano del efecto de dos grupos).
|
||||
|
||||
Importa el modulo hoja directamente (`effect_size_cohens_d`) para no depender de
|
||||
que el paquete reexporte la funcion en su __init__ (lo integra el orquestador al
|
||||
cerrar el grupo papers). El pytest del repo tiene pythonpath=["functions", ...],
|
||||
asi que el modulo hoja se resuelve por su nombre directo.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from effect_size_cohens_d import effect_size_cohens_d
|
||||
|
||||
|
||||
def test_golden_large_effect():
|
||||
# group_a: mean 3, var muestral 2.5; group_b: mean 5, var 2.5.
|
||||
# pooled_sd = sqrt(2.5) ~= 1.5811388.
|
||||
# cohens_d = (3-5)/1.5811388 ~= -1.264911.
|
||||
# J = 1 - 3/(4*10-9) = 1 - 3/31 = 0.9032258.
|
||||
# hedges_g = d * J = -1.2649111 * 0.9032258 ~= -1.142500.
|
||||
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
|
||||
assert abs(out["cohens_d"] - (-1.26491)) < 1e-4
|
||||
assert abs(out["hedges_g"] - (-1.14250)) < 1e-4
|
||||
assert out["interpretation"] == "large"
|
||||
assert out["n_a"] == 5
|
||||
assert out["n_b"] == 5
|
||||
assert abs(out["mean_a"] - 3.0) < 1e-12
|
||||
assert abs(out["mean_b"] - 5.0) < 1e-12
|
||||
assert abs(out["pooled_sd"] - math.sqrt(2.5)) < 1e-9
|
||||
assert "note" not in out
|
||||
|
||||
|
||||
def test_hedges_g_menor_en_magnitud_que_cohens_d():
|
||||
# La correccion J esta en (0, 1), asi que |g| < |d| siempre.
|
||||
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
|
||||
assert abs(out["hedges_g"]) < abs(out["cohens_d"])
|
||||
|
||||
|
||||
def test_interpretation_thresholds():
|
||||
# negligible: |d| < 0.2. Medias casi iguales con varianza grande.
|
||||
neg = effect_size_cohens_d([0, 10, 20, 30], [1, 11, 21, 31])
|
||||
assert neg["interpretation"] == "negligible"
|
||||
assert abs(neg["cohens_d"]) < 0.2
|
||||
|
||||
# small: 0.2 <= |d| < 0.5.
|
||||
small = effect_size_cohens_d([0, 10, 20, 30], [4, 14, 24, 34])
|
||||
assert small["interpretation"] == "small"
|
||||
assert 0.2 <= abs(small["cohens_d"]) < 0.5
|
||||
|
||||
# medium: 0.5 <= |d| < 0.8.
|
||||
medium = effect_size_cohens_d([0, 10, 20, 30], [9, 19, 29, 39])
|
||||
assert medium["interpretation"] == "medium"
|
||||
assert 0.5 <= abs(medium["cohens_d"]) < 0.8
|
||||
|
||||
|
||||
def test_signo_positivo_cuando_a_mayor_que_b():
|
||||
out = effect_size_cohens_d([10, 12, 14, 16], [1, 2, 3, 4])
|
||||
assert out["cohens_d"] > 0
|
||||
assert out["interpretation"] == "large"
|
||||
|
||||
|
||||
def test_varianza_cero_no_lanza():
|
||||
out = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert math.isnan(out["hedges_g"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["pooled_sd"] == 0.0
|
||||
assert "note" in out
|
||||
assert "varianza cero" in out["note"]
|
||||
|
||||
|
||||
def test_n_insuficiente_no_lanza():
|
||||
out = effect_size_cohens_d([3], [1, 2, 3])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert math.isnan(out["hedges_g"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["n_a"] == 1
|
||||
assert out["n_b"] == 3
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_listas_vacias_no_lanza():
|
||||
out = effect_size_cohens_d([], [])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert math.isnan(out["hedges_g"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["n_a"] == 0
|
||||
assert out["n_b"] == 0
|
||||
assert "note" in out
|
||||
|
||||
|
||||
def test_un_grupo_vacio_no_lanza():
|
||||
out = effect_size_cohens_d([1, 2, 3], [])
|
||||
assert math.isnan(out["cohens_d"])
|
||||
assert out["interpretation"] == "undefined"
|
||||
assert out["n_b"] == 0
|
||||
assert "note" in out
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
name: extract_null_mask
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def extract_null_mask(query_fn, table: str, columns: list, max_rows: int = 5000) -> dict"
|
||||
description: "Extrae la mascara de nulos (1=falta / 0=presente) de una muestra de filas de una tabla, una lista 0/1 por columna alineada por fila, para alimentar el capitulo de calidad / patron de nulos de AutomaticEDA sin que el capitulo toque la base de datos. Recibe un lector read-only inyectado `query_fn(sql) -> dict` (mismo contrato que duckdb_query_readonly / pg_query / el `_q` de profile_table) y NO abre ninguna conexion por su cuenta. Construye UNA sola query que proyecta por cada columna `CASE WHEN \"col\" IS NULL THEN 1 ELSE 0 END` con identificadores escapados y LIMIT. Devuelve dict dict-no-throw: columns (efectivamente leidas, en orden), mask (lista int 0/1 por columna, misma longitud todas) y n. Una celda None se cuenta defensivamente como 1 (falta)."
|
||||
tags: [eda, nulls, missing, datascience, automatic-eda, extraction, read-only, duckdb, postgres, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: query_fn
|
||||
desc: "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 o el `_q` de profile_table). NO se abre ninguna conexion dentro de la funcion: toda la lectura pasa por query_fn. Si es None -> error."
|
||||
- name: table
|
||||
desc: "nombre de la tabla de la que muestrear la mascara de nulos. Se escapa con comillas dobles en la query. Vacio o None -> status error."
|
||||
- name: columns
|
||||
desc: "lista de nombres de columna a evaluar. Cada una produce una entrada en `mask` con una lista 0/1 paralela por fila (1=IS NULL, 0=presente). Cada nombre se escapa con comillas dobles. Vacia o None -> status error."
|
||||
- name: max_rows
|
||||
desc: "limite de filas a muestrear (clausula LIMIT). Default 5000. Protege frente a tablas enormes; con LIMIT obtienes el primer tramo, no un muestreo uniforme."
|
||||
output: "dict (nunca lanza). En exito: {'status':'ok','table':str,'columns':[str,...] (en orden),'mask':{col:[int 0/1,...],...} (1=falta/IS NULL, 0=presente; todas las listas con misma longitud = n),'n':int}. En error (sin lanzar): {'status':'error','error':str,'table':str,'columns':[],'mask':{},'n':0}. Errores: query_fn None, table vacia, columns vacia, o query_fn devuelve status!='ok' (se propaga su error)."
|
||||
tested: true
|
||||
tests: ["test_golden_mask_alineada", "test_celda_none_cuenta_como_falta", "test_columns_vacia_status_error", "test_query_fn_status_error_propaga", "test_query_fn_none_da_error_sin_reventar", "test_sql_contiene_case_y_limit"]
|
||||
test_file_path: "python/functions/datascience/extract_null_mask_test.py"
|
||||
file_path: "python/functions/datascience/extract_null_mask.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.extract_null_mask import extract_null_mask
|
||||
from infra import duckdb_query_readonly
|
||||
|
||||
# El lector read-only se inyecta como closure (igual que el `_q` de profile_table).
|
||||
db = "data/clientes.duckdb"
|
||||
def _q(sql):
|
||||
return duckdb_query_readonly(db, sql)
|
||||
|
||||
res = extract_null_mask(_q, "clientes", ["email", "telefono", "edad"])
|
||||
# res == {
|
||||
# "status": "ok",
|
||||
# "table": "clientes",
|
||||
# "columns": ["email", "telefono", "edad"],
|
||||
# "mask": {
|
||||
# "email": [0, 0, 1, 0, ...], # fila 2 sin email
|
||||
# "telefono": [1, 0, 1, 0, ...],
|
||||
# "edad": [0, 0, 0, 1, ...],
|
||||
# },
|
||||
# "n": 5000,
|
||||
# }
|
||||
|
||||
# % de nulos por columna a partir de la muestra:
|
||||
pct = {c: 100 * sum(bits) / max(res["n"], 1) for c, bits in res["mask"].items()}
|
||||
|
||||
# Se entrega al capitulo de calidad sin que este toque la BD:
|
||||
ctx = {"null_mask": res}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el capitulo de calidad / patron de nulos de AutomaticEDA necesita saber
|
||||
DONDE faltan los valores (no solo cuantos) y NO debe abrir la base de datos por
|
||||
su cuenta: extraes aqui la mascara 0/1 por columna alineada por fila y se la pasas
|
||||
en `ctx['null_mask']`. Usala siempre que quieras detectar co-ocurrencia de nulos
|
||||
(filas que fallan en varias columnas a la vez), calcular el % de nulos sobre una
|
||||
muestra, o pintar un heatmap de missingness reutilizando un unico lector read-only
|
||||
inyectado, en vez de hacer N `COUNT(*) WHERE col IS NULL` por separado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee de la base de datos a traves de `query_fn`. No abre conexiones
|
||||
por su cuenta — depende por completo del lector inyectado. Sigue el estilo
|
||||
dict-no-throw del grupo `eda`: nunca lanza; ante cualquier fallo devuelve
|
||||
`{"status":"error","error":...}` con `columns=[]`, `mask={}`, `n=0`.
|
||||
- **`error_type` en el frontmatter es `error_go_core` por convencion del registry**
|
||||
(toda funcion impura debe declararlo y el indexer lo exige), pero el codigo
|
||||
NO lanza esa excepcion: degrada al dict de error. Es metadata, no comportamiento.
|
||||
- **Muestra, no censo**: con `LIMIT max_rows` obtienes el primer tramo de filas que
|
||||
devuelva el backend, no un muestreo uniforme ni la tabla entera. El % de nulos
|
||||
derivado es una estimacion sobre esa muestra; para el conteo exacto usa un
|
||||
agregado `COUNT(*)`/`COUNT(col)` aparte.
|
||||
- **Alineacion por fila**: `mask[col][i]` corresponde a la misma fila `i` que
|
||||
`mask[otra_col][i]`. Todas las listas tienen longitud `n`, asi que puedes cruzar
|
||||
columnas por indice (co-ocurrencia de nulos) sin re-alinear.
|
||||
- **Defensa None -> 1**: el SQL ya devuelve 0/1, pero si una celda llega como `None`
|
||||
(CASE no aplicado, columna ausente en la fila, backend que nulifica) se cuenta
|
||||
como 1 (falta). Un valor inesperado no convertible a int se trata como presente (0).
|
||||
- **No loguear los datos crudos**: aunque `mask` es solo 0/1, los nombres de columna
|
||||
pueden revelar el esquema. En trazas usa `n` y el numero de columnas, no el dict
|
||||
completo.
|
||||
@@ -1,101 +0,0 @@
|
||||
"""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)}
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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
|
||||
@@ -3,19 +3,19 @@ name: fdr_correction
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict"
|
||||
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh') o Bonferroni (FWER, 'bonferroni'). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)."
|
||||
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, p-value, data-mining-bias, python]
|
||||
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh'), Bonferroni (FWER, 'bonferroni') o Holm-Bonferroni (FWER step-down, 'holm', mas potente que Bonferroni simple). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)."
|
||||
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, holm, holm-bonferroni, fwer, p-value, data-mining-bias, python]
|
||||
params:
|
||||
- name: pvalues
|
||||
desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)."
|
||||
- name: alpha
|
||||
desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)."
|
||||
- name: method
|
||||
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador). Cualquier otro valor devuelve un dict con note."
|
||||
output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
|
||||
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador); 'holm' = Holm-Bonferroni (controla FWER, step-down, uniformemente mas potente que Bonferroni simple). Cualquier otro valor devuelve un dict con note."
|
||||
output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str ('bh' | 'bonferroni' | 'holm')}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -23,7 +23,7 @@ returns_optional: false
|
||||
error_type: ""
|
||||
imports: [math]
|
||||
tested: true
|
||||
tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos"]
|
||||
tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos", "test_holm_golden_rechaza_dos_de_cuatro", "test_holm_entre_bonferroni_y_bh", "test_none_se_propaga_alineado_holm", "test_lista_vacia_holm_devuelve_note"]
|
||||
test_file_path: "python/functions/datascience/fdr_correction_test.py"
|
||||
file_path: "python/functions/datascience/fdr_correction.py"
|
||||
---
|
||||
@@ -45,6 +45,13 @@ bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||
print(bon["reject"]) # -> [True, False, False]
|
||||
print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0]
|
||||
|
||||
# Holm-Bonferroni (step-down): controla el FWER como Bonferroni pero es mas
|
||||
# potente; rechaza al menos tanto como Bonferroni simple, nunca menos.
|
||||
holm = fdr_correction([0.01, 0.04, 0.03, 0.005], alpha=0.05, method="holm")
|
||||
print(holm["reject"]) # -> [True, False, False, True]
|
||||
print(holm["p_values_adjusted"]) # -> [0.03, 0.06, 0.06, 0.02]
|
||||
print(holm["n_rejected"]) # -> 2
|
||||
|
||||
# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la
|
||||
# lista completa de pares y recuperar el mapeo 1:1.
|
||||
mix = fdr_correction([0.001, None, 0.9])
|
||||
@@ -61,8 +68,11 @@ combinaciones y se quede con las que "pasan". Sin corregir, con N pruebas y
|
||||
alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas
|
||||
correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la
|
||||
familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"`
|
||||
por defecto (mejor potencia); `"bonferroni"` cuando un falso positivo sea muy
|
||||
costoso y prefieras maxima cautela.
|
||||
por defecto (mejor potencia); `"holm"` (Holm-Bonferroni, FWER step-down) cuando
|
||||
quieras controlar el FWER pero sin la perdida de potencia de Bonferroni simple
|
||||
(rechaza al menos tanto como `"bonferroni"`, nunca menos); `"bonferroni"` cuando
|
||||
un falso positivo sea muy costoso y prefieras la maxima cautela del metodo mas
|
||||
simple.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -76,8 +86,16 @@ costoso y prefieras maxima cautela.
|
||||
eso puedes pasar la lista completa de pares aunque algunos no tengan test.
|
||||
- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que
|
||||
`len(pvalues)` si hay `None`.
|
||||
- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos
|
||||
descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso
|
||||
- BH controla cosa distinta que Bonferroni/Holm: BH la tasa de falsos
|
||||
descubrimientos (FDR); Bonferroni y Holm la probabilidad de *cualquier* falso
|
||||
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
|
||||
- `"holm"` y `"bonferroni"` controlan ambos el FWER, pero Holm es step-down y
|
||||
uniformemente mas potente: rechaza al menos tantas hipotesis como Bonferroni
|
||||
simple sobre el mismo set, nunca menos. Si controlas FWER, `"holm"` domina a
|
||||
`"bonferroni"` salvo que necesites el ajuste mas simple por interpretabilidad.
|
||||
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
|
||||
con `note`.
|
||||
con `note`. Los metodos validos son `"bh"`, `"bonferroni"` y `"holm"`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-30) — añade method="holm" (Holm-Bonferroni step-down, FWER, más potente que Bonferroni simple).
|
||||
|
||||
@@ -5,12 +5,15 @@ todos los pares de una matriz de asociacion), la probabilidad de obtener al meno
|
||||
un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria
|
||||
de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical
|
||||
Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo
|
||||
mediante dos metodos clasicos:
|
||||
mediante tres metodos clasicos:
|
||||
|
||||
- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos
|
||||
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
|
||||
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia
|
||||
(Family-Wise Error Rate, FWER). Mas conservador.
|
||||
- Holm-Bonferroni (``"holm"``): controla el FWER como Bonferroni pero es un
|
||||
procedimiento step-down uniformemente mas potente; rechaza al menos tantas
|
||||
hipotesis como Bonferroni simple, nunca menos.
|
||||
|
||||
No usa dependencias externas: aritmetica de la libreria estandar.
|
||||
"""
|
||||
@@ -35,8 +38,9 @@ def _is_valid_p(v) -> bool:
|
||||
def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict:
|
||||
"""Corrige una lista de p-valores por comparaciones multiples.
|
||||
|
||||
Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y
|
||||
devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y
|
||||
Aplica Benjamini-Hochberg (FDR), Bonferroni (FWER) o Holm-Bonferroni
|
||||
(FWER, step-down) sobre ``pvalues`` y devuelve, alineado posicion a
|
||||
posicion con la entrada, el p-valor ajustado y
|
||||
si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las
|
||||
posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de
|
||||
``[0, 1]`` o no numerico) se conservan en la salida como ``None`` /
|
||||
@@ -53,8 +57,10 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
otros valores no validos en posiciones sin test disponible; se
|
||||
propagan como ``None`` en la salida y no cuentan como prueba.
|
||||
alpha: nivel de significancia objetivo tras la correccion (default 0.05).
|
||||
Para BH es el umbral del FDR; para Bonferroni, del FWER.
|
||||
method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER).
|
||||
Para BH es el umbral del FDR; para Bonferroni y Holm, del FWER.
|
||||
method: ``"bh"`` (Benjamini-Hochberg, FDR), ``"bonferroni"`` (FWER) o
|
||||
``"holm"`` (Holm-Bonferroni, FWER step-down, mas potente que
|
||||
Bonferroni simple).
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
@@ -68,7 +74,7 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
n_tests: numero de p-valores validos usados en la correccion (m).
|
||||
n_rejected: numero de hipotesis rechazadas (significativas).
|
||||
alpha: nivel de significancia aplicado (float).
|
||||
method: metodo aplicado (``"bh"`` o ``"bonferroni"``).
|
||||
method: metodo aplicado (``"bh"``, ``"bonferroni"`` o ``"holm"``).
|
||||
|
||||
Casos degenerados (lista vacia, sin p-valores validos o metodo
|
||||
desconocido) anaden ademas una clave ``note`` y devuelven listas
|
||||
@@ -76,7 +82,7 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
en las posiciones invalidas).
|
||||
"""
|
||||
method_norm = (method or "").strip().lower()
|
||||
if method_norm not in {"bh", "bonferroni"}:
|
||||
if method_norm not in {"bh", "bonferroni", "holm"}:
|
||||
n = len(pvalues)
|
||||
return {
|
||||
"p_values_adjusted": [None] * n,
|
||||
@@ -86,8 +92,8 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
"alpha": float(alpha),
|
||||
"method": method,
|
||||
"note": (
|
||||
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) "
|
||||
"o 'bonferroni'"
|
||||
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg), "
|
||||
"'bonferroni' o 'holm' (Holm-Bonferroni)"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -129,6 +135,20 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
|
||||
padj = min(1.0, p * m)
|
||||
adjusted[orig_idx] = padj
|
||||
reject[orig_idx] = padj <= a
|
||||
elif method_norm == "holm":
|
||||
# Holm-Bonferroni (step-down). Ordena p ascendente; para el rank k
|
||||
# (1-indexed) el p ajustado crudo es (m - k + 1) * p_(k). Impon
|
||||
# monotonicidad acumulada (no decreciente) recorriendo de menor a mayor:
|
||||
# padj_(k) = max(padj_(k-1), min(1, (m-k+1)*p_(k))), con padj_(0)=0.
|
||||
order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc
|
||||
prev = 0.0
|
||||
for k in range(1, m + 1):
|
||||
orig_idx, p = order[k - 1]
|
||||
raw = min(1.0, (m - k + 1) * p)
|
||||
padj = max(prev, raw)
|
||||
prev = padj
|
||||
adjusted[orig_idx] = padj
|
||||
reject[orig_idx] = padj <= a
|
||||
else:
|
||||
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
|
||||
# con la monotonicidad acumulada de derecha a izquierda.
|
||||
|
||||
@@ -82,7 +82,8 @@ def test_solo_none_devuelve_note():
|
||||
|
||||
|
||||
def test_metodo_desconocido_devuelve_note():
|
||||
out = fdr_correction([0.01, 0.02], method="holm")
|
||||
# 'holm' ya es un metodo valido (v1.1.0); usamos uno realmente desconocido.
|
||||
out = fdr_correction([0.01, 0.02], method="sidak")
|
||||
assert "note" in out
|
||||
assert out["n_rejected"] == 0
|
||||
assert out["reject"] == [False, False]
|
||||
@@ -97,3 +98,66 @@ def test_todos_significativos():
|
||||
assert bon["n_rejected"] == 3
|
||||
assert all(bh["reject"])
|
||||
assert all(bon["reject"])
|
||||
|
||||
|
||||
def test_holm_golden_rechaza_dos_de_cuatro():
|
||||
# Holm-Bonferroni (step-down) sobre [0.01, 0.04, 0.03, 0.005], m=4, alpha=0.05.
|
||||
# Ordenado ascendente: 0.005, 0.01, 0.03, 0.04.
|
||||
# padj_(1) = 4*0.005 = 0.02
|
||||
# padj_(2) = max(0.02, 3*0.01=0.03) = 0.03
|
||||
# padj_(3) = max(0.03, 2*0.03=0.06) = 0.06
|
||||
# padj_(4) = max(0.06, 1*0.04=0.04) = 0.06
|
||||
# Mapeado al orden de entrada [0.01, 0.04, 0.03, 0.005]:
|
||||
# 0.01 -> 0.03, 0.04 -> 0.06, 0.03 -> 0.06, 0.005 -> 0.02
|
||||
out = fdr_correction([0.01, 0.04, 0.03, 0.005], alpha=0.05, method="holm")
|
||||
assert out["method"] == "holm"
|
||||
assert out["n_tests"] == 4
|
||||
adj = out["p_values_adjusted"]
|
||||
assert abs(adj[0] - 0.03) < 1e-9
|
||||
assert abs(adj[1] - 0.06) < 1e-9
|
||||
assert abs(adj[2] - 0.06) < 1e-9
|
||||
assert abs(adj[3] - 0.02) < 1e-9
|
||||
assert out["reject"] == [True, False, False, True]
|
||||
assert out["n_rejected"] == 2
|
||||
|
||||
|
||||
def test_holm_entre_bonferroni_y_bh():
|
||||
# Holm controla FWER como Bonferroni pero es step-down: rechaza AL MENOS
|
||||
# tanto como Bonferroni simple, y a lo sumo tanto como BH (FDR, menos
|
||||
# conservador). Cadena de potencia: bonferroni <= holm <= bh.
|
||||
pvalues = [0.01, 0.02, 0.04, 0.005]
|
||||
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||
holm = fdr_correction(pvalues, alpha=0.05, method="holm")
|
||||
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||
assert holm["n_rejected"] >= bon["n_rejected"]
|
||||
assert holm["n_rejected"] <= bh["n_rejected"]
|
||||
# En este set Holm gana potencia frente a Bonferroni simple (estricto).
|
||||
assert holm["n_rejected"] > bon["n_rejected"]
|
||||
|
||||
# Un set donde Holm es estrictamente mas conservador que BH.
|
||||
pvals2 = [0.01, 0.02, 0.03, 0.04]
|
||||
bon2 = fdr_correction(pvals2, alpha=0.05, method="bonferroni")
|
||||
holm2 = fdr_correction(pvals2, alpha=0.05, method="holm")
|
||||
bh2 = fdr_correction(pvals2, alpha=0.05, method="bh")
|
||||
assert holm2["n_rejected"] >= bon2["n_rejected"]
|
||||
assert holm2["n_rejected"] < bh2["n_rejected"]
|
||||
|
||||
|
||||
def test_none_se_propaga_alineado_holm():
|
||||
# None se propaga alineado tambien con holm: la posicion central no cuenta
|
||||
# como prueba (m=2) y se devuelve como None / False.
|
||||
out = fdr_correction([0.001, None, 0.9], method="holm")
|
||||
assert out["n_tests"] == 2
|
||||
assert out["p_values_adjusted"][1] is None
|
||||
assert out["reject"][1] is False
|
||||
assert out["reject"][0] is True
|
||||
assert len(out["reject"]) == 3
|
||||
|
||||
|
||||
def test_lista_vacia_holm_devuelve_note():
|
||||
out = fdr_correction([], method="holm")
|
||||
assert out["p_values_adjusted"] == []
|
||||
assert out["reject"] == []
|
||||
assert out["n_tests"] == 0
|
||||
assert out["n_rejected"] == 0
|
||||
assert "note" in out
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
id: missingness_corr_heatmap_figure_py_datascience
|
||||
name: missingness_corr_heatmap_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def missingness_corr_heatmap_figure(matrix, labels, title=\"Co-ocurrencia de ausencias\") -> \"matplotlib.figure.Figure\""
|
||||
description: "Construye una figura matplotlib (heatmap) de la matriz NxN de correlación de ausencias entre columnas: +1 = dos columnas suelen ser nulas a la vez, -1 = cuando una falta la otra está presente, 0 = ausencias independientes. Usa ax.imshow con coolwarm fijado a [-1,1], ticks con los labels truncados (X rotados 45º), colorbar y anota el valor de cada celda si N<=12. Devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA (capítulo de datos faltantes). Backend Agg sin pyplot global; defensivo ante matrix/labels vacíos o celdas no numéricas (nunca lanza)."
|
||||
tags: [eda, missing, missingness, correlation, heatmap, matplotlib, figure, visualization, datascience, impure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib]
|
||||
example: |
|
||||
from datascience.missingness_corr_heatmap_figure import missingness_corr_heatmap_figure
|
||||
matrix = [
|
||||
[1.0, 0.82, -0.10],
|
||||
[0.82, 1.0, 0.05],
|
||||
[-0.10, 0.05, 1.0],
|
||||
]
|
||||
labels = ["telefono", "movil", "email"]
|
||||
fig = missingness_corr_heatmap_figure(matrix, labels, title="Co-ocurrencia de ausencias")
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure_with_axes"
|
||||
- "test_empty_matrix_does_not_raise_and_returns_figure"
|
||||
- "test_empty_labels_returns_message_figure"
|
||||
- "test_large_matrix_omits_annotations"
|
||||
- "test_ragged_and_non_numeric_cells_are_handled"
|
||||
test_file_path: "python/functions/datascience/missingness_corr_heatmap_figure_test.py"
|
||||
file_path: "python/functions/datascience/missingness_corr_heatmap_figure.py"
|
||||
params:
|
||||
- name: matrix
|
||||
desc: "Lista de listas (NxN) de floats en [-1,1]: la correlación de ausencias por pares de columnas. Puede venir vacía. Filas de longitud desigual se toleran (se rellenan/recortan a N); celdas None, NaN o no numéricas se coercen a 0.0. No se muta el original."
|
||||
- name: labels
|
||||
desc: "Lista de N nombres de columna, paralela a matrix. Puede venir vacía (devuelve figura \"sin columnas con ausencia variable\"). Se truncan a ~14 chars con elipsis para los ticks; los originales no se mutan."
|
||||
- name: title
|
||||
desc: "Título de la figura. Se trunca a ~60 chars con elipsis si es muy largo. Default \"Co-ocurrencia de ausencias\"."
|
||||
output: "Un matplotlib.figure.Figure (figsize 6.4x5.2, dpi 150) con un Axes heatmap (imshow vmin=-1, vmax=1, cmap coolwarm) más una colorbar etiquetada \"correlación de ausencias\". Ticks en ambos ejes con los labels truncados (X rotados 45º). Si N<=12 cada celda lleva su valor numérico anotado (texto blanco sobre celdas saturadas, oscuro sobre pálidas); con N grande se omiten las anotaciones para no saturar. Si matrix o labels vienen vacíos devuelve una Figure con texto centrado \"sin columnas con ausencia variable\"; cualquier error inesperado se captura y devuelve una Figure con el mensaje de error (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience.missingness_corr_heatmap_figure import missingness_corr_heatmap_figure
|
||||
|
||||
# Correlación de ausencias entre 3 columnas de contacto:
|
||||
# telefono y movil tienden a faltar juntos (0.82); email es casi independiente.
|
||||
matrix = [
|
||||
[1.00, 0.82, -0.10],
|
||||
[0.82, 1.00, 0.05],
|
||||
[-0.10, 0.05, 1.00],
|
||||
]
|
||||
labels = ["telefono", "movil", "email"]
|
||||
|
||||
fig = missingness_corr_heatmap_figure(
|
||||
matrix,
|
||||
labels,
|
||||
title="Co-ocurrencia de ausencias",
|
||||
)
|
||||
|
||||
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||
fig.savefig("/tmp/missingness_heatmap.png")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala en el capítulo de datos faltantes de un informe EDA cuando quieras ver de
|
||||
un vistazo qué columnas faltan juntas (mismo formulario sin rellenar, mismo
|
||||
proceso roto) frente a columnas cuyas ausencias son independientes. Pásale la
|
||||
matriz de correlación de ausencias (calculada sobre la máscara de nulos, p. ej.
|
||||
`df.isnull().corr()`) restringida a las columnas que de verdad tienen ausencia
|
||||
variable, junto con sus nombres. Es la pareja "estructura" del ranking de % de
|
||||
nulos: las barras dicen *cuánto* falta cada columna, este heatmap dice *si las
|
||||
ausencias están relacionadas* entre columnas.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
|
||||
directamente, así que es segura de llamar en bucle desde el renderer.
|
||||
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
|
||||
guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
|
||||
- **Escala de color fija en [-1, 1].** `vmin=-1`, `vmax=1` están fijados a
|
||||
propósito para que el color sea comparable entre informes y entre columnas. No
|
||||
se autoescala al rango real de la matriz; valores fuera de `[-1, 1]` se
|
||||
saturan al extremo del colormap.
|
||||
- **Anotaciones solo con N<=12.** Por encima de 12 columnas el grid de números
|
||||
se vuelve ilegible y se omite; queda solo el color + la colorbar. Filtra a las
|
||||
columnas con ausencia variable antes de llamar para no llegar a matrices
|
||||
enormes.
|
||||
- **Defensiva, nunca lanza.** `matrix=[]`, `labels=[]`, filas cortas, celdas
|
||||
`None`/`NaN`/no numéricas o cualquier error inesperado se manejan sin propagar:
|
||||
en el peor caso devuelve una `Figure` con "sin columnas con ausencia variable"
|
||||
o con el texto del error. No envuelvas la llamada en try/except por miedo a un
|
||||
raise — no lo hay.
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Impure EDA helper: heatmap of missingness co-occurrence (`eda` group).
|
||||
|
||||
Builds a matplotlib heatmap of the pairwise missingness correlation matrix of a
|
||||
dataset: a value near ``+1`` means two columns tend to be null together, near
|
||||
``-1`` means when one is null the other tends to be present, and ``0`` means
|
||||
their absences are independent. Returns a ready-to-rasterize
|
||||
``matplotlib.figure.Figure``; it never shows nor saves it.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||
global state and is safe to call repeatedly from a report renderer.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
# Muted gray for secondary text (no-data / fallback messages).
|
||||
_MUTED_TEXT = "#5f6b7a"
|
||||
# Soft red for the error fallback message (kept readable, not alarming).
|
||||
_ERROR_TEXT = "#b00020"
|
||||
|
||||
|
||||
def _truncate(text, width: int = 14) -> str:
|
||||
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
|
||||
s = "" if text is None else str(text)
|
||||
if len(s) <= width:
|
||||
return s
|
||||
if width <= 1:
|
||||
return s[:width]
|
||||
return s[: width - 1] + "…"
|
||||
|
||||
|
||||
def _message_figure(message: str, color: str = _MUTED_TEXT) -> "Figure":
|
||||
"""Return a fallback ``Figure`` carrying a single centered message."""
|
||||
fig = Figure(figsize=(6.4, 4.0), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color=color,
|
||||
wrap=True,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def missingness_corr_heatmap_figure(
|
||||
matrix,
|
||||
labels,
|
||||
title: str = "Co-ocurrencia de ausencias",
|
||||
) -> "matplotlib.figure.Figure":
|
||||
"""Build a heatmap figure of a missingness correlation matrix.
|
||||
|
||||
Renders an ``NxN`` matrix of missingness correlations in ``[-1, 1]`` with a
|
||||
diverging ``coolwarm`` colormap (fixed ``vmin=-1``, ``vmax=1`` so the color
|
||||
scale is comparable across reports). Both axes are tick-labelled with the
|
||||
column names (truncated to ~14 chars; the X labels rotated 45°). A colorbar
|
||||
is attached. When the matrix is small (``N <= 12``) each cell is annotated
|
||||
with its numeric value; for larger matrices the annotations are omitted to
|
||||
avoid an unreadable grid.
|
||||
|
||||
The function is fully defensive: empty/ragged/non-numeric input never raises.
|
||||
When there is nothing valid to draw it returns a ``Figure`` carrying a
|
||||
centered "sin columnas con ausencia variable" message, and any unexpected
|
||||
error is caught and turned into a fallback ``Figure`` carrying the error text.
|
||||
|
||||
Args:
|
||||
matrix: List of lists (``NxN``) of floats in ``[-1, 1]`` — the pairwise
|
||||
missingness correlation. May be empty; rows of unequal length are
|
||||
tolerated by treating the matrix as invalid only when it is empty or
|
||||
its label count does not match. Non-numeric/``None`` cells are
|
||||
coerced to ``0.0``.
|
||||
labels: List of ``N`` column names, parallel to ``matrix``. May be empty.
|
||||
Truncated for display; the originals are not mutated.
|
||||
title: Figure title. Default "Co-ocurrencia de ausencias".
|
||||
|
||||
Returns:
|
||||
A ``matplotlib.figure.Figure`` with a single heatmap Axes plus a
|
||||
colorbar. The caller is responsible for rasterizing/closing it.
|
||||
"""
|
||||
try:
|
||||
# --- Validate shape: need a non-empty square-ish matrix with labels.
|
||||
if (
|
||||
not isinstance(matrix, (list, tuple))
|
||||
or not isinstance(labels, (list, tuple))
|
||||
or len(matrix) == 0
|
||||
or len(labels) == 0
|
||||
):
|
||||
return _message_figure("sin columnas con ausencia variable")
|
||||
|
||||
n = len(labels)
|
||||
# Build a clean NxN grid: coerce each cell to float, default 0.0, pad/clip
|
||||
# rows so a ragged input never crashes imshow.
|
||||
grid = []
|
||||
for i in range(n):
|
||||
row_src = matrix[i] if i < len(matrix) else []
|
||||
if not isinstance(row_src, (list, tuple)):
|
||||
row_src = []
|
||||
row = []
|
||||
for j in range(n):
|
||||
cell = row_src[j] if j < len(row_src) else 0.0
|
||||
try:
|
||||
val = float(cell)
|
||||
except (TypeError, ValueError):
|
||||
val = 0.0
|
||||
if val != val: # NaN guard.
|
||||
val = 0.0
|
||||
row.append(val)
|
||||
grid.append(row)
|
||||
|
||||
fig = Figure(figsize=(6.4, 5.2), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
|
||||
im = ax.imshow(grid, vmin=-1, vmax=1, cmap="coolwarm", aspect="equal")
|
||||
|
||||
short = [_truncate(lab, 14) for lab in labels]
|
||||
ax.set_xticks(range(n))
|
||||
ax.set_yticks(range(n))
|
||||
ax.set_xticklabels(short, rotation=45, ha="right", fontsize=8)
|
||||
ax.set_yticklabels(short, fontsize=8)
|
||||
|
||||
# Annotate each cell only when the grid is small enough to stay legible.
|
||||
if n <= 12:
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
val = grid[i][j]
|
||||
# White text over saturated (dark) cells, dark over pale.
|
||||
txt_color = "white" if abs(val) >= 0.55 else "#202020"
|
||||
ax.text(
|
||||
j,
|
||||
i,
|
||||
f"{val:.2f}",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=7,
|
||||
color=txt_color,
|
||||
)
|
||||
|
||||
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
||||
cbar.ax.tick_params(labelsize=8)
|
||||
cbar.set_label("correlación de ausencias", fontsize=8)
|
||||
|
||||
if title:
|
||||
ax.set_title(_truncate(title, 60), fontsize=12, loc="center", pad=10)
|
||||
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
|
||||
return _message_figure(f"error al dibujar heatmap: {exc}", color=_ERROR_TEXT)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests para missingness_corr_heatmap_figure (heatmap de ausencias, grupo eda).
|
||||
|
||||
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
|
||||
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
|
||||
estado entre tests.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from missingness_corr_heatmap_figure import missingness_corr_heatmap_figure
|
||||
|
||||
|
||||
def _identity_matrix(n):
|
||||
"""Matriz NxN con diagonal 1.0 y resto 0.0 (correlación de ausencias)."""
|
||||
return [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
|
||||
|
||||
|
||||
def test_returns_figure_with_axes():
|
||||
matrix = [[1.0, 0.3, -0.2], [0.3, 1.0, 0.5], [-0.2, 0.5, 1.0]]
|
||||
labels = ["edad", "ingresos", "ciudad"]
|
||||
fig = missingness_corr_heatmap_figure(matrix, labels, title="ausencias")
|
||||
assert isinstance(fig, Figure)
|
||||
# Heatmap (>=1 axes) + colorbar añade su propio Axes -> al menos 1.
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_matrix_does_not_raise_and_returns_figure():
|
||||
fig = missingness_corr_heatmap_figure([], [], title="vacía")
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_labels_returns_message_figure():
|
||||
fig = missingness_corr_heatmap_figure([[1.0]], [], title="sin labels")
|
||||
assert isinstance(fig, Figure)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_large_matrix_omits_annotations():
|
||||
n = 16
|
||||
fig = missingness_corr_heatmap_figure(
|
||||
_identity_matrix(n), [f"col_{i}" for i in range(n)]
|
||||
)
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_ragged_and_non_numeric_cells_are_handled():
|
||||
# Fila corta + celda None + celda string -> se rellenan/coercen sin lanzar.
|
||||
matrix = [[1.0, None], ["x", 1.0, 0.5]]
|
||||
labels = ["a", "b"]
|
||||
fig = missingness_corr_heatmap_figure(matrix, labels)
|
||||
assert isinstance(fig, Figure)
|
||||
plt.close(fig)
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: missingness_correlation
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def missingness_correlation(null_mask: dict, top_k: int = 20) -> dict"
|
||||
description: "Co-ocurrencia de ausencias: nucleo del capitulo de missingness del grupo eda. Recibe la mascara binaria de nulos de una tabla (1 = falta, 0 = presente, alineada por fila) y mide hasta que punto las columnas faltan juntas. Calcula la matriz de correlacion de Pearson entre los vectores binarios de ausencia de las columnas con varianza (al menos un 1 y un 0), mas las cifras de solapamiento de conjuntos por par (co-missing, either-missing, Jaccard). Excluye las columnas constantes en su ausencia (correlacion indefinida) y reporta cuantas. Compone la funcion atomica pearson del registry; no la reimplementa. Lectura defensiva; NUNCA lanza."
|
||||
tags: [eda, missingness, correlation, pearson, co-occurrence, jaccard, datascience]
|
||||
params:
|
||||
- name: null_mask
|
||||
desc: "dict {col: [int 0/1, ...]} con la mascara de ausencias de la tabla, alineada por fila: 1 = el valor falta en esa fila, 0 = presente. Todas las listas se asumen de la misma longitud (numero de filas). Valores truthy distintos de 0 se tratan como ausencia; entradas no-lista se ignoran sin romper."
|
||||
- name: top_k
|
||||
desc: "Numero maximo de pares a devolver en `pairs`, ordenados por valor absoluto de correlacion descendente. Default 20. Solo limita la lista de pares; la matriz cubre siempre todas las columnas con varianza."
|
||||
output: "dict con: columns (columnas con varianza en la ausencia, en orden de entrada); matrix (len(columns) x len(columns) de correlacion de Pearson entre las mascaras binarias, diagonal 1.0); pairs (hasta top_k pares i<j ordenados por |corr| desc, cada uno {a, b, corr, co_missing, either_missing, jaccard} donde co_missing = filas en que ambas faltan, either_missing = filas en que al menos una falta, jaccard = co_missing/either_missing o 0.0 si either_missing=0); n_excluded (nº de columnas con algun nulo pero sin varianza, constantes en la ausencia); excluded_cols (esas columnas en orden de entrada). Si hay <2 columnas con varianza, columns/matrix/pairs van vacios pero n_excluded/excluded_cols se rellenan. NUNCA lanza."
|
||||
uses_functions: [pearson_py_datascience]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_co_ocurrencia_fuerte_corr_uno_jaccard_uno", "test_ausencias_disjuntas_corr_negativa_jaccard_cero", "test_columna_sin_varianza_se_excluye", "test_menos_de_dos_columnas_con_varianza_vacio_pero_cuenta_excluidas", "test_mask_vacio_todo_vacio", "test_top_k_limita_pares", "test_no_lanza_con_entradas_raras"]
|
||||
test_file_path: "python/functions/datascience/missingness_correlation_test.py"
|
||||
file_path: "python/functions/datascience/missingness_correlation.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.missingness_correlation import missingness_correlation
|
||||
|
||||
# Mascara de ausencias de 6 filas. 1 = falta, 0 = presente.
|
||||
mask = {
|
||||
"ingresos": [1, 0, 1, 0, 1, 0], # falta junto a "deducciones"
|
||||
"deducciones": [1, 0, 1, 0, 1, 0], # mismas filas que "ingresos"
|
||||
"telefono": [0, 0, 0, 1, 0, 0], # casi siempre presente
|
||||
"verificado": [1, 1, 1, 1, 1, 1], # siempre ausente -> constante, excluida
|
||||
}
|
||||
out = missingness_correlation(mask, top_k=10)
|
||||
|
||||
print(out["columns"]) # ['ingresos', 'deducciones', 'telefono']
|
||||
print(out["n_excluded"]) # 1
|
||||
print(out["excluded_cols"]) # ['verificado']
|
||||
|
||||
# El par mas fuerte: ingresos y deducciones faltan siempre juntas.
|
||||
top = out["pairs"][0]
|
||||
print(top["a"], top["b"], round(top["corr"], 3)) # ingresos deducciones 1.0
|
||||
print(top["co_missing"], top["either_missing"], top["jaccard"]) # 3 3 1.0
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Usala en el capitulo de **missingness** de `AutomaticEDA` cuando ya tengas la mascara binaria de nulos por columna y quieras detectar **patrones de ausencia conjunta**: que columnas faltan siempre juntas (posible misma fuente/proceso roto) y cuales faltan de forma independiente.
|
||||
- Cuando necesites ordenar los pares de columnas por fuerza de co-ocurrencia (|corr|) para priorizar que bloques de ausencia investigar o imputar juntos.
|
||||
- Cuando quieras la cifra de solapamiento de conjuntos (Jaccard, co-missing) ademas de la correlacion lineal, para distinguir "faltan juntas" de "estan presentes juntas".
|
||||
- Antes de elegir una estrategia de imputacion: dos columnas con corr de ausencia ~1.0 no aportan informacion independiente sobre por que falta la otra.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion pura, sin I/O y determinista. Lectura defensiva: entradas no-dict, columnas no-lista o vacias se ignoran sin lanzar.
|
||||
- Solo entran al calculo las columnas con **varianza en la ausencia** (al menos un 1 y al menos un 0). Una columna siempre-presente (todo 0) no aporta ausencia y **no** se cuenta como excluida; una columna siempre-ausente o constante con nulos (todo 1) tiene correlacion indefinida y se excluye, sumando a `n_excluded` / `excluded_cols`.
|
||||
- Con menos de 2 columnas con varianza, `columns`/`matrix`/`pairs` quedan vacios pero `n_excluded`/`excluded_cols` se rellenan igual — el caller debe contemplar el caso "sin pares".
|
||||
- La correlacion es la de Pearson sobre vectores binarios (equivale al coeficiente phi). El signo importa: corr negativa = las ausencias tienden a ser **complementarias** (cuando una falta, la otra suele estar presente).
|
||||
- Asume todas las listas alineadas por fila y de la misma longitud. Si vienen de longitudes distintas, `pearson` opera sobre el solapamiento que permita `zip` y degrada a 0.0 cuando no hay varianza efectiva; alinea la mascara antes de llamar.
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Co-ocurrencia de ausencias: matriz de correlacion de Pearson entre mascaras de nulos.
|
||||
|
||||
Funcion pura del grupo eda, nucleo del capitulo de missingness. Recibe la mascara
|
||||
binaria de ausencias de una tabla (1 = falta, 0 = presente, alineada por fila) y
|
||||
mide hasta que punto las columnas faltan juntas. Para cada par de columnas con
|
||||
varianza en su ausencia calcula la correlacion de Pearson entre los vectores
|
||||
binarios, mas las cifras de solapamiento de conjuntos (co-missing, either-missing,
|
||||
Jaccard). Compone la funcion atomica `pearson` del registry; no reimplementa la
|
||||
correlacion. Lectura defensiva; NUNCA lanza.
|
||||
"""
|
||||
|
||||
from datascience import pearson
|
||||
|
||||
|
||||
def missingness_correlation(null_mask, top_k=20) -> dict:
|
||||
"""Correlacion de co-ocurrencia de ausencias entre columnas.
|
||||
|
||||
Args:
|
||||
null_mask: dict {col: [int 0/1, ...]} alineado por fila (1 = el valor
|
||||
falta en esa fila). Todas las listas se asumen de la misma longitud.
|
||||
top_k: numero maximo de pares a devolver, ordenados por |corr| desc.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- columns: columnas con varianza en la ausencia (al menos un 1 y al
|
||||
menos un 0), en orden de entrada.
|
||||
- matrix: matriz len(columns) x len(columns) de correlacion de Pearson
|
||||
entre las mascaras binarias, diagonal 1.0.
|
||||
- pairs: lista de hasta top_k pares (i<j) ordenados por |corr| desc.
|
||||
Cada par: {a, b, corr, co_missing, either_missing, jaccard}.
|
||||
- n_excluded: numero de columnas con algun nulo pero sin varianza
|
||||
(constantes en la ausencia: siempre presentes o siempre ausentes).
|
||||
- excluded_cols: lista de esas columnas (en orden de entrada).
|
||||
|
||||
Si hay menos de 2 columnas con varianza, columns/matrix/pairs van vacios
|
||||
pero n_excluded/excluded_cols se rellenan igualmente. NUNCA lanza.
|
||||
"""
|
||||
# Salida base, defensiva ante entradas no-dict.
|
||||
result = {
|
||||
"columns": [],
|
||||
"matrix": [],
|
||||
"pairs": [],
|
||||
"n_excluded": 0,
|
||||
"excluded_cols": [],
|
||||
}
|
||||
|
||||
if not isinstance(null_mask, dict) or not null_mask:
|
||||
return result
|
||||
|
||||
varying = [] # columnas con varianza en la ausencia
|
||||
varying_vecs = [] # sus vectores binarios saneados (floats 0.0/1.0)
|
||||
excluded_cols = [] # columnas con nulos pero sin varianza (constantes)
|
||||
|
||||
for col, raw in null_mask.items():
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
continue
|
||||
# Sanea a 0/1: cualquier valor truthy distinto de 0 cuenta como ausencia.
|
||||
vec = [1 if bool(v) else 0 for v in raw]
|
||||
if not vec:
|
||||
continue
|
||||
ones = sum(vec)
|
||||
zeros = len(vec) - ones
|
||||
if ones > 0 and zeros > 0:
|
||||
varying.append(col)
|
||||
varying_vecs.append([float(v) for v in vec])
|
||||
elif ones > 0:
|
||||
# Tiene nulos pero todos (constante en la ausencia): sin varianza.
|
||||
excluded_cols.append(col)
|
||||
# ones == 0 -> columna siempre presente, sin nulos: no se cuenta como
|
||||
# excluida (no aporta ausencia al analisis de co-ocurrencia).
|
||||
|
||||
result["n_excluded"] = len(excluded_cols)
|
||||
result["excluded_cols"] = excluded_cols
|
||||
|
||||
n = len(varying)
|
||||
if n < 2:
|
||||
return result
|
||||
|
||||
result["columns"] = list(varying)
|
||||
|
||||
# Matriz de correlacion de Pearson, diagonal 1.0.
|
||||
matrix = [[0.0] * n for _ in range(n)]
|
||||
for i in range(n):
|
||||
matrix[i][i] = 1.0
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
r = pearson(varying_vecs[i], varying_vecs[j])
|
||||
matrix[i][j] = r
|
||||
matrix[j][i] = r
|
||||
result["matrix"] = matrix
|
||||
|
||||
# Pares con cifras de solapamiento de conjuntos.
|
||||
pairs = []
|
||||
for i in range(n):
|
||||
vi = varying_vecs[i]
|
||||
for j in range(i + 1, n):
|
||||
vj = varying_vecs[j]
|
||||
co_missing = 0
|
||||
either_missing = 0
|
||||
for a, b in zip(vi, vj):
|
||||
a_miss = a != 0.0
|
||||
b_miss = b != 0.0
|
||||
if a_miss and b_miss:
|
||||
co_missing += 1
|
||||
if a_miss or b_miss:
|
||||
either_missing += 1
|
||||
jaccard = co_missing / either_missing if either_missing > 0 else 0.0
|
||||
pairs.append({
|
||||
"a": varying[i],
|
||||
"b": varying[j],
|
||||
"corr": matrix[i][j],
|
||||
"co_missing": co_missing,
|
||||
"either_missing": either_missing,
|
||||
"jaccard": jaccard,
|
||||
})
|
||||
|
||||
pairs.sort(key=lambda p: abs(p["corr"]), reverse=True)
|
||||
result["pairs"] = pairs[:top_k] if top_k is not None and top_k >= 0 else pairs
|
||||
|
||||
return result
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Tests para missingness_correlation."""
|
||||
|
||||
from datascience.missingness_correlation import missingness_correlation
|
||||
|
||||
|
||||
def test_co_ocurrencia_fuerte_corr_uno_jaccard_uno():
|
||||
# a y b faltan EXACTAMENTE en las mismas filas -> corr 1.0, jaccard 1.0.
|
||||
mask = {
|
||||
"a": [1, 0, 1, 0, 1, 0],
|
||||
"b": [1, 0, 1, 0, 1, 0],
|
||||
}
|
||||
out = missingness_correlation(mask)
|
||||
assert out["columns"] == ["a", "b"]
|
||||
assert out["n_excluded"] == 0
|
||||
# Diagonal 1.0, off-diagonal ~1.0.
|
||||
assert out["matrix"][0][0] == 1.0
|
||||
assert out["matrix"][1][1] == 1.0
|
||||
assert abs(out["matrix"][0][1] - 1.0) < 1e-9
|
||||
assert len(out["pairs"]) == 1
|
||||
pair = out["pairs"][0]
|
||||
assert {pair["a"], pair["b"]} == {"a", "b"}
|
||||
assert abs(pair["corr"] - 1.0) < 1e-9
|
||||
assert pair["co_missing"] == 3 # filas 0,2,4
|
||||
assert pair["either_missing"] == 3 # mismas filas
|
||||
assert abs(pair["jaccard"] - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_ausencias_disjuntas_corr_negativa_jaccard_cero():
|
||||
# a y b nunca faltan en la misma fila -> co_missing 0, jaccard 0, corr <= 0.
|
||||
mask = {
|
||||
"a": [1, 1, 0, 0],
|
||||
"b": [0, 0, 1, 1],
|
||||
}
|
||||
out = missingness_correlation(mask)
|
||||
assert out["columns"] == ["a", "b"]
|
||||
pair = out["pairs"][0]
|
||||
assert pair["co_missing"] == 0
|
||||
assert pair["either_missing"] == 4
|
||||
assert pair["jaccard"] == 0.0
|
||||
# Solapamiento nulo + ausencias complementarias -> correlacion negativa.
|
||||
assert pair["corr"] < 0.0
|
||||
assert abs(pair["corr"] - out["matrix"][0][1]) < 1e-12
|
||||
|
||||
|
||||
def test_columna_sin_varianza_se_excluye():
|
||||
# c esta siempre presente (todo 0): no aporta ausencia -> no entra ni como
|
||||
# excluida. d esta siempre ausente (todo 1): tiene nulos pero sin varianza
|
||||
# -> excluida y n_excluded incrementa. a y b tienen varianza.
|
||||
mask = {
|
||||
"a": [1, 0, 1, 0],
|
||||
"b": [1, 0, 0, 0],
|
||||
"c": [0, 0, 0, 0], # siempre presente
|
||||
"d": [1, 1, 1, 1], # siempre ausente, constante
|
||||
}
|
||||
out = missingness_correlation(mask)
|
||||
assert out["columns"] == ["a", "b"]
|
||||
assert "d" in out["excluded_cols"]
|
||||
assert "c" not in out["excluded_cols"]
|
||||
assert out["n_excluded"] == 1
|
||||
# Matriz solo de las columnas con varianza.
|
||||
assert len(out["matrix"]) == 2
|
||||
assert len(out["matrix"][0]) == 2
|
||||
|
||||
|
||||
def test_menos_de_dos_columnas_con_varianza_vacio_pero_cuenta_excluidas():
|
||||
# Solo una columna con varianza (a) + una constante-ausente (d).
|
||||
mask = {
|
||||
"a": [1, 0, 1, 0],
|
||||
"d": [1, 1, 1, 1],
|
||||
}
|
||||
out = missingness_correlation(mask)
|
||||
assert out["columns"] == []
|
||||
assert out["matrix"] == []
|
||||
assert out["pairs"] == []
|
||||
assert out["n_excluded"] == 1
|
||||
assert out["excluded_cols"] == ["d"]
|
||||
|
||||
|
||||
def test_mask_vacio_todo_vacio():
|
||||
out = missingness_correlation({})
|
||||
assert out == {
|
||||
"columns": [],
|
||||
"matrix": [],
|
||||
"pairs": [],
|
||||
"n_excluded": 0,
|
||||
"excluded_cols": [],
|
||||
}
|
||||
|
||||
|
||||
def test_top_k_limita_pares():
|
||||
# 4 columnas con varianza -> 6 pares; top_k=2 deja 2.
|
||||
mask = {
|
||||
"a": [1, 0, 1, 0, 0],
|
||||
"b": [1, 0, 0, 1, 0],
|
||||
"c": [0, 1, 1, 0, 1],
|
||||
"d": [1, 1, 0, 0, 1],
|
||||
}
|
||||
out = missingness_correlation(mask, top_k=2)
|
||||
assert len(out["columns"]) == 4
|
||||
assert len(out["pairs"]) == 2
|
||||
# Ordenados por |corr| desc.
|
||||
assert abs(out["pairs"][0]["corr"]) >= abs(out["pairs"][1]["corr"])
|
||||
|
||||
|
||||
def test_no_lanza_con_entradas_raras():
|
||||
# Valores no-lista y no-dict no deben romper.
|
||||
assert missingness_correlation(None)["columns"] == []
|
||||
mask = {
|
||||
"a": [1, 0, 1, 0],
|
||||
"b": [1, 0, 1, 0],
|
||||
"bad": "not a list",
|
||||
"empty": [],
|
||||
}
|
||||
out = missingness_correlation(mask)
|
||||
assert out["columns"] == ["a", "b"]
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
id: missingness_overview_py_datascience
|
||||
name: missingness_overview
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def missingness_overview(null_mask) -> dict"
|
||||
description: "Resumen de ausencias a nivel de dataset a partir de una máscara de nulos 0/1 por columna ({col: [1=falta, 0=presente]} alineada por fila). Calcula celdas y porcentaje de datos faltantes, cuántas columnas tienen algún nulo y cuántas filas son completas vs. incompletas. Estilo dict-no-throw del grupo eda: nunca lanza. Lectura defensiva — no-dict o dict vacío devuelve todo a 0; columnas no-lista se tratan como vacías; listas de longitud distinta se alinean a la longitud máxima rellenando la cola corta como presente (0); valores None/no-int cuentan como presente; sin ZeroDivisionError."
|
||||
tags: [eda, missing, missingness, nulls, profiling, datascience, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
example: |
|
||||
from datascience.missingness_overview import missingness_overview
|
||||
mask = {
|
||||
"a": [1, 0, 0, 0, 1],
|
||||
"b": [1, 0, 1, 0, 0],
|
||||
"c": [0, 0, 0, 0, 1],
|
||||
}
|
||||
missingness_overview(mask)
|
||||
# n_missing_cells=5, missing_cell_pct≈33.33, complete_rows=2, incomplete_rows=3
|
||||
tested: true
|
||||
tests:
|
||||
- "test_cooccurrence_three_cols_exact"
|
||||
- "test_empty_dict_all_zero"
|
||||
- "test_output_keys_contract"
|
||||
- "test_not_a_dict_returns_zero"
|
||||
- "test_no_nulls_all_complete"
|
||||
- "test_none_values_treated_as_present"
|
||||
- "test_unequal_lengths_pad_with_max"
|
||||
- "test_columns_present_but_no_rows"
|
||||
- "test_never_raises_on_garbage"
|
||||
test_file_path: "python/functions/datascience/missingness_overview_test.py"
|
||||
file_path: "python/functions/datascience/missingness_overview.py"
|
||||
params:
|
||||
- name: null_mask
|
||||
desc: "Dict {col_name: [int 0/1, ...]} con la máscara de nulos por columna, alineada por fila (1 = el valor falta, 0 = el valor está presente). Normalmente todas las listas tienen la misma longitud = nº de filas. Lectura defensiva: si no es dict o está vacío se devuelve todo a 0; columnas cuyo valor no es lista/tupla se tratan como vacías; listas de longitud distinta se alinean a la longitud máxima (las posiciones inexistentes de las columnas más cortas cuentan como presentes, 0); valores None o no enteros cuentan como presentes."
|
||||
output: "Dict con exactamente 9 claves, todas siempre presentes (la función nunca lanza): n_rows (longitud de fila = longitud máxima entre columnas, 0 si vacío), n_cols (nº de columnas), n_cols_with_null (columnas con >=1 falta), n_missing_cells (suma total de 1s), missing_cell_pct (0-100 = n_missing_cells / (n_rows*n_cols) * 100), complete_rows (filas sin ninguna falta), incomplete_rows (filas con >=1 falta), complete_pct (0-100), incomplete_pct (0-100). Los porcentajes son 0.0 cuando el denominador es 0 (sin ZeroDivisionError)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience.missingness_overview import missingness_overview
|
||||
|
||||
# Máscara de nulos por columna: 1 = falta, 0 = presente, alineada por fila.
|
||||
mask = {
|
||||
"a": [1, 0, 0, 0, 1],
|
||||
"b": [1, 0, 1, 0, 0],
|
||||
"c": [0, 0, 0, 0, 1],
|
||||
}
|
||||
|
||||
missingness_overview(mask)
|
||||
# {
|
||||
# "n_rows": 5,
|
||||
# "n_cols": 3,
|
||||
# "n_cols_with_null": 3, # a, b y c tienen al menos una falta
|
||||
# "n_missing_cells": 5, # 2 (a) + 2 (b) + 1 (c)
|
||||
# "missing_cell_pct": 33.33, # 5 / (5*3) * 100
|
||||
# "complete_rows": 2, # filas 1 y 3 sin ninguna falta
|
||||
# "incomplete_rows": 3, # filas 0 (a&b), 2 (b), 4 (a&c)
|
||||
# "complete_pct": 40.0, # 2 / 5 * 100
|
||||
# "incomplete_pct": 60.0, # 3 / 5 * 100
|
||||
# }
|
||||
|
||||
missingness_overview({})
|
||||
# Todo a 0: {"n_rows": 0, "n_cols": 0, "n_cols_with_null": 0,
|
||||
# "n_missing_cells": 0, "missing_cell_pct": 0.0,
|
||||
# "complete_rows": 0, "incomplete_rows": 0,
|
||||
# "complete_pct": 0.0, "incomplete_pct": 0.0}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala al perfilar un dataset cuando ya tienes una máscara de nulos 0/1 por
|
||||
columna (p. ej. derivada del paso de carga/perfilado del EDA) y quieres la foto
|
||||
global de ausencias en una llamada: cuánta proporción de celdas falta, cuántas
|
||||
columnas están afectadas y, sobre todo, cuántas filas quedan completas vs.
|
||||
incompletas. Es el bloque resumen del capítulo de calidad/missingness de un EDA,
|
||||
y la base para decidir estrategias de imputación o de borrado de filas. Como es
|
||||
pura y dict-no-throw, puedes alimentarla con la máscara tal cual sin validarla
|
||||
antes: entradas malformadas degradan a ceros en vez de romper el pipeline.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`n_rows` es la longitud máxima entre columnas.** Con listas de longitud
|
||||
desigual, las posiciones que faltan en las columnas más cortas se cuentan como
|
||||
presentes (`0`); no se descartan filas. En el caso normal (todas las listas de
|
||||
igual longitud) `n_rows` es simplemente esa longitud.
|
||||
- **Solo el valor exacto `1` cuenta como falta.** `None`, `0`, cadenas y
|
||||
cualquier otro valor se tratan como presentes. `True` (== 1) también cuenta
|
||||
como falta por la igualdad.
|
||||
- **Porcentajes en escala 0-100**, no fracciones. División por cero protegida:
|
||||
con `n_rows*n_cols == 0` los porcentajes salen `0.0`.
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Pure EDA helper: dataset-level missingness overview from a 0/1 null mask.
|
||||
|
||||
Part of the `eda` capability group. Consumes a per-column null mask
|
||||
(``{col_name: [int 0/1, ...]}`` aligned by row, ``1`` = value is missing,
|
||||
``0`` = value is present) and derives dataset-wide missingness metrics: cell
|
||||
count and percentage of missing data, how many columns carry any null, and how
|
||||
many rows are complete vs. incomplete.
|
||||
|
||||
Dict-no-throw style of the `eda` group: it NEVER raises. A non-dict, an empty
|
||||
dict, malformed columns, ragged lists or non-int cell values all degrade
|
||||
gracefully to the zero/contract output. Stdlib only.
|
||||
|
||||
Ragged-length policy: columns are allowed to have different lengths. ``n_rows``
|
||||
is the **maximum** column length; positions that don't exist in a shorter
|
||||
column are treated as present (``0``). This keeps the ``n_rows * n_cols`` cell
|
||||
grid well defined without dropping rows.
|
||||
"""
|
||||
|
||||
|
||||
def _is_missing(value) -> int:
|
||||
"""Return ``1`` iff ``value`` denotes a missing cell, else ``0``.
|
||||
|
||||
Only an exact equality to ``1`` (covers ``int`` ``1`` and ``float`` ``1.0``)
|
||||
counts as missing. ``None``, ``0``, strings and any other value are treated
|
||||
as present. The comparison cannot raise for standard inputs.
|
||||
"""
|
||||
try:
|
||||
return 1 if value == 1 else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def missingness_overview(null_mask) -> dict:
|
||||
"""Summarize dataset-level missingness from a 0/1 null mask.
|
||||
|
||||
Args:
|
||||
null_mask: Dict ``{col_name: [int 0/1, ...]}`` where each list is aligned
|
||||
by row (``1`` = missing, ``0`` = present). Lists are normally all the
|
||||
same length (= number of rows). Defensive: a non-dict or empty dict
|
||||
returns the all-zero contract; non-list columns are treated as empty;
|
||||
ragged lists are aligned to the maximum length, padding the missing
|
||||
tail of shorter columns as present (``0``); ``None`` / non-int cells
|
||||
count as present.
|
||||
|
||||
Returns:
|
||||
Dict with exactly these keys, all always present (the function never
|
||||
raises): ``n_rows``, ``n_cols``, ``n_cols_with_null``,
|
||||
``n_missing_cells``, ``missing_cell_pct`` (0-100), ``complete_rows``,
|
||||
``incomplete_rows``, ``complete_pct`` (0-100), ``incomplete_pct``
|
||||
(0-100). Percentages are ``0.0`` when the denominator is zero (no
|
||||
``ZeroDivisionError``).
|
||||
"""
|
||||
zero = {
|
||||
"n_rows": 0,
|
||||
"n_cols": 0,
|
||||
"n_cols_with_null": 0,
|
||||
"n_missing_cells": 0,
|
||||
"missing_cell_pct": 0.0,
|
||||
"complete_rows": 0,
|
||||
"incomplete_rows": 0,
|
||||
"complete_pct": 0.0,
|
||||
"incomplete_pct": 0.0,
|
||||
}
|
||||
|
||||
if not isinstance(null_mask, dict) or not null_mask:
|
||||
return dict(zero)
|
||||
|
||||
# Normalize every column to a list; non-list columns become empty.
|
||||
cols = {}
|
||||
for name, seq in null_mask.items():
|
||||
cols[name] = seq if isinstance(seq, (list, tuple)) else []
|
||||
|
||||
n_cols = len(cols)
|
||||
lengths = [len(seq) for seq in cols.values()]
|
||||
n_rows = max(lengths) if lengths else 0
|
||||
|
||||
if n_rows == 0:
|
||||
# Columns exist but carry no rows: everything zero except n_cols.
|
||||
out = dict(zero)
|
||||
out["n_cols"] = n_cols
|
||||
return out
|
||||
|
||||
n_missing_cells = 0
|
||||
n_cols_with_null = 0
|
||||
row_has_missing = [False] * n_rows
|
||||
|
||||
for seq in cols.values():
|
||||
col_len = len(seq)
|
||||
col_has_null = False
|
||||
for r in range(n_rows):
|
||||
if r < col_len and _is_missing(seq[r]):
|
||||
n_missing_cells += 1
|
||||
row_has_missing[r] = True
|
||||
col_has_null = True
|
||||
if col_has_null:
|
||||
n_cols_with_null += 1
|
||||
|
||||
incomplete_rows = sum(1 for flag in row_has_missing if flag)
|
||||
complete_rows = n_rows - incomplete_rows
|
||||
|
||||
total_cells = n_rows * n_cols
|
||||
missing_cell_pct = (n_missing_cells / total_cells * 100.0) if total_cells else 0.0
|
||||
complete_pct = complete_rows / n_rows * 100.0
|
||||
incomplete_pct = incomplete_rows / n_rows * 100.0
|
||||
|
||||
return {
|
||||
"n_rows": n_rows,
|
||||
"n_cols": n_cols,
|
||||
"n_cols_with_null": n_cols_with_null,
|
||||
"n_missing_cells": n_missing_cells,
|
||||
"missing_cell_pct": missing_cell_pct,
|
||||
"complete_rows": complete_rows,
|
||||
"incomplete_rows": incomplete_rows,
|
||||
"complete_pct": complete_pct,
|
||||
"incomplete_pct": incomplete_pct,
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Tests para missingness_overview."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from missingness_overview import missingness_overview
|
||||
|
||||
|
||||
# Output contract: every call returns exactly these 9 keys.
|
||||
EXPECTED_KEYS = {
|
||||
"n_rows",
|
||||
"n_cols",
|
||||
"n_cols_with_null",
|
||||
"n_missing_cells",
|
||||
"missing_cell_pct",
|
||||
"complete_rows",
|
||||
"incomplete_rows",
|
||||
"complete_pct",
|
||||
"incomplete_pct",
|
||||
}
|
||||
|
||||
|
||||
def test_cooccurrence_three_cols_exact():
|
||||
# 3 columns, 5 rows. Hand-computed expectations:
|
||||
# col a missing at rows 0, 4 -> 2
|
||||
# col b missing at rows 0, 2 -> 2
|
||||
# col c missing at row 4 -> 1
|
||||
# n_missing_cells = 5, total_cells = 5*3 = 15 -> 33.333...%
|
||||
# row 0 (a&b co-occur) -> incomplete
|
||||
# row 1 (all present) -> complete
|
||||
# row 2 (b only) -> incomplete
|
||||
# row 3 (all present) -> complete
|
||||
# row 4 (a&c co-occur) -> incomplete
|
||||
mask = {
|
||||
"a": [1, 0, 0, 0, 1],
|
||||
"b": [1, 0, 1, 0, 0],
|
||||
"c": [0, 0, 0, 0, 1],
|
||||
}
|
||||
out = missingness_overview(mask)
|
||||
assert out["n_rows"] == 5
|
||||
assert out["n_cols"] == 3
|
||||
assert out["n_cols_with_null"] == 3
|
||||
assert out["n_missing_cells"] == 5
|
||||
assert out["missing_cell_pct"] == pytest.approx(33.33333333, abs=1e-6)
|
||||
assert out["complete_rows"] == 2
|
||||
assert out["incomplete_rows"] == 3
|
||||
assert out["complete_pct"] == pytest.approx(40.0)
|
||||
assert out["incomplete_pct"] == pytest.approx(60.0)
|
||||
|
||||
|
||||
def test_empty_dict_all_zero():
|
||||
out = missingness_overview({})
|
||||
assert out == {
|
||||
"n_rows": 0,
|
||||
"n_cols": 0,
|
||||
"n_cols_with_null": 0,
|
||||
"n_missing_cells": 0,
|
||||
"missing_cell_pct": 0.0,
|
||||
"complete_rows": 0,
|
||||
"incomplete_rows": 0,
|
||||
"complete_pct": 0.0,
|
||||
"incomplete_pct": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_output_keys_contract():
|
||||
# The 9-key contract holds even for the garbage/zero path.
|
||||
assert set(missingness_overview({}).keys()) == EXPECTED_KEYS
|
||||
assert set(missingness_overview({"a": [1, 0]}).keys()) == EXPECTED_KEYS
|
||||
|
||||
|
||||
def test_not_a_dict_returns_zero():
|
||||
for bad in (None, [1, 0, 1], 42, "nope", 3.14):
|
||||
out = missingness_overview(bad)
|
||||
assert out["n_rows"] == 0
|
||||
assert out["n_cols"] == 0
|
||||
assert out["n_missing_cells"] == 0
|
||||
assert out["missing_cell_pct"] == 0.0
|
||||
|
||||
|
||||
def test_no_nulls_all_complete():
|
||||
mask = {"a": [0, 0, 0], "b": [0, 0, 0]}
|
||||
out = missingness_overview(mask)
|
||||
assert out["n_rows"] == 3
|
||||
assert out["n_cols"] == 2
|
||||
assert out["n_cols_with_null"] == 0
|
||||
assert out["n_missing_cells"] == 0
|
||||
assert out["missing_cell_pct"] == 0.0
|
||||
assert out["complete_rows"] == 3
|
||||
assert out["incomplete_rows"] == 0
|
||||
assert out["complete_pct"] == pytest.approx(100.0)
|
||||
assert out["incomplete_pct"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_none_values_treated_as_present():
|
||||
# None and other non-1 values count as present (0).
|
||||
mask = {"a": [None, 1, None, "x", 0]}
|
||||
out = missingness_overview(mask)
|
||||
assert out["n_rows"] == 5
|
||||
assert out["n_cols"] == 1
|
||||
assert out["n_missing_cells"] == 1 # only the explicit 1 at row 1
|
||||
assert out["n_cols_with_null"] == 1
|
||||
assert out["complete_rows"] == 4
|
||||
assert out["incomplete_rows"] == 1
|
||||
|
||||
|
||||
def test_unequal_lengths_pad_with_max():
|
||||
# Ragged lists: n_rows = max length; shorter column padded as present.
|
||||
# a = [1, 1] -> missing at rows 0, 1
|
||||
# b = [0] -> row 1 padded to present
|
||||
# n_rows = 2, n_cols = 2, total_cells = 4, n_missing_cells = 2 -> 50%
|
||||
mask = {"a": [1, 1], "b": [0]}
|
||||
out = missingness_overview(mask)
|
||||
assert out["n_rows"] == 2
|
||||
assert out["n_cols"] == 2
|
||||
assert out["n_cols_with_null"] == 1
|
||||
assert out["n_missing_cells"] == 2
|
||||
assert out["missing_cell_pct"] == pytest.approx(50.0)
|
||||
assert out["complete_rows"] == 0
|
||||
assert out["incomplete_rows"] == 2
|
||||
assert out["incomplete_pct"] == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_columns_present_but_no_rows():
|
||||
# Columns exist but all empty -> zero metrics, n_cols preserved.
|
||||
out = missingness_overview({"a": [], "b": []})
|
||||
assert out["n_rows"] == 0
|
||||
assert out["n_cols"] == 2
|
||||
assert out["n_missing_cells"] == 0
|
||||
assert out["missing_cell_pct"] == 0.0
|
||||
assert out["complete_pct"] == 0.0
|
||||
|
||||
|
||||
def test_never_raises_on_garbage():
|
||||
# Non-list column values, mixed junk -> must not raise.
|
||||
mask = {"a": "not a list", "b": 123, "c": [1, 0, 1]}
|
||||
out = missingness_overview(mask)
|
||||
assert set(out.keys()) == EXPECTED_KEYS
|
||||
assert out["n_rows"] == 3
|
||||
assert out["n_cols"] == 3
|
||||
assert out["n_missing_cells"] == 2 # only col c contributes
|
||||
assert out["n_cols_with_null"] == 1
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
id: missingness_rank_bar_figure_py_datascience
|
||||
name: missingness_rank_bar_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def missingness_rank_bar_figure(names, pcts, title=\"% de valores faltantes por columna\") -> \"matplotlib.figure.Figure\""
|
||||
description: "Construye una figura matplotlib de barras horizontales que ordena las columnas de un dataset por su porcentaje de valores faltantes (0-100), la mayor arriba, etiquetando cada barra con su NN.N% al final. Usa ax.barh, eje X fijo 0-100 y labels truncados a ~22 chars. Devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA (capítulo de datos faltantes). Backend Agg sin pyplot global; defensivo ante listas vacías, longitudes desiguales o valores no numéricos (nunca lanza)."
|
||||
tags: [eda, missing, missingness, ranking, bar, barh, matplotlib, figure, visualization, datascience, impure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib]
|
||||
example: |
|
||||
from datascience.missingness_rank_bar_figure import missingness_rank_bar_figure
|
||||
names = ["edad", "ingresos", "ciudad", "email"]
|
||||
pcts = [12.5, 40.0, 3.2, 0.0]
|
||||
fig = missingness_rank_bar_figure(names, pcts, title="% de valores faltantes por columna")
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure_with_axes"
|
||||
- "test_sorted_descending_largest_on_top"
|
||||
- "test_empty_lists_do_not_raise_and_returns_figure"
|
||||
- "test_xlim_is_zero_to_hundred"
|
||||
- "test_length_mismatch_and_non_numeric_are_handled"
|
||||
test_file_path: "python/functions/datascience/missingness_rank_bar_figure_test.py"
|
||||
file_path: "python/functions/datascience/missingness_rank_bar_figure.py"
|
||||
params:
|
||||
- name: names
|
||||
desc: "Lista de nombres de columna. Puede venir vacía (devuelve figura \"sin datos faltantes\"). Los items se convierten a str y se truncan a ~22 chars con elipsis para las etiquetas del eje Y; los originales no se mutan."
|
||||
- name: pcts
|
||||
desc: "Lista paralela a names con el % de nulos en [0,100]. Valores None, NaN o no numéricos se coercen a 0.0 y los negativos se recortan a 0. Si len(names) != len(pcts) se recorta al menor de ambos para no romper."
|
||||
- name: title
|
||||
desc: "Título de la figura. Se trunca a ~60 chars con elipsis si es muy largo. Default \"% de valores faltantes por columna\"."
|
||||
output: "Un matplotlib.figure.Figure (figsize 6.4 x alto adaptativo según nº de barras, dpi 150) con un Axes de barras horizontales (ax.barh) ordenadas por % descendente, la mayor arriba. Eje X fijado a [0,100] con label \"% faltante\", etiquetas del eje Y truncadas a ~22 chars, y cada barra anotada con su NN.N% al final. Si names o pcts vienen vacíos devuelve una Figure con texto centrado \"sin datos faltantes\"; cualquier error inesperado se captura y devuelve una Figure con el mensaje de error (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience.missingness_rank_bar_figure import missingness_rank_bar_figure
|
||||
|
||||
# % de nulos por columna (p. ej. (df.isnull().mean() * 100).
|
||||
names = ["edad", "ingresos", "ciudad", "email"]
|
||||
pcts = [12.5, 40.0, 3.2, 0.0]
|
||||
|
||||
fig = missingness_rank_bar_figure(
|
||||
names,
|
||||
pcts,
|
||||
title="% de valores faltantes por columna",
|
||||
)
|
||||
|
||||
# ingresos (40.0%) queda arriba; email (0.0%) abajo.
|
||||
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||
fig.savefig("/tmp/missingness_rank.png")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala al abrir el capítulo de datos faltantes de un informe EDA para responder
|
||||
"¿qué columnas están más incompletas?" de un vistazo. Pásale los nombres de
|
||||
columna y el % de nulos de cada una (`(df.isnull().mean() * 100).round(1)`); la
|
||||
función se encarga de ordenar de mayor a menor y poner la peor arriba. Es la
|
||||
pareja "magnitud" del heatmap de co-ocurrencia: las barras dicen *cuánto* falta
|
||||
en cada columna, el heatmap dice *si esas ausencias están relacionadas* entre
|
||||
columnas.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
|
||||
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
|
||||
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
|
||||
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
|
||||
directamente, así que es segura de llamar en bucle desde el renderer.
|
||||
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
|
||||
guarda. Quien la consume debe rasterizarla y luego liberarla
|
||||
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
|
||||
- **Espera porcentajes 0-100, no fracciones 0-1.** El eje X está fijado a
|
||||
`[0, 100]`. Si pasas fracciones (`0.4` en vez de `40.0`) las barras saldrán
|
||||
pegadas al origen. Multiplica por 100 antes de llamar.
|
||||
- **Alto adaptativo.** La altura de la figura crece con el número de barras
|
||||
(hasta un tope) para que reports con muchas columnas sigan legibles; aun así,
|
||||
conviene filtrar a las columnas con algún nulo antes de llamar para no listar
|
||||
decenas de barras a 0%.
|
||||
- **Defensiva, nunca lanza.** Listas vacías, longitudes desiguales, valores
|
||||
`None`/`NaN`/no numéricos o cualquier error inesperado se manejan sin propagar:
|
||||
en el peor caso devuelve una `Figure` con "sin datos faltantes" o con el texto
|
||||
del error. No envuelvas la llamada en try/except por miedo a un raise — no lo
|
||||
hay.
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Impure EDA helper: ranked bar figure of missing-value share (`eda` group).
|
||||
|
||||
Builds a horizontal bar chart ranking the columns of a dataset by their
|
||||
percentage of missing values (0-100), largest at the top, each bar labelled with
|
||||
its ``NN.N%`` at the end. Returns a ready-to-rasterize
|
||||
``matplotlib.figure.Figure``; it never shows nor saves it.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It uses the headless
|
||||
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
|
||||
global state and is safe to call repeatedly from a report renderer.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
# Muted gray for secondary text (no-data / fallback messages).
|
||||
_MUTED_TEXT = "#5f6b7a"
|
||||
# Soft red for the error fallback message.
|
||||
_ERROR_TEXT = "#b00020"
|
||||
# Bar fill — a calm blue that reads well on white at report size.
|
||||
_BAR_COLOR = "#4C72B0"
|
||||
|
||||
|
||||
def _truncate(text, width: int = 22) -> str:
|
||||
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
|
||||
s = "" if text is None else str(text)
|
||||
if len(s) <= width:
|
||||
return s
|
||||
if width <= 1:
|
||||
return s[:width]
|
||||
return s[: width - 1] + "…"
|
||||
|
||||
|
||||
def _message_figure(message: str, color: str = _MUTED_TEXT) -> "Figure":
|
||||
"""Return a fallback ``Figure`` carrying a single centered message."""
|
||||
fig = Figure(figsize=(6.4, 4.0), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color=color,
|
||||
wrap=True,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def missingness_rank_bar_figure(
|
||||
names,
|
||||
pcts,
|
||||
title: str = "% de valores faltantes por columna",
|
||||
) -> "matplotlib.figure.Figure":
|
||||
"""Build a horizontal ranked bar figure of missing-value share per column.
|
||||
|
||||
Pairs each column name with its missing percentage, sorts by percentage
|
||||
descending and draws horizontal bars with the largest at the top. The X axis
|
||||
is pinned to ``[0, 100]`` so bars are comparable across reports, each bar is
|
||||
annotated with its ``NN.N%`` at the end, and the Y tick labels are truncated
|
||||
to ~22 chars.
|
||||
|
||||
The function is fully defensive: empty/mismatched/non-numeric input never
|
||||
raises. When there is nothing valid to draw it returns a ``Figure`` carrying
|
||||
a centered "sin datos faltantes" message, and any unexpected error is caught
|
||||
and turned into a fallback ``Figure`` carrying the error text.
|
||||
|
||||
Args:
|
||||
names: List of column names. May be empty. Items are stringified and
|
||||
truncated for display; the originals are not mutated.
|
||||
pcts: List parallel to ``names`` of missing-value percentages in
|
||||
``[0, 100]``. Non-numeric/``None`` values are coerced to ``0.0`` and
|
||||
negatives are clamped to ``0``. The list is truncated to
|
||||
``min(len(names), len(pcts))`` so a length mismatch never crashes.
|
||||
title: Figure title. Default "% de valores faltantes por columna".
|
||||
|
||||
Returns:
|
||||
A ``matplotlib.figure.Figure`` with a single horizontal-bar Axes. The
|
||||
caller is responsible for rasterizing/closing it.
|
||||
"""
|
||||
try:
|
||||
if (
|
||||
not isinstance(names, (list, tuple))
|
||||
or not isinstance(pcts, (list, tuple))
|
||||
or len(names) == 0
|
||||
or len(pcts) == 0
|
||||
):
|
||||
return _message_figure("sin datos faltantes")
|
||||
|
||||
# --- Pair names with coerced percentages, tolerating length mismatch.
|
||||
pairs = []
|
||||
for name, pct in zip(names, pcts):
|
||||
try:
|
||||
val = float(pct)
|
||||
except (TypeError, ValueError):
|
||||
val = 0.0
|
||||
if val != val: # NaN guard.
|
||||
val = 0.0
|
||||
val = max(0.0, val)
|
||||
pairs.append((name, val))
|
||||
|
||||
if not pairs:
|
||||
return _message_figure("sin datos faltantes")
|
||||
|
||||
# Sort by percentage descending; barh draws bottom-up, so the largest
|
||||
# ends at the top when we reverse the order before plotting.
|
||||
pairs.sort(key=lambda p: p[1], reverse=True)
|
||||
ordered = list(reversed(pairs)) # smallest first -> largest on top.
|
||||
|
||||
labels = [_truncate(name, 22) for name, _ in ordered]
|
||||
values = [val for _, val in ordered]
|
||||
y_pos = range(len(ordered))
|
||||
|
||||
# Height scales with the number of bars so dense reports stay readable.
|
||||
height = max(2.4, min(0.4 * len(ordered) + 1.2, 14.0))
|
||||
fig = Figure(figsize=(6.4, height), dpi=150)
|
||||
ax = fig.add_subplot(111)
|
||||
|
||||
ax.barh(list(y_pos), values, color=_BAR_COLOR, edgecolor="white")
|
||||
ax.set_yticks(list(y_pos))
|
||||
ax.set_yticklabels(labels, fontsize=8)
|
||||
ax.set_xlim(0, 100)
|
||||
ax.set_xlabel("% faltante", fontsize=9)
|
||||
|
||||
# Annotate each bar with its percentage at the end of the bar.
|
||||
for y, val in zip(y_pos, values):
|
||||
ax.text(
|
||||
min(val + 1.5, 99.0),
|
||||
y,
|
||||
f"{val:.1f}%",
|
||||
va="center",
|
||||
ha="left" if val < 90 else "right",
|
||||
fontsize=7,
|
||||
color="#202020",
|
||||
)
|
||||
|
||||
if title:
|
||||
ax.set_title(_truncate(title, 60), fontsize=12, loc="left", pad=10)
|
||||
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
|
||||
return _message_figure(f"error al dibujar barras: {exc}", color=_ERROR_TEXT)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests para missingness_rank_bar_figure (barras de % faltante, grupo eda).
|
||||
|
||||
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
|
||||
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
|
||||
estado entre tests.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from missingness_rank_bar_figure import missingness_rank_bar_figure
|
||||
|
||||
|
||||
def test_returns_figure_with_axes():
|
||||
names = ["edad", "ingresos", "ciudad"]
|
||||
pcts = [12.5, 40.0, 3.2]
|
||||
fig = missingness_rank_bar_figure(names, pcts, title="faltantes")
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_sorted_descending_largest_on_top():
|
||||
names = ["a", "b", "c"]
|
||||
pcts = [10.0, 50.0, 25.0]
|
||||
fig = missingness_rank_bar_figure(names, pcts)
|
||||
ax = fig.axes[0]
|
||||
# barh dibuja de abajo arriba; la mayor (50, "b") debe quedar arriba (mayor y).
|
||||
bars = ax.patches
|
||||
# El último parche (mayor índice y) corresponde a la barra superior.
|
||||
widths = [b.get_width() for b in bars]
|
||||
assert max(widths) == 50.0
|
||||
# La barra con la mayor anchura es la de mayor coordenada y (arriba).
|
||||
top_bar = max(bars, key=lambda b: b.get_y())
|
||||
assert top_bar.get_width() == 50.0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_lists_do_not_raise_and_returns_figure():
|
||||
fig = missingness_rank_bar_figure([], [], title="vacía")
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_xlim_is_zero_to_hundred():
|
||||
fig = missingness_rank_bar_figure(["a"], [42.0])
|
||||
ax = fig.axes[0]
|
||||
assert ax.get_xlim() == (0.0, 100.0)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_length_mismatch_and_non_numeric_are_handled():
|
||||
# Más names que pcts + un pct None -> zip recorta y None se coacciona a 0.
|
||||
names = ["a", "b", "c"]
|
||||
pcts = [None, 30.0]
|
||||
fig = missingness_rank_bar_figure(names, pcts)
|
||||
assert isinstance(fig, Figure)
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
name: missingness_row_patterns
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def missingness_row_patterns(null_mask, top_n=10) -> dict"
|
||||
description: "Agrupa las filas de un dataset por su patron de ausencias (estilo matriz de missingno): para cada fila, el patron es la tupla ORDENADA de columnas que faltan en esa fila (las que tienen 1 en el null_mask). Cuenta la frecuencia de cada patron distinto, incluido el patron vacio (fila completa). Devuelve el top_n por frecuencia con su pct sobre el total. Pura, lectura defensiva, NUNCA lanza; {} -> n_rows 0."
|
||||
tags: [eda, missingness, missingno, patterns, profiling, datascience, data-quality]
|
||||
params:
|
||||
- name: null_mask
|
||||
desc: "Dict {col: [0/1, ...]} alineado por fila, donde 1 = la celda falta en esa fila y 0 = presente. Todas las columnas deberian tener la misma longitud (una entrada por fila); si difieren, n_rows es la lista mas larga y las celdas fuera de rango cuentan como presentes. Las claves se ordenan por str(col) para canonizar el patron. {} (o no-dict) -> n_rows 0."
|
||||
- name: top_n
|
||||
desc: "Maximo de patrones devueltos en `patterns`, rankeados por n_rows desc (desempate: menos columnas primero, luego nombres de columna). El recuento total de patrones distintos siempre se reporta en `n_patterns`, no se trunca. Default 10. Valores negativos -> 0; no-int -> 10."
|
||||
output: "Dict {n_rows: int (filas totales), n_patterns: int (patrones distintos, incluye el patron vacio = fila completa), complete_rows: int (filas con patron vacio, nada falta), patterns: lista del top_n ordenada por n_rows desc con [{missing_cols: [col,...] (vacio = fila completa), n_rows: int, pct: float 0-100 sobre n_rows total, redondeado a 2 decimales}]}. Para {} devuelve n_rows 0 y patterns []. NUNCA lanza."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_patron_dominante_completas_singleton", "test_mask_vacio", "test_top_n_trunca_pero_cuenta_todos"]
|
||||
test_file_path: "python/functions/datascience/missingness_row_patterns_test.py"
|
||||
file_path: "python/functions/datascience/missingness_row_patterns.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.missingness_row_patterns import missingness_row_patterns
|
||||
|
||||
# null_mask alineado por fila: 1 = la celda falta en esa fila.
|
||||
null_mask = {
|
||||
"A": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
"B": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
"C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
|
||||
}
|
||||
out = missingness_row_patterns(null_mask, top_n=10)
|
||||
print(out["n_rows"], out["n_patterns"], out["complete_rows"]) # 10 3 5
|
||||
for p in out["patterns"]:
|
||||
label = p["missing_cols"] or "(fila completa)"
|
||||
print(label, p["n_rows"], p["pct"])
|
||||
# (fila completa) 5 50.0
|
||||
# ['A', 'B'] 4 40.0
|
||||
# ['C'] 1 10.0
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Usala en el capitulo de calidad/ausencias de `AutomaticEDA` para mostrar la "matriz de patrones de missingno": en vez de pintar celda a celda, resume que combinaciones de columnas se quedan en blanco juntas y con que frecuencia.
|
||||
- Cuando ya tengas el null_mask por columna (1=falta) y quieras detectar co-ausencia estructural ("A y B siempre faltan juntas") antes de decidir una imputacion o un drop conjunto de columnas.
|
||||
- Cuando necesites una tabla compacta "patron -> nº filas -> pct" para un report o un grafico de barras de los patrones de ausencia mas comunes, separando ademas cuantas filas estan completas (`complete_rows`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion pura, sin I/O y determinista. Lectura defensiva: `{}` o un no-dict devuelven `n_rows` 0 con `patterns` []. NUNCA lanza.
|
||||
- El patron vacio (fila completa, `missing_cols=[]`) SI cuenta como patron: aparece en `n_patterns` y puede aparecer en `patterns`. El consumidor lo etiqueta como "(fila completa)".
|
||||
- `pct` es sobre `n_rows` total (0-100), redondeado a 2 decimales. La suma de los `pct` de TODOS los patrones es 100; si `top_n` trunca, los `pct` mostrados sumaran menos.
|
||||
- Las columnas se ordenan por `str(col)` para canonizar cada patron, asi `{A,B}` y `{B,A}` colapsan al mismo patron `["A", "B"]`.
|
||||
- Una celda cuenta como ausente solo si vale 1 (`int(cell) == 1`); 0, None y valores no numericos se tratan como presentes.
|
||||
- Si las listas de columnas tienen longitudes distintas, `n_rows` es la mas larga y las posiciones fuera de rango de una columna corta cuentan como presentes (0).
|
||||
@@ -1,107 +0,0 @@
|
||||
"""missingness_row_patterns — distinct per-row missingness patterns (missingno matrix style).
|
||||
|
||||
Pure function: no I/O, deterministic, NEVER raises. Given a per-column null mask
|
||||
aligned by row ({col: [0/1, ...]}, 1 = missing), it groups rows by their missing
|
||||
"pattern" — the sorted tuple of column names that are missing in that row — and
|
||||
counts how often each distinct pattern occurs.
|
||||
|
||||
This mirrors the missingno matrix idea: instead of plotting per-cell nullity, it
|
||||
collapses each row to the SET of columns it lacks, surfacing co-missing structure
|
||||
(e.g. "A and B always go missing together"). The empty pattern (a fully complete
|
||||
row) is a first-class pattern and may appear in the result with missing_cols=[];
|
||||
the caller labels it "(fila completa)".
|
||||
"""
|
||||
|
||||
|
||||
def _is_missing(cell) -> bool:
|
||||
"""A cell counts as missing when it equals 1 (truthy 0/1 mask).
|
||||
|
||||
None / 0 / non-numeric are treated as present. Defensive: never raises.
|
||||
"""
|
||||
try:
|
||||
return int(cell) == 1
|
||||
except (TypeError, ValueError):
|
||||
return bool(cell)
|
||||
|
||||
|
||||
def missingness_row_patterns(null_mask, top_n=10) -> dict:
|
||||
"""Count distinct per-row missingness patterns from a column null mask.
|
||||
|
||||
For each row, its pattern is the sorted tuple of column names missing in that
|
||||
row (the columns whose value is 1). The frequency of each distinct pattern is
|
||||
counted, including the empty pattern (a complete row with nothing missing).
|
||||
|
||||
Args:
|
||||
null_mask: Dict {col: [0/1, ...]} aligned by row, where 1 means the cell
|
||||
is missing in that row. Read defensively; columns with differing
|
||||
lengths are tolerated (n_rows is the longest list; out-of-range cells
|
||||
count as present). Empty dict -> n_rows 0.
|
||||
top_n: Maximum number of patterns returned in `patterns`, ranked by
|
||||
n_rows desc (tiebreak: fewer columns first, then column names). The
|
||||
full count of distinct patterns is always reported in `n_patterns`.
|
||||
|
||||
Returns:
|
||||
Dict:
|
||||
{
|
||||
"n_rows": int, # total rows
|
||||
"n_patterns": int, # distinct patterns (incl. the empty pattern)
|
||||
"complete_rows": int, # rows with the empty pattern (nothing missing)
|
||||
"patterns": [ # top_n patterns, n_rows desc
|
||||
{"missing_cols": [col, ...], "n_rows": int, "pct": float} # [] = complete row
|
||||
],
|
||||
}
|
||||
For {} (or a non-dict) returns n_rows 0 and patterns []. NEVER raises.
|
||||
"""
|
||||
empty = {"n_rows": 0, "n_patterns": 0, "complete_rows": 0, "patterns": []}
|
||||
if not isinstance(null_mask, dict) or not null_mask:
|
||||
return empty
|
||||
|
||||
# Stable, canonical column order so each row's pattern tuple is sorted.
|
||||
items = sorted(null_mask.items(), key=lambda kv: str(kv[0]))
|
||||
names = [str(k) for k, _ in items]
|
||||
lists = [v if isinstance(v, (list, tuple)) else [] for _, v in items]
|
||||
|
||||
n_rows = max((len(lst) for lst in lists), default=0)
|
||||
if n_rows == 0:
|
||||
return empty
|
||||
|
||||
# Defensive parsing of top_n.
|
||||
try:
|
||||
limit = int(top_n)
|
||||
except (TypeError, ValueError):
|
||||
limit = 10
|
||||
if limit < 0:
|
||||
limit = 0
|
||||
|
||||
counts: dict = {}
|
||||
n_cols = len(names)
|
||||
for r in range(n_rows):
|
||||
# names is sorted, so iterating in order yields an already-sorted tuple.
|
||||
pattern = tuple(
|
||||
names[c]
|
||||
for c in range(n_cols)
|
||||
if r < len(lists[c]) and _is_missing(lists[c][r])
|
||||
)
|
||||
counts[pattern] = counts.get(pattern, 0) + 1
|
||||
|
||||
complete_rows = counts.get((), 0)
|
||||
n_patterns = len(counts)
|
||||
|
||||
# Rank: n_rows desc, then fewer columns first, then column names (deterministic).
|
||||
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], len(kv[0]), kv[0]))
|
||||
|
||||
patterns = [
|
||||
{
|
||||
"missing_cols": list(pat),
|
||||
"n_rows": cnt,
|
||||
"pct": round(100.0 * cnt / n_rows, 2),
|
||||
}
|
||||
for pat, cnt in ordered[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"n_rows": n_rows,
|
||||
"n_patterns": n_patterns,
|
||||
"complete_rows": complete_rows,
|
||||
"patterns": patterns,
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Tests para missingness_row_patterns."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from missingness_row_patterns import missingness_row_patterns
|
||||
|
||||
_EXPECTED_KEYS = {"n_rows", "n_patterns", "complete_rows", "patterns"}
|
||||
|
||||
|
||||
def test_patron_dominante_completas_singleton():
|
||||
"""Golden: {A,B} co-faltan en 4 filas + 5 filas completas + 1 singleton {C}."""
|
||||
# 10 filas. A y B faltan juntas en las filas 0-3; filas 4-8 completas;
|
||||
# la fila 9 solo le falta C.
|
||||
null_mask = {
|
||||
"A": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
"B": [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
"C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
|
||||
}
|
||||
out = missingness_row_patterns(null_mask)
|
||||
|
||||
assert set(out.keys()) == _EXPECTED_KEYS
|
||||
assert out["n_rows"] == 10
|
||||
# 3 patrones distintos: (A,B), () y (C,).
|
||||
assert out["n_patterns"] == 3
|
||||
# 5 filas completas (filas 4-8).
|
||||
assert out["complete_rows"] == 5
|
||||
|
||||
# Orden: n_rows desc; desempate menos columnas primero.
|
||||
# () tiene 5 filas, (A,B) 4, (C,) 1.
|
||||
pats = out["patterns"]
|
||||
assert len(pats) == 3
|
||||
|
||||
assert pats[0]["missing_cols"] == []
|
||||
assert pats[0]["n_rows"] == 5
|
||||
assert pats[0]["pct"] == 50.0
|
||||
|
||||
assert pats[1]["missing_cols"] == ["A", "B"]
|
||||
assert pats[1]["n_rows"] == 4
|
||||
assert pats[1]["pct"] == 40.0
|
||||
|
||||
assert pats[2]["missing_cols"] == ["C"]
|
||||
assert pats[2]["n_rows"] == 1
|
||||
assert pats[2]["pct"] == 10.0
|
||||
|
||||
# Tipos de salida.
|
||||
assert isinstance(out["n_rows"], int)
|
||||
assert isinstance(pats[0]["pct"], float)
|
||||
|
||||
|
||||
def test_mask_vacio():
|
||||
"""{} -> n_rows 0, sin patrones, nunca lanza."""
|
||||
out = missingness_row_patterns({})
|
||||
assert out == {
|
||||
"n_rows": 0,
|
||||
"n_patterns": 0,
|
||||
"complete_rows": 0,
|
||||
"patterns": [],
|
||||
}
|
||||
# No dict / None tambien degradan a vacio sin lanzar.
|
||||
assert missingness_row_patterns(None)["n_rows"] == 0
|
||||
# Columnas presentes pero listas vacias -> n_rows 0.
|
||||
assert missingness_row_patterns({"A": [], "B": []})["patterns"] == []
|
||||
|
||||
|
||||
def test_top_n_trunca_pero_cuenta_todos():
|
||||
"""top_n limita `patterns`, pero n_patterns reporta TODOS los distintos."""
|
||||
null_mask = {
|
||||
"A": [0, 1, 1, 0, 1],
|
||||
"B": [0, 0, 0, 1, 1],
|
||||
"C": [0, 0, 0, 0, 1],
|
||||
}
|
||||
# Filas: () (A,) (A,) (B,) (A,B,C)
|
||||
out = missingness_row_patterns(null_mask, top_n=2)
|
||||
|
||||
assert out["n_rows"] == 5
|
||||
assert out["n_patterns"] == 4 # (), (A,), (B,), (A,B,C)
|
||||
assert out["complete_rows"] == 1
|
||||
# Solo 2 patrones devueltos pese a haber 4.
|
||||
assert len(out["patterns"]) == 2
|
||||
# (A,) domina con 2 filas; desempate del 2o entre los de 1 fila -> () (0 cols).
|
||||
assert out["patterns"][0]["missing_cols"] == ["A"]
|
||||
assert out["patterns"][0]["n_rows"] == 2
|
||||
assert out["patterns"][1]["missing_cols"] == []
|
||||
assert out["patterns"][1]["n_rows"] == 1
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: preregister_hypothesis
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict"
|
||||
description: "Pre-registra (congela) la hipotesis y el plan de analisis de un paper ANTES de mirar los datos: antidoto al HARKing (Hypothesizing After the Results are Known). Escribe/actualiza <paper_dir>/preregistration.md con un frontmatter (paper_slug, frozen_at, content_hash, status) y un cuerpo markdown DETERMINISTA derivado de (hypotheses, analysis_plan) (mismo input -> mismo cuerpo byte a byte, claves ordenadas alfabeticamente). El content_hash es sha256 del cuerpo NORMALIZADO (strip por linea + colapso de blancos), nunca del frontmatter. Una vez status=frozen es INMUTABLE: re-congelar con el mismo contenido es idempotente (no reescribe, devuelve unchanged) y re-congelar con contenido distinto se RECHAZA (no sobrescribe, devuelve error) para que no se pueda ajustar la hipotesis a los resultados. Estilo dict-no-throw: nunca lanza."
|
||||
tags: [papers, preregistration, reproducibility, anti-harking, python]
|
||||
params:
|
||||
- name: paper_dir
|
||||
desc: "ruta del directorio del paper, p.ej. 'papers/0001-mi-paper'. Debe existir (no se crea aqui). El paper_slug del frontmatter es el basename del dir. Si no existe o no es str -> {status:error, path, note} sin crash ni creacion."
|
||||
- name: hypotheses
|
||||
desc: "dict de hipotesis, p.ej. {'h0': 'no hay diferencia ...', 'h1': 'el grupo A > grupo B ...'}. Se renderiza en la seccion '## Hypotheses' con una linea por clave, ordenadas alfabeticamente para determinismo."
|
||||
- name: analysis_plan
|
||||
desc: "dict con el plan de analisis, p.ej. {'test': 'welch_t_test', 'effect_size_metric': 'cohens_d', 'decision_rule': 'rechazar H0 si p<0.05 tras Holm y |d|>=0.5', 'planned_n': 100, 'multiple_correction': 'holm'}. Se renderiza en '## Analysis plan' con una linea por clave (ordenadas alfabeticamente). Acepta valores no-str (int, etc.)."
|
||||
output: "dict dict-no-throw (NUNCA lanza). status='frozen' cuando escribe el archivo por primera vez o congela un draft previo ({status, path, content_hash, frozen_at}). status='unchanged' cuando ya estaba frozen con el mismo content_hash: no reescribe y preserva el archivo byte-identico incl. el frozen_at original ({status, path, content_hash, frozen_at}). status='error' cuando paper_dir no existe, ya esta frozen con un hash distinto (rechazo anti-HARKing, no sobrescribe), inputs invalidos o error de I/O ({status, path, note, [content_hash]})."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [hashlib]
|
||||
tested: true
|
||||
tests: ["test_golden_congela_y_escribe_archivo", "test_idempotente_mismo_input_no_reescribe", "test_inmutabilidad_anti_harking_rechaza_contenido_distinto", "test_error_paper_dir_inexistente_no_crash_no_crea"]
|
||||
test_file_path: "python/functions/datascience/preregister_hypothesis_test.py"
|
||||
file_path: "python/functions/datascience/preregister_hypothesis.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import os, tempfile
|
||||
from datascience import preregister_hypothesis
|
||||
|
||||
# Un directorio de paper que ya existe.
|
||||
paper_dir = tempfile.mkdtemp(prefix="0001-")
|
||||
|
||||
hypotheses = {
|
||||
"h0": "no hay diferencia entre el grupo A y el grupo B",
|
||||
"h1": "el grupo A tiene mayor conversion que el grupo B",
|
||||
}
|
||||
analysis_plan = {
|
||||
"test": "welch_t_test",
|
||||
"effect_size_metric": "cohens_d",
|
||||
"decision_rule": "rechazar H0 si p<0.05 tras Holm y |d|>=0.5",
|
||||
"planned_n": 100,
|
||||
"multiple_correction": "holm",
|
||||
}
|
||||
|
||||
# 1) Primera vez: congela y escribe <paper_dir>/preregistration.md
|
||||
r1 = preregister_hypothesis(paper_dir, hypotheses, analysis_plan)
|
||||
print(r1["status"]) # -> "frozen"
|
||||
print(r1["content_hash"]) # sha256 del cuerpo
|
||||
|
||||
# 2) Mismo input: idempotente, no reescribe.
|
||||
r2 = preregister_hypothesis(paper_dir, hypotheses, analysis_plan)
|
||||
print(r2["status"]) # -> "unchanged"
|
||||
|
||||
# 3) Cambiar la hipotesis tras congelar (HARKing): rechazado, archivo intacto.
|
||||
r3 = preregister_hypothesis(paper_dir, {"h0": "...", "h1": "otra cosa"}, analysis_plan)
|
||||
print(r3["status"]) # -> "error"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Llamala al ARRANCAR el analisis de un paper, antes de tocar los datos, para
|
||||
dejar por escrito (y firmado por hash) que vas a probar y como vas a decidir.
|
||||
Es el primer paso de un flujo reproducible: pre-registras la hipotesis y el plan
|
||||
(`test`, `effect_size_metric`, `decision_rule`, `planned_n`,
|
||||
`multiple_correction`), y solo despues corres el analisis y comparas con lo
|
||||
pre-registrado. Si mas tarde el analisis "descubre" otra hipotesis que encaja
|
||||
mejor con los datos, el pre-registro congelado deja en evidencia el cambio: no se
|
||||
puede reescribir. Combinala con `effect_size_cohens_d` y `fdr_correction` para
|
||||
cerrar el plan declarado (effect size + correccion de multiples comparaciones).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Inmutabilidad (el corazon)**: una vez `status: frozen`, el pre-registro NO se
|
||||
puede editar. Re-congelar con el MISMO contenido es idempotente (`unchanged`,
|
||||
no reescribe, preserva incluso el `frozen_at` original). Re-congelar con
|
||||
contenido DISTINTO devuelve `error` y deja el archivo intacto: asi se mata el
|
||||
HARKing. Para cambiar de verdad la hipotesis hay que borrar el archivo a mano y
|
||||
asumir explicitamente que ya no es un pre-registro valido.
|
||||
- **dict-no-throw**: la funcion NUNCA lanza. Cualquier error previsible
|
||||
(directorio inexistente, inputs no-dict, fallo de I/O, excepcion inesperada) se
|
||||
captura y se devuelve como `{"status": "error", "note": ...}`. Siempre incluye
|
||||
`path` (la ruta esperada del `preregistration.md`).
|
||||
- **El hash es SOLO del cuerpo, nunca del frontmatter**: el frontmatter contiene
|
||||
el propio `content_hash` y el `frozen_at` (timestamp), asi que incluirlos en el
|
||||
hash seria circular y romperia la idempotencia. El cuerpo se normaliza antes de
|
||||
hashear (strip por linea + colapso de lineas en blanco + strip final): cambios
|
||||
irrelevantes de whitespace no alteran el hash, pero cambios de contenido SI.
|
||||
- **Determinismo**: el cuerpo se genera con las claves de `hypotheses` y
|
||||
`analysis_plan` ordenadas alfabeticamente, de modo que el orden de insercion del
|
||||
dict no afecta al hash. Mismo `(hypotheses, analysis_plan)` -> mismo cuerpo y
|
||||
mismo hash, byte a byte.
|
||||
- **No crea el directorio del paper**: si `paper_dir` no existe, devuelve `error`
|
||||
sin crear nada (ni el dir ni el archivo).
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Congela (pre-registra) la hipotesis y el plan de analisis de un paper.
|
||||
|
||||
Anti-HARKing (Hypothesizing After the Results are Known): el pre-registro fija
|
||||
la hipotesis y el plan de analisis ANTES de mirar los datos. Una vez congelado
|
||||
(``status: frozen``) es INMUTABLE: cualquier intento posterior de re-congelar con
|
||||
un contenido distinto se RECHAZA en vez de sobrescribir, de modo que no se puede
|
||||
"ajustar" la hipotesis a los resultados despues de verlos.
|
||||
|
||||
Escribe/actualiza ``<paper_dir>/preregistration.md`` con un frontmatter
|
||||
(``paper_slug``, ``frozen_at``, ``content_hash``, ``status``) y un cuerpo
|
||||
markdown DETERMINISTA derivado de ``(hypotheses, analysis_plan)``.
|
||||
|
||||
Estilo dict-no-throw: NUNCA lanza; cualquier error previsible se captura y se
|
||||
devuelve como ``{"status": "error", "note": ...}``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _build_body(hypotheses: dict, analysis_plan: dict) -> str:
|
||||
"""Construye el cuerpo markdown del pre-registro de forma DETERMINISTA.
|
||||
|
||||
Mismo ``(hypotheses, analysis_plan)`` -> mismo cuerpo byte a byte. Las claves
|
||||
se ordenan alfabeticamente para no depender del orden de insercion del dict.
|
||||
"""
|
||||
lines = ["## Hypotheses", ""]
|
||||
for k in sorted(hypotheses.keys()):
|
||||
lines.append(f"- **{k}**: {hypotheses[k]}")
|
||||
lines.append("")
|
||||
lines.append("## Analysis plan")
|
||||
lines.append("")
|
||||
for k in sorted(analysis_plan.keys()):
|
||||
lines.append(f"- **{k}**: {analysis_plan[k]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize(body: str) -> str:
|
||||
"""Normaliza el cuerpo para el hash: strip por linea + colapsa blancos.
|
||||
|
||||
Cambios irrelevantes de whitespace (espacios al final, dobles lineas en
|
||||
blanco) no alteran el hash; cambios de contenido SI. Esto hace el hash
|
||||
robusto sin perder la capacidad de detectar ediciones reales.
|
||||
"""
|
||||
out = []
|
||||
prev_blank = False
|
||||
for raw in body.splitlines():
|
||||
line = raw.strip()
|
||||
if line == "":
|
||||
if prev_blank:
|
||||
continue
|
||||
prev_blank = True
|
||||
else:
|
||||
prev_blank = False
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def _content_hash(body: str) -> str:
|
||||
"""sha256 hex del cuerpo NORMALIZADO (nunca del frontmatter)."""
|
||||
return hashlib.sha256(_normalize(body).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
"""Parsea el frontmatter ``--- ... ---`` simple (key: value) de un .md."""
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return {}
|
||||
fm = {}
|
||||
for line in parts[1].splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
fm[key.strip()] = value.strip()
|
||||
return fm
|
||||
|
||||
|
||||
def _render_file(slug: str, frozen_at: str, content_hash: str, body: str) -> str:
|
||||
"""Compone el archivo completo: frontmatter frozen + cuerpo."""
|
||||
return (
|
||||
"---\n"
|
||||
f"paper_slug: {slug}\n"
|
||||
f"frozen_at: {frozen_at}\n"
|
||||
f"content_hash: {content_hash}\n"
|
||||
"status: frozen\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
|
||||
def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict:
|
||||
"""Congela la hipotesis y el plan de analisis de un paper (anti-HARKing).
|
||||
|
||||
Escribe ``<paper_dir>/preregistration.md`` con frontmatter ``status: frozen``
|
||||
y un cuerpo markdown determinista. Una vez congelado es inmutable.
|
||||
|
||||
Args:
|
||||
paper_dir: ruta del directorio del paper (p.ej. ``"papers/0001-mi-paper"``).
|
||||
El ``paper_slug`` es el basename del directorio. Debe existir.
|
||||
hypotheses: dict de hipotesis, p.ej.
|
||||
``{"h0": "no hay diferencia ...", "h1": "grupo A > grupo B ..."}``.
|
||||
analysis_plan: dict con el plan, p.ej.
|
||||
``{"test": "welch_t_test", "effect_size_metric": "cohens_d",
|
||||
"decision_rule": "...", "planned_n": 100, "multiple_correction": "holm"}``.
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw (NUNCA lanza). Claves segun el caso:
|
||||
- frozen: {"status": "frozen", "path", "content_hash", "frozen_at"}
|
||||
- unchanged: {"status": "unchanged", "path", "content_hash", "frozen_at"}
|
||||
- error: {"status": "error", "path", "note", ...}
|
||||
"""
|
||||
expected_path = os.path.join(paper_dir, "preregistration.md")
|
||||
try:
|
||||
# 1) El directorio del paper debe existir; no se crea aqui.
|
||||
if not isinstance(paper_dir, str) or not os.path.isdir(paper_dir):
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"paper_dir no existe: {paper_dir}",
|
||||
}
|
||||
|
||||
if not isinstance(hypotheses, dict) or not isinstance(analysis_plan, dict):
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": "hypotheses y analysis_plan deben ser dict",
|
||||
}
|
||||
|
||||
slug = os.path.basename(os.path.normpath(paper_dir))
|
||||
|
||||
# 2) + 3) Cuerpo determinista y su hash (solo del cuerpo, no del frontmatter).
|
||||
body = _build_body(hypotheses, analysis_plan)
|
||||
new_hash = _content_hash(body)
|
||||
|
||||
# 5) Logica de escritura.
|
||||
if os.path.exists(expected_path):
|
||||
existing = ""
|
||||
try:
|
||||
with open(expected_path, "r", encoding="utf-8") as fh:
|
||||
existing = fh.read()
|
||||
except OSError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"no se pudo leer el pre-registro existente: {exc}",
|
||||
}
|
||||
fm = _parse_frontmatter(existing)
|
||||
old_status = fm.get("status", "")
|
||||
old_hash = fm.get("content_hash", "")
|
||||
old_frozen_at = fm.get("frozen_at", "")
|
||||
|
||||
if old_status == "frozen":
|
||||
if old_hash == new_hash:
|
||||
# Idempotente: mismo contenido ya congelado. No se reescribe.
|
||||
return {
|
||||
"status": "unchanged",
|
||||
"path": expected_path,
|
||||
"content_hash": new_hash,
|
||||
"frozen_at": old_frozen_at,
|
||||
}
|
||||
# Inmutabilidad: ya congelado con OTRO hash -> se rechaza (anti-HARKing).
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"content_hash": new_hash,
|
||||
"note": (
|
||||
"pre-registro inmutable: ya esta congelado (frozen) con un "
|
||||
"hash distinto; un pre-registro no se puede editar tras "
|
||||
"congelarse"
|
||||
),
|
||||
}
|
||||
# status != "frozen" (p.ej. draft) -> se congela ahora.
|
||||
|
||||
# Archivo nuevo o draft existente: congelar con timestamp actual.
|
||||
frozen_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
file_text = _render_file(slug, frozen_at, new_hash, body)
|
||||
try:
|
||||
with open(expected_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(file_text)
|
||||
except OSError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"no se pudo escribir el pre-registro: {exc}",
|
||||
}
|
||||
return {
|
||||
"status": "frozen",
|
||||
"path": expected_path,
|
||||
"content_hash": new_hash,
|
||||
"frozen_at": frozen_at,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 - dict-no-throw: nunca propagar.
|
||||
return {
|
||||
"status": "error",
|
||||
"path": expected_path,
|
||||
"note": f"error inesperado: {exc}",
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests para preregister_hypothesis (pre-registro inmutable, anti-HARKing).
|
||||
|
||||
Importa el modulo hoja directamente (`preregister_hypothesis`) para no depender
|
||||
de que el paquete reexporte la funcion en su __init__ (lo integra el orquestador
|
||||
al cerrar el grupo papers). El pytest del repo resuelve el modulo hoja por su
|
||||
nombre directo.
|
||||
|
||||
Todos los tests son hermeticos y deterministas: usan el fixture `tmp_path` de
|
||||
pytest; NUNCA escriben en `papers/`.
|
||||
"""
|
||||
|
||||
from preregister_hypothesis import preregister_hypothesis
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
parts = text.split("---", 2)
|
||||
fm = {}
|
||||
for line in parts[1].splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
fm[key.strip()] = value.strip()
|
||||
return fm
|
||||
|
||||
|
||||
HYP = {"h0": "no hay diferencia entre A y B", "h1": "el grupo A > grupo B"}
|
||||
PLAN = {
|
||||
"test": "welch_t_test",
|
||||
"effect_size_metric": "cohens_d",
|
||||
"decision_rule": "rechazar H0 si p<0.05 tras Holm y |d|>=0.5",
|
||||
"planned_n": 100,
|
||||
"multiple_correction": "holm",
|
||||
}
|
||||
|
||||
|
||||
def test_golden_congela_y_escribe_archivo(tmp_path):
|
||||
paper = tmp_path / "0001-x"
|
||||
paper.mkdir()
|
||||
|
||||
res = preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
|
||||
assert res["status"] == "frozen"
|
||||
pre = paper / "preregistration.md"
|
||||
assert pre.exists()
|
||||
|
||||
text = pre.read_text(encoding="utf-8")
|
||||
fm = _parse_frontmatter(text)
|
||||
assert fm["status"] == "frozen"
|
||||
assert fm["paper_slug"] == "0001-x"
|
||||
assert fm["content_hash"] # no vacio
|
||||
assert fm["frozen_at"] # no vacio
|
||||
assert res["content_hash"] == fm["content_hash"]
|
||||
assert res["frozen_at"] == fm["frozen_at"]
|
||||
|
||||
|
||||
def test_idempotente_mismo_input_no_reescribe(tmp_path):
|
||||
paper = tmp_path / "0001-x"
|
||||
paper.mkdir()
|
||||
pre = paper / "preregistration.md"
|
||||
|
||||
first = preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
assert first["status"] == "frozen"
|
||||
bytes_before = pre.read_bytes()
|
||||
|
||||
second = preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
assert second["status"] == "unchanged"
|
||||
# Mismo hash y frozen_at original preservado.
|
||||
assert second["content_hash"] == first["content_hash"]
|
||||
assert second["frozen_at"] == first["frozen_at"]
|
||||
# El archivo NO cambio byte a byte (incl. frozen_at).
|
||||
assert pre.read_bytes() == bytes_before
|
||||
|
||||
|
||||
def test_inmutabilidad_anti_harking_rechaza_contenido_distinto(tmp_path):
|
||||
paper = tmp_path / "0001-x"
|
||||
paper.mkdir()
|
||||
pre = paper / "preregistration.md"
|
||||
|
||||
preregister_hypothesis(str(paper), HYP, PLAN)
|
||||
bytes_frozen = pre.read_bytes()
|
||||
|
||||
# Intento de re-congelar con una hipotesis DISTINTA (HARKing) -> rechazado.
|
||||
hyp_tramposo = {"h0": "no hay diferencia", "h1": "el grupo B > grupo A (cambiado tras ver datos)"}
|
||||
res = preregister_hypothesis(str(paper), hyp_tramposo, PLAN)
|
||||
|
||||
assert res["status"] == "error"
|
||||
# Asercion mas importante: el archivo en disco SIGUE siendo el original.
|
||||
assert pre.read_bytes() == bytes_frozen
|
||||
|
||||
|
||||
def test_error_paper_dir_inexistente_no_crash_no_crea(tmp_path):
|
||||
missing = tmp_path / "no-existe"
|
||||
res = preregister_hypothesis(str(missing), HYP, PLAN)
|
||||
|
||||
assert res["status"] == "error"
|
||||
# No se creo el directorio ni el archivo.
|
||||
assert not missing.exists()
|
||||
assert not (missing / "preregistration.md").exists()
|
||||
Reference in New Issue
Block a user