3  Calidad del dato: limpieza, nulos y valores atípicos

mlops
calidad-datos
limpieza
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

12 de septiembre de 2026

Serie MLOps y Producción · de la ingeniería de datos al monitoreo.

Módulo I — Fundamentos y arquitectura de sistemas de IA

La sesión 2 entregó una foto con esquema, hash y linaje. Esa foto puede ser estructuralmente válida y, a la vez, inservible: nulos disfrazados de -1, el mismo cliente dos veces, un ingreso que es un cero de más. El lema no es garbage in, garbage out como consigna. Es una afirmación de diseño:

Un modelo no corrige un contrato de dato roto. Lo aprende.

Esta sesión juzga la plata. No vigila producción (eso es 3a y 12). No diseña \(T(x)\) (sesión 4). No mete la imputación dentro de un Pipeline de scikit-learn como contrato de ejecución (sesión 5) —aunque sí adelanta la regla de oro: la estadística de imputación se estima en train.

Objetivos de aprendizaje

Al finalizar, el estudiante estará en capacidad de:

  1. Evaluar una foto con las seis dimensiones de calidad y una taxonomía de suciedad.
  2. Distinguir centinelas de NaN y no confiar en .isna() a ciegas.
  3. Clasificar ausencias como MCAR, MAR o MNAR y elegir una imputación compatible con el mecanismo.
  4. Estimar imputadores solo en train y medir el sesgo de imputar con todo el lote.
  5. Detectar outliers (IQR, \(z\), Isolation Forest) y decidir conservar, recortar o separar —no “borrar por default”.
  6. Publicar un reporte de calidad que autorice o vete el paso a la sesión 4.

1) Dimensiones: el criterio, no el síntoma

Dimensión Pregunta Falla típica
Completitud ¿Falta lo que debería existir? Ingreso vacío; o -1 de centinela
Consistencia ¿La misma entidad se escribe igual? Bogota / BOGOTÁ
Exactitud ¿El valor es el del mundo? Edad 180
Unicidad ¿Un hecho, una fila? Doble clic en enviar
Validez ¿Tipo, rango, formato? Nota 10 en escala 1–5
Actualidad ¿Sigue vigente? Precio de 2022 en 2026

La sesión 2 ya exigió validez de esquema (columnas, tipos). Aquí se juzga la foto tipada.

1.1 Taxonomía de suciedad

  1. Faltantes explícitos (NaN) o disfrazados (centinela).
  2. Duplicados exactos y casi-duplicados.
  3. Inconsistencia de categoría o de formato.
  4. Error de tipo que sobrevivió al ETL (una fecha-texto).
  5. Fuera de dominio (regla de negocio).
  6. Outlier estadístico: puede ser error o hecho raro.
  7. Integridad referencial (llave huérfana): la sesión 2 la contó; aquí se decide si veta el lote.

Cada tipo vulnera una dimensión distinta. Tratarlos a todos con dropna() es una sola medicina para siete enfermedades.

1.2 Corte con 3a, 4 y 12

Pregunta Esta sesión Otra
¿Esta foto de entrenamiento es usable? sí
¿El centinela en producción se vigila cada semana? se nombra 3a, 12
¿log1p o one-hot? no 4
¿PSI de \(x\) contra referencia? no 12

2) Semilla sucia: inyectar para poder diagnosticar

Un dataset sintético de clientes permite saber qué suciedad hay. En un dataset real esa certeza no existe: por eso el reporte, no la memoria.

from pathlib import Path
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.impute import KNNImputer, SimpleImputer
from sklearn.ensemble import IsolationForest
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error

RUTA = Path("artefactos_sesion3")
RUTA.mkdir(exist_ok=True)
rng = np.random.default_rng(3)
n = 700

df = pd.DataFrame({
    "cliente_id": [f"c{i:04d}" for i in range(n)],
    "edad": rng.integers(18, 75, size=n),
    "ingresos_mensuales": rng.lognormal(mean=14.6, sigma=0.45, size=n).round(0),
    "monto_compra": rng.lognormal(mean=11.2, sigma=0.6, size=n).round(0),
    "ciudad": rng.choice(["Bogotá", "Medellín", "Cali", "Barranquilla"], size=n),
    "genero": rng.choice(["F", "M"], size=n),
    "satisfaccion": rng.integers(1, 6, size=n),
})
print(df.head(3).to_string(index=False))
print("n limpio de laboratorio =", len(df))
cliente_id  edad  ingresos_mensuales  monto_compra   ciudad genero  satisfaccion
     c0000    64           1635808.0       62552.0   Bogotá      M             1
     c0001    22           1287729.0       68342.0 Medellín      F             3
     c0002    28           4398648.0      105240.0   Bogotá      M             4
