---
title: "Tratamiento de datos faltantes"
pagetitle: "Tratamiento de datos faltantes"
open-graph:
description: "Distingue los mecanismos MCAR, MAR y MNAR de datos faltantes y muestra cómo detectarlos visualmente antes de elegir un método de imputación."
twitter-card:
description: "Distingue los mecanismos MCAR, MAR y MNAR de datos faltantes y muestra cómo detectarlos visualmente antes de elegir un método de imputación."
description: "Distingue los mecanismos MCAR, MAR y MNAR de datos faltantes y muestra cómo detectarlos visualmente antes de elegir un método de imputación."
format:
html:
toc: true
toc-depth: 3
toc-title: "Contenido"
code-fold: true
code-tools: true
theme: cosmo
highlight-style: github
execute:
echo: true
warning: false
message: false
jupyter: python3
---
```{python}
#| echo: false
import numpy as np
import pandas as pd
import plotly.express as px
np.random.seed(42)
```
{{< include _labkit.qmd >}}
En una tabla de ventas como BigMart, `Item_Weight` puede faltar porque falló la captura del proveedor. En cambio, si `Outlet_Size` falta sobre todo en tiendas de cierto tipo, la ausencia contiene información sobre la tienda. Antes de elegir un método, pregunta **qué filas faltan, en qué grupos y desde cuándo**. `NaN`[^nan] indica que no se registró un valor; no explica la causa.
## Un primer ejemplo visual
Supongamos que conocemos el peso de todos los productos de una población simulada y ocultamos algunos a propósito. Podemos comparar la distribución completa con la que quedaría disponible para analizar. En datos reales, la curva completa normalmente no está disponible: aquí la conocemos porque hemos simulado los datos.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
n = 4000
x_true = np.random.normal(loc=12, scale=2.2, size=n)
# Missing más probable en valores altos -> patrón tipo MNAR
p_missing = 1 / (1 + np.exp(-(x_true - 13.5) * 1.4))
mask_missing = np.random.rand(n) < p_missing
x_obs = x_true.copy()
x_obs[mask_missing] = np.nan
plot_df = pd.concat([
pd.DataFrame({"Item_Weight": x_true, "Grupo": "Distribución completa"}),
pd.DataFrame({"Item_Weight": x_obs[~np.isnan(x_obs)], "Grupo": "Datos observados"})
], ignore_index=True)
fig = px.histogram(
plot_df,
x="Item_Weight",
color="Grupo",
barmode="overlay",
opacity=0.65,
nbins=40,
title="Los valores faltantes pueden distorsionar la distribución observada"
)
fig.update_layout(
height=450,
xaxis_title="Item_Weight",
yaxis_title="Frecuencia",
legend_title=""
)
fig.show()
print(f"Media verdadera: {x_true.mean():.3f}")
print(f"Media observada: {np.nanmean(x_obs):.3f}")
print(f"Proporción missing: {mask_missing.mean():.3%}")
```
En este ejemplo, los pesos altos tienen más probabilidad de desaparecer y la media observada queda por debajo de la verdadera. Los histogramas tienen tamaños de muestra distintos; compara sobre todo su **forma** y las medias impresas, no solo la altura de las barras.
## Ejemplo: tres mecanismos de ausencia
Usaremos $R_i=1$ cuando el peso del producto $i$ está ausente. Los nombres MCAR, MAR y MNAR describen **supuestos sobre el proceso que generó la ausencia**, no etiquetas que podamos deducir con certeza de una tabla incompleta[^mecanismos].
### MCAR: pérdida independiente de los datos
Un fallo aleatorio del sistema elimina algunas filas sin tener en cuenta pesos ni características de las tiendas. En la simulación, cada registro tiene la misma probabilidad de perderse.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
import numpy as np
import pandas as pd
import plotly.express as px
np.random.seed(42)
# 1. Generamos una variable completa
n = 4000
x_true = np.random.normal(loc=12, scale=2.2, size=n)
# 2. Introducimos missing completamente al azar
p_missing = 0.20
mcar_mask = np.random.rand(n) < p_missing
x_mcar = x_true.copy()
x_mcar[mcar_mask] = np.nan
# 3. Construimos dataframe largo para comparar distribuciones
plot_df = pd.concat([
pd.DataFrame({
"Item_Weight": x_true,
"Grupo": "Distribución completa"
}),
pd.DataFrame({
"Item_Weight": x_mcar[~np.isnan(x_mcar)],
"Grupo": "Datos observados (MCAR)"
})
], ignore_index=True)
# 4. Histograma interactivo
fig = px.histogram(
plot_df,
x="Item_Weight",
color="Grupo",
barmode="overlay",
opacity=0.65,
nbins=40,
title="MCAR: la muestra observada se parece a la distribución original"
)
fig.update_layout(
height=450,
xaxis_title="Item_Weight",
yaxis_title="Frecuencia",
legend_title=""
)
fig.show()
# 5. Resumen numérico
print(f"Proporción missing: {mcar_mask.mean():.2%}")
print(f"Media verdadera: {x_true.mean():.4f}")
print(f"Media observada: {np.nanmean(x_mcar):.4f}")
print(f"Desv. estándar verdadera: {x_true.std():.4f}")
print(f"Desv. estándar observada: {np.nanstd(x_mcar):.4f}")
```
La similitud de las medias es esperable en promedio, pero no garantiza que una muestra concreta no tenga error de muestreo. Eliminar filas MCAR también reduce la cantidad de información.
### MAR: pérdida explicada por variables observadas
La probabilidad de que falte `Item_Weight` depende de `Outlet_Type`, que sí conocemos. Dentro de cada tipo de tienda, el mecanismo simulado ya no depende del peso. Como los pesos también varían por tipo, analizar solo los valores observados cambia la composición de la muestra.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
import numpy as np
import pandas as pd
import plotly.express as px
np.random.seed(42)
# 1. Datos sintéticos
n = 5000
outlet_type = np.random.choice(
["Supermarket", "Grocery Store", "Mini Market"],
size=n,
p=[0.50, 0.40, 0.10]
)
# 2. Item_Weight claramente distinto por grupo
weight = np.select(
[outlet_type == "Supermarket",
outlet_type == "Grocery Store",
outlet_type == "Mini Market"],
[
np.random.normal(15.0, 1.8, n), # más alto
np.random.normal(8.8, 1.2, n), # más bajo
np.random.normal(11.8, 1.0, n) # intermedio
]
)
df_true = pd.DataFrame({
"Outlet_Type": outlet_type,
"Item_Weight": weight
})
# 3. Missing MAR: depende solo de Outlet_Type
p_missing = np.select(
[df_true["Outlet_Type"] == "Supermarket",
df_true["Outlet_Type"] == "Grocery Store",
df_true["Outlet_Type"] == "Mini Market"],
[0.03, 0.75, 0.20]
)
mar_mask = np.random.rand(n) < p_missing
df_mar = df_true.copy()
df_mar.loc[mar_mask, "Item_Weight"] = np.nan
# 4. Missing por grupo
miss_by_group = (
df_mar.assign(missing=df_mar["Item_Weight"].isna())
.groupby("Outlet_Type", as_index=False)["missing"]
.mean()
)
fig1 = px.bar(
miss_by_group,
x="Outlet_Type",
y="missing",
title="MAR: la probabilidad de missing depende de Outlet_Type",
labels={"missing": "Proporción missing", "Outlet_Type": "Outlet Type"}
)
fig1.update_layout(height=420, yaxis_tickformat=".0%")
fig1.show()
print("Proporción missing por grupo:")
print(miss_by_group.round(3))
print("\nMedia verdadera:", round(df_true["Item_Weight"].mean(), 3), "Media observada:", round(df_mar["Item_Weight"].mean(), 3))
```
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
plot_df = pd.concat([
pd.DataFrame({
"Item_Weight": df_true["Item_Weight"],
"Grupo": "Distribución completa"
}),
pd.DataFrame({
"Item_Weight": df_mar["Item_Weight"].dropna(),
"Grupo": "Datos observados"
})
], ignore_index=True)
mean_true = df_true["Item_Weight"].mean()
mean_obs = df_mar["Item_Weight"].mean()
fig2 = px.histogram(
plot_df,
x="Item_Weight",
color="Grupo",
barmode="overlay",
opacity=0.65,
nbins=40,
title="Bajo MAR, la muestra observada puede dejar de ser representativa"
)
fig2.add_vline(
x=mean_true,
line_width=3,
line_dash="dash",
line_color="#1f77b4",
annotation_text=f"Media completa = {mean_true:.2f}",
annotation_position="top left"
)
fig2.add_vline(
x=mean_obs,
line_width=3,
line_dash="dot",
line_color="#d62728",
annotation_text=f"Media observada = {mean_obs:.2f}",
annotation_position="top right"
)
fig2.update_layout(
height=430,
xaxis_title="Item_Weight",
yaxis_title="Frecuencia",
legend_title=""
)
fig2.show()
```
La primera gráfica muestra **dónde** se concentran los faltantes; la segunda muestra el efecto sobre la distribución agregada. La variable auxiliar `Outlet_Type` permite estudiar y modelar ese patrón. MAR no significa que la falta sea aleatoria sin condiciones.
### MNAR: pérdida ligada al valor ausente
Imaginemos que un proceso de captura omite con más frecuencia pesos grandes y que esa causa no queda explicada por ninguna columna observada. El mecanismo depende del propio peso que falta.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
import numpy as np
import pandas as pd
import plotly.express as px
from plotly.subplots import make_subplots
import plotly.graph_objects as go
np.random.seed(42)
# 1. Generamos la población verdadera
n = 5000
weight_true = np.random.normal(12, 2.2, n)
df_true = pd.DataFrame({
"Item_Weight": weight_true
})
# 2. Probabilidad de missing depende del propio valor
# valores altos tienen más probabilidad de faltar
logit = -6 + 0.55 * weight_true
p_missing = 1 / (1 + np.exp(-logit))
mnar_mask = np.random.rand(n) < p_missing
df_mnar = df_true.copy()
df_mnar.loc[mnar_mask, "Item_Weight"] = np.nan
# 3. Comparación de distribuciones
plot_df = pd.concat([
pd.DataFrame({
"Item_Weight": df_true["Item_Weight"],
"Grupo": "Distribución completa"
}),
pd.DataFrame({
"Item_Weight": df_mnar["Item_Weight"].dropna(),
"Grupo": "Datos observados"
})
], ignore_index=True)
fig = px.histogram(
plot_df,
x="Item_Weight",
color="Grupo",
barmode="overlay",
opacity=0.65,
nbins=40,
title="MNAR: los valores altos desaparecen con mayor probabilidad"
)
fig.update_layout(
height=430,
xaxis_title="Item_Weight",
yaxis_title="Frecuencia"
)
fig.show()
print(f"Media verdadera: {df_true['Item_Weight'].mean():.3f}")
print(f"Media observada: {df_mnar['Item_Weight'].mean():.3f}")
print(f"Proporción missing: {mnar_mask.mean():.2%}")
```
No podemos confirmar MNAR solo mirando los pesos registrados. Necesitamos conocimiento del proceso de captura, una muestra de seguimiento o un análisis de sensibilidad[^sensibilidad].
### Laboratorio: tres mecanismos, un mismo promedio
Las tres simulaciones anteriores usan datos distintos. Aquí los comparamos sobre **la misma población** de 1 500 productos: las tiendas *Grocery* venden productos más ligeros (media 10 kg) que los *Supermarket* (media 14 kg). Tú eliges cómo se pierden los pesos y el laboratorio compara tres estimadores de la media: casos completos, imputación por la media global e imputación por la media **de cada tipo de tienda**.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="miss-mech-lab">
<p class="plab-eyebrow">Laboratorio · Mecanismos de ausencia</p>
<h3 class="plab-title">¿Cuándo sesga la media observada y cuándo lo corrige una variable auxiliar?</h3>
<p class="plab-sub">La población completa es conocida porque la simulamos; en un proyecto real solo verías las barras azules.</p>
<div class="plab-controls">
<div class="plab-field" style="flex:2 1 260px">Mecanismo
<div class="plab-seg" id="mm-mech">
<button type="button" data-value="mcar" aria-pressed="true">MCAR · al azar</button>
<button type="button" data-value="mar">MAR · por tipo</button>
<button type="button" data-value="mnar">MNAR · por peso</button>
</div>
</div>
<label>Proporción objetivo de faltantes: <b id="mm-rate-v"></b><input type="range" id="mm-rate" min="0.05" max="0.7" step="0.05" value="0.3"></label>
<label>Intensidad de la dependencia: <b id="mm-str-v"></b><input type="range" id="mm-str" min="0" max="2" step="0.1" value="1.2"></label>
</div>
<div class="plab-grid">
<div class="plab-panel"><h4>Distribución completa frente a observada</h4>
<svg id="mm-hist" role="img" aria-label="Histograma de pesos completos y observados"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-axis)"></i>población completa</span><span><i style="background:var(--lab-s1)"></i>pesos observados</span><span><i style="background:var(--lab-text)"></i>media verdadera</span><span><i style="background:var(--lab-s5)"></i>media observada</span></div>
</div>
<div class="plab-panel"><h4>Tasa de faltantes por tipo de tienda</h4>
<svg id="mm-rates" role="img" aria-label="Barras de proporción de faltantes por grupo"></svg>
<div class="plab-scroll" style="margin-top:.6rem"><div id="mm-table"></div></div>
</div>
</div>
<p class="plab-note" id="mm-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('miss-mech-lab');
const $ = s => root.querySelector(s);
const N = 1500, rand = K.rng(42);
// Población fija: grupo (0 = Grocery, 1 = Supermarket), peso y un número uniforme para decidir la ausencia.
const pop = Array.from({ length: N }, () => { const g = rand() < 0.4 ? 0 : 1; return { g, w: (g ? 14 : 10) + 1.8 * rand.normal(), u: rand() }; });
const trueMean = K.mean(pop.map(p => p.w)), trueSd = K.sd(pop.map(p => p.w), 1);
const sig = x => 1 / (1 + Math.exp(-x)), logit = p => Math.log(p / (1 - p));
const mech = K.segmented($('#mm-mech'), () => render());
function render() {
const m = mech.get(), rate = +$('#mm-rate').value, str = +$('#mm-str').value;
$('#mm-rate-v').textContent = K.fmt(100 * rate, 0) + ' %';
$('#mm-str-v').textContent = m === 'mcar' ? 'no aplica' : K.fmt(str, 1);
$('#mm-str').disabled = m === 'mcar';
const score = p => m === 'mcar' ? 0 : m === 'mar' ? (p.g ? -1 : 1) : (p.w - 12) / 2;
const miss = pop.map(p => p.u < sig(logit(rate) + 2 * str * score(p)));
const obs = pop.filter((_, i) => !miss[i]);
const obsW = obs.map(p => p.w), obsMean = K.mean(obsW);
const groupMean = [0, 1].map(g => K.mean(obs.filter(p => p.g === g).map(p => p.w)));
const nG = [0, 1].map(g => pop.filter(p => p.g === g).length);
const byGroup = (nG[0] * groupMean[0] + nG[1] * groupMean[1]) / N;
const imputed = pop.map((p, i) => miss[i] ? obsMean : p.w);
const sdImp = K.sd(imputed, 1), realRate = miss.filter(Boolean).length / N;
// Histograma
const lo = 3, hi = 21, bins = 36, bw = (hi - lo) / bins;
const count = arr => { const c = new Array(bins).fill(0); arr.forEach(v => { const k = Math.floor((v - lo) / bw); if (k >= 0 && k < bins) c[k]++; }); return c; };
const cAll = count(pop.map(p => p.w)), cObs = count(obsW), ymax = Math.max(...cAll) * 1.12;
const c = K.chart($('#mm-hist'), { width: 420, height: 280, x: [lo, hi], y: [0, ymax], margin: { left: 40, right: 12, top: 12, bottom: 38 } });
c.axes({ xTicks: 6, yTicks: 4, xLabel: 'Item_Weight (kg)', yLabel: 'productos' });
cAll.forEach((v, k) => { const r = c.rect(lo + k * bw, 0, lo + (k + 1) * bw, v, 'muted'); r.style.fillOpacity = .25; });
cObs.forEach((v, k) => { const r = c.rect(lo + k * bw + bw * .12, 0, lo + (k + 1) * bw - bw * .12, v, 's1'); r.style.fillOpacity = .75; });
const lt = c.line(trueMean, 0, trueMean, ymax, 'line'); lt.style.stroke = 'var(--lab-text)';
c.line(obsMean, 0, obsMean, ymax, 's5 line dash');
// Tasas por grupo
const rates = [0, 1].map(g => { const idx = pop.map((p, i) => p.g === g ? i : -1).filter(i => i >= 0); return idx.filter(i => miss[i]).length / idx.length; });
const lowW = pop.map((p, i) => p.w < 12 ? i : -1).filter(i => i >= 0), highW = pop.map((p, i) => p.w >= 12 ? i : -1).filter(i => i >= 0);
const rw = [lowW, highW].map(ix => ix.filter(i => miss[i]).length / ix.length);
const r = K.chart($('#mm-rates'), { width: 420, height: 200, x: [0, 4], y: [0, 1], margin: { left: 40, right: 12, top: 18, bottom: 34 } });
r.axes({ xTicks: 0, yTicks: 4, yFmt: v => K.fmt(100 * v, 0) + ' %', xCats: [[.5, 'Grocery'], [1.5, 'Supermarket'], [2.5, 'peso < 12'], [3.5, 'peso ≥ 12']] });
[...rates, ...rw].forEach((v, k) => { r.rect(k + .2, 0, k + .8, v, k < 2 ? 's4' : 's2'); r.text(k + .5, v, K.fmt(100 * v, 0) + ' %', { 'text-anchor': 'middle', dy: -5, class: 'strong' }); });
const rows = [['Media verdadera (desconocida en la práctica)', trueMean], ['Casos completos / imputación por media global', obsMean], ['Imputación por media de cada tipo de tienda', byGroup]];
$('#mm-table').innerHTML = '<table class="plab-table"><thead><tr><th>Estimador de la media</th><th>kg</th><th>sesgo</th></tr></thead><tbody>' +
rows.map(([lab, v], i) => `<tr${i ? '' : ' class="hl"'}><td>${lab}</td><td>${K.fmt(v, 2)}</td><td>${i ? (v - trueMean >= 0 ? '+' : '') + K.fmt(v - trueMean, 2) : '—'}</td></tr>`).join('') +
`<tr><td>Desviación estándar verdadera / tras imputar con la media</td><td colspan="2">${K.fmt(trueSd, 2)} → ${K.fmt(sdImp, 2)}</td></tr></tbody></table>`;
const note = $('#mm-note'), b1 = Math.abs(obsMean - trueMean), b2 = Math.abs(byGroup - trueMean);
const msg = `Faltan ${K.fmt(100 * realRate, 1)} % de los pesos. `;
if (m === 'mcar') { note.className = 'plab-note good'; note.textContent = msg + `Bajo MCAR la media observada apenas se desvía (${K.fmt(b1, 2)} kg): perder filas cuesta precisión, no introduce sesgo sistemático. Aun así, imputar con la media reduce la desviación estándar de ${K.fmt(trueSd, 2)} a ${K.fmt(sdImp, 2)}.`; }
else if (m === 'mar') { note.className = b1 > .15 ? 'plab-note warn' : 'plab-note'; note.textContent = msg + `Bajo MAR la media observada se sesga ${K.fmt(b1, 2)} kg porque sobran supermercados entre los casos completos. Condicionar en el tipo de tienda lo corrige casi por completo (sesgo ${K.fmt(b2, 2)} kg).`; }
else { note.className = 'plab-note bad'; note.textContent = msg + `Bajo MNAR ambos estimadores fallan: sesgo ${K.fmt(b1, 2)} kg con la media global y ${K.fmt(b2, 2)} kg por grupo. La variable auxiliar no contiene la causa de la ausencia; hace falta información externa o un análisis de sensibilidad.`; }
}
root.querySelectorAll('input').forEach(i => i.addEventListener('input', render));
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio simula 1 500 productos y permite cambiar el mecanismo de ausencia. Resultado esperado: bajo MCAR la media observada casi no se sesga; bajo MAR se sesga, pero promediar por tipo de tienda lo corrige; bajo MNAR ninguno de los dos estimadores lo corrige.
:::
**Guía del laboratorio.**
1. Con MCAR, mueve la proporción de faltantes de 5 % a 70 %. Comprueba que la media observada se mantiene cerca de la verdadera mientras la desviación estándar tras imputar con la media **disminuye**. ¿Por qué ocurre lo segundo aunque no haya sesgo?
2. Cambia a MAR con intensidad 1.2. Identifica en el panel derecho qué grupo pierde más pesos y explica el signo del sesgo de la media observada. Verifica que el estimador por grupo lo corrige.
3. Cambia a MNAR. Observa que las tasas por tipo de tienda casi no se distinguen, pero las tasas por peso sí. ¿Qué implica que la evidencia de MNAR esté precisamente en la columna que no observamos?
::: {.callout-note title="Antes de imputar"}
Cuenta los faltantes por columna, por tipo de tienda y por periodo. Verifica si un código especial como `0` o `"sin dato"` representa ausencia. Documenta la definición de cada campo y el momento en que estará disponible durante la predicción.
:::
## De la intuición al cálculo: imputar un peso
En esta muestra pequeña conocemos `Item_Outlet_Sales` solo como **variable auxiliar de demostración**. Si las ventas son el objetivo de un modelo predictivo, no debemos usarlas para imputar predictores que estarán disponibles antes de la venta[^objetivo].
| Producto | `Item_Weight` (kg) | Ventas observadas |
|---|---:|---:|
| FDA15 | 9.30 | 3735.13 |
| DRC01 | 5.92 | 1200.00 |
| FDN15 | — | 1098.56 |
| FDX07 | 19.20 | 4138.61 |
**Media observada.** Para FDN15, la media de los tres pesos registrados es $(9.30+5.92+19.20)/3\approx 11.47$ kg. Es sencilla, pero todas las filas incompletas reciben el mismo número y la dispersión se contrae.
**Cuánto se contrae.** La varianza muestral[^varmuestral] de los tres pesos observados es
$$
s^2_{\mathrm{obs}}=\frac{(9.30-11.47)^2+(5.92-11.47)^2+(19.20-11.47)^2}{3-1}\approx\frac{4.72+30.82+59.72}{2}\approx 47.63 .
$$
Al añadir el valor imputado $11.47$, el numerador no cambia (esa fila está exactamente en la media, así que aporta cero), pero el divisor pasa de $2$ a $3$: $s^2_{\mathrm{imp}}=95.26/3\approx31.75$. La desviación estándar cae de $6.90$ a $5.64$ kg **sin que hayamos aprendido nada nuevo** sobre los pesos. Un modelo entrenado con esa columna verá los datos más concentrados de lo que son.
**Vecino más cercano.** Si comparamos únicamente las ventas disponibles, DRC01 es el más cercano a FDN15: $|1200-1098.56|=101.44$. Con $k=1$ y ese único atributo auxiliar, imputaríamos **5.92 kg**. La distancia y el resultado cambian si añadimos otras variables o las escalamos; `KNNImputer` usa por defecto vecinos con peso uniforme[^knn].
**Pregunta para discutir.** ¿Cuál de los dos valores parece más razonable? No podemos saberlo para FDN15 sin una observación verdadera. Por eso evaluaremos métodos ocultando temporalmente valores que sí conocemos.
## Qué muestran las imputaciones
### Imputación simple y distribución
Esta gráfica de Plotly del capítulo original compara la distribución simulada completa con varias imputaciones simples. La modalidad se calcula después de redondear el peso a una décima; esa discretización solo sirve para ilustrar el método.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
import numpy as np
import pandas as pd
import plotly.express as px
from sklearn.impute import SimpleImputer
np.random.seed(42)
# -----------------------------
# 1. Datos sintéticos "reales"
# -----------------------------
n = 4000
true_weight = np.random.normal(12, 2.0, n)
df_true = pd.DataFrame({
"Item_Weight": true_weight
})
# Missing MCAR
mask = np.random.rand(n) < 0.25
df_missing = df_true.copy()
df_missing.loc[mask, "Item_Weight"] = np.nan
# Para mostrar la moda de forma interesante,
# discretizamos un poco la variable
df_missing["Item_Weight_rounded"] = df_missing["Item_Weight"].round(1)
df_true["Item_Weight_rounded"] = df_true["Item_Weight"].round(1)
# -----------------------------
# 2. Imputaciones simples
# -----------------------------
mean_imp = SimpleImputer(strategy="mean")
median_imp = SimpleImputer(strategy="median")
mostfreq_imp = SimpleImputer(strategy="most_frequent")
const_imp = SimpleImputer(strategy="constant", fill_value=10.0)
weight_mean = mean_imp.fit_transform(df_missing[["Item_Weight"]]).flatten()
weight_median = median_imp.fit_transform(df_missing[["Item_Weight"]]).flatten()
weight_const = const_imp.fit_transform(df_missing[["Item_Weight"]]).flatten()
# Para moda usamos la versión redondeada
weight_mode = mostfreq_imp.fit_transform(df_missing[["Item_Weight_rounded"]]).flatten()
# -----------------------------
# 3. DataFrame largo para Plotly
# -----------------------------
plot_df = pd.concat([
pd.DataFrame({
"Item_Weight": df_true["Item_Weight"],
"Método": "Original"
}),
pd.DataFrame({
"Item_Weight": weight_mean,
"Método": "Media"
}),
pd.DataFrame({
"Item_Weight": weight_median,
"Método": "Mediana"
}),
pd.DataFrame({
"Item_Weight": weight_mode,
"Método": "Moda"
}),
pd.DataFrame({
"Item_Weight": weight_const,
"Método": "Constante = 10"
}),
], ignore_index=True)
# -----------------------------
# 4. Histograma interactivo
# -----------------------------
fig = px.histogram(
plot_df,
x="Item_Weight",
color="Método",
barmode="overlay",
opacity=0.55,
nbins=40,
title="Comparación de imputaciones simples"
)
fig.add_vline(
x=df_true["Item_Weight"].mean(),
line_dash="dash",
line_color="black",
annotation_text="media original"
)
fig.update_layout(
height=500,
xaxis_title="Item_Weight",
yaxis_title="Frecuencia",
legend_title="Método"
)
fig.show()
# -----------------------------
# 5. Resumen numérico
# -----------------------------
summary = pd.DataFrame({
"Método": ["Original", "Media", "Mediana", "Moda", "Constante = 10"],
"Media": [
df_true["Item_Weight"].mean(),
pd.Series(weight_mean).mean(),
pd.Series(weight_median).mean(),
pd.Series(weight_mode).mean(),
pd.Series(weight_const).mean(),
],
"Desv. estándar": [
df_true["Item_Weight"].std(),
pd.Series(weight_mean).std(),
pd.Series(weight_median).std(),
pd.Series(weight_mode).std(),
pd.Series(weight_const).std(),
]
}).round(3)
```
Observa el pico que produce reemplazar muchos faltantes por un mismo número. Mantener aproximadamente la media no implica conservar la varianza ni las relaciones con otras columnas.
### KNN: los vecinos importan
En el siguiente ejemplo original se ocultan valores conocidos de una simulación. El panel derecho permite ver los vecinos y comparar una imputación con el valor ocultado. Los puntos rojos son una referencia didáctica, no información disponible al imputar datos reales.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.impute import KNNImputer
from sklearn.neighbors import NearestNeighbors
np.random.seed(42)
# ----------------------------
# 1. Datos sintéticos
# ----------------------------
n = 400
price = np.random.normal(50, 10, n)
size = np.random.normal(100, 20, n)
# relación estructural
weight = 0.08 * price + 0.02 * size + np.random.normal(0, 1, n)
df = pd.DataFrame({
"Price": price,
"Size": size,
"Item_Weight": weight
})
# ----------------------------
# 2. Introducir missing
# ----------------------------
mask = np.random.rand(n) < 0.25
df_missing = df.copy()
df_missing.loc[mask, "Item_Weight"] = np.nan
# ----------------------------
# 3. Imputación KNN
# ----------------------------
k = 5
imputer = KNNImputer(n_neighbors=k)
df_knn = pd.DataFrame(
imputer.fit_transform(df_missing),
columns=df.columns
)
# ----------------------------
# 4. Elegir un caso missing para ilustrar vecinos
# ----------------------------
missing_idx = df_missing[df_missing["Item_Weight"].isna()].index[0]
observed_df = df_missing[df_missing["Item_Weight"].notna()].copy()
# vecinos usando variables observadas (Price, Size)
nn = NearestNeighbors(n_neighbors=k)
nn.fit(observed_df[["Price", "Size"]])
target_point = df_missing.loc[[missing_idx], ["Price", "Size"]]
distances, neighbor_pos = nn.kneighbors(target_point)
neighbor_idx = observed_df.iloc[neighbor_pos[0]].index
# valor real ocultado artificialmente
true_hidden_value = df.loc[missing_idx, "Item_Weight"]
# ----------------------------
# 5. Figura en dos paneles
# ----------------------------
fig = make_subplots(
rows=1, cols=2,
subplot_titles=[
"Antes",
"Después de KNN"
]
)
# --- Panel izquierdo ---
fig.add_trace(
go.Scatter(
x=observed_df["Price"],
y=observed_df["Item_Weight"],
mode="markers",
name="Observados",
opacity=0.55,
marker=dict(size=7)
),
row=1, col=1
)
# Mostrar las posiciones reales eliminadas artificialmente
fig.add_trace(
go.Scatter(
x=df.loc[mask, "Price"],
y=df.loc[mask, "Item_Weight"],
mode="markers",
name="Valores reales eliminados",
marker=dict(size=8, symbol="x", color="crimson")
),
row=1, col=1
)
fig.add_trace(
go.Scatter(
x=[df.loc[missing_idx, "Price"]],
y=[df.loc[missing_idx, "Item_Weight"]],
mode="markers",
name="Caso ejemplo (real oculto)",
marker=dict(size=14, symbol="star", color="black")
),
row=1, col=1
)
# --- Panel derecho ---
fig.add_trace(
go.Scatter(
x=df["Price"],
y=df["Item_Weight"],
mode="markers",
name="Datos reales",
opacity=0.20,
marker=dict(size=6)
),
row=1, col=2
)
fig.add_trace(
go.Scatter(
x=observed_df.loc[neighbor_idx, "Price"],
y=observed_df.loc[neighbor_idx, "Item_Weight"],
mode="markers",
name=f"{k} vecinos más cercanos",
marker=dict(size=11, symbol="diamond")
),
row=1, col=2
)
# valor real eliminado artificialmente
fig.add_trace(
go.Scatter(
x=[df.loc[missing_idx, "Price"]],
y=[true_hidden_value],
mode="markers",
name="Valor real oculto",
marker=dict(size=14, symbol="x", color="crimson")
),
row=1, col=2
)
# valor imputado
fig.add_trace(
go.Scatter(
x=[df_knn.loc[missing_idx, "Price"]],
y=[df_knn.loc[missing_idx, "Item_Weight"]],
mode="markers",
name="Valor imputado",
marker=dict(size=15, symbol="star", color="black")
),
row=1, col=2
)
# línea horizontal del imputado
fig.add_hline(
y=df_knn.loc[missing_idx, "Item_Weight"],
line_dash="dot",
annotation_text=f"imputado = {df_knn.loc[missing_idx, 'Item_Weight']:.2f}",
row=1, col=2
)
# línea horizontal del valor real
fig.add_hline(
y=true_hidden_value,
line_dash="dash",
annotation_text=f"real = {true_hidden_value:.2f}",
row=1, col=2
)
fig.update_xaxes(title_text="Price", row=1, col=1)
fig.update_yaxes(title_text="Item_Weight", row=1, col=1)
fig.update_xaxes(title_text="Price", row=1, col=2)
fig.update_yaxes(title_text="Item_Weight", row=1, col=2)
fig.update_layout(
height=520,
legend_title=""
)
fig.show()
```
Al usar KNN, escala las variables numéricas pertinentes y revisa qué columnas entran en la distancia. El vecino más cercano en una dimensión puede ser muy distinto del vecino en varias dimensiones.
### Imputación múltiple: representar incertidumbre
Una sola tabla completada trata cada valor imputado como si fuese conocido. Para inferencia estadística, podemos crear varias tablas plausibles, ajustar el análisis en cada una y combinar estimaciones y varianzas[^multiple]. La figura original usa `IterativeImputer(sample_posterior=True)` para generar cinco realizaciones; el valor predeterminado `sample_posterior=False` genera una imputación determinista y **no equivale por sí solo a imputación múltiple**.
```{python}
#| echo: false
#| fig-width: 12
#| fig-height: 5.5
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
np.random.seed(42)
# ----------------------------
# 1. Datos sintéticos
# ----------------------------
n = 400
price = np.random.normal(50, 10, n)
size = np.random.normal(100, 20, n)
weight = 0.08 * price + 0.02 * size + np.random.normal(0, 1, n)
df = pd.DataFrame({
"Price": price,
"Size": size,
"Item_Weight": weight
})
# ----------------------------
# 2. Introducir missing
# ----------------------------
mask = np.random.rand(n) < 0.25
df_missing = df.copy()
df_missing.loc[mask, "Item_Weight"] = np.nan
# ----------------------------
# 3. Varias imputaciones
# ----------------------------
imputations = []
for seed in range(5):
imp = IterativeImputer(
sample_posterior=True,
random_state=seed
)
imputed = pd.DataFrame(
imp.fit_transform(df_missing),
columns=df.columns
)
imputed["Dataset"] = f"Imputación {seed+1}"
imputations.append(imputed)
# ----------------------------
# 4. Datos para graficar
# ----------------------------
observed = df.loc[~mask].copy()
removed_true = df.loc[mask].copy() # valores reales eliminados
imputed_only = []
for i, imp_df in enumerate(imputations, start=1):
tmp = imp_df.loc[mask, ["Price", "Item_Weight"]].copy()
tmp["Dataset"] = f"Imputación {i}"
imputed_only.append(tmp)
imputed_only = pd.concat(imputed_only, ignore_index=True)
# ----------------------------
# 5. Figura
# ----------------------------
fig = make_subplots(
rows=1, cols=2,
subplot_titles=[
"Antes: observados y valores reales eliminados",
"Después: varias imputaciones plausibles"
]
)
# Panel izquierdo: observados
fig.add_trace(
go.Scatter(
x=observed["Price"],
y=observed["Item_Weight"],
mode="markers",
name="Observados",
opacity=0.55,
marker=dict(size=7, color="steelblue")
),
row=1, col=1
)
# Panel izquierdo: valores reales eliminados
fig.add_trace(
go.Scatter(
x=removed_true["Price"],
y=removed_true["Item_Weight"],
mode="markers",
name="Valores reales eliminados",
marker=dict(size=9, symbol="x", color="crimson", line=dict(width=1))
),
row=1, col=1
)
# Panel derecho: observados de referencia
fig.add_trace(
go.Scatter(
x=observed["Price"],
y=observed["Item_Weight"],
mode="markers",
name="Observados (referencia)",
opacity=0.20,
marker=dict(size=6, color="gray")
),
row=1, col=2
)
# Panel derecho: imputaciones
colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"]
for i, dataset in enumerate(imputed_only["Dataset"].unique()):
sub = imputed_only[imputed_only["Dataset"] == dataset]
fig.add_trace(
go.Scatter(
x=sub["Price"],
y=sub["Item_Weight"],
mode="markers",
name=dataset,
opacity=0.7,
marker=dict(size=7, color=colors[i])
),
row=1, col=2
)
# fig.update_layout(
# height=500,
# title="Imputación múltiple: valores reales ocultos vs varias imputaciones plausibles",
# legend_title=""
# )
fig.update_xaxes(title_text="Price", row=1, col=1)
fig.update_yaxes(title_text="Item_Weight", row=1, col=1)
fig.update_xaxes(title_text="Price", row=1, col=2)
fig.update_yaxes(title_text="Item_Weight", row=1, col=2)
fig.show()
```
Las cinco nubes muestran incertidumbre entre imputaciones; ninguna recupera necesariamente cada valor individual. Esta simulación visualiza el principio. Un análisis inferencial completo también necesita combinar los resultados de los modelos ajustados en cada tabla.
## Formalización breve
Sea $Y=(Y_{\mathrm{obs}},Y_{\mathrm{mis}})$ el conjunto completo, $R$ el indicador de ausencia y $X$ las variables auxiliares observadas. De manera esquemática:
- **MCAR:** $P(R\mid Y,X)=P(R)$. La ausencia es independiente de los valores.
- **MAR:** $P(R\mid Y_{\mathrm{obs}},Y_{\mathrm{mis}},X)=P(R\mid Y_{\mathrm{obs}},X)$. Una vez condicionamos[^indcond] en lo observado, el valor ausente no añade información sobre $R$.
- **MNAR:** la probabilidad de ausencia todavía depende de $Y_{\mathrm{mis}}$ después de condicionar en lo observado.
La media de casos completos estima $E[Y\mid R=0]$[^esperanza]. Por la ley de la probabilidad total,
$$
E[Y]=P(R=0)\,E[Y\mid R=0]+P(R=1)\,E[Y\mid R=1],
$$
así que el **sesgo**[^sesgo] de usar solo los casos completos es $E[Y\mid R=0]-E[Y]=P(R=1)\,\big(E[Y\mid R=0]-E[Y\mid R=1]\big)$: crece con la proporción de faltantes **y** con la diferencia entre quienes responden y quienes no. Bajo MCAR esa diferencia es cero. Bajo MAR puede no serlo, pero dentro de cada valor de $X$ sí lo es, por lo que promediar primero dentro de los grupos de $X$ y luego ponderar por su tamaño elimina el sesgo:
$$
\hat\mu_{\mathrm{MAR}}=\sum_{x}\hat P(X=x)\,\bar y_{\mathrm{obs}}(x).
$$
Es exactamente el estimador «por tipo de tienda» del laboratorio de mecanismos. Bajo MNAR no existe una corrección que use solo lo observado.
Para una columna numérica, la imputación por media usa $\hat y_i=\bar y_{\mathrm{obs}}$ cuando $R_i=1$. Con $n_{\mathrm{obs}}$ valores observados de $n$, la varianza de la columna imputada es
$$
s^2_{\mathrm{imp}}=\frac{n_{\mathrm{obs}}-1}{n-1}\,s^2_{\mathrm{obs}},
$$
porque los valores imputados no aportan desviaciones respecto a la media. En el ejemplo, $\frac{2}{3}\cdot47.63=31.75$. En KNN, $\hat y_i=k^{-1}\sum_{j\in N_k(i)}y_j$, donde $N_k(i)$ reúne vecinos con el valor observado. Ambas producen **estimaciones**, no recuperaciones seguras del dato original.
## Laboratorio: elegir y poner a prueba una imputación
**Objetivo.** Comparar media, mediana y KNN en el mismo ejemplo de productos. **Hipótesis.** Un método que use ventas como variable auxiliar debería ganar cuando peso y ventas guarden una relación útil.
1. Cambia el método y el número de vecinos. Anota la imputación del peso realmente ausente de FDN15.
2. Ocultamos además el peso conocido de NCD19 **solo para validar**. Compara la predicción con su valor real y registra el error absoluto.
3. Cambia entre moda y categoría «Faltante» para `Outlet_Size`. Comprueba qué celdas cambian y cuáles no.
4. Explica por qué el menor error en **una sola fila** no prueba que un método sea mejor para toda la población. Compara después el «error medio (dejar uno fuera)»[^loo], que oculta por turnos cada peso conocido. ¿Cambia el método ganador? Propón cómo repetir esta validación por grupos y periodos en un conjunto real.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="missing42-lab">
<p class="plab-eyebrow">Laboratorio · Imputar y validar</p>
<h3 class="plab-title">Media, mediana o vecinos: ¿quién estima mejor un peso oculto?</h3>
<p class="plab-sub">FDN15 no tiene peso. NCD19 sí lo tiene (12.40 kg), pero lo ocultamos para poder medir el error.</p>
<div class="plab-controls">
<div class="plab-field" style="flex:2 1 240px">Método para el peso
<div class="plab-seg" id="missing42-method">
<button type="button" data-value="mean" aria-pressed="true">Media</button>
<button type="button" data-value="median">Mediana</button>
<button type="button" data-value="knn">KNN por ventas</button>
</div>
</div>
<label>Vecinos k (KNN): <b id="missing42-k-value">2</b><input id="missing42-k" type="range" min="1" max="4" value="2"></label>
<label><code>Outlet_Size</code> ausente
<select id="missing42-size"><option value="mode">Moda observada</option><option value="missing">Categoría «Faltante»</option></select></label>
</div>
<div class="plab-grid" style="grid-template-columns:1fr">
<div class="plab-panel"><h4>Ventas (auxiliar) frente a peso</h4>
<svg id="missing42-chart" role="img" aria-label="Dispersión de ventas y peso con las estimaciones"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>donantes</span><span><i class="dot" style="background:var(--lab-s2)"></i>estimación</span><span><i class="dot" style="background:var(--lab-s3)"></i>valor real de NCD19</span><span><i style="background:var(--lab-s4)"></i>vecinos usados</span></div>
</div>
<div class="plab-panel"><h4>Tabla resultante</h4>
<div class="plab-scroll"><div id="missing42-rows"></div></div>
<div class="plab-stats">
<div class="plab-stat"><span>Estimación FDN15</span><strong id="missing42-fdn"></strong></div>
<div class="plab-stat"><span>Error en NCD19</span><strong id="missing42-err"></strong></div>
<div class="plab-stat"><span>Error medio (dejar uno fuera)</span><strong id="missing42-loo"></strong></div>
</div>
</div>
</div>
<p class="plab-note" id="missing42-result" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('missing42-lab');
const $ = s => root.querySelector(s);
const rows = [
{ id: 'FDA15', sales: 3735.13, weight: 9.30, size: 'Medium' },
{ id: 'DRC01', sales: 1200.00, weight: 5.92, size: null },
{ id: 'FDN15', sales: 1098.56, weight: null, size: 'Small' },
{ id: 'FDX07', sales: 4138.61, weight: 19.20, size: null },
{ id: 'NCD19', sales: 3500.00, weight: 12.40, size: 'High', validation: true },
{ id: 'FDP10', sales: 2500.00, weight: 15.00, size: 'Medium' }
];
const method = K.segmented($('#missing42-method'), () => render());
const kInput = $('#missing42-k'), size = $('#missing42-size');
const donors = rows.filter(r => r.weight !== null && !r.validation);
function estimate(row, pool) {
const w = pool.map(r => r.weight);
if (method.get() === 'mean') return { value: K.mean(w), used: pool };
if (method.get() === 'median') return { value: K.median(w), used: pool };
const used = [...pool].sort((a, b) => Math.abs(a.sales - row.sales) - Math.abs(b.sales - row.sales)).slice(0, Math.min(+kInput.value, pool.length));
return { value: K.mean(used.map(r => r.weight)), used };
}
function render() {
$('#missing42-k-value').textContent = kInput.value;
kInput.disabled = method.get() !== 'knn';
const val = rows.find(r => r.validation), fdn = rows.find(r => r.id === 'FDN15');
const eVal = estimate(val, donors), eFdn = estimate(fdn, donors);
const err = Math.abs(eVal.value - val.weight);
// Validación dejando uno fuera: cada peso conocido se oculta y se estima con los demás.
const known = rows.filter(r => r.weight !== null);
const loo = K.mean(known.map(r => Math.abs(estimate(r, known.filter(q => q !== r)).value - r.weight)));
$('#missing42-fdn').textContent = K.fmt(eFdn.value, 2) + ' kg';
$('#missing42-err').textContent = K.fmt(err, 2) + ' kg';
$('#missing42-loo').textContent = K.fmt(loo, 2) + ' kg';
const c = K.chart($('#missing42-chart'), { width: 420, height: 300, x: [800, 4400], y: [0, 22], margin: { left: 40, right: 14, top: 12, bottom: 40 } });
c.axes({ xTicks: 5, yTicks: 5, xLabel: 'Item_Outlet_Sales (auxiliar)', yLabel: 'Item_Weight (kg)', xFmt: v => K.fmt(v, 0) });
[[fdn, eFdn], [val, eVal]].forEach(([r, e]) => {
if (method.get() === 'knn') e.used.forEach(d => c.line(r.sales, e.value, d.sales, d.weight, 's4 line thin dash'));
else { const l = c.line(800, e.value, 4400, e.value, 's2 line thin dash'); l.style.opacity = .5; }
});
donors.forEach(d => { c.dot(d.sales, d.weight, 6, 's1 dot'); c.text(d.sales, d.weight, d.id, { dx: 8, dy: -6 }); });
c.dot(val.sales, val.weight, 6, 's3 dot'); c.text(val.sales, val.weight, 'NCD19 real', { dx: 8, dy: -6, class: 's3' });
[[fdn, eFdn], [val, eVal]].forEach(([r, e]) => { c.dot(r.sales, e.value, 6.5, 's2 dot'); c.text(r.sales, e.value, r.id + ' *', { dx: 8, dy: 14, class: 's2' }); });
$('#missing42-rows').innerHTML = '<table class="plab-table"><thead><tr><th>Producto</th><th>Ventas</th><th>Peso (kg)</th><th>Outlet_Size</th><th>Uso</th></tr></thead><tbody>' + rows.map(r => {
const hidden = r.weight === null || r.validation;
const weight = hidden ? `${K.fmt(estimate(r, donors).value, 2)} *` : K.fmt(r.weight, 2);
const outletSize = r.size ?? (size.value === 'mode' ? 'Medium *' : 'Faltante *');
const use = r.validation ? 'Validación' : r.weight === null ? 'Ausente' : 'Donante';
return `<tr${hidden ? ' class="hl"' : ''}><td>${r.id}</td><td>${K.fmt(r.sales, 0)}</td><td>${weight}</td><td>${outletSize}</td><td>${use}</td></tr>`;
}).join('') + '</tbody></table>';
$('#missing42-result').textContent = `NCD19: ${K.fmt(eVal.value, 2)} kg estimados frente a 12.40 kg reales (error ${K.fmt(err, 2)} kg). Con validación dejando uno fuera sobre los ${known.length} pesos conocidos, el error medio es ${K.fmt(loo, 2)} kg: compáralo entre métodos antes de elegir.`;
}
[kInput, size].forEach(el => el.addEventListener('input', render));
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
El laboratorio interactivo se encuentra en la versión HTML. Para resolverlo sin controles: calcula la media y la mediana de $9.30$, $5.92$, $19.20$ y $15.00$; después predice el peso de NCD19 (12.40 kg, ocultado) usando los $k$ vecinos en ventas y compara el error absoluto.
:::
## Implementación sin fuga de información
En un problema supervisado, separamos primero entrenamiento y prueba, y ajustamos cualquier imputador **solo con entrenamiento**. Aquí `Item_Outlet_Sales` es la variable objetivo: nunca se usa como predictor para imputar `Item_Weight`. Las variables auxiliares son un precio y un tipo de tienda simulados.
```{python}
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(42)
n = 300
outlet = rng.choice(['Supermarket', 'Grocery'], size=n, p=[0.6, 0.4])
weight = rng.normal(12, 2, n)
price = rng.normal(100, 15, n)
sales = 150 + 30 * weight + 4 * price + rng.normal(0, 50, n)
X = pd.DataFrame({'Item_Weight': weight, 'Item_MRP': price, 'Outlet_Type': outlet})
y = pd.Series(sales, name='Item_Outlet_Sales')
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
# MAR simulado: la ausencia de peso depende del tipo de tienda observado.
for part in (X_train, X_test):
probability = np.where(part['Outlet_Type'].eq('Grocery'), 0.45, 0.10)
part.loc[rng.random(len(part)) < probability, 'Item_Weight'] = np.nan
numeric = Pipeline([('imputer', SimpleImputer(strategy='median', add_indicator=True))])
preprocess = ColumnTransformer([
('numeric', numeric, ['Item_Weight', 'Item_MRP']),
('outlet', OneHotEncoder(handle_unknown='ignore'), ['Outlet_Type'])
])
model = Pipeline([('preprocess', preprocess), ('regression', LinearRegression())])
model.fit(X_train, y_train)
print('R² en prueba:', round(model.score(X_test, y_test), 3))
print('Faltantes en train:', X_train['Item_Weight'].isna().sum())
print('Faltantes en test:', X_test['Item_Weight'].isna().sum())
```
La puntuación es solo una comprobación de que el flujo se ejecuta; para elegir estrategia, compara alternativas con validación cruzada dentro del conjunto de entrenamiento y deja la prueba para la evaluación final. Un indicador de ausencia puede ayudar cuando el patrón aporta señal; compruébalo empíricamente[^indicador].
::: {.callout-tip title="Práctica profesional: documentar y vigilar"}
Guarda el imputador junto con el modelo, vigila la tasa de faltantes por grupo y periodo, y registra qué campos están disponibles en el momento de inferencia. Compara una línea base simple con métodos más costosos según la métrica, la latencia y el volumen **de tu sistema**. No existe un umbral universal para descartar columnas ni un método de imputación superior en todos los datos. [scikit-learn: errores comunes y fuga de datos](https://scikit-learn.org/stable/common_pitfalls.html) · [scikit-learn: imputación](https://scikit-learn.org/stable/modules/impute.html).
:::
[^nan]: `NaN` significa *Not a Number*. En pandas, también pueden aparecer otros marcadores de ausencia como `None` o `pd.NA`, según el tipo de columna.
[^mecanismos]: MCAR significa *Missing Completely At Random*; MAR, *Missing At Random*; MNAR, *Missing Not At Random*. MAR es una independencia **condicional** respecto al valor ausente.
[^sensibilidad]: Un análisis de sensibilidad repite el cálculo bajo varios supuestos plausibles sobre los valores no observados para ver cuánto cambia la conclusión.
[^objetivo]: La variable objetivo es el resultado que se desea predecir. Usarla para construir un predictor crea fuga de información si ese resultado todavía no existe al hacer la predicción.
[^knn]: Por defecto, `KNNImputer` usa la distancia `nan_euclidean` y `weights="uniform"`; también admite ponderar vecinos por distancia. [Documentación de KNNImputer](https://scikit-learn.org/stable/modules/generated/sklearn.impute.KNNImputer.html).
[^multiple]: La imputación múltiple considera incertidumbre entre tablas imputadas. `IterativeImputer` puede producir realizaciones con `sample_posterior=True`, pero el análisis y la combinación de estimaciones quedan a cargo del usuario. [Documentación de IterativeImputer](https://scikit-learn.org/stable/modules/generated/sklearn.impute.IterativeImputer.html).
[^indicador]: Un indicador de ausencia es una columna binaria que registra si el valor original faltaba. `SimpleImputer(add_indicator=True)` la añade para las columnas con faltantes durante el ajuste. [Documentación de SimpleImputer](https://scikit-learn.org/stable/modules/generated/sklearn.impute.SimpleImputer.html).
[^varmuestral]: **Varianza muestral:** $s^2=\frac{1}{n-1}\sum_i (y_i-\bar y)^2$. Se divide entre $n-1$ porque las desviaciones se miden respecto a $\bar y$, calculada con los mismos datos; así $s^2$ es un estimador insesgado de la varianza poblacional.
[^esperanza]: **Esperanza condicional:** $E[Y\mid R=0]$ es el valor promedio de $Y$ restringido a la subpoblación donde $R=0$ (valor observado). Si las subpoblaciones $R=0$ y $R=1$ tienen promedios distintos, mirar solo una de ellas da una imagen distorsionada del total.
[^sesgo]: **Sesgo de un estimador:** diferencia entre el valor que el estimador da en promedio, sobre muchas muestras posibles, y el parámetro que pretende estimar. Un estimador sesgado no mejora al aumentar el tamaño de muestra: converge al valor equivocado.
[^indcond]: **Independencia condicional:** $R$ y $Y_{\mathrm{mis}}$ pueden estar relacionadas en general y, aun así, ser independientes **dentro** de cada grupo definido por las variables observadas. Es la diferencia entre «la ausencia depende del peso» y «la ausencia depende del tipo de tienda, que a su vez se relaciona con el peso».
[^loo]: **Validación dejando uno fuera (*leave-one-out*):** se oculta una observación, se estima con las demás y se repite con cada observación. Promediar los errores reduce la dependencia de una sola fila afortunada, aunque con pocos datos la estimación sigue siendo muy variable.