---
title: "Conversión de Datos, Codificación y Escalado"
pagetitle: "Conversión de Datos, Codificación y Escalado"
open-graph:
description: "Muestra por qué escalar variables antes de usar KNN, con un laboratorio interactivo que compara Min-Max, StandardScaler y RobustScaler."
twitter-card:
description: "Muestra por qué escalar variables antes de usar KNN, con un laboratorio interactivo que compara Min-Max, StandardScaler y RobustScaler."
description: "Muestra por qué escalar variables antes de usar KNN, con un laboratorio interactivo que compara Min-Max, StandardScaler y RobustScaler."
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
---
{{< include _labkit.qmd >}}
Muchos modelos, entre ellos KNN, regresión logística y redes neuronales, necesitan una representación numérica de las variables. Una etiqueta como "Grocery Store" requiere codificación; el número asignado debe respetar el significado de la categoría.
Pero el hecho de que todo deba ser numérico presenta un desafío profundo: **la escala y la naturaleza de los números importan enormemente**.
## El problema de la escala
Imagina que estamos construyendo un modelo predictivo de ventas para **BigMart Sales** mediante k-Vecinos Más Cercanos (KNN)[^5]. Nuestro dataset tiene dos variables numéricas principales:
- **Item_MRP** (Precio Máximo al Por Menor): Varía de \$30 a \$250.
- **Item_Visibility** (Visibilidad del producto en anaquel): Varía de 0.00 a 0.30.
El algoritmo KNN calcula la distancia euclidiana entre dos productos $A$ y $B$:
$$d(A, B) = \sqrt{(MRP_A - MRP_B)^2 + (Visibilidad_A - Visibilidad_B)^2}$$
Si el producto A tiene un Item_MRP de \$50 y una visibilidad de 0.02, y el producto B tiene un Item_MRP de \$51 y una visibilidad de 0.28:
$$d(A, B) = \sqrt{(50 - 51)^2 + (0.02 - 0.28)^2} = \sqrt{(-1)^2 + (-0.26)^2} = \sqrt{1 + 0.0676} \approx 1.03$$
Como vemos, **la variable "Item_MRP" domina casi por completo el cálculo de la distancia**. Para el algoritmo, una diferencia inmensa en visibilidad (del 2% al 28%) es matemáticamente irrelevante comparada con una modesta diferencia de \$1 en el precio. Sin un escalado adecuado, nuestro modelo de machine learning se vuelve ciego a las variables numéricamente pequeñas, sin importar su poder predictivo real.
### Laboratorio: el espacio que ve KNN
El ejemplo anterior tenía dos productos. Aquí tenemos nueve productos de entrenamiento y uno nuevo (★) cuyo vecindario queremos encontrar. La gráfica dibuja los datos **con la misma escala en ambos ejes**, porque así es como la distancia euclidiana «ve» el espacio: si una variable tiene un rango mil veces menor, su eje queda aplastado.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="conv-knn-lab">
<p class="plab-eyebrow">Laboratorio · Escala y distancias</p>
<h3 class="plab-title">¿Quiénes son los 3 vecinos más cercanos de ★?</h3>
<p class="plab-sub">Los parámetros de cada escalador se aprenden con los nueve productos de entrenamiento y se aplican también a ★.</p>
<div class="plab-controls">
<div class="plab-field" style="flex:3 1 300px">Transformación antes de medir distancias
<div class="plab-seg" id="knn-scaler">
<button type="button" data-value="none" aria-pressed="true">Sin escalar</button>
<button type="button" data-value="minmax">Min-Max</button>
<button type="button" data-value="standard">StandardScaler</button>
<button type="button" data-value="robust">RobustScaler</button>
</div>
</div>
</div>
<div class="plab-grid">
<div class="plab-panel"><h4>Espacio de distancias (ejes con igual escala)</h4>
<svg id="knn-chart" role="img" aria-label="Productos en el espacio transformado con los vecinos resaltados"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s2)"></i>producto nuevo ★</span><span><i class="dot" style="background:var(--lab-s1)"></i>vecino</span><span><i class="dot" style="background:var(--lab-axis)"></i>otros</span></div>
</div>
<div class="plab-panel"><h4>Distancias a ★ y peso de cada variable</h4>
<div class="plab-scroll"><div id="knn-table"></div></div>
</div>
</div>
<p class="plab-note" id="knn-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('conv-knn-lab');
const $ = s => root.querySelector(s);
const train = [
['P1', 51, 0.28], ['P2', 58, 0.03], ['P3', 45, 0.25], ['P4', 120, 0.02], ['P5', 95, 0.05],
['P6', 60, 0.15], ['P7', 180, 0.10], ['P8', 240, 0.22], ['P9', 70, 0.04]
];
const query = ['★', 50, 0.02];
function fitScaler(kind, col) {
if (kind === 'minmax') { const lo = Math.min(...col), hi = Math.max(...col); return v => (v - lo) / (hi - lo); }
if (kind === 'standard') { const m = K.mean(col), s = K.sd(col); return v => (v - m) / s; }
if (kind === 'robust') { const med = K.median(col), iqr = K.quantile(col, .75) - K.quantile(col, .25); return v => (v - med) / iqr; }
return v => v;
}
const scaler = K.segmented($('#knn-scaler'), () => render());
function render() {
const kind = scaler.get();
const f = [1, 2].map(j => fitScaler(kind, train.map(r => r[j])));
const T = train.map(r => [r[0], f[0](r[1]), f[1](r[2]), r]);
const q = [f[0](query[1]), f[1](query[2])];
const d = T.map(t => { const dx = (t[1] - q[0]) ** 2, dy = (t[2] - q[1]) ** 2; return { name: t[0], raw: t[3], dist: Math.sqrt(dx + dy), share: dx / (dx + dy || 1), p: [t[1], t[2]] }; })
.sort((a, b) => a.dist - b.dist);
const nn = new Set(d.slice(0, 3).map(x => x.name));
// Ventana con igual escala en ambos ejes: el rango mayor fija el tamaño del cuadrado.
const xs = [...T.map(t => t[1]), q[0]], ys = [...T.map(t => t[2]), q[1]];
const cx = (Math.min(...xs) + Math.max(...xs)) / 2, cy = (Math.min(...ys) + Math.max(...ys)) / 2;
const half = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) * .58;
const c = K.chart($('#knn-chart'), { width: 400, height: 394, x: [cx - half, cx + half], y: [cy - half, cy + half], margin: { left: 46, right: 14, top: 14, bottom: 40 } });
const lab = kind === 'none' ? ['Item_MRP ($)', 'Item_Visibility'] : ['Item_MRP transformado', 'Item_Visibility transformada'];
c.axes({ xTicks: 5, yTicks: 5, xLabel: lab[0], yLabel: lab[1], xFmt: v => K.fmt(v, Math.abs(half) > 20 ? 0 : 1) });
d.slice(0, 3).forEach(x => c.line(q[0], q[1], x.p[0], x.p[1], 's1 line thin dash'));
d.forEach(x => { c.dot(x.p[0], x.p[1], 6, (nn.has(x.name) ? 's1' : 'muted') + ' dot'); c.text(x.p[0], x.p[1], x.name, { dx: 8, dy: -6, class: nn.has(x.name) ? 'strong' : '' }); });
c.dot(q[0], q[1], 8, 's2 dot'); c.text(q[0], q[1], '★ nuevo', { dx: 9, dy: 14, class: 's2' });
$('#knn-table').innerHTML = '<table class="plab-table"><thead><tr><th>Producto</th><th>MRP</th><th>Visib.</th><th>Distancia</th><th>% por MRP</th></tr></thead><tbody>' +
d.map((x, i) => `<tr${i < 3 ? ' class="hl"' : ''}><td>${i < 3 ? (i + 1) + '.º ' : ''}${x.name}</td><td>${x.raw[1]}</td><td>${K.fmt(x.raw[2], 2)}</td><td>${K.fmt(x.dist, kind === 'none' ? 2 : 3)}</td><td>${K.fmt(100 * x.share, 1)} %</td></tr>`).join('') + '</tbody></table>';
const avgShare = K.mean(d.map(x => x.share)), names = d.slice(0, 3).map(x => x.name).join(', '), note = $('#knn-note');
if (kind === 'none') { note.className = 'plab-note bad'; note.textContent = `Sin escalar, el precio explica en promedio el ${K.fmt(100 * avgShare, 1)} % de cada distancia al cuadrado. Los vecinos (${names}) se eligen casi solo por precio: P1 tiene visibilidad 0.28 frente a 0.02 de ★ y aun así resulta el más cercano.`; }
else { note.className = 'plab-note good'; note.textContent = `Con ${kind === 'minmax' ? 'Min-Max' : kind === 'standard' ? 'StandardScaler' : 'RobustScaler'} ambas variables compiten: el precio aporta en promedio el ${K.fmt(100 * avgShare, 1)} % de cada distancia al cuadrado. Vecinos: ${names}. Que cambien no dice cuál escala es «correcta»; eso se decide validando el modelo.`; }
}
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio muestra nueve productos en el espacio que usa la distancia euclidiana. Sin escalar, los tres vecinos del producto nuevo $(50, 0.02)$ son P1, P3 y P2, elegidos casi solo por precio; con cualquiera de los tres escaladores, P1 y P3 (visibilidad alta) salen del vecindario.
:::
**Guía del laboratorio.** Anota los tres vecinos sin escalar y con cada escalador. ¿Qué productos salen del vecindario en cuanto escalas y por qué? Observa la columna «% por MRP»: sin escalar, supera el 99 % en casi todas las filas. Con Min-Max y StandardScaler los vecinos son P2, P9 y P5; con RobustScaler el tercero pasa a ser P6. Explica esa diferencia a partir de los parámetros que usa cada escalador.
## El peligro de la falsa ordinalidad
Ahora consideremos las variables categóricas. Supongamos que tenemos la variable categórica "Outlet_Type" (Tipo de tienda) del dataset BigMart con valores: `Grocery Store`, `Supermarket Type1`, `Supermarket Type3`.
Una solución ingenua sería asignarles números arbitrarios:
- `Grocery Store` = 1
- `Supermarket Type1` = 2
- `Supermarket Type3` = 3
Si introducimos estos números a un modelo lineal (por ejemplo, regresión lineal), el algoritmo tratará sus diferencias como distancias numéricas que no justifican las categorías:
1. **Orden falso**: Asumirá que `Supermarket Type1` > `Grocery Store` y `Supermarket Type3` > `Supermarket Type1`.
2. **Distancia falsa**: Asumirá que la distancia entre `Grocery Store` y `Supermarket Type1` ($2-1=1$) es igual a la distancia entre `Supermarket Type1` y `Supermarket Type3` ($3-2=1$).
3. **Efecto lineal impuesto**: Un incremento de 1 en el código tendrá el mismo efecto estimado entre las categorías 1 y 2 que entre 2 y 3.
Codificar variables nominales con números enteros arbitrarios destruye los supuestos de la mayoría de los modelos introduciendo patrones falsos que el modelo intentará aprender. Necesitamos técnicas que conviertan categorías a números sin inyectar relaciones espurias.
---
## Desarrollo manual paso a paso
Para comprender verdaderamente lo que hacen las librerías bajo el capó, realicemos las transformaciones más importantes a mano sobre un mini-dataset simulado de 4 productos de **BigMart Sales**:
| ID | Item_MRP | Outlet_Size | Outlet_Type | Target (Item_Outlet_Sales) |
|----|----------|-------------|-------------|----------------------------|
| 1 | 45.0 | Small | Grocery Store | 500 |
| 2 | 120.0 | Medium | Supermarket Type1 | 2500 |
| 3 | 180.0 | High | Supermarket Type3 | 4200 |
| 4 | 500.0 | Small | Grocery Store | 800 |
*Nota:* El `Item_MRP` del ID 4 (\$500.0) es un valor atípico (*outlier*) relativo a los otros tres.
### Min-Max scaling (normalización)
Queremos escalar "Item_MRP" a un rango $[0, 1]$.
La fórmula es: $x' = \frac{x - x_{\min}}{x_{\max} - x_{\min}}$
1. $x_{\min} = 45.0$
2. $x_{\max} = 500.0$
3. Rango = $500.0 - 45.0 = 455.0$
Cálculos:
- $x'_1 = \frac{45.0 - 45.0}{455.0} = 0.0$
- $x'_2 = \frac{120.0 - 45.0}{455.0} = \frac{75.0}{455.0} \approx 0.165$
- $x'_3 = \frac{180.0 - 45.0}{455.0} = \frac{135.0}{455.0} \approx 0.297$
- $x'_4 = \frac{500.0 - 45.0}{455.0} = 1.0$
*Observación clave:* El outlier (500.0) comprime a los demás productos en el tercio inferior del rango ($0.0$ a $0.297$), reduciendo su separabilidad.
### StandardScaler ($z$-score)
Escalamos "Item_MRP" para que tenga media 0 y varianza 1.
1. Media ($\mu$): $\frac{45 + 120 + 180 + 500}{4} = \frac{845}{4} = 211.25$
2. Varianza poblacional[^varpob] ($\sigma^2$):
- $(45 - 211.25)^2 = (-166.25)^2 = 27,639.06$
- $(120 - 211.25)^2 = (-91.25)^2 = 8,326.56$
- $(180 - 211.25)^2 = (-31.25)^2 = 976.56$
- $(500 - 211.25)^2 = (288.75)^2 = 83,376.56$
- Suma = $120,318.75$
- $\sigma^2 = \frac{120,318.75}{4} = 30,079.69 \implies \sigma = \sqrt{30,079.69} \approx 173.44$
Cálculos de $z_i = \frac{x_i - \mu}{\sigma}$:
- $z_1 = \frac{45 - 211.25}{173.44} \approx -0.959$
- $z_2 = \frac{120 - 211.25}{173.44} \approx -0.526$
- $z_3 = \frac{180 - 211.25}{173.44} \approx -0.180$
- $z_4 = \frac{500 - 211.25}{173.44} \approx 1.665$
### RobustScaler (mediana y rango intercuartílico)
Escalamos usando estadísticas robustas a outliers: $x_{\text{rob}} = \frac{x - \text{mediana}}{\text{IQR}}$.
1. Datos ordenados: $[45.0, 120.0, 180.0, 500.0]$
2. Mediana = $\frac{120.0 + 180.0}{2} = 150.0$
3. Con el percentil interpolado que usa `RobustScaler` de scikit-learn por defecto: $Q_1 = 101.25$ y $Q_3 = 260.0$[^6].
4. $\text{IQR} = Q_3 - Q_1 = 260.0 - 101.25 = 158.75$.
Cálculos:
- $x_{\text{rob}, 1} = \frac{45 - 150}{158.75} \approx -0.661$
- $x_{\text{rob}, 2} = \frac{120 - 150}{158.75} \approx -0.189$
- $x_{\text{rob}, 3} = \frac{180 - 150}{158.75} \approx 0.189$
- $x_{\text{rob}, 4} = \frac{500 - 150}{158.75} \approx 2.205$
**Lectura del resultado:** la mediana cambia menos que la media ante un extremo, pero con solo cuatro datos el cuartil superior también depende del valor 500. "Robusto" no significa inmune.
### One-Hot Encoding y la trampa de la variable ficticia
Codificamos `Outlet_Type`: `{Grocery Store, Supermarket Type1, Supermarket Type3}`. Sin embargo, si tuviéramos cientos de categorías, One-Hot Encoding induciría la *Maldición de la Dimensionalidad*[^1] y requeriría el uso de *matrices dispersas*[^2] para optimizar la memoria.
| ID | Outlet_Type_Grocery | Outlet_Type_Supermarket1 | Outlet_Type_Supermarket3 |
|----|:-------------------:|:------------------------:|:------------------------:|
| 1 | 1 | 0 | 0 |
| 2 | 0 | 1 | 0 |
| 3 | 0 | 0 | 1 |
| 4 | 1 | 0 | 0 |
*La trampa de la variable ficticia:* Observe que la suma de las tres columnas es siempre igual a 1:
$$\text{Grocery} + \text{Supermarket1} + \text{Supermarket3} = 1$$
Si un modelo lineal sin regularización incluye un intercepto $\beta_0$, existe colinealidad perfecta[^4]. Una opción es descartar una columna de referencia (`drop='first'`); no es una regla universal para árboles ni para todo modelo regularizado.
### Target encoding (mean encoding) suavizado
El *Target Encoding* reemplaza cada categoría por el promedio del target (`Item_Outlet_Sales`) en esa categoría:
- Media global del target ($\bar{y}_{\text{global}}$) = $\frac{500 + 2500 + 4200 + 800}{4} = 2000.0$
- **Grocery Store**: 2 ocurrencias. Ventas = $[500, 800]$. Media local $\bar{y}_c = 650.0$.
- **Supermarket Type1**: 1 ocurrencia. Ventas = $[2500]$. Media local $\bar{y}_c = 2500.0$.
- **Supermarket Type3**: 1 ocurrencia. Ventas = $[4200]$. Media local $\bar{y}_c = 4200.0$.
Para evitar sobreajuste en categorías poco frecuentes, aplicamos **suavizado bayesiano**[^bayes] (*m-estimate*):
$$ \hat{S}_i = \frac{n_c \cdot \bar{y}_c + m \cdot \bar{y}_{\text{global}}}{n_c + m} $$
Con factor de suavizado $m = 2$ (cálculo ilustrativo que **no** debe aplicarse directamente a las mismas filas de entrenamiento):
- **Grocery Store**: $\frac{(2 \times 650) + (2 \times 2000)}{2 + 2} = \frac{1300 + 4000}{4} = 1325.0$
- **Supermarket Type1**: $\frac{(1 \times 2500) + (2 \times 2000)}{1 + 2} = \frac{2500 + 4000}{3} \approx 2166.67$
- **Supermarket Type3**: $\frac{(1 \times 4200) + (2 \times 2000)}{1 + 2} = \frac{4200 + 4000}{3} \approx 2733.33$
---
## Formalización teórica y matemática
### Escaladores numéricos
1. **Min-Max Scaler**: Mapea una variable continua al intervalo $[a, b]$ (típicamente $[0, 1]$):
$$ x' = a + \frac{x - x_{\min}}{x_{\max} - x_{\min}} (b - a) $$
Es altamente sensible a outliers, ya que un único valor extremo dilata el denominador $x_{\max} - x_{\min}$.
2. **StandardScaler ($z$-score)**: Transforma la variable para que tenga media $\mu = 0$ y desviación estándar $\sigma = 1$:
$$ z = \frac{x - \mu}{\sigma}, \quad \mathbb{E}[Z] = 0, \quad \operatorname{Var}(Z) = 1 $$
Ambas igualdades salen de la linealidad de la esperanza[^linealidad]: $\mathbb{E}[(X-\mu)/\sigma]=(\mathbb{E}[X]-\mu)/\sigma=0$ y $\operatorname{Var}(aX+b)=a^2\operatorname{Var}(X)$, con $a=1/\sigma$. Min-Max y StandardScaler son transformaciones afines: conservan el orden y las proporciones entre diferencias, así que **no cambian la forma** de la distribución ni reducen la influencia relativa de un valor extremo.
3. **RobustScaler**: Utiliza estadísticas de posición no paramétricas resistentes a valores extremos:
$$ x_{\text{rob}} = \frac{x - \operatorname{mediana}(X)}{\text{IQR}(X)}, \quad \text{IQR}(X) = Q_3(X) - Q_1(X) $$
4. **Transformaciones de Potencia**: Diseñadas para estabilizar la varianza y aproximar normalidad en distribuciones asimétricas (sesgadas):
- **Box-Cox**[^boxcox] (solo para $y > 0$):
$$ y^{(\lambda)} = \begin{cases} \frac{y^\lambda - 1}{\lambda} & \text{si } \lambda \neq 0 \\ \ln(y) & \text{si } \lambda = 0 \end{cases} $$
- **Yeo-Johnson** (soporta valores positivos, cero y negativos).
### Codificación categórica
1. **One-Hot Encoding**:
Definimos un conjunto finito de categorías $\mathcal{C} = \{c_1, c_2, \dots, c_K\}$.
La función de codificación es $h: \mathcal{C} \to \{0, 1\}^K$ tal que $h(c_j)$ es un vector donde la componente $j$ es 1 y el resto son 0.
Si se incluye la matriz de diseño $X$ con las $K$ columnas junto a una columna de unos $\mathbf{1}$ para el intercepto:
$$ \sum_{k=1}^K X_{i,k} = 1 \quad \forall i $$
Esto genera una dependencia lineal exacta, haciendo que la matriz $X^T X$ sea singular e imposible de invertir mediante mínimos cuadrados ordinarios.
2. **Target encoding con suavizado**:
Sea $y_i$ el objetivo continuo asociado a la observación $i$ con categoría $c$. Combinamos la media local con la global mediante la siguiente fórmula ilustrativa de suavizado:
$$ \hat{S}_c = \lambda(n_c) \bar{y}_c + (1 - \lambda(n_c)) \bar{y}_{\text{global}} $$
Donde $\lambda(n_c) = \frac{n_c}{n_c + m}$ es el factor de contracción (*shrinkage*). Cuando $n_c \to \infty$, $\lambda(n_c) \to 1$ (confianza en la media local). Cuando $n_c \to 0$, $\lambda(n_c) \to 0$ (confianza en la media global).
---
## Laboratorio interactivo: banco de pruebas de escalado y codificación (BigMart)
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="conv-bench-lab">
<p class="plab-eyebrow">Laboratorio · Escaladores y codificadores</p>
<h3 class="plab-title">Un precio extremo y una categoría rara</h3>
<p class="plab-sub">Usa los cuatro productos del desarrollo manual. Mueve el precio del producto 4 y compara los tres escaladores a la vez; después cambia la codificación de <code>Outlet_Type</code>.</p>
<div class="plab-panel">
<div class="plab-controls">
<label>Precio del producto 4 (atípico): <b id="bench-out-v"></b><input type="range" id="bench-out" min="200" max="1000" step="25" value="500"></label>
</div>
<svg id="bench-strips" class="wide" role="img" aria-label="Valores escalados por los tres escaladores"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>productos 1–3</span><span><i class="dot" style="background:var(--lab-s5)"></i>producto 4 (atípico)</span><span><i style="background:var(--lab-s3)"></i>separación entre los productos 1 y 3</span></div>
<div class="plab-scroll" style="margin-top:.6rem"><div id="bench-table"></div></div>
</div>
<div class="plab-panel" style="margin-top:1rem">
<div class="plab-controls">
<div class="plab-field" style="flex:2 1 260px">Codificación de <code>Outlet_Type</code>
<div class="plab-seg" id="bench-enc">
<button type="button" data-value="onehot" aria-pressed="true">One-Hot</button>
<button type="button" data-value="drop">One-Hot con drop='first'</button>
<button type="button" data-value="target">Target encoding</button>
</div>
</div>
<label id="bench-m-wrap">Suavizado m: <b id="bench-m-v"></b><input type="range" id="bench-m" min="0" max="10" step="0.5" value="2"></label>
</div>
<svg id="bench-target" class="wide" role="img" aria-label="Medias locales, global y codificadas por categoría"></svg>
<div class="plab-scroll"><div id="bench-enc-table"></div></div>
<p class="plab-note" id="bench-enc-note" aria-live="polite"></p>
</div>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('conv-bench-lab');
const $ = s => root.querySelector(s);
const base = [45, 120, 180];
const outlets = ['Grocery Store', 'Supermarket Type1', 'Supermarket Type3', 'Grocery Store'];
const targets = [500, 2500, 4200, 800];
const cats = ['Grocery Store', 'Supermarket Type1', 'Supermarket Type3'];
function scalers(x) {
const lo = Math.min(...x), hi = Math.max(...x), m = K.mean(x), s = K.sd(x);
const med = K.median(x), q1 = K.quantile(x, .25), q3 = K.quantile(x, .75);
return [
{ name: 'Min-Max', f: v => (v - lo) / (hi - lo), par: `mín ${K.fmt(lo, 0)}, máx ${K.fmt(hi, 0)}` },
{ name: 'StandardScaler', f: v => (v - m) / s, par: `μ ${K.fmt(m, 1)}, σ ${K.fmt(s, 1)}` },
{ name: 'RobustScaler', f: v => (v - med) / (q3 - q1), par: `mediana ${K.fmt(med, 1)}, IQR ${K.fmt(q3 - q1, 1)}` }
];
}
function renderScale() {
const out = +$('#bench-out').value; $('#bench-out-v').textContent = '$' + out;
const x = [...base, out], S = scalers(x);
const c = K.chart($('#bench-strips'), { width: 640, height: 230, x: [-1.5, 6], y: [0, 3], margin: { left: 110, right: 16, top: 10, bottom: 34 } });
c.axes({ xTicks: 8, yTicks: 0, xLabel: 'valor escalado', xFmt: v => K.fmt(v, 0) });
S.forEach((sc, i) => {
const y = 2.5 - i, v = x.map(sc.f);
c.line(-1.5, y, 6, y, 'muted line thin').style.opacity = .35;
c.text(-1.5, y, sc.name, { 'text-anchor': 'end', dx: -8, dy: 4, class: 'strong' });
const band = c.rect(v[0], y - .16, v[2], y + .16, 's3'); band.style.fillOpacity = .22; band.style.strokeWidth = 0;
c.text((v[0] + v[2]) / 2, y, 'separación ' + K.fmt(v[2] - v[0], 2), { 'text-anchor': 'middle', dy: -14, class: 's3' });
v.forEach((z, k) => c.dot(Math.min(z, 5.9), y, 6, (k === 3 ? 's5' : 's1') + ' dot'));
if (v[3] > 5.9) c.text(5.9, y, `→ ${K.fmt(v[3], 2)}`, { dx: 8, dy: 4, class: 's5' });
});
$('#bench-table').innerHTML = '<table class="plab-table"><thead><tr><th>Escalador</th><th>Parámetros aprendidos</th><th>ID 1</th><th>ID 2</th><th>ID 3</th><th>ID 4</th></tr></thead><tbody>' +
S.map(sc => `<tr><td>${sc.name}</td><td>${sc.par}</td>${x.map((v, k) => `<td${k === 3 ? ' style="color:var(--lab-s5)"' : ''}>${K.fmt(sc.f(v), 3)}</td>`).join('')}</tr>`).join('') + '</tbody></table>';
}
const enc = K.segmented($('#bench-enc'), () => renderEnc());
function renderEnc() {
const mode = enc.get(), m = +$('#bench-m').value, note = $('#bench-enc-note');
$('#bench-m-v').textContent = K.fmt(m, 1);
$('#bench-m-wrap').style.visibility = mode === 'target' ? 'visible' : 'hidden';
const svg = $('#bench-target');
if (mode !== 'target') {
svg.style.display = 'none';
const cols = mode === 'drop' ? cats.slice(1) : cats;
$('#bench-enc-table').innerHTML = '<table class="plab-table"><thead><tr><th>ID</th><th>Outlet_Type</th>' + cols.map(c => `<th>${c.replace('Supermarket ', 'SM_').replace('Grocery Store', 'Grocery')}</th>`).join('') + '<th>suma</th></tr></thead><tbody>' +
outlets.map((o, i) => { const v = cols.map(c => +(o === c)); return `<tr><td>${i + 1}</td><td>${o}</td>${v.map(b => `<td${b ? ' class="hl"' : ''}>${b}</td>`).join('')}<td>${K.sum(v)}</td></tr>`; }).join('') + '</tbody></table>';
note.className = mode === 'drop' ? 'plab-note' : 'plab-note warn';
note.textContent = mode === 'drop'
? 'Sin la columna Grocery, dos ceros significan «Grocery Store». Desaparece la dependencia lineal con el intercepto, pero una categoría nueva con handle_unknown="ignore" también produciría dos ceros.'
: 'Cada fila suma 1 (columna «suma»): junto con el intercepto de una regresión, las tres columnas son linealmente dependientes y XᵀX no se puede invertir.';
return;
}
svg.style.display = '';
const g = K.mean(targets);
const st = cats.map(cat => { const y = targets.filter((_, i) => outlets[i] === cat); const loc = K.mean(y); return { cat, y, n: y.length, loc, enc: (y.length * loc + m * g) / (y.length + m) }; });
const c = K.chart(svg, { width: 640, height: 220, x: [0, 4600], y: [0, 3], margin: { left: 130, right: 16, top: 10, bottom: 36 } });
c.axes({ xTicks: 5, yTicks: 0, xLabel: 'Item_Outlet_Sales', xFmt: v => K.fmt(v, 0) });
c.line(g, 0, g, 3, 'muted line dash');
c.text(g, 3, `media global ${K.fmt(g, 0)}`, { 'text-anchor': 'middle', dy: 10 });
st.forEach((s, i) => {
const y = 2.35 - i * .85;
c.text(0, y, `${s.cat} (n=${s.n})`, { 'text-anchor': 'end', dx: -8, dy: 4, class: 'strong' });
s.y.forEach(v => c.dot(v, y, 4, 'muted'));
if (Math.abs(s.enc - s.loc) > 30) c.arrow(s.loc, y, s.enc + (s.enc > s.loc ? -60 : 60), y, 's2');
c.dot(s.loc, y, 6.5, 's1 dot'); c.dot(s.enc, y, 6.5, 's2 dot');
c.text(s.enc, y, K.fmt(s.enc, 0), { 'text-anchor': 'middle', dy: -11, class: 's2' });
});
$('#bench-enc-table').innerHTML = '<table class="plab-table"><thead><tr><th>Categoría</th><th>n</th><th>media local</th><th>peso local n/(n+m)</th><th>codificado</th></tr></thead><tbody>' +
st.map(s => `<tr><td>${s.cat}</td><td>${s.n}</td><td>${K.fmt(s.loc, 1)}</td><td>${K.fmt(s.n / (s.n + m), 2)}</td><td class="hl">${K.fmt(s.enc, 1)}</td></tr>`).join('') + '</tbody></table>';
note.className = 'plab-note';
note.textContent = `Puntos azules: media local; naranjas: valor codificado. Con m = ${K.fmt(m, 1)}, una categoría con una sola fila conserva solo el ${K.fmt(100 / (1 + m), 0)} % de su media local y toma el resto de la global (${K.fmt(g, 0)}). El suavizado no evita que una fila use su propio objetivo: eso lo resuelve la codificación fuera de pliegue.`;
}
$('#bench-out').addEventListener('input', renderScale);
$('#bench-m').addEventListener('input', renderEnc);
renderScale(); renderEnc();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
*(En la versión web del libro, un laboratorio interactivo compara los tres escaladores al mover el precio atípico y muestra cómo el suavizado $m$ acerca cada categoría a la media global).*
:::
### Guía del laboratorio
**Objetivo.** Distinguir entre el efecto de un valor extremo sobre tres escaladores y el efecto de la frecuencia de una categoría sobre su codificación.
1. Fija el precio atípico en 500 y anota el valor escalado del producto 2 con Min-Max, StandardScaler y RobustScaler. Contrasta RobustScaler con el cálculo manual anterior.
2. Mueve el precio a 1000 sin cambiar los otros tres. Registra qué estadísticas cambian y si alguno de los tres escaladores es completamente inmune.
3. En codificación, activa y desactiva `drop='first'`; identifica la categoría representada por dos ceros. Después cambia a *target encoding* y compara `m=0` con `m=10` para una categoría con una sola fila.
**Comprobación.** Para 500, Min-Max asigna al producto 2 aproximadamente 0.165 y RobustScaler aproximadamente −0.189. Si no coinciden, revisa el percentil usado para los cuartiles. En *target encoding*, al aumentar $m$ las categorías con una observación se acercan más a la media global.
**Interpretación y entrega.** Escribe dos frases: cuál escalador elegirías para un modelo de distancias con precios extremos y qué información adicional necesitas antes de decidirlo; explica por qué las medias de ventas mostradas en este panel no deben calcularse con el objetivo de la propia fila al entrenar.
::: {.callout-warning title="El panel enseña la fórmula, no un pipeline de entrenamiento"}
El panel usa las cuatro ventas para calcular la media de cada categoría. En un modelo real, ajusta el codificador solo con el conjunto de entrenamiento y utiliza codificación fuera de pliegue para las filas de entrenamiento; transforma validación y prueba con el codificador ya ajustado. Así evitas que una fila reciba información de su propio objetivo[^3].
:::
---
## Implementación computacional (Python y pandas)
A continuación, mostramos cómo implementar estas transformaciones de manera profesional sobre el caso **BigMart Sales**.
::: {.callout-tip title="Mejores prácticas en la industria"}
- **Conserva el esquema de categorías:** `pandas.get_dummies()` sirve para explorar, pero exige alinear columnas entre entrenamiento e inferencia. Un `OneHotEncoder` ajustado en entrenamiento conserva ese esquema y facilita integrarlo en un `Pipeline`[^7].
- **Escala según el modelo:** los árboles de decisión que hacen cortes por umbral suelen ser invariantes a transformaciones monótonas de una variable; los modelos de distancias y los penalizados suelen ser sensibles a la escala[^5].
:::
```{python}
import pandas as pd
import numpy as np
from sklearn.preprocessing import (
StandardScaler, MinMaxScaler, RobustScaler,
OneHotEncoder, TargetEncoder
)
# 1. Dataset Sintético representativo de BigMart Sales
np.random.seed(42)
n_samples = 100
data = pd.DataFrame({
'Item_MRP': np.random.normal(140, 45, n_samples),
'Item_Weight': np.random.normal(12.8, 4.2, n_samples),
'Outlet_Type': np.random.choice(['Grocery Store', 'Supermarket Type1', 'Supermarket Type2', 'Supermarket Type3'], n_samples, p=[0.2, 0.5, 0.15, 0.15]),
'Item_Fat_Content': np.random.choice(['Low Fat', 'Regular'], n_samples),
'Outlet_Establishment_Year': np.random.choice([1985, 1997, 1999, 2004, 2009], n_samples),
'Item_Outlet_Sales': np.random.exponential(2000, n_samples)
})
# Inyectar un Outlier masivo en Item_MRP para probar el RobustScaler
data.loc[99, 'Item_MRP'] = 750.0
data.head()
```
### Escalado numérico comparativo
```{python}
# Instanciamos los escaladores
std_scaler = StandardScaler()
minmax_scaler = MinMaxScaler()
robust_scaler = RobustScaler()
# Aplicamos y concatenamos para comparar
scaled_df = pd.DataFrame({
'MRP_Original': data['Item_MRP'],
'MRP_Standard': std_scaler.fit_transform(data[['Item_MRP']]).flatten(),
'MRP_MinMax': minmax_scaler.fit_transform(data[['Item_MRP']]).flatten(),
'MRP_Robust': robust_scaler.fit_transform(data[['Item_MRP']]).flatten(),
})
# Mostrar las últimas filas donde está el Outlier
scaled_df.tail()
```
### Codificación categórica
::: {.callout-important title="Regla de oro en producción"}
Para categorías nuevas, decide explícitamente una política: `handle_unknown='ignore'` en `OneHotEncoder` produce ceros para una categoría desconocida. Comprueba si esa representación puede confundirse con la categoría de referencia cuando usas `drop='first'`; otra opción es agrupar categorías infrecuentes.
:::
```{python}
# 1. One-Hot Encoding (Variables Nominales de Baja Cardinalidad)
ohe = OneHotEncoder(sparse_output=False, handle_unknown='ignore')
outlet_encoded = ohe.fit_transform(data[['Outlet_Type']])
outlet_columns = ohe.get_feature_names_out(['Outlet_Type'])
df_ohe = pd.DataFrame(outlet_encoded, columns=outlet_columns)
df_ohe.head(3)
```
```{python}
# 2. Target Encoding con regularización automática en scikit-learn
target_enc = TargetEncoder(smooth="auto")
data['Outlet_Type_TargetEncoded'] = target_enc.fit_transform(data[['Outlet_Type']], data['Item_Outlet_Sales'])
# Inspección de las medias codificadas por tipo de tienda
data[['Outlet_Type', 'Outlet_Type_TargetEncoded', 'Item_Outlet_Sales']].drop_duplicates('Outlet_Type')
```
::: {.callout-warning title="Target Leakage"}
El *target encoding* usa la variable $Y$ (`Item_Outlet_Sales`) para transformar las $X$. `TargetEncoder.fit_transform` utiliza particiones internas para codificar las filas de entrenamiento fuera del pliegue[^3]. Esta protección no sustituye la separación externa de entrenamiento, validación y prueba: ajusta todo el preprocesamiento dentro de cada pliegue externo.
:::
### Ingeniería de fechas y ciclos
```{python}
# Transformar año de establecimiento en Antigüedad de la tienda
current_year = 2013 # Año de corte del dataset BigMart
data['Outlet_Age'] = current_year - data['Outlet_Establishment_Year']
data[['Outlet_Establishment_Year', 'Outlet_Age']].head(3)
```
---
## Referencias y definiciones
[^1]: **Maldición de la Dimensionalidad**: A medida que aumenta el número de características (dimensiones), el volumen del espacio de características crece exponencialmente. Los datos se vuelven dispersos, y las medidas de distancia (como la Euclidiana) pierden significado, degradando severamente modelos espaciales como KNN. Ocurre rápido si aplicamos One-Hot a campos como `Item_Identifier` (más de 1,500 códigos únicos).
[^2]: **Matrices Dispersas (CSR - Compressed Sparse Row)**: Estructura de datos en memoria que no guarda los ceros. Una matriz One-Hot de alta cardinalidad tiene más del 99% de ceros. Una matriz CSR solo guarda las coordenadas y valores de las celdas no nulas, ahorrando gigabytes de memoria RAM.
[^3]: **Target Leakage (Fuga de la Variable Objetivo)**: Ocurre cuando información del target se filtra en las variables predictoras durante el preprocesamiento. En Target Encoding ingenuo, promediar el target incluyendo el registro actual permite al modelo memorizar el dataset en lugar de generalizar.
[^4]: **Trampa de la variable ficticia (Dummy Variable Trap)**: Dependencia lineal exacta entre intercepto y todas las columnas binarias de una categoría exhaustiva. En mínimos cuadrados ordinarios puede eliminarse una columna o el intercepto; otros estimadores requieren una evaluación distinta.
[^5]: **Escala y modelos**: KNN usa distancias entre observaciones. Un árbol que separa con umbrales sobre una sola variable conserva, en general, el orden de los valores bajo una transformación monótona; detalles numéricos e implementaciones pueden producir diferencias. Escalar redes neuronales y modelos penalizados suele facilitar la optimización o comparación de coeficientes, pero debe comprobarse en validación.
[^6]: **Percentil interpolado**: Para cuatro datos ordenados, el percentil 25 queda a 0.75 del tramo entre el primero y el segundo; el 75 queda a 0.25 del tramo entre el tercero y el cuarto. Hay otras convenciones de cuartiles, por lo que conviene declarar la usada.
[^7]: **Pipeline**: Secuencia que ajusta transformaciones y estimador juntos. En validación cruzada, cada ajuste aprende estadísticas solo de la parte de entrenamiento del pliegue.
[^varpob]: **Varianza poblacional frente a muestral:** aquí dividimos entre $n=4$ porque `StandardScaler` usa esa convención (equivale a `numpy.std` con `ddof=0`). La varianza muestral divide entre $n-1$ y es la que usan `pandas.Series.std` y la mayoría de pruebas estadísticas. Con pocos datos la diferencia es visible: $\sqrt{120{,}318.75/3}\approx200.3$ frente a $173.4$.
[^bayes]: **Suavizado bayesiano:** el valor codificado es la media *a posteriori* de la categoría si, antes de ver sus datos, suponemos que su media se parece a la global con una confianza equivalente a $m$ observaciones «imaginarias». Con pocas filas reales domina esa creencia previa; con muchas, dominan los datos.
[^boxcox]: **Box-Cox:** el parámetro $\lambda$ no se elige a mano: `PowerTransformer` lo estima por máxima verosimilitud, buscando el valor con el que los datos transformados se parecen más a una distribución normal. Como todo parámetro aprendido, debe ajustarse solo con entrenamiento.
[^linealidad]: **Linealidad de la esperanza:** para constantes $a$ y $b$, $\mathbb{E}[aX+b]=a\,\mathbb{E}[X]+b$. La varianza, en cambio, ignora los desplazamientos y escala con el cuadrado: $\operatorname{Var}(aX+b)=a^2\operatorname{Var}(X)$.