n limpio de laboratorio = 700

2.1 Inyección de validez, consistencia y exactitud

sucio = df.copy()
# exactitud / validez
idx_edad = rng.choice(sucio.index, 8, replace=False)
sucio.loc[idx_edad, "edad"] = rng.choice([0, -3, 180, 210], size=8)
idx_sat = rng.choice(sucio.index, 6, replace=False)
sucio.loc[idx_sat, "satisfaccion"] = rng.choice([0, 9, 10], size=6)
# centinela disfrazado de ingreso
idx_sent = rng.choice(sucio.index, 20, replace=False)
sucio.loc[idx_sent, "ingresos_mensuales"] = -1
# consistencia
idx_ciu = rng.choice(sucio.index, 25, replace=False)
sucio.loc[idx_ciu, "ciudad"] = rng.choice(["BOGOTA", "bogota", "Bogota "], size=25)
idx_gen = rng.choice(sucio.index, 18, replace=False)
sucio.loc[idx_gen, "genero"] = rng.choice(["f", "m", "F ", "M "], size=18)
print("edades imposibles:", int(((sucio["edad"] < 15) | (sucio["edad"] > 100)).sum()))
print("centinelas -1    :", int((sucio["ingresos_mensuales"] == -1).sum()))
edades imposibles: 8
centinelas -1    : 20

2.2 Ausencias con mecanismo conocido

  • MCAR. La ciudad se pierde al azar: \(P(\text{falta}\mid x)=P(\text{falta})\).
  • MAR. El ingreso falta más cuando la edad es alta: \(P(\text{falta}\mid x)=P(\text{falta}\mid \text{edad})\).
  • MNAR. El monto falta cuando el monto mismo es alto (el cliente VIP no declara): la probabilidad depende del valor no observado.
sucio["ciudad"] = sucio["ciudad"].astype(object)
mcar = rng.choice(sucio.index, 40, replace=False)
sucio.loc[mcar, "ciudad"] = np.nan

mar = sucio.index[sucio["edad"] >= 60]
mar = rng.choice(mar, size=min(50, len(mar)), replace=False)
sucio.loc[mar, "ingresos_mensuales"] = np.nan

mnar = sucio.index[sucio["monto_compra"] >= sucio["monto_compra"].quantile(0.92)]
mnar = rng.choice(mnar, size=min(35, len(mnar)), replace=False)
sucio.loc[mnar, "monto_compra"] = np.nan
print("nulos ciudad / ingreso / monto:",
      int(sucio["ciudad"].isna().sum()),
      int(sucio["ingresos_mensuales"].isna().sum()),
      int(sucio["monto_compra"].isna().sum()))
nulos ciudad / ingreso / monto: 40 50 35

2.3 Duplicados exactos y casi-duplicados

exactos = sucio.sample(15, random_state=3)
casi = sucio.sample(10, random_state=4).copy()
casi["ciudad"] = casi["ciudad"].astype(str).str.upper()
sucio = pd.concat([sucio, exactos, casi], ignore_index=True)
print("n tras inyectar duplicados =", len(sucio))
print("duplicados exactos (todas las cols):", int(sucio.duplicated().sum()))
n tras inyectar duplicados = 725
duplicados exactos (todas las cols): 15

3) Diagnóstico: perfil antes de tocar

Tratar antes de perfilar es destruir evidencia. El perfil responde: tipos, nulos, duplicados, rangos, categorías.

def perfil(df: pd.DataFrame) -> pd.DataFrame:
    filas = []
    for c in df.columns:
        s = df[c]
        filas.append({
            "columna": c,
            "dtype": str(s.dtype),
            "nulos": int(s.isna().sum()),
            "nulos_pct": round(100 * s.isna().mean(), 2),
            "unicos": int(s.nunique(dropna=True)),
            "min": s.min() if pd.api.types.is_numeric_dtype(s) else "",
            "max": s.max() if pd.api.types.is_numeric_dtype(s) else "",
        })
    return pd.DataFrame(filas)


rep0 = perfil(sucio)
print(rep0.to_string(index=False))
rep0.to_csv(RUTA / "perfil_sucio.csv", index=False)
           columna   dtype  nulos  nulos_pct  unicos      min        max
        cliente_id  object      0       0.00     700                    
              edad   int64      0       0.00      61       -3        210
