Capítulo 4 de 37 · básico
Valores faltantes y datos sucios
De qué trata este capítulo
Allá en el capítulo del umbral dejé una nota al pie sobre los datos de Pima y seguí adelante: algunas de esas lecturas de glucosa son cero, lo cual es imposible, así que trátalas con sospecha. Este capítulo es donde pago esa nota al pie por completo. Resulta que la sospecha era toda la historia. Casi la mitad de la columna de insulina son ceros, y una lectura de insulina en cero significa que el paciente está muerto, no que su páncreas esté ocioso. Eso no son mediciones. Son valores faltantes disfrazados de medición, y todos los modelos de este libro se los van a tragar felices y van a empeorar en silencio.
Así que antes de correr cualquier algoritmo, alguien tiene que encontrar los
huecos en los datos y decidir qué hacer con ellos. De eso va este capítulo.
Construimos las herramientas desde cero en NumPy y pandas: un detector que
atrapa valores faltantes incluyendo los disfrazados, tres formas de rellenarlos
(media, mediana, moda) con un argumento de cuándo conviene cada una, y dos reglas
de outliers que discrepan a propósito. Luego dejamos que el SimpleImputer de
scikit-learn rellene los mismos huecos y verificamos que llegamos a números
idénticos. El dataset es otra vez el de Pima, elegido porque su suciedad es
famosa e instructiva, y la revelación es una gráfica de barras que cuenta
exactamente cuántos ceros imposibles se esconden en cada columna. Son más de los
que imaginas.
Un poco de historia
El estudio formal de los datos faltantes es más joven de lo que pensarías. Durante la mayor parte del siglo XX la jugada estándar era tirar cualquier fila con un hueco — el "complete-case analysis" — y esperar que las filas que borraste se parecieran a las que conservaste. Donald Rubin mató esa esperanza en un artículo de 1976, "Inference and Missing Data", que le dio al campo el vocabulario que sigue usando. El punto de Rubin era que no puedes decidir cómo manejar un hueco hasta que entiendes por qué falta el valor, y nombró tres casos. Missing completely at random (MCAR): los huecos no tienen que ver con nada, un tubo de ensayo que se cayó. Missing at random (MAR): los huecos dependen de datos que sí tienes, como pacientes mayores que se saltan un examen con más frecuencia. Y missing not at random (MNAR): el hueco depende del propio valor faltante, como una báscula que se niega a reportar pesos arriba de 300 libras. Los tres casos requieren tres tratamientos distintos, y el peligroso es MNAR, porque la ausencia es información y borrarla tira señal a la basura. Guarda esa idea — la columna de insulina de Pima está más cerca de MNAR de lo que a nadie le gusta admitir, y nos muerde al final del capítulo.
La otra mitad del linaje de este capítulo es John Tukey y el análisis exploratorio de datos. El libro de Tukey de 1977, "Exploratory Data Analysis", hizo un argumento que hoy suena obvio y no lo era: antes de ajustar nada, mira los datos. Grafícalos, resúmelos con estadísticas que unos cuantos valores locos no puedan arruinar, encuentra los puntos que no encajan. El boxplot es suyo, y también la regla de las cercas que usaremos para outliers — la mediana y el rango intercuartílico en lugar de la media y la desviación estándar, precisamente porque la media y la desviación estándar son lo primero que un outlier corrompe. Rubin te dice que respetes los huecos; Tukey te dice que mires antes de saltar. Este capítulo es los dos, en código.
La intuición
Aquí está la trampa en una frase: un valor faltante no tiene que estar en blanco
para faltar. En un mundo limpio, la ausencia de una medición se guarda como
NaN, un null, una celda vacía — algo que un detector honesto puede encontrar.
En el mundo real, quien construyó la tabla necesitaba poner un número ahí, y el
cero estaba a la mano. Así que las lecturas faltantes de insulina se volvieron
0, las presiones arteriales faltantes se volvieron 0, y ahora tu dataset está
lleno de valores que pasan cualquier chequeo de tipos, cargan sin advertencia
alguna y son pura ficción.
La pista es el conocimiento del dominio, no el código. Un índice de masa corporal de cero es una persona sin masa. Una presión arterial diastólica de cero es un cadáver. Una vez que sabes que la columna físicamente no puede ser cero, cada cero en ella es una confesión de que la medición nunca ocurrió. Esta es la forma más común en que los datos reales te mienten, y es invisible a menos que la busques. Esto es lo que aparece cuando la buscas en el set de Pima — el conteo de ceros imposibles en cada columna que los tiene:
El número sobre cada barra es el conteo crudo de ceros imposibles; el eje es ese conteo como porcentaje de los 768 pacientes. Insulina son 374 ceros — el 48.7% de la columna es falso. El grosor de pliegue cutáneo son 227, casi el 30%. Presión arterial, BMI y glucosa tienen huecos más pequeños pero reales. Súmalo todo y hay 652 celdas individuales en este dataset de benchmark "limpio y listo para modelar" que son valores faltantes disfrazados. Si entrenaras con la tabla cruda le estarías enseñando a un modelo que la mitad de tus pacientes secretamente no producen insulina, lo cual no es un hecho sobre la diabetes. Es un hecho sobre captura de datos.
Las matemáticas
Tres piezas pequeñas de matemáticas mueven este capítulo, y ninguna es pesada. Primero, los rellenos. Para imputar una columna reemplazas cada entrada faltante con un resumen de los valores que no faltan. La media es el promedio:
donde la suma corre solo sobre las entradas observadas (no faltantes). La mediana es el valor de en medio de esas mismas entradas una vez que las ordenas:
con el -ésimo valor observado más pequeño. La media y la mediana coinciden en una columna simétrica y discrepan en una sesgada, y el tamaño de esa discrepancia es todo el argumento sobre cuál usar — lo haré concreto sobre la columna de insulina en un momento.
Segundo, las dos reglas de outliers, que son simplemente dos definiciones distintas de "demasiado lejos del centro". La regla del z-score mide distancia en desviaciones estándar desde la media:
es la desviación estándar de la columna, y el umbral usual es 3. La regla de Tukey mide en cambio la distancia desde los cuartiles en unidades del rango intercuartílico:
donde y son los percentiles 25 y 75 y es el ancho de la mitad central de los datos. Las dos reglas discrepan, y la razón por la que discrepan es el punto: los propios ingredientes del z-score, y , están inflados por los mismos outliers que intenta atrapar, así que en una columna sesgada marca de menos. La regla del IQR está construida con percentiles, que un puñado de valores extremos no puede mover, así que sigue funcionando donde el z-score se ablanda.
Para qué sirve, para qué no
La virtud de la imputación es que conserva tus filas. Borra a cada paciente con una lectura de insulina faltante y tiraste a 374 de 768 personas — la mitad de tus datos, perdida, para parchar una columna. Rellena los huecos en cambio y cada fila sobrevive con todas sus demás mediciones intactas. Ese es el argumento a favor de imputar, y en un dataset así de pequeño es decisivo. El costo es que estás inventando números, y un valor rellenado es una suposición vestida de hecho. Rellena con una constante como la mediana y encoges la varianza de la columna, debilitas sus correlaciones y plantas un pico sospechoso de valores idénticos del que un modelo posterior puede agarrarse por las razones equivocadas. La imputación con media y mediana es el default barato y suficientemente honesto; no es gratis.
La decisión de criterio es borrar versus imputar, y se reduce a cuánto falta y por qué. Una columna con 1% faltante la imputas sin pensarlo dos veces. Una columna con 48% faltante — insulina, aquí — es un dilema real: la mitad queda inventada después de rellenarla, y ninguna estadística de resumen puede conjurar la señal que nunca se registró. A veces la respuesta correcta para una columna así de vacía es tirar la columna, no las filas. Y a veces, cuando la ausencia misma es informativa (el MNAR de Rubin), la mejor jugada es imputar un valor de relleno y agregar una bandera que marque qué valores faltaban, para que el modelo pueda aprender de la ausencia en lugar de dejarse engañar por el relleno. Ese último truco es el que nos salva al final.
Los datos
El mismo set de diabetes de los indígenas Pima de la semana 1: 768 pacientes, ocho mediciones cada uno, un resultado binario para el diagnóstico de diabetes. Lo recolectó el National Institute of Diabetes and Digestive and Kidney Diseases; ha sido el set de enseñanza estándar para problemas de datos faltantes durante décadas, precisamente por el desastre que acabamos de graficar. Cinco de las ocho columnas de features codifican la ausencia como cero: glucosa, presión arterial, grosor de pliegue cutáneo, insulina y BMI. Las otras tres no — embarazos en cero es un valor real y común, y la edad y el puntaje de pedigrí diabético nunca son cero — así que un "trata todos los ceros como faltantes" a ciegas corrompería la columna de embarazos. El detector tiene que saber de qué columnas sospechar. Ese conocimiento es conocimiento del dominio, escrito a mano en el código, porque no existe una prueba estadística que te diga que un cero es imposible; solo un doctor puede.
Dividiremos una sola vez — 576 de entrenamiento, 192 de prueba, estratificado sobre el resultado, semilla 0 — y esa división queda fija para cada accuracy de esta página. Todo lo que sigue ajusta sus estadísticas solo con las filas de entrenamiento. La columna de insulina es la que vigilaremos de cerca, porque es la peor infractora y porque su forma hace imposible pasar por alto el argumento de media contra mediana.
Constrúyelo, una función a la vez
El primer trabajo es la detección, y es la única función del archivo que
necesita saber algo del mundo. Los NaN reales son fáciles; los ceros
disfrazados necesitan una decisión por columna sobre si el cero siquiera es
posible:
def missing_mask(X, cols_missing_zero):
"""Boolean mask, True where a value is missing — real NaN OR a disguised zero.
X is (N, D). `cols_missing_zero` is a boolean length-D vector marking the
columns where 0 secretly means "missing". For those columns a 0 counts as
missing; for the rest, only an actual NaN does. The whole chapter turns on
this one idea: missingness can hide in plain sight as a legal-looking number.
"""
mask = np.isnan(X)
mask[:, cols_missing_zero] |= (X[:, cols_missing_zero] == 0)
return mask
def missing_counts(X, names, cols_missing_zero):
"""Per-column (count, percent) of missing values, using missing_mask."""
mask = missing_mask(X, cols_missing_zero)
n = X.shape[0]
return [{"feature": names[j], "count": int(mask[:, j].sum()),
"percent": round(100.0 * mask[:, j].sum() / n, 2)}
for j in range(X.shape[1])]
La máscara es el cimiento sobre el que se para todo lo demás. missing_mask
arranca con los nulls honestos vía np.isnan, luego enciende los ceros
imposibles exactamente en las columnas que marcamos como sospechosas, y deja en
paz la de embarazos. missing_counts solo los cuenta por columna — esa es la
función detrás de la gráfica de revelación de arriba. Una vez que tienes la
máscara, los rellenos son casi triviales, y todos comparten una regla no
negociable: calcula el resumen solo con los valores observados, nunca incluyendo
los huecos que estás a punto de rellenar.
def impute_fit(X, mask, strategy="median"):
"""Learn the fill value for each column from the NON-missing rows only.
For every column we ignore the missing entries and compute one summary of
what's left: the mean, the median, or the mode (most frequent value). Median
is the default on purpose — it sits in the bulk of a skewed column where the
mean gets dragged into the empty tail. Returns a length-D vector of fills.
"""
D = X.shape[1]
fill = np.empty(D)
for j in range(D):
col = X[~mask[:, j], j] # observed values only
if col.size == 0: # whole column missing
fill[j] = 0.0
elif strategy == "mean":
fill[j] = col.mean()
elif strategy == "median":
fill[j] = np.median(col)
elif strategy == "mode":
vals, counts = np.unique(col, return_counts=True)
fill[j] = vals[counts.argmax()] # first max on ties, like sklearn
else:
raise ValueError(f"unknown strategy {strategy!r}")
return fill
def impute_transform(X, mask, fill):
"""Replace every masked entry with its column's fitted fill value."""
out = X.copy()
rows, cols = np.where(mask)
out[rows, cols] = fill[cols]
return out
impute_fit lee un número por columna de las entradas no faltantes — una media,
una mediana o una moda — e impute_transform deposita ese número en cada celda
enmascarada. Separar fit de transform es la misma disciplina que en el capítulo
de escalado: ajustas con train, aplicas el relleno congelado a test, y la propia
distribución del set de prueba nunca se filtra a los números con los que
rellenaste. La mediana es el argumento por defecto a propósito, por una razón que
la animación está a punto de hacer visual. Ahora la pieza que convierte un
pasivo en una feature — el indicador de was-missing:
def missing_indicator(mask, cols_missing_zero):
"""Binary "was this value imputed?" columns for the features that had gaps.
Imputing throws away one bit of truth: whether the number is real or a
guess. Appending an indicator per imputed column hands that bit back to the
model, which can then learn "a missing insulin reading is itself a signal".
Returns an (N, k) 0/1 array, one column per feature in cols_missing_zero.
"""
return mask[:, cols_missing_zero].astype(float)
Esto es una columna de ceros y unos por cada feature con huecos, marcando qué valores fueron imputados. Cuesta casi nada y le entrega al modelo un pedazo de verdad que la imputación de otro modo destruye: si cada número es real o inventado. En un dataset donde la ausencia es informativa, esta es la diferencia entre una tabla limpia-pero-lobotomizada y una tabla limpia que conservó su señal. Al final, las dos reglas de outliers, directo de las matemáticas:
def zscore_outliers(col, thresh=3.0):
"""Flag points more than `thresh` standard deviations from the mean.
z = (x - mean) / std. The classic rule calls anything past |z| = 3 an
outlier. It assumes a roughly bell-shaped column, so it under-flags a
skewed one (the mean and std are already inflated by the very tail you're
hunting) — which is exactly why the IQR rule below is the safer default.
"""
mu, sigma = col.mean(), col.std()
if sigma == 0:
return np.zeros(col.shape, dtype=bool)
z = (col - mu) / sigma
return np.abs(z) > thresh
def iqr_outliers(col, k=1.5):
"""Flag points outside [Q1 - k·IQR, Q3 + k·IQR] — Tukey's rule.
IQR = Q3 - Q1 is the width of the middle half of the data. Tukey's fences
sit k=1.5 IQRs beyond the quartiles. Built from percentiles, the rule
doesn't care how long the tail is, so it keeps working on skewed columns
where the z-score quietly fails.
"""
q1, q3 = np.percentile(col, [25, 75])
iqr = q3 - q1
lo, hi = q1 - k * iqr, q3 + k * iqr
return (col < lo) | (col > hi)
zscore_outliers marca cualquier cosa más allá de tres desviaciones estándar;
iqr_outliers marca cualquier cosa fuera de las cercas de Tukey. Sobre los
valores observados de insulina discrepan, y por mucho — esa discrepancia es la
lección práctica de las dos reglas, y leemos los conteos exactos de
results.json más abajo.
Míralo trabajar
Este es el argumento a favor de la mediana sobre la media, hecho sobre la columna real de insulina, un cuadro a la vez. El histograma son las lecturas de insulina de los 768 pacientes; las dos líneas verticales son la media de la columna (roja, punteada) y la mediana (verde, sólida). Observa dónde quedan mientras limpiamos la columna.
El cuadro uno es la columna cruda, y es un desastre que se ve a simple vista. Esa torre ámbar en el cero son 374 pacientes — los faltantes disfrazados — y arrastra la media hasta 80 μU/mL y la mediana hasta 30, ambas sin sentido porque están calculadas sobre una pila de ceros falsos. El cuadro dos tira los ceros y muestra la distribución real de los 394 pacientes que sí fueron medidos, y ahora ves la forma que lo decide todo: una joroba sesgada a la derecha con una cola larga de valores altos. La mediana cae en 125 μU/mL, sentada en la parte gorda donde realmente está la mayoría de los pacientes. A la media la cola la arrastra hasta 156 — 31 unidades más arriba, estacionada en una región donde viven comparativamente pocos pacientes reales.
Los cuadros tres y cuatro son los dos rellenos. La imputación con la media deposita cada uno de los 374 valores faltantes en 156, construyendo un pico morado allá en la cola dispersa, una multitud de pacientes inventados parados donde casi ningún paciente real estuvo. La imputación con la mediana los deposita en 125, dentro del grueso, junto a gente que se les parece. Ningún relleno es la verdad — ambos inventan 374 números — pero los números inventados de la mediana son mucho menos absurdos, y esa es toda la regla: en una columna sesgada, la imputación con la media amontona tus suposiciones donde no hay datos. Ve por la mediana. Entre más sesgada la columna, más ancha la brecha entre las dos líneas, y la insulina está lo bastante sesgada como para leer el argumento directamente de la gráfica sin una sola estadística.
La implementación completa
El archivo entero, sin librería — la detección, los tres rellenos, el indicador de was-missing y las dos reglas de outliers, exactamente como los usaron los checkpoints y la animación:
"""Finding and fixing messy data, built from scratch.
The work before any model runs: spot the missing values (including the ones
disguised as zeros), fill them in a way that doesn't lie, and flag the outliers.
Everything here is pure NumPy (+ pandas for loading). The design mirrors the
scaler chapter — a `fit` reads a statistic off the TRAINING rows, a `transform`
applies it — so imputation never leaks test-set numbers into the fill.
Every function below appears in the chapter one step at a time (the
`# region:` markers are what the book's include directives pull in).
"""
import numpy as np
import pandas as pd
# Columns where a value of 0 is physically impossible, so 0 is a stand-in for
# "not recorded" — a disguised missing value. A glucose, blood pressure, skin
# fold, insulin, or BMI of zero would mean a dead patient. Pregnancies of 0 is
# real, Age is never 0, the pedigree score is never 0 — those are left alone.
ZERO_IS_MISSING = ("Glucose", "BloodPressure", "SkinThickness", "Insulin", "BMI")
# region: detect_missing
def missing_mask(X, cols_missing_zero):
"""Boolean mask, True where a value is missing — real NaN OR a disguised zero.
X is (N, D). `cols_missing_zero` is a boolean length-D vector marking the
columns where 0 secretly means "missing". For those columns a 0 counts as
missing; for the rest, only an actual NaN does. The whole chapter turns on
this one idea: missingness can hide in plain sight as a legal-looking number.
"""
mask = np.isnan(X)
mask[:, cols_missing_zero] |= (X[:, cols_missing_zero] == 0)
return mask
def missing_counts(X, names, cols_missing_zero):
"""Per-column (count, percent) of missing values, using missing_mask."""
mask = missing_mask(X, cols_missing_zero)
n = X.shape[0]
return [{"feature": names[j], "count": int(mask[:, j].sum()),
"percent": round(100.0 * mask[:, j].sum() / n, 2)}
for j in range(X.shape[1])]
# endregion
# region: impute
def impute_fit(X, mask, strategy="median"):
"""Learn the fill value for each column from the NON-missing rows only.
For every column we ignore the missing entries and compute one summary of
what's left: the mean, the median, or the mode (most frequent value). Median
is the default on purpose — it sits in the bulk of a skewed column where the
mean gets dragged into the empty tail. Returns a length-D vector of fills.
"""
D = X.shape[1]
fill = np.empty(D)
for j in range(D):
col = X[~mask[:, j], j] # observed values only
if col.size == 0: # whole column missing
fill[j] = 0.0
elif strategy == "mean":
fill[j] = col.mean()
elif strategy == "median":
fill[j] = np.median(col)
elif strategy == "mode":
vals, counts = np.unique(col, return_counts=True)
fill[j] = vals[counts.argmax()] # first max on ties, like sklearn
else:
raise ValueError(f"unknown strategy {strategy!r}")
return fill
def impute_transform(X, mask, fill):
"""Replace every masked entry with its column's fitted fill value."""
out = X.copy()
rows, cols = np.where(mask)
out[rows, cols] = fill[cols]
return out
# endregion
# region: was_missing
def missing_indicator(mask, cols_missing_zero):
"""Binary "was this value imputed?" columns for the features that had gaps.
Imputing throws away one bit of truth: whether the number is real or a
guess. Appending an indicator per imputed column hands that bit back to the
model, which can then learn "a missing insulin reading is itself a signal".
Returns an (N, k) 0/1 array, one column per feature in cols_missing_zero.
"""
return mask[:, cols_missing_zero].astype(float)
# endregion
# region: outliers
def zscore_outliers(col, thresh=3.0):
"""Flag points more than `thresh` standard deviations from the mean.
z = (x - mean) / std. The classic rule calls anything past |z| = 3 an
outlier. It assumes a roughly bell-shaped column, so it under-flags a
skewed one (the mean and std are already inflated by the very tail you're
hunting) — which is exactly why the IQR rule below is the safer default.
"""
mu, sigma = col.mean(), col.std()
if sigma == 0:
return np.zeros(col.shape, dtype=bool)
z = (col - mu) / sigma
return np.abs(z) > thresh
def iqr_outliers(col, k=1.5):
"""Flag points outside [Q1 - k·IQR, Q3 + k·IQR] — Tukey's rule.
IQR = Q3 - Q1 is the width of the middle half of the data. Tukey's fences
sit k=1.5 IQRs beyond the quartiles. Built from percentiles, the rule
doesn't care how long the tail is, so it keeps working on skewed columns
where the z-score quietly fails.
"""
q1, q3 = np.percentile(col, [25, 75])
iqr = q3 - q1
lo, hi = q1 - k * iqr, q3 + k * iqr
return (col < lo) | (col > hi)
# endregion
def load_data(path="../data/diabetes.csv"):
"""The Pima diabetes set: 8 features, a binary Outcome, and hidden zeros.
Returns (X, y, names, cols_missing_zero): X is (N, 8) float, y is (N,) int
0/1, names lists the feature columns in order, and cols_missing_zero is the
boolean vector marking columns where 0 means missing.
"""
df = pd.read_csv(path)
names = [c for c in df.columns if c != "Outcome"]
X = df[names].to_numpy(float)
y = df["Outcome"].to_numpy(int)
cols_missing_zero = np.array([n in ZERO_IS_MISSING for n in names])
return X, y, names, cols_missing_zero
La versión de librería
En producción no harías la imputación a mano, y el SimpleImputer de
scikit-learn es lo que usarías en su lugar. Es el mismo objeto fit/transform que
todo lo demás en la librería, y hace exactamente lo que hace nuestro par
impute_fit/impute_transform — con un detalle de preparación. SimpleImputer
se guía por NaN por defecto, así que los ceros disfrazados tienen que
convertirse primero en nulls reales; no puede saber que un cero es imposible,
igual que nuestro detector tampoco podía sin que se lo dijeran.
def sklearn_impute(Xtr, Xte, strategy):
"""Fit a SimpleImputer on TRAIN, transform both splits.
strategy is 'mean', 'median', or 'most_frequent' (our 'mode'). Missing
entries must already be np.nan. Fitting on Xtr alone and transforming Xte
with those learned fills is the no-leakage pattern — the imputer never sees
a test row while learning what to fill with.
"""
imp = SimpleImputer(strategy=strategy, missing_values=np.nan)
imp.fit(Xtr)
return imp.transform(Xtr), imp.transform(Xte), imp.statistics_
strategy="mean", "median" y "most_frequent" se alinean uno a uno con
nuestra media, mediana y moda, y el checkpoint del capítulo verifica que nuestros
rellenos desde cero coinciden con las estadísticas aprendidas del SimpleImputer
y con su salida transformada dentro de 1e-9, para las tres estrategias, antes
de dibujar cualquier gráfica. Si una fórmula se desvía, la corrida falla
ruidosamente en lugar de publicar una imagen equivocada. El modelo que califica
cada estrategia de limpieza se mantiene fijo para que lo único que cambie sea
cómo se manejaron los valores faltantes — un estandarizador alimentando una
regresión logística, ajustado solo con train:
def classify_accuracy(Xtr, ytr, Xte, yte, seed=0):
"""Test accuracy of one fixed classifier on whatever cleaning it's handed.
A standardizer plus logistic regression, both frozen across every run and
fit on the training split only. Any change in this number is the cleaning
strategy's doing, not the model's — that isolation is the experiment.
"""
scaler = StandardScaler().fit(Xtr)
clf = LogisticRegression(max_iter=1000, random_state=seed)
clf.fit(scaler.transform(Xtr), ytr)
return float(clf.score(scaler.transform(Xte), yte))
Desde cero contra la librería
El clásico duelo de desde-cero contra librería es un mero trámite aquí — el checkpoint ya demostró que los dos imputadores producen números idénticos, así que una gráfica de barras de "nuestra mediana contra la mediana de sklearn" serían dos barras de la misma altura. La comparación que vale la pena es de lo que trata todo este capítulo: qué le hace cada estrategia de limpieza a un modelo posterior. Mismo clasificador fijo, misma división train/test, cuatro corridas — los ceros sucios crudos sin tocar, luego imputación con media, luego con mediana, luego con mediana más la bandera de was-missing:
Ahora lee esto con honestidad, porque no dice lo que un capítulo ordenado querría que dijera. Los ceros sucios crudos anotan 0.781 de test accuracy — el más alto de los cuatro — y las dos imputaciones ingenuas caen ambas a 0.750, cinco pacientes peor. Limpiar los datos hizo al modelo peor. No es un error en el código; es la columna de insulina de Pima estando más cerca de MNAR que del azar. Que a un paciente le hayan medido la insulina o no está en sí correlacionado con su resultado, así que los ceros imposibles estaban contrabandeando por accidente una señal real hacia el modelo, y la imputación con media y mediana la borró al hacer que cada valor faltante pareciera uno ordinario. Esta es exactamente la advertencia de Rubin de 1976, apareciendo como un número: cuando la ausencia es informativa, borrarla o sobrescribirla tira datos a la basura.
Pero no puedes quedarte con los ceros. Un valor fisiológicamente imposible envenena cada distancia, cada coeficiente, cada estadística de resumen más adelante, y es una mentira sentada en tu tabla esperando engañar a la siguiente persona que la consulte. El arreglo es la última barra: imputa con la mediana para que los números sean honestos, y agrega la bandera de was-missing para que el modelo aún pueda aprender de la ausencia. Eso recupera la señal sobre datos limpios — 0.771, de vuelta a un paciente de distancia de la línea base sucia — sin nada de la corrupción. La media y la mediana empatan aquí, y por eso hice el caso de media contra mediana sobre la distribución y no sobre este accuracy; los dos rellenos aterrizan en el mismo número downstream en esta división aunque la mediana sea el relleno demostrablemente más sensato. La lección no es que alguna barra gane. Es que "limpiar los datos" no es un botón, y si imputas sin preguntarte por qué faltaban los valores, puedes tallar una señal real hasta sacarla de tu dataset.
Conclusiones
Mira tus datos antes de modelarlos. No un resumen, no las primeras cinco filas — míralos de verdad, columna por columna, los rangos y los ceros y las cosas que no pueden ser ciertas. He visto pipelines entrenar durante horas sobre tablas donde media columna era un valor de relleno de captura que nadie había notado, y ninguna cantidad de ajuste del modelo arregla una feature que es 48% ficción. Basura entra, basura sale es la línea más vieja de la computación y sigue ganándole a cualquier elección de algoritmo sobre la que vayas a agonizar después. El set de Pima es el benchmark "limpio" estándar y trae 652 valores faltantes disfrazados; asume que el tuyo está más sucio de lo que dice la documentación, porque lo está.
Las reglas de trabajo son cortas. La ausencia disfrazada está en todos lados donde el cero es un valor legal para una columna que físicamente no puede ser cero — cázala con conocimiento del dominio, no con un chequeo de nulls. Cuando rellenes, imputa con la mediana las columnas sesgadas; la animación mostró por qué: la imputación con la media amontona tus suposiciones allá en la cola vacía donde no vive ningún dato real. Cuando falta mucho, o cuando sospechas que la ausencia misma carga señal, agrega una columna indicadora de was-missing para que el modelo aprenda de la ausencia en lugar de dejarse engañar por el relleno — esa columna baratísima fue lo que convirtió nuestros datos limpios de una degradación de vuelta en un empate con la línea base sucia. Y nunca imputes con estadísticas calculadas sobre tu set de prueba; ajusta el relleno con train, aplícalo en todos lados, la misma disciplina que con el escalado. Haz bien esta parte y cada modelo del resto del libro mejora gratis. Sáltatela y el algoritmo más sofisticado del mundo solo aprende tus errores más rápido.