3Calidad 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:
Evaluar una foto con las seis dimensiones de calidad y una taxonomía de suciedad.
Distinguir centinelas de NaN y no confiar en .isna() a ciegas.
Clasificar ausencias como MCAR, MAR o MNAR y elegir una imputación compatible con el mecanismo.
Estimar imputadores solo en train y medir el sesgo de imputar con todo el lote.
Detectar outliers (IQR, \(z\), Isolation Forest) y decidir conservar, recortar o separar —no “borrar por default”.
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
Faltantes explícitos (NaN) o disfrazados (centinela).
Duplicados exactos y casi-duplicados.
Inconsistencia de categoría o de formato.
Error de tipo que sobrevivió al ETL (una fecha-texto).
Fuera de dominio (regla de negocio).
Outlier estadístico: puede ser error o hecho raro.
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.
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
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.
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.
trabajo = sucio.copy()# listwise: tirar filas con ingreso nulolistwise = 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.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 - q1return (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 * sdbase = 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"]]) ==-1print("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
Corregir si hay evidencia de error (edad 180 → nulo de dominio, ya hecho).
Winsorizar si el modelo lineal se rompe con la cola y el negocio acepta techo.
Separar (otro segmento: mayoristas) si el hecho es real.
Borrar por IQR “para que el histograma se vea bien” es estética, no calidad.
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)returnfloat(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-onlya, 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\,\%\).
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 Tfoto.to_csv(RUTA /"foto_calidad.csv", index=False)print("publicada:", RUTA /"foto_calidad.csv", "| n =", len(foto))print("columnas:", list(foto.columns))
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
Confiar en .isna() y no buscar -1, 9999, N/A.
Imputar la media global cuando el hueco es MAR o MNAR.
fit del imputador sobre todo el lote, incluido el test.
Borrar outliers de IQR para “verse profesional”.
drop_duplicates() sin declarar grano.
Tratar la foto de entrenamiento como si fuera el monitor de producción.
Entrenar sobre un lote que el scorecard habría vetado.
14) Checklist
¿Hay perfil antes de tratar?
¿Se destaparon centinelas?
¿Se nombró el mecanismo (MCAR/MAR/MNAR) por columna clave?
¿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 insorted(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.