ingresos_mensuales float64     51       7.03     633     -1.0  9212502.0
      monto_compra float64     37       5.10     664  14082.0   349307.0
            ciudad  object     40       5.52      11                    
            genero  object      0       0.00       6                    
      satisfaccion   int64      0       0.00       7        0         10

3.1 Centinelas: .isna() miente

print("isna ingresos:", int(sucio["ingresos_mensuales"].isna().sum()))
print("valor == -1  :", int((sucio["ingresos_mensuales"] == -1).sum()))
print("un perfil que no busca -1 declara completitud falsa")
isna ingresos: 51
valor == -1  : 19
un perfil que no busca -1 declara completitud falsa

3.2 Vista descriptiva

print(sucio[["edad", "ingresos_mensuales", "monto_compra", "satisfaccion"]].describe().round(1))
print("ciudades:", sucio["ciudad"].value_counts(dropna=False).to_dict())
print("generos :", sucio["genero"].value_counts(dropna=False).to_dict())
        edad  ingresos_mensuales  monto_compra  satisfaccion
count  725.0               674.0         688.0         725.0
mean    46.4           2394463.9       82096.1           3.0
std     20.7           1189550.7       43531.5           1.5
min     -3.0                -1.0       14082.0           0.0
25%     31.0           1598683.2       48380.5           2.0
50%     46.0           2209894.0       74019.5           3.0
75%     59.0           3008346.5      104426.5           4.0
max    210.0           9212502.0      349307.0          10.0
ciudades: {'Bogotá': 176, 'Cali': 169, 'Medellín': 157, 'Barranquilla': 150, nan: 40, 'Bogota ': 12, 'BOGOTA': 7, 'CALI': 5, 'bogota': 4, 'MEDELLÍN': 2, 'BOGOTÁ': 2, 'NAN': 1}
generos : {'F': 354, 'M': 353, 'f': 8, 'm': 7, 'M ': 2, 'F ': 1}
fig, axes = plt.subplots(1, 3, figsize=(10, 3.2))
sucio["edad"].hist(ax=axes[0], bins=20, color="0.4")
axes[0].set_title("edad (incluye 180)")
sucio["monto_compra"].hist(ax=axes[1], bins=30, color="0.4")
axes[1].set_title("monto")
sucio["genero"].astype(str).value_counts().plot(kind="bar", ax=axes[2], color="0.4")
axes[2].set_title("genero crudo")
fig.tight_layout()
fig.savefig(RUTA / "perfil_hist.png", dpi=110)
plt.close()
print("figura:", RUTA / "perfil_hist.png")
figura: artefactos_sesion3\perfil_hist.png

4) Unicidad: exactos y casi-duplicados

El duplicado exacto es un reintento. El casi-duplicado es el mismo cliente con BOGOTA frente a Bogotá. Borrar exactos es seguro si el grano es “un cliente, una fila”. Borrar casi-duplicados exige una llave de negocio (cliente_id), no drop_duplicates() ciego sobre todas las columnas.

n_antes = len(sucio)
sucio = sucio.drop_duplicates().copy()
print(f"exactos: {n_antes} → {len(sucio)}")
print("ids repetidos (casi o reenvío):", int(sucio["cliente_id"].duplicated().sum()))
exactos: 725 → 710
ids repetidos (casi o reenvío): 10

Si cliente_id se repite, no se borra la fila “más rara” sin regla. Se conserva la más reciente si hay timestamp (sesión 2) o se veta el lote. Aquí el laboratorio no tiene ts: se reporta y se deja la primera.

n_antes = len(sucio)
sucio = sucio.drop_duplicates(subset=["cliente_id"], keep="first").copy()
print(f"por cliente_id: {n_antes} → {len(sucio)}")
por cliente_id: 710 → 700

5) Consistencia y dominio

Unificar texto es llave. Recortar edad 180 es dominio. No son outliers: el dominio se escribe antes de mirar cuartiles.

sucio["ciudad"] = (
    sucio["ciudad"].astype(str).str.strip().str.title()
    .replace({"Nan": np.nan, "Bogota": "Bogotá"})
)
sucio["genero"] = sucio["genero"].astype(str).str.strip().str.upper().replace({"NAN": np.nan})
fuera_edad = (sucio["edad"] < 15) | (sucio["edad"] > 90)
fuera_sat = ~sucio["satisfaccion"].isin([1, 2, 3, 4, 5])
print("edad fuera de dominio:", int(fuera_edad.sum()))
print("satisfaccion inválida:", int(fuera_sat.sum()))
sucio.loc[fuera_edad, "edad"] = np.nan
sucio.loc[fuera_sat, "satisfaccion"] = np.nan
edad fuera de dominio: 8
satisfaccion inválida: 6

El valor imposible se convierte en nulo explícito. Inventar edad = 40 “porque es la media” es imputar antes de declarar el mecanismo.

print("ciudades:", sucio["ciudad"].value_counts(dropna=False).to_dict())
print("generos :", sucio["genero"].value_counts(dropna=False).to_dict())
ciudades: {'Bogotá': 198, 'Cali': 165, 'Medellín': 152, 'Barranquilla': 145, nan: 40}
generos : {'M': 351, 'F': 349}

5.1 Centinela → nulo

sucio.loc[sucio["ingresos_mensuales"] == -1, "ingresos_mensuales"] = np.nan
print("nulos de ingreso tras destapar -1:", int(sucio["ingresos_mensuales"].isna().sum()))
nulos de ingreso tras destapar -1: 68

6) Mecanismo de ausencia: la imputación no es un menú

Sea \(R=1\) si el valor falta. Rubin:

  • MCAR. \(R \perp X\). Borrar filas no sesga; solo pierde potencia.
  • MAR. \(R \perp X_{\text{faltante}} \mid X_{\text{observado}}\). Imputar usando lo observado es defendible.
  • MNAR. \(R\) depende del valor faltante. Ni la media ni el \(k\)-NN ven esa dependencia: hace falta un modelo de no-respuesta o una cota.

Imputar la media del ingreso cuando el hueco es MAR por edad aplana a los mayores. El laboratorio lo muestra.

print(perfil(sucio)[["columna", "nulos", "nulos_pct"]].to_string(index=False))
           columna  nulos  nulos_pct
        cliente_id      0       0.00
              edad      8       1.14
ingresos_mensuales     68       9.71
      monto_compra     35       5.00
            ciudad     40       5.71
            genero      0       0.00
      satisfaccion      6       0.86

6.1 Tres estrategias, una columna (ingreso, MAR)

trabajo = sucio.copy()
# listwise: tirar filas con ingreso nulo
listwise = trabajo.dropna(subset=["ingresos_mensuales"])
imp_media = SimpleImputer(strategy="median")
trabajo["ing_mediana"] = imp_media.fit_transform(trabajo[["ingresos_mensuales"]])
print("n original", len(trabajo), "| n listwise", len(listwise))
print("mediana real (lab, sin nulos originales) no la tenemos aquí;")
print("mediana imputada:", float(np.nanmedian(trabajo["ingresos_mensuales"])))
print("media de edad en filas CON ingreso :", round(trabajo.loc[trabajo["ingresos_mensuales"].notna(), "edad"].mean(), 2))
print("media de edad en filas SIN ingreso :", round(trabajo.loc[trabajo["ingresos_mensuales"].isna(), "edad"].mean(), 2))
print("si la segunda es mayor, el hueco es MAR por edad: la mediana global no lo ve.")
n original 700 | n listwise 632
mediana real (lab, sin nulos originales) no la tenemos aquí;
mediana imputada: 2260955.0
media de edad en filas CON ingreso : 43.83
media de edad en filas SIN ingreso : 60.77
si la segunda es mayor, el hueco es MAR por edad: la mediana global no lo ve.

6.2 \(k\)-NN usa lo observado (edad, monto)

cols = ["edad", "ingresos_mensuales", "monto_compra"]
knn = KNNImputer(n_neighbors=5)
imp_knn = pd.DataFrame(knn.fit_transform(trabajo[cols]), columns=cols, index=trabajo.index)
print("ingreso: mediana global vs kNN (primeros nulos)")
nulos = trabajo.index[trabajo["ingresos_mensuales"].isna()][:5]
cmp = pd.DataFrame({
    "edad": trabajo.loc[nulos, "edad"],
    "mediana_global": float(np.nanmedian(trabajo["ingresos_mensuales"])),
    "knn": imp_knn.loc[nulos, "ingresos_mensuales"],
})
print(cmp.to_string(index=False))
ingreso: mediana global vs kNN (primeros nulos)
 edad  mediana_global       knn
 47.0       2260955.0 3498842.6
 67.0       2260955.0 2054695.8
 18.0       2260955.0 3065768.8
 73.0       2260955.0 2054695.8
 68.0       2260955.0 2054695.8

6.3 Frontera train / test: imputar con todo el lote es una fuga

Si la mediana se calcula con las filas que luego serán test, el test ya participó en \(T\). No es la sesión 5, pero la invariante es la misma: fit en train, transform en test.

visible = trabajo.dropna(subset=["monto_compra"]).copy()
Xtr, Xte = train_test_split(visible, test_size=0.25, random_state=3)
med_train = float(Xtr["ingresos_mensuales"].median())
med_todo = float(visible["ingresos_mensuales"].median())
print(f"mediana train = {med_train:.0f} | mediana todo = {med_todo:.0f}")
print("se publica la de train. la del lote completo es la cifra que no debe usarse.")
mediana train = 2268580 | mediana todo = 2245800
se publica la de train. la del lote completo es la cifra que no debe usarse.
imp = SimpleImputer(strategy="median")
imp.fit(Xtr[["ingresos_mensuales"]])
Xte_ok = Xte.copy()
Xte_ok["ingresos_mensuales"] = imp.transform(Xte[["ingresos_mensuales"]])
print("estadística congelada (statistics_):", imp.statistics_)
print("nulos en test tras transform:", int(pd.isna(Xte_ok["ingresos_mensuales"]).sum()))
estadística congelada (statistics_): [2268580.]
nulos en test tras transform: 0

6.4 MNAR: el \(k\)-NN no adivina lo que no ve

Los montos faltantes son altos. Imputarlos con vecinos de monto observado (más bajos) tira hacia abajo la cola. El reporte debe decir: “monto es MNAR; no se imputa; se modela la no-respuesta o se deja el nulo para un indicador”.

obs = trabajo.loc[trabajo["monto_compra"].notna(), "monto_compra"]
print("mediana monto observado :", float(obs.median()))
print("los nulos de monto se inyectaron en el decil alto: imputarlos es mentir la cola")
trabajo["monto_faltante"] = trabajo["monto_compra"].isna().astype(int)
print("indicador MNAR — tasa:", float(trabajo["monto_faltante"].mean()))
mediana monto observado : 74033.0
los nulos de monto se inyectaron en el decil alto: imputarlos es mentir la cola
indicador MNAR — tasa: 0.05

6.5 Cuándo usar cada estrategia

guia = pd.DataFrame([
    {"estrategia": "listwise", "cuando": "MCAR y n pequeño de huecos", "riesgo": "perder potencia; sesgo si no es MCAR"},
    {"estrategia": "mediana train", "cuando": "MAR débil, cola pesada", "riesgo": "aplana grupos (edad alta)"},
    {"estrategia": "kNN / iterativo", "cuando": "MAR con covariables observadas", "riesgo": "caro; fuga si fit en todo"},
    {"estrategia": "indicador + no imputar", "cuando": "MNAR o nulo informativo", "riesgo": "el modelo debe poder leer el indicador"},
])
print(guia.to_string(index=False))
            estrategia                         cuando                                 riesgo
              listwise     MCAR y n pequeño de huecos   perder potencia; sesgo si no es MCAR
         mediana train         MAR débil, cola pesada              aplana grupos (edad alta)
       kNN / iterativo MAR con covariables observadas              caro; fuga si fit en todo
indicador + no imputar        MNAR o nulo informativo el modelo debe poder leer el indicador

7) Outliers: extremos no son pecados

Un outlier es un punto raro respecto de una distribución y un método. El IQR marca \(x \notin [Q_1-1.5\,\mathrm{IQR},\, Q_3+1.5\,\mathrm{IQR}]\). El \(z\)-score asume algo parecido a campana. Isolation Forest marca rareza multivariada.

Ninguno sabe si el punto es un cero de más o un mayorista real. Esa es una pregunta de dominio.

def marca_iqr(s: pd.Series, k: float = 1.5) -> pd.Series:
    q1, q3 = s.quantile(0.25), s.quantile(0.75)
    iqr = q3 - q1
    return (s < q1 - k * iqr) | (s > q3 + k * iqr)


def marca_z(s: pd.Series, z: float = 3.0) -> pd.Series:
    mu, sd = s.mean(), s.std(ddof=0)
    return (s - mu).abs() > z * sd


base = trabajo.dropna(subset=["monto_compra", "edad"]).copy()
base["out_iqr"] = marca_iqr(base["monto_compra"])
base["out_z"] = marca_z(base["monto_compra"])
print("IQR :", int(base["out_iqr"].sum()), "| z :", int(base["out_z"].sum()),
      "| intersección:", int((base["out_iqr"] & base["out_z"]).sum()))
IQR : 12 | z : 6 | intersección: 6
fig, ax = plt.subplots(figsize=(6, 3.2))
ax.boxplot(base["monto_compra"].to_numpy(), vert=False)
ax.set_title("monto_compra: IQR ve la cola; no sabe si es error")
fig.tight_layout()
fig.savefig(RUTA / "boxplot_monto.png", dpi=110)
plt.close()
print("figura:", RUTA / "boxplot_monto.png")
figura: artefactos_sesion3\boxplot_monto.png

7.1 Isolation Forest: raro en el plano, no en un eje

iso = IsolationForest(contamination=0.03, random_state=3)
base["out_iso"] = iso.fit_predict(base[["edad", "monto_compra"]]) == -1
print("multivariados:", int(base["out_iso"].sum()))
print("solo IQR     :", int((base["out_iqr"] & ~base["out_iso"]).sum()))
print("solo ISO     :", int((base["out_iso"] & ~base["out_iqr"]).sum()))
multivariados: 20
solo IQR     : 1
solo ISO     : 9

7.2 Tres destinos, no uno

  1. Corregir si hay evidencia de error (edad 180 → nulo de dominio, ya hecho).
  2. Winsorizar si el modelo lineal se rompe con la cola y el negocio acepta techo.
  3. Separar (otro segmento: mayoristas) si el hecho es real.

Borrar por IQR “para que el histograma se vea bien” es estética, no calidad.

p99 = float(base["monto_compra"].quantile(0.99))
base["monto_win"] = base["monto_compra"].clip(upper=p99)
print(f"techo p99 = {p99:.0f}")
print("máximo crudo / winsor:", float(base["monto_compra"].max()), float(base["monto_win"].max()))
techo p99 = 197313
máximo crudo / winsor: 349307.0 197312.64999999988

7.3 Decisión de laboratorio

En este comercio didáctico, edad imposible ya es nulo. El monto extremo se conserva y se documenta: no hay evidencia de digitación. Isolation Forest se usa como alerta, no como borrador.

print("regla: no se borra monto por IQR; se reporta n_alerta =", int(base["out_iso"].sum()))
regla: no se borra monto por IQR; se reporta n_alerta = 20

8) ¿La limpieza cambia lo que un modelo tonto ve?

Un regresor lineal del monto a partir de edad e ingreso no es la sesión 6. Es un instrumento: si la foto sucia y la foto limpia dan MAE distintos, la suciedad era señal para \(f\). Eso no autoriza a limpiar mirando un test de verdad: aquí el split es interno al laboratorio.

def mae_lineal(frame, cols_x, y):
    d = frame.dropna(subset=cols_x + [y])
    Xtr, Xte, ytr, yte = train_test_split(d[cols_x], d[y], test_size=0.25, random_state=3)
    m = LinearRegression().fit(Xtr, ytr)
    return float(mean_absolute_error(yte, m.predict(Xte))), len(d)


# sucio de laboratorio: con centinelas aún mezclados es otro universo;
# usamos 'trabajo' (centinelas destapados) vs imputación train-only
a, na = mae_lineal(trabajo, ["edad", "ingresos_mensuales"], "monto_compra")
limpio = trabajo.copy()
limpio["ingresos_mensuales"] = SimpleImputer(strategy="median").fit_transform(
    limpio[["ingresos_mensuales"]]
)
b, nb = mae_lineal(limpio, ["edad", "ingresos_mensuales"], "monto_compra")
print(f"MAE sucio-tipado  ({na} filas): {a:.1f}")
print(f"MAE imputado med. ({nb} filas): {b:.1f}")
print("la diferencia mide sensibilidad a la foto, no un campeonato de modelos")
MAE sucio-tipado  (595 filas): 31965.0
MAE imputado med. (658 filas): 34289.2
la diferencia mide sensibilidad a la foto, no un campeonato de modelos

9) Scorecard y umbral de veto

Una foto se publica o se veta. El umbral es de negocio, no de pandas. Ejemplo didáctico: vetar si nulos de una columna clave \(> 15\,\%\), si duplicados de llave \(> 2\,\%\), o si el dominio de edad falla en \(> 1\,\%\).

score = {
    "n": int(len(trabajo)),
    "nulos_ingreso_pct": round(100 * trabajo["ingresos_mensuales"].isna().mean(), 2),
    "nulos_monto_pct": round(100 * trabajo["monto_compra"].isna().mean(), 2),
    "centinelas_restantes": int((trabajo["ingresos_mensuales"] == -1).sum()),
    "ciudades_distintas": int(trabajo["ciudad"].nunique(dropna=True)),
}
veto = (
    score["nulos_ingreso_pct"] > 15
    or score["nulos_monto_pct"] > 15
    or score["centinelas_restantes"] > 0
)
score["decision"] = "veto" if veto else "publicar_plata_limpia"
(RUTA / "reporte_calidad.json").write_text(
    json.dumps(score, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(json.dumps(score, indent=2, ensure_ascii=False))
{
  "n": 700,
  "nulos_ingreso_pct": 9.71,
  "nulos_monto_pct": 5.0,
  "centinelas_restantes": 0,
  "ciudades_distintas": 4,
  "decision": "publicar_plata_limpia"
}

El veto no es un semáforo de producción (sesión 12). Es una puerta antes de diseñar \(T(x)\). Entrenar sobre un lote vetado es la versión académica de garbage in.

9.1 Publicar la foto que la sesión 4 puede leer

foto = trabajo.copy()
foto["ingresos_mensuales"] = SimpleImputer(strategy="median").fit_transform(
    foto[["ingresos_mensuales"]]
)
# monto MNAR: se deja nulo + indicador; la sesión 4 decidirá si el indicador entra en T
foto.to_csv(RUTA / "foto_calidad.csv", index=False)
print("publicada:", RUTA / "foto_calidad.csv", "| n =", len(foto))
print("columnas:", list(foto.columns))
publicada: artefactos_sesion3\foto_calidad.csv | n = 700
columnas: ['cliente_id', 'edad', 'ingresos_mensuales', 'monto_compra', 'ciudad', 'genero', 'satisfaccion', 'ing_mediana', 'monto_faltante']

10) Unidad de análisis

Calidad se declara por grano. Si la fila es un cliente, un duplicado de cliente_id es error. Si la fila es una compra, repetir cliente_id es lo normal. La sesión 2 fijó el grano; esta sesión no lo cambia a escondidas con un drop_duplicates() sobre todas las columnas de un hecho repetible.

print("grano declarado: un cliente_id, una fila de laboratorio")
print("si esto fuera lineas de venta, drop_duplicates(subset=['cliente_id']) sería un delito de grano")
grano declarado: un cliente_id, una fila de laboratorio
si esto fuera lineas de venta, drop_duplicates(subset=['cliente_id']) sería un delito de grano

11) Pequeña \(n\) y porcentajes mentirosos

“12 % de nulos” en 25 filas es tres celdas. El scorecard debe llevar \(n\), no solo el porcentaje. La sesión 12 usará la misma precaución con el F1 de una semana de 30 positivos.

print("regla del reporte: se escribe n y pct, nunca pct solo")
print("ejemplo:", {"n": 25, "nulos": 3, "pct": 12.0, "lectura": "tres celdas, no una crisis"})
regla del reporte: se escribe n y pct, nunca pct solo
ejemplo: {'n': 25, 'nulos': 3, 'pct': 12.0, 'lectura': 'tres celdas, no una crisis'}

12) Lo que no se hace aquí

  • No se calcula PSI ni se compara con la semana pasada (12).
  • No se pone un great expectations en el CI (11).
  • No se encodea ciudad (4).
  • No se declara un test ciego de \(f\) (6).

Si el taller pide eso, está mal enunciado para este cuaderno.

12.1 Completitud no es “cero nulos”

Un formulario con el 2 % de ciudad vacía puede ser aceptable. Un ingreso con 2 % vacío y 8 % en -1 no. La dimensión se mide después de destapar centinelas. El umbral es del dueño del problema (sesión 1), no del científico que prefiere un heatmap limpio.

print("completitud ingreso aparente (isna):", round(100 * sucio["ingresos_mensuales"].isna().mean(), 2))
print("el -1 ya fue destapado; si se midiera antes, el pct mentía")
completitud ingreso aparente (isna): 9.71
el -1 ya fue destapado; si se midiera antes, el pct mentía

12.2 Consistencia de categorías: el diccionario se versiona

Bogota → Bogotá es una regla. Si mañana llega Nueva Bogotá, el replace silencioso la deja o la aplasta. El diccionario de normalización es un artefacto (CSV de alias), no un if eterno en una celda.

alias = pd.DataFrame({"crudo": ["BOGOTA", "bogota", "Bogota "], "canon": ["Bogotá", "Bogotá", "Bogotá"]})
alias.to_csv(RUTA / "alias_ciudad.csv", index=False)
print(alias.to_string(index=False))
print("si un crudo no está en alias, se reporta; no se adivina")
  crudo  canon
 BOGOTA Bogotá
 bogota Bogotá
Bogota  Bogotá
si un crudo no está en alias, se reporta; no se adivina

12.3 Exactitud frente a validez

Validez: la edad es un entero en \([15,90]\). Exactitud: esa persona tiene 47 años. Un 47 inventado es válido e inexacto. La limpieza de esta sesión ataca validez y unicidad; la exactitud exige una fuente de verdad que a menudo no está en el CSV.

print("válido e inexacto: edad=47 escrita a mano en un nulo MAR")
print("esta sesión no finge esa fuente de verdad; por eso imputar no es 'corregir'")
válido e inexacto: edad=47 escrita a mano en un nulo MAR
esta sesión no finge esa fuente de verdad; por eso imputar no es 'corregir'

12.4 El Isolation Forest también se ajusta en train

contamination=0.03 estimado sobre todo el lote incluye al test en la definición de “raro”. Misma invariante que el imputador. En el laboratorio se mostró el método; en un flujo honesto, fit es de train.

iso_tr = IsolationForest(contamination=0.03, random_state=3)
iso_tr.fit(Xtr[["edad", "ingresos_mensuales"]].fillna(med_train))
print("fit en train. predict en test no rehace la noción de rareza con el holdout.")
print("alertas test:", int((iso_tr.predict(Xte[["edad", "ingresos_mensuales"]].fillna(med_train)) == -1).sum()))
fit en train. predict en test no rehace la noción de rareza con el holdout.
alertas test: 4

13) Errores frecuentes

  1. Confiar en .isna() y no buscar -1, 9999, N/A.
  2. Imputar la media global cuando el hueco es MAR o MNAR.
  3. fit del imputador sobre todo el lote, incluido el test.
  4. Borrar outliers de IQR para “verse profesional”.
  5. drop_duplicates() sin declarar grano.
  6. Tratar la foto de entrenamiento como si fuera el monitor de producción.
  7. Entrenar sobre un lote que el scorecard habría vetado.

14) Checklist

  1. ¿Hay perfil antes de tratar?
  2. ¿Se destaparon centinelas?
  3. ¿Se nombró el mecanismo (MCAR/MAR/MNAR) por columna clave?
  4. ¿El imputador se ajustó en train?
  5. ¿El outlier tiene destino escrito (corregir / techo / separar / conservar)?
  6. ¿El reporte decide publicar o vetar con \(n\) y umbrales?

15) Conclusiones

Calidad no es un dropna al inicio del notebook. Es un juicio con dimensiones, mecanismos y una puerta. Esta sesión tomó la plata de la sesión 2, destapó centinelas, distinguió ausencias, reservó la estadística de imputación al train y se negó a borrar la cola por estética. La sesión 4 recibe una foto autorizada y diseña \(T(x)\). Quien salte de un CSV crudo a un one-hot está diseñando representación sobre un contrato que nadie firmó.

16) Taller

A. Invente un centinela 9999 en edad. ¿Lo ve el perfil de nulos? Añada la regla al destapado.

B. Compare MAE de un lineal con listwise frente a mediana-train. ¿Cuál cambia más: \(n\) o el error?

C. Marque monto con IQR \(k=3\) en vez de \(1.5\). ¿Cuántos puntos “dejan” de ser outliers? ¿Cambió el mundo o el método?

D. Escriba la frase de veto o publicación en una línea, con \(n\) y un umbral.

E. Argumente por qué imputar monto (MNAR) con \(k\)-NN es peor que dejar el indicador.

print("Artefactos sesión 3:")
for p in sorted(RUTA.iterdir()):
    print(f"  {p.name:24s} {p.stat().st_size:6d} B")
print("Taller A–E. Si borró la cola 'para que se vea bien', vuelva a la 7.2.")
Artefactos sesión 3:
  alias_ciudad.csv             62 B
  boxplot_monto.png         13180 B
  foto_calidad.csv          39412 B
  perfil_hist.png           28607 B
  perfil_sucio.csv            296 B
  reporte_calidad.json        171 B
Taller A–E. Si borró la cola 'para que se vea bien', vuelva a la 7.2.

💬 ¿Te sirvió?

Deja en los comentarios una duda o un caso donde aplicarías esto — respondo todos. Sígueme para no perderte el próximo artículo de la serie y comparte con alguien que esté aprendiendo arquitectura de IA.

👉 El código completo está disponible para ejecutar directamente.