---
title: "Vecinos más cercanos"
pagetitle: "Clasificación con k vecinos más cercanos (k-NN)"
open-graph:
description: "El clasificador de k vecinos más cercanos: distancias, escalamiento, elección de k, voto ponderado, garantías teóricas, maldición de la dimensionalidad y búsqueda eficiente, con desarrollo manual, laboratorios interactivos y código en Python."
twitter-card:
description: "El clasificador de k vecinos más cercanos: distancias, escalamiento, elección de k, voto ponderado, garantías teóricas, maldición de la dimensionalidad y búsqueda eficiente, con desarrollo manual, laboratorios interactivos y código en Python."
description: "El clasificador de k vecinos más cercanos: distancias, escalamiento, elección de k, voto ponderado, garantías teóricas, maldición de la dimensionalidad y búsqueda eficiente, con desarrollo manual, laboratorios interactivos y código en Python."
author: "Diego Villalba"
lang: es
format:
html:
toc: true
toc-depth: 3
toc-title: "Contenido"
code-fold: true
code-tools: true
code-summary: "Mostrar código"
theme: cosmo
highlight-style: github
execute:
echo: true
warning: false
message: false
bibliography: [referencias_fundamentos_clasificacion.bib, referencias_arboles_regresion_lineal.bib, referencias_clasificacion.bib, ../../references.bib]
jupyter: python3
---
{{< include ../preprocesamiento/_labkit.qmd >}}
El clasificador de **$k$ vecinos más cercanos** ($k$-NN, *k-nearest neighbors*) es quizá la idea más antigua y más intuitiva del aprendizaje supervisado: para decidir la clase de un caso nuevo, se buscan los casos de entrenamiento más parecidos y se responde lo que diga la mayoría de ellos. Fue propuesto por @fix1951discriminatory como un método de discriminación «no paramétrico» y @cover1967nearest demostraron que, con suficientes datos, su error nunca es mayor que el doble del mínimo posible.
$k$-NN no construye una fórmula ni un árbol: **guarda los datos** y difiere todo el trabajo al momento de predecir. Por eso se le llama aprendizaje *perezoso* (*lazy learning*) o basado en instancias. Esa simplicidad lo convierte en el mejor punto de partida para entender tres ideas que valen para todos los clasificadores: la importancia de la **representación** de los datos (qué significa «parecido»), el control de la **complejidad** con un hiperparámetro y los problemas que trae la **alta dimensión**.
## Intuición: ¿quién compra un seguro?
Una aseguradora ofreció un seguro de gastos médicos a ocho clientes y registró su **edad**, su **ingreso** mensual en pesos y si **compró** el seguro.
| Cliente | Edad | Ingreso ($) | ¿Compró? |
|---|---:|---:|---|
| 1 | 20 | 30 000 | no |
| 2 | 30 | 50 000 | no |
| 3 | 36 | 10 000 | no |
| 4 | 28 | 38 000 | no |
| 5 | 52 | 34 000 | sí |
| 6 | 56 | 60 000 | sí |
| 7 | 60 | 18 000 | sí |
| 8 | 46 | 55 000 | sí |
: Clientes de entrenamiento. {#tbl-seguro}
A simple vista, la **edad** separa las clases: todos los mayores de 45 compraron y ninguno de los menores. El ingreso no parece importar. Llega una clienta nueva de **54 años** con un ingreso de **31 000 pesos**. La respuesta de sentido común es «comprará». Veamos qué responde $k$-NN.
## Construcción paso a paso
### Paso 1. Distancias con las variables tal como vienen
La distancia euclidiana entre la clienta nueva $\mathbf{q} = (54, 31\,000)$ y el cliente 1 es
$$
d(\mathbf{q}, \mathbf{x}_1) = \sqrt{(54 - 20)^2 + (31\,000 - 30\,000)^2} = \sqrt{1\,156 + 1\,000\,000} \approx 1\,000.6.
$$
Repitiendo el cálculo para los ocho clientes y ordenando:
| Orden | Cliente | $(\Delta \text{edad})^2$ | $(\Delta \text{ingreso})^2$ | Distancia | Clase |
|---:|---:|---:|---:|---:|---|
| 1 | 1 | 1 156 | 1 000 000 | 1 000.6 | no |
| 2 | 5 | 4 | 9 000 000 | 3 000.0 | sí |
| 3 | 4 | 676 | 49 000 000 | 7 000.0 | no |
| 4 | 7 | 36 | 169 000 000 | 13 000.0 | sí |
: Los cuatro vecinos más cercanos con las variables originales. {#tbl-seguro-crudo}
Con $k = 3$ los vecinos son los clientes 1, 5 y 4: dos «no» contra un «sí». **$k$-NN predice que no comprará.** El problema está en las columnas centrales: la diferencia de edad aporta a lo más unos miles a la suma de cuadrados, mientras que la de ingreso aporta millones. La distancia es, en la práctica, la diferencia de ingresos; la edad, la única variable útil, **no cuenta**. Si el ingreso se hubiera registrado en miles de pesos la conclusión sería otra. Un clasificador cuya respuesta depende de las unidades de medida no es confiable.
### Paso 2. Llevar las variables a una escala común
La solución es **escalar** las variables antes de medir distancias. Con la normalización **min-max**[^escalado], cada variable se transforma a $[0, 1]$ con el mínimo y el máximo **del entrenamiento**:
$$
z = \frac{x - \min}{\max - \min}, \qquad
z_{\text{edad}} = \frac{\text{edad} - 20}{60 - 20}, \qquad
z_{\text{ingreso}} = \frac{\text{ingreso} - 10\,000}{60\,000 - 10\,000}.
$$
Para la clienta nueva: $z_{\text{edad}} = 34/40 = 0.85$ y $z_{\text{ingreso}} = 21\,000/50\,000 = 0.42$.
| Cliente | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Nueva |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| $z_{\text{edad}}$ | 0.00 | 0.25 | 0.40 | 0.20 | 0.80 | 0.90 | 1.00 | 0.65 | 0.85 |
| $z_{\text{ingreso}}$ | 0.40 | 0.80 | 0.00 | 0.56 | 0.48 | 1.00 | 0.16 | 0.90 | 0.42 |
: Variables escaladas a [0, 1]. {#tbl-seguro-escalado}
### Paso 3. Distancias en la escala común
Ahora, por ejemplo, $d(\mathbf{q}, \mathbf{x}_5) = \sqrt{(0.85-0.80)^2 + (0.42-0.48)^2} = \sqrt{0.0025 + 0.0036} \approx 0.078$. Todas las distancias, ordenadas:
| Orden | Cliente | Distancia | Clase | Peso $1/d$ |
|---:|---:|---:|---|---:|
| 1 | 5 | 0.078 | sí | 12.80 |
| 2 | 7 | 0.300 | sí | 3.33 |
| 3 | 8 | 0.520 | sí | 1.92 |
| 4 | 6 | 0.582 | sí | 1.72 |
| 5 | 3 | 0.616 | no | 1.62 |
| 6 | 4 | 0.665 | no | 1.50 |
| 7 | 2 | 0.710 | no | 1.41 |
| 8 | 1 | 0.850 | no | 1.18 |
: Distancias euclidianas en la escala [0, 1]. {#tbl-seguro-distancias}
Con $k = 3$ los vecinos son 5, 7 y 8: tres «sí». **Ahora $k$-NN predice que comprará**, con una proporción de votos de $3/3$. Nada cambió en los datos; solo la manera de medir el parecido.
### Paso 4. El efecto de $k$
La proporción de vecinos de cada clase es una estimación de la probabilidad de la clase:
| $k$ | Votos «sí» | Votos «no» | $\hat{p}(\text{sí} \mid \mathbf{q})$ | Predicción |
|---:|---:|---:|---:|---|
| 1 | 1 | 0 | 1.00 | sí |
| 3 | 3 | 0 | 1.00 | sí |
| 5 | 4 | 1 | 0.80 | sí |
| 7 | 4 | 3 | 0.57 | sí |
| 8 | 4 | 4 | 0.50 | empate |
: Votos según el número de vecinos. {#tbl-seguro-k}
A medida que $k$ crece se consultan clientes cada vez menos parecidos y la estimación se acerca a la proporción global de la clase ($4/8$). Con $k = n$ el clasificador ignora a la clienta y responde siempre la clase mayoritaria: es la referencia del capítulo anterior. En el otro extremo, con $k = 1$, la respuesta depende de un solo cliente, que podría ser una excepción.
### Paso 5. Voto ponderado por la distancia
El voto uniforme trata igual al cliente 5, a distancia 0.078, que al cliente 3, a 0.616. Una variante natural pondera cada voto por el inverso de la distancia. Con $k = 7$:
$$
\text{peso}(\text{sí}) = 12.80 + 3.33 + 1.92 + 1.72 = 19.78, \qquad
\text{peso}(\text{no}) = 1.62 + 1.50 + 1.41 = 4.54,
$$
de modo que $\hat{p}(\text{sí} \mid \mathbf{q}) = 19.78 / (19.78 + 4.54) \approx 0.81$, frente al $0.57$ del voto uniforme. La ponderación hace que el resultado sea menos sensible a la elección de $k$, porque los vecinos lejanos casi no cuentan.
## Formalización
### La regla de $k$-NN
Sea $\mathcal{D} = \{(\mathbf{x}_i, y_i)\}_{i=1}^n$ el conjunto de entrenamiento y $d$ una función de distancia. Para un punto $\mathbf{x}$, sea $N_k(\mathbf{x})$ el conjunto de índices de sus $k$ vecinos más cercanos. La estimación de la probabilidad de la clase $c$ y la predicción son
$$
\hat{p}(c \mid \mathbf{x}) = \frac{1}{k} \sum_{i \in N_k(\mathbf{x})} \mathbb{1}[y_i = c], \qquad
\hat{y}(\mathbf{x}) = \arg\max_{c} \, \hat{p}(c \mid \mathbf{x}).
$$ {#eq-knn}
Es decir, $k$-NN imita al clasificador de Bayes ($\arg\max_c p(c \mid \mathbf{x})$) sustituyendo la probabilidad verdadera por una proporción **local**. Con pesos $w_i = 1/d(\mathbf{x}, \mathbf{x}_i)$ la versión ponderada es
$$
\hat{p}_w(c \mid \mathbf{x}) = \frac{\sum_{i \in N_k(\mathbf{x})} w_i \, \mathbb{1}[y_i = c]}{\sum_{i \in N_k(\mathbf{x})} w_i}.
$$ {#eq-knn-ponderado}
Si $\mathbf{x}$ coincide exactamente con un punto de entrenamiento ($d = 0$), se usa solo ese punto. En **regresión**, $k$-NN predice el promedio (simple o ponderado) de las respuestas de los vecinos, $\hat{f}(\mathbf{x}) = \frac{1}{k}\sum_{i \in N_k(\mathbf{x})} y_i$.
**Empates.** En clasificación binaria se recomienda un $k$ impar. Con varias clases pueden quedar empates aun con $k$ impar; `scikit-learn` los resuelve eligiendo la clase que aparece primero en el orden de las etiquetas, y otra opción común es usar la clase del vecino más cercano entre las empatadas.
### Distancias
La familia de **Minkowski** de orden $s \ge 1$ generaliza las distancias más usadas:
$$
d_s(\mathbf{x}, \mathbf{z}) = \Big(\sum_{j=1}^{p} |x_j - z_j|^s\Big)^{1/s}.
$$ {#eq-minkowski}
| $s$ | Nombre | Fórmula | Forma de la «bola» en 2D |
|---|---|---|---|
| 1 | Manhattan | $\sum_j \lvert x_j - z_j \rvert$ | rombo |
| 2 | Euclidiana | $\sqrt{\sum_j (x_j - z_j)^2}$ | círculo |
| $\infty$ | Chebyshev | $\max_j \lvert x_j - z_j \rvert$ | cuadrado |
: Casos particulares de la distancia de Minkowski. {#tbl-minkowski}
La «bola» es el conjunto de puntos a distancia menor que $r$ de un centro; para $k$-NN es la forma del vecindario. Con Chebyshev, la clienta nueva tendría como tercer vecino al cliente 3 (distancia $\max(0.45, 0.42) = 0.45$) en lugar del 8: la métrica cambia quiénes son los vecinos.
Otras distancias útiles:
- **Mahalanobis**, $d_M(\mathbf{x}, \mathbf{z}) = \sqrt{(\mathbf{x} - \mathbf{z})^\top \boldsymbol{\Sigma}^{-1} (\mathbf{x} - \mathbf{z})}$, con $\boldsymbol{\Sigma}$ la matriz de covarianza: corrige a la vez escala y correlación entre variables.
- **Hamming**, la proporción de atributos categóricos distintos, para datos como los del problema de tenis.
- **Gower**, que combina variables numéricas y categóricas en una sola distancia[^gower].
- **Coseno**, $1 - \frac{\mathbf{x}^\top\mathbf{z}}{\|\mathbf{x}\|\|\mathbf{z}\|}$, habitual para textos representados como vectores de frecuencias, donde importa la dirección y no la longitud.
### Escalamiento: parte del modelo
Como mostró el Paso 1, $k$-NN no es invariante a las unidades. Las dos opciones más comunes son la normalización **min-max** y la **estandarización** $z = (x - \bar{x})/s$. En ambos casos los parámetros ($\min$, $\max$, $\bar{x}$, $s$) se calculan **solo con el entrenamiento** y se aplican a los puntos nuevos; por eso el escalador forma parte del modelo y debe ir dentro de un *pipeline*. El capítulo de conversión de tipos y escalamiento tiene un laboratorio dedicado a este efecto.
Escalar es una forma de **elegir la importancia** de cada variable. Una variable irrelevante escalada a $[0, 1]$ pesa en la distancia lo mismo que una muy informativa, y añade ruido; por eso $k$-NN se beneficia mucho de una buena selección de variables.
### Garantías teóricas
Sea $R^{*}$ el error de Bayes. @cover1967nearest demostraron que, cuando $n \to \infty$, el error del vecino más cercano, $R_1$, cumple con $K$ clases
$$
R^{*} \le R_1 \le R^{*}\Big(2 - \frac{K}{K-1}R^{*}\Big) \le 2R^{*}.
$$ {#eq-cover-hart}
Con infinitos datos, el vecino más cercano está tan próximo que tiene casi la misma distribución de clases que el punto consultado; el 1-NN acierta cuando ambos «sortean» la misma clase. Además, si $k \to \infty$ y $k/n \to 0$, $k$-NN es **consistente**: su error converge a $R^{*}$ [@stone1977consistent]. La condición $k/n \to 0$ pide que el vecindario tenga cada vez más puntos sin dejar de ser local.
## Laboratorio: el vecindario de $k$-NN
Arrastra la clienta nueva (el rombo) por el plano, cambia $k$, la distancia y el tipo de voto. El contorno discontinuo es la «bola» que contiene a los $k$ vecinos: círculo, rombo o cuadrado según la métrica. El estado inicial reproduce la @tbl-seguro-distancias.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="knn-lab">
<p class="plab-eyebrow">Laboratorio · Vecinos más cercanos</p>
<h3 class="plab-title">¿Quiénes son los vecinos y cuánto vale su voto?</h3>
<p class="plab-sub">Arrastra el rombo (o haz clic en el plano) para mover el punto consultado. Las regiones sombreadas muestran la predicción en cada punto del plano con la configuración actual.</p>
<div class="plab-controls">
<div class="plab-field">Datos
<div class="plab-seg" id="knn-data">
<button data-value="seguro" aria-pressed="true">Ocho clientes</button>
<button data-value="tres">Tres clases</button>
</div>
</div>
<div class="plab-field">Distancia
<div class="plab-seg" id="knn-metric">
<button data-value="2" aria-pressed="true">Euclidiana</button>
<button data-value="1">Manhattan</button>
<button data-value="inf">Chebyshev</button>
</div>
</div>
<div class="plab-field">Voto
<div class="plab-seg" id="knn-weight">
<button data-value="uniform" aria-pressed="true">Uniforme</button>
<button data-value="distance">Ponderado 1/d</button>
</div>
</div>
<label>Vecinos k: <b id="knn-k-v"></b><input type="range" id="knn-k" min="1" max="8" step="1" value="3"></label>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>Punto consultado</span><strong id="knn-q"></strong></div>
<div class="plab-stat"><span>Predicción</span><strong id="knn-pred"></strong></div>
<div class="plab-stat"><span id="knn-prob-l">Probabilidad estimada</span><strong id="knn-prob"></strong></div>
<div class="plab-stat"><span>Radio del vecindario</span><strong id="knn-r"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4>Plano escalado a [0, 1]</h4>
<svg id="knn-svg" role="img" aria-label="Puntos de entrenamiento, punto consultado arrastrable, vecinos y regiones de decisión"></svg>
<div class="plab-legend" id="knn-legend"></div>
</div>
<div class="plab-panel">
<h4>Distancias al punto consultado</h4>
<div id="knn-tab" class="plab-scroll"></div>
</div>
</div>
<p class="plab-note" id="knn-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('knn-lab');
const $ = s => root.querySelector(s);
const SETS = {
seguro: {
pts: [[0,0.4,0],[0.25,0.8,0],[0.4,0,0],[0.2,0.56,0],[0.8,0.48,1],[0.9,1,1],[1,0.16,1],[0.65,0.9,1]],
names: ['no compró', 'compró'], cls: ['s2', 's1'], q: [0.85, 0.42], kmax: 8, k0: 3, xl: 'edad escalada', yl: 'ingreso escalado'
},
tres: {
pts: [[0.52,0.37,0],[0.3,0.63,0],[0.19,0.67,0],[0.02,0.67,0],[0.14,0.98,0],[0.28,0.65,0],[0.21,0.61,0],[0.11,0.65,0],[0.31,0.67,0],[0.37,0.67,0],[0.25,0.9,0],[0.32,0.63,0],
[0.68,0.82,1],[0.95,0.71,1],[0.67,0.88,1],[0.58,0.71,1],[0.81,0.83,1],[0.71,0.84,1],[0.33,0.88,1],[0.58,0.53,1],[0.74,0.84,1],[0.64,0.61,1],[0.7,0.74,1],[0.88,0.85,1],
[0.58,0.44,2],[0.52,0.18,2],[0.63,0.38,2],[0.52,0.2,2],[0.58,0.02,2],[0.64,0.36,2],[0.34,0.31,2],[0.42,0.4,2],[0.29,0.18,2],[0.64,0.45,2],[0.27,0.24,2],[0.59,0.22,2]],
names: ['clase A', 'clase B', 'clase C'], cls: ['s1', 's2', 's3'], q: [0.45, 0.52], kmax: 15, k0: 5, xl: 'x₁', yl: 'x₂'
}
};
let q = SETS.seguro.q.slice();
const dist = (m, a, b, c, d) => {
const u = Math.abs(a - c), v = Math.abs(b - d);
return m === '1' ? u + v : m === 'inf' ? Math.max(u, v) : Math.hypot(u, v);
};
// Vecinos ordenados (empates por índice) y probabilidades por clase.
function classify(S, a, b, k, m, w) {
const nc = S.names.length;
const ds = S.pts.map((p, i) => ({ i, d: dist(m, a, b, p[0], p[1]), c: p[2] })).sort((u, v) => u.d - v.d || u.i - v.i);
const nb = ds.slice(0, k), score = Array(nc).fill(0);
const exact = nb.filter(n => n.d < 1e-12);
if (w === 'distance' && exact.length) exact.forEach(n => score[n.c] += 1);
else nb.forEach(n => score[n.c] += w === 'distance' ? 1 / n.d : 1);
const tot = K.sum(score), prob = score.map(s => s / tot);
let best = 0; prob.forEach((p, c) => { if (p > prob[best] + 1e-12) best = c; });
const tie = prob.filter(p => Math.abs(p - prob[best]) < 1e-12).length > 1;
return { ds, nb, prob, best, tie };
}
const dataSeg = K.segmented($('#knn-data'), () => { const S = SETS[dataSeg.get()]; q = S.q.slice(); $('#knn-k').max = S.kmax; $('#knn-k').value = S.k0; render(); });
const metSeg = K.segmented($('#knn-metric'), render);
const wSeg = K.segmented($('#knn-weight'), render);
// Contorno de la bola de radio r en la métrica m.
function ball(c, m, r) {
let pts = [];
if (m === '2') for (let t = 0; t <= 64; t++) pts.push([q[0] + r * Math.cos(t / 32 * Math.PI), q[1] + r * Math.sin(t / 32 * Math.PI)]);
else if (m === '1') pts = [[q[0] + r, q[1]], [q[0], q[1] + r], [q[0] - r, q[1]], [q[0], q[1] - r], [q[0] + r, q[1]]];
else pts = [[q[0] + r, q[1] + r], [q[0] - r, q[1] + r], [q[0] - r, q[1] - r], [q[0] + r, q[1] - r], [q[0] + r, q[1] + r]];
c.path(pts, 'muted line dash thin');
}
function render() {
const S = SETS[dataSeg.get()], k = Math.min(+$('#knn-k').value, S.pts.length), m = metSeg.get(), w = wSeg.get();
$('#knn-k-v').textContent = k;
const res = classify(S, q[0], q[1], k, m, w);
const c = K.chart($('#knn-svg'), { width: 380, height: 380, x: [-0.05, 1.05], y: [-0.05, 1.05], margin: { left: 44, right: 12, top: 12, bottom: 40 } });
const G = 44, step = 1.1 / G;
for (let i = 0; i < G; i++) for (let j = 0; j < G; j++) {
const a = -0.05 + (i + 0.5) * step, b = -0.05 + (j + 0.5) * step, r = classify(S, a, b, k, m, w);
const cell = c.rect(a - step / 2, b - step / 2, a + step / 2, b + step / 2, S.cls[r.best]);
cell.style.fillOpacity = r.tie ? 0.04 : 0.15; cell.style.stroke = 'none';
}
c.axes({ xTicks: 5, yTicks: 5, xLabel: S.xl, yLabel: S.yl, grid: false, xFmt: v => K.fmt(v, 1) });
const radius = res.nb[res.nb.length - 1].d;
ball(c, m, radius);
res.nb.forEach(n => c.line(q[0], q[1], S.pts[n.i][0], S.pts[n.i][1], S.cls[n.c] + ' line thin'));
const inNb = new Set(res.nb.map(n => n.i));
S.pts.forEach((p, i) => {
const d = c.dot(p[0], p[1], inNb.has(i) ? 6.5 : 4.5, S.cls[p[2]] + ' dot');
if (!inNb.has(i)) d.style.opacity = 0.55;
if (dataSeg.get() === 'seguro') c.text(p[0], p[1], String(i + 1), { dx: 8, dy: -6, class: 'strong' });
});
const s = 9, X = c.X(q[0]), Y = c.Y(q[1]);
K.el('path', { d: `M${X},${Y - s}L${X + s},${Y}L${X},${Y + s}L${X - s},${Y}Z`, class: (res.tie ? 'muted' : S.cls[res.best]) + ' dot drag' }, c.g).style.strokeWidth = 2;
$('#knn-legend').innerHTML = S.names.map((n, i) => `<span><i class="dot" style="background:var(--lab-${S.cls[i]})"></i>${n}</span>`).join('') +
'<span><i style="background:var(--lab-axis)"></i>contorno del vecindario</span>';
$('#knn-q').textContent = `(${K.fmt(q[0], 2)}, ${K.fmt(q[1], 2)})`;
$('#knn-pred').textContent = res.tie ? 'empate' : S.names[res.best];
$('#knn-prob-l').textContent = 'Probabilidad estimada (' + S.names.map(n => n.replace('no compró', 'no').replace('compró', 'sí').replace('clase ', '')).join(' / ') + ')';
$('#knn-prob').textContent = S.names.map((n, i) => K.fmt(res.prob[i], 2)).join(' / ');
$('#knn-r').textContent = K.fmt(radius, 3);
$('#knn-tab').innerHTML = '<table class="plab-table"><thead><tr><th>Orden</th><th>Punto</th><th>Clase</th><th>Distancia</th><th>Peso 1/d</th></tr></thead><tbody>' +
res.ds.slice(0, Math.min(S.pts.length, Math.max(8, k + 2))).map((n, r) => `<tr${r < k ? ' class="hl"' : ''}><td>${r + 1}</td><td>${n.i + 1}</td><td>${S.names[n.c]}</td><td>${K.fmt(n.d, 3)}</td><td>${n.d < 1e-12 ? '∞' : K.fmt(1 / n.d, 2)}</td></tr>`).join('') +
'</tbody></table><p class="plab-sub" style="margin:.4rem 0 0">Resaltados: los k vecinos. Empates en la distancia se resuelven por el número de punto.</p>';
const note = $('#knn-note'), sorted = [...res.prob].sort((a, b) => b - a), margin = sorted[0] - sorted[1];
if (res.tie) {
note.className = 'plab-note warn';
note.textContent = `Empate entre clases con k = ${k}: el voto no decide. scikit-learn elegiría la primera clase en orden; conviene usar un k impar (en binario) o el voto ponderado.`;
} else if (k === S.pts.length) {
note.className = 'plab-note warn';
note.textContent = `Con k = n = ${k} se consulta a todos los puntos: el voto uniforme responde siempre la clase mayoritaria, sin importar dónde esté el punto. Prueba el voto ponderado: los puntos cercanos vuelven a dominar.`;
} else {
note.className = margin < 0.25 ? 'plab-note warn' : 'plab-note good';
note.textContent = `Predicción: ${S.names[res.best]} con probabilidad estimada ${K.fmt(res.prob[res.best], 2)}. El vecindario tiene radio ${K.fmt(radius, 3)} en la distancia ${m === '2' ? 'euclidiana' : m === '1' ? 'Manhattan' : 'de Chebyshev'}.` +
(margin < 0.25 ? ' El margen entre clases es pequeño: el punto está cerca de una frontera.' : '');
}
}
// Arrastre del punto consultado.
const svg = $('#knn-svg'); let dragging = false;
const moveTo = e => { const [a, b] = svg.__chart.pointer(e); q = [K.clamp(a, 0, 1), K.clamp(b, 0, 1)]; render(); };
svg.addEventListener('pointerdown', e => { dragging = true; svg.setPointerCapture(e.pointerId); moveTo(e); });
svg.addEventListener('pointermove', e => { if (dragging) moveTo(e); });
svg.addEventListener('pointerup', () => { dragging = false; });
$('#knn-k').addEventListener('input', render);
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio permite mover el punto consultado y cambiar $k$, la distancia y el tipo de voto. Valores de referencia con los ocho clientes y el punto $(0.85, 0.42)$: con distancia euclidiana y $k = 3$, los vecinos son 5, 7 y 8, a distancias 0.078, 0.300 y 0.520, y la predicción es «compró» con probabilidad 1. Con distancia de Chebyshev y $k = 3$ el tercer vecino pasa a ser el cliente 3 (0.45) y la probabilidad baja a 0.67. Con $k = 7$ y voto uniforme la probabilidad es 0.57; con voto ponderado, 0.81.
:::
**Guía del laboratorio.**
1. Comprueba el estado inicial contra la @tbl-seguro-distancias: vecinos, distancias y pesos.
2. Predice antes de mover: con distancia de Chebyshev y $k = 3$, ¿quién será el tercer vecino? Compruébalo y observa la forma cuadrada del vecindario.
3. Sube $k$ a 7 con voto uniforme y anota la probabilidad. Cambia a voto ponderado. ¿Por qué sube tanto?
4. Con $k = 8$ y voto uniforme, arrastra el punto por todo el plano. ¿Por qué el color de fondo no cambia? ¿Qué pasa al activar el voto ponderado?
5. Cambia a *Tres clases* con $k = 1$ y observa la frontera alrededor de los puntos aislados (por ejemplo, el punto 19, de la clase B, metido entre los de la clase A). Sube $k$ a 9: ¿qué le pasa a la isla?
6. Busca un punto donde, con $k = 4$ y voto uniforme, haya empate entre dos clases. ¿Cómo lo resuelve el voto ponderado?
## Elegir $k$
$k$ es el hiperparámetro que controla la complejidad de $k$-NN, como mostró el laboratorio del capítulo anterior: $k$ pequeño da fronteras irregulares (poco sesgo, mucha varianza); $k$ grande, fronteras suaves (más sesgo, menos varianza). En la práctica:
- se elige con **validación cruzada**, nunca con el conjunto de prueba;
- en problemas binarios se prefieren valores impares;
- una heurística inicial razonable es $k \approx \sqrt{n}$, que después se refina con validación;
- el voto ponderado reduce la sensibilidad a $k$ y suele permitir valores más grandes.
Una propiedad conveniente de $k$-NN es que la **validación cruzada dejando uno fuera** es barata: predecir el punto $i$ sin él equivale a buscar sus $k$ vecinos excluyéndolo, sin volver a «entrenar» nada.
## La maldición de la dimensionalidad
$k$-NN confía en que haya puntos de entrenamiento **cerca** de cualquier punto nuevo. En pocas dimensiones es razonable; en muchas, deja de serlo. @bellman1961adaptive llamó a este fenómeno la **maldición de la dimensionalidad**, y tiene dos caras.
**Los vecindarios dejan de ser locales.** Supongamos datos uniformes en el hipercubo $[0, 1]^p$. Un subcubo de lado $e$ contiene, en promedio, una fracción $e^p$ de los datos. Para capturar una fracción $r$ hay que tomar un lado
$$
e_p(r) = r^{1/p}.
$$ {#eq-lado-hipercubo}
Con $r = 0.10$: en $p = 1$ basta $e = 0.10$; en $p = 2$, $e = 0.32$; en $p = 10$, $e = 0.79$; y en $p = 100$, $e = 0.977$. Para reunir solo el 10 % de los datos hay que abarcar casi todo el rango de cada variable [@hastie2009]. El «vecino más cercano» ya no es parecido.
**Las distancias se concentran.** Al crecer $p$, la distancia al vecino más cercano y al más lejano se vuelven casi iguales, de modo que el cociente $d_{\min}/d_{\max}$ tiende a 1 [@beyer1999nearest]. Si todos los puntos están a la misma distancia, «el más cercano» es casi arbitrario.
La consecuencia práctica es que $k$-NN necesita pocas variables **relevantes**. Antes de usarlo conviene seleccionar variables o reducir la dimensión (por ejemplo con PCA), y desconfiar de representaciones con cientos de columnas *one-hot*.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="dim-lab">
<p class="plab-eyebrow">Laboratorio · Maldición de la dimensionalidad</p>
<h3 class="plab-title">¿Sigue habiendo un vecino «cercano» en 100 dimensiones?</h3>
<p class="plab-sub">Se generan n puntos uniformes en el hipercubo [0, 1]^p y se miden sus distancias euclidianas a un punto consultado, también aleatorio. El histograma muestra cada distancia dividida entre la distancia promedio.</p>
<div class="plab-controls">
<label>Dimensión p: <b id="dim-p-v"></b><input type="range" id="dim-p" min="0" max="11" step="1" value="1"></label>
<label>Número de puntos n: <b id="dim-n-v"></b><input type="range" id="dim-n" min="100" max="2000" step="100" value="500"></label>
<div class="plab-field">Muestra<button class="plab-btn" id="dim-new">Nueva muestra</button></div>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>Distancia mínima</span><strong id="dim-min"></strong></div>
<div class="plab-stat"><span>Distancia máxima</span><strong id="dim-max"></strong></div>
<div class="plab-stat"><span>Cociente d<sub>min</sub>/d<sub>max</sub></span><strong id="dim-ratio"></strong></div>
<div class="plab-stat"><span>Lado para capturar el 10 %</span><strong id="dim-edge"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4>Distancias relativas (d / promedio)</h4>
<svg id="dim-hist" role="img" aria-label="Histograma de las distancias relativas al punto consultado"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s1)"></i>distancias</span><span><i style="background:var(--lab-s2)"></i>vecino más cercano</span><span><i style="background:var(--lab-s4)"></i>más lejano</span></div>
</div>
<div class="plab-panel">
<h4>Concentración según la dimensión</h4>
<svg id="dim-curve" role="img" aria-label="Cociente entre la distancia mínima y la máxima y lado del hipercubo en función de la dimensión"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s1)"></i>d<sub>min</sub>/d<sub>max</sub> (simulado)</span><span><i style="background:var(--lab-s3)"></i>lado e<sub>p</sub>(0.1) = 0.1<sup>1/p</sup></span><span><i class="dot" style="background:var(--lab-s2)"></i>p actual</span></div>
</div>
</div>
<p class="plab-note" id="dim-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('dim-lab');
const $ = s => root.querySelector(s);
const PS = [1, 2, 3, 5, 10, 15, 20, 30, 50, 70, 100, 200];
let seed = 11;
function sample(p, n, sd) {
const r = K.rng(sd * 1009 + p), q = Array.from({ length: p }, () => r());
const d = [];
for (let i = 0; i < n; i++) { let s = 0; for (let j = 0; j < p; j++) { const u = r() - q[j]; s += u * u; } d.push(Math.sqrt(s)); }
return d;
}
function render() {
const p = PS[+$('#dim-p').value], n = +$('#dim-n').value;
$('#dim-p-v').textContent = p; $('#dim-n-v').textContent = n;
const d = sample(p, n, seed), mn = Math.min(...d), mx = Math.max(...d), mu = K.mean(d);
$('#dim-min').textContent = K.fmt(mn, 3); $('#dim-max').textContent = K.fmt(mx, 3);
$('#dim-ratio').textContent = K.fmt(mn / mx, 3); $('#dim-edge').textContent = K.fmt(0.1 ** (1 / p), 3);
// Histograma de d / promedio en [0, 2.5].
const B = 40, hi = 2.5, w = hi / B, cnt = Array(B).fill(0);
d.forEach(v => { const b = Math.min(B - 1, Math.floor(v / mu / w)); cnt[b]++; });
const top = Math.max(...cnt) / n;
const c = K.chart($('#dim-hist'), { width: 380, height: 250, x: [0, hi], y: [0, top * 1.1], margin: { left: 44, bottom: 40 } });
c.axes({ xTicks: 5, yTicks: 4, xLabel: 'distancia / promedio', yLabel: 'proporción', xFmt: v => K.fmt(v, 1), yFmt: v => K.fmt(v, 2) });
cnt.forEach((v, i) => { if (v) c.rect(i * w, 0, (i + 1) * w, v / n, 's1').style.fillOpacity = 0.55; });
c.line(mn / mu, 0, mn / mu, top * 1.05, 's2 line');
c.line(mx / mu, 0, mx / mu, top * 1.05, 's4 line');
// Curva del cociente frente a p (misma semilla y n).
const curve = PS.map(pp => { const dd = sample(pp, n, seed); return [pp, Math.min(...dd) / Math.max(...dd)]; });
const cc = K.chart($('#dim-curve'), { width: 380, height: 250, x: [0, 200], y: [0, 1], margin: { left: 44, bottom: 40 } });
cc.axes({ xTicks: 5, yTicks: 5, xLabel: 'dimensión p', yLabel: 'cociente / lado', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 1) });
const edge = []; for (let pp = 1; pp <= 200; pp++) edge.push([pp, 0.1 ** (1 / pp)]);
cc.path(edge, 's3 line');
cc.path(curve, 's1 line');
curve.forEach(u => cc.dot(u[0], u[1], 3, 's1 dot'));
cc.dot(p, mn / mx, 6, 's2 dot');
const note = $('#dim-note');
note.className = mn / mx > 0.5 ? 'plab-note bad' : mn / mx > 0.2 ? 'plab-note warn' : 'plab-note good';
note.textContent = p <= 3
? `En p = ${p} el vecino más cercano está a ${K.fmt(mn, 3)} y el más lejano a ${K.fmt(mx, 3)}: hay vecinos genuinamente cercanos y la idea de k-NN tiene sentido.`
: `En p = ${p} el más cercano está a ${K.fmt(mn / mu, 2)} veces la distancia promedio y el más lejano a ${K.fmt(mx / mu, 2)} veces: las distancias se amontonan. Para capturar el 10 % de los datos en un subcubo hay que tomar un lado de ${K.fmt(0.1 ** (1 / p), 3)} en cada variable.`;
}
root.querySelectorAll('input[type=range]').forEach(el => el.addEventListener('input', render));
$('#dim-new').addEventListener('click', () => { seed++; render(); });
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, un laboratorio simula $n$ puntos uniformes en $[0, 1]^p$ y muestra el histograma de distancias a un punto consultado. Valores de referencia (deterministas): el lado del subcubo que captura el 10 % de los datos es 0.100 en $p = 1$, 0.316 en $p = 2$, 0.794 en $p = 10$ y 0.977 en $p = 100$. En la simulación con $n = 500$, el cociente $d_{\min}/d_{\max}$ es casi 0 en $p = 1$ o 2, del orden de 0.3 en $p = 10$ y de 0.7 en $p = 100$.
:::
**Guía del laboratorio.**
1. Con $p = 2$, observa el histograma: ¿qué tan lejos está el vecino más cercano en comparación con el promedio?
2. Predice antes de mover: en $p = 100$, ¿el cociente $d_{\min}/d_{\max}$ estará más cerca de 0 o de 1? Compruébalo.
3. En $p = 100$ sube $n$ a 2000. ¿Cuánto mejora el cociente? ¿Cuántos puntos crees que harían falta para recuperar la situación de $p = 2$?
4. Explica con la curva verde por qué un vecindario que contiene el 10 % de los datos deja de ser «local» en alta dimensión.
## Costo computacional y búsqueda eficiente
Entrenar $k$-NN cuesta casi nada: basta guardar los datos. Predecir es caro: con búsqueda exhaustiva (*fuerza bruta*), cada consulta calcula $n$ distancias de $p$ componentes, es decir $O(np)$, y necesita tener en memoria todo el conjunto de entrenamiento. Con millones de casos y predicciones en tiempo real esto se vuelve un problema. Hay tres familias de soluciones:
- **Estructuras de búsqueda exacta.** Los árboles *k-d* [@friedman1977algorithm] y los *ball trees* organizan los datos jerárquicamente y descartan regiones completas que no pueden contener al vecino. Si un nodo tiene centro $\mathbf{M}_p$ y radio $r_p$, y la mejor distancia encontrada hasta ahora es $B$, la desigualdad del triángulo garantiza que el nodo puede descartarse cuando $d(\mathbf{x}, \mathbf{M}_p) - r_p > B$ [@fukunaga1975branch]. Funcionan muy bien en dimensión baja o moderada; en dimensión alta degeneran hacia la fuerza bruta.
- **Búsqueda aproximada.** Bibliotecas como FAISS o los grafos HNSW aceptan devolver un vecino *casi* más cercano a cambio de consultas miles de veces más rápidas. Son la base de los buscadores semánticos y de los sistemas de recomendación modernos.
- **Reducir el conjunto de entrenamiento.** La **edición** de @wilson1972asymptotic elimina los puntos que sus propios vecinos clasifican mal (ruido y casos en zonas de traslape), lo que suaviza la frontera. La **condensación** de @hart1968condensed conserva solo los puntos necesarios para que 1-NN tome las mismas decisiones: empieza con un punto y agrega cada caso que el subconjunto actual clasifica mal.
## Implementación en Python
### El ejemplo manual con `scikit-learn`
`KNeighborsClassifier` implementa la @eq-knn. Lo usamos dos veces: con las variables originales y dentro de un *pipeline* que primero aplica `MinMaxScaler`.
```{python}
#| label: knn-seguro
import numpy as np
import pandas as pd
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import MinMaxScaler
from sklearn.pipeline import make_pipeline
seguro = pd.DataFrame({
"edad": [20, 30, 36, 28, 52, 56, 60, 46],
"ingreso": [30_000, 50_000, 10_000, 38_000, 34_000, 60_000, 18_000, 55_000],
"compra": ["no", "no", "no", "no", "sí", "sí", "sí", "sí"],
}, index=range(1, 9))
X, y = seguro[["edad", "ingreso"]], seguro["compra"]
nueva = pd.DataFrame({"edad": [54], "ingreso": [31_000]})
crudo = KNeighborsClassifier(n_neighbors=3).fit(X, y)
escalado = make_pipeline(MinMaxScaler(), KNeighborsClassifier(n_neighbors=3)).fit(X, y)
for nombre, modelo, Xq in [("sin escalar", crudo, nueva),
("min-max", escalado[-1], escalado[0].transform(nueva))]:
dist, idx = modelo.kneighbors(Xq)
print(f"{nombre:>12}: vecinos {list(X.index[idx[0]])}, distancias {np.round(dist[0], 3)}")
print("\nPredicción sin escalar:", crudo.predict(nueva)[0])
print("Predicción con min-max:", escalado.predict(nueva)[0])
```
La probabilidad estimada (`predict_proba`) para distintos valores de $k$ y ambos tipos de voto reproduce la @tbl-seguro-k y el Paso 5:
```{python}
#| label: knn-seguro-k
filas = []
for k in [1, 3, 5, 7, 8]:
fila = {"k": k}
for pesos in ["uniform", "distance"]:
m = make_pipeline(MinMaxScaler(), KNeighborsClassifier(n_neighbors=k, weights=pesos)).fit(X, y)
fila[f"P(sí) {pesos}"] = m.predict_proba(nueva)[0, list(m.classes_).index("sí")]
filas.append(fila)
pd.DataFrame(filas).round(3)
```
### Un problema real: el conjunto *wine*
El conjunto *wine* de `scikit-learn` tiene 178 vinos de tres cultivares descritos por 13 mediciones químicas en escalas muy distintas: la prolina toma valores entre 278 y 1680, mientras que el tono está entre 0.48 y 1.71. Es el escenario típico en el que escalar marca la diferencia.
```{python}
#| label: knn-wine-escala
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold, GridSearchCV
from sklearn.preprocessing import StandardScaler
vino = load_wine(as_frame=True)
X_v, y_v = vino.data, vino.target
X_tr, X_te, y_tr, y_te = train_test_split(X_v, y_v, test_size=0.3, stratify=y_v, random_state=0)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
print(X_v[["proline", "magnesium", "hue", "nonflavanoid_phenols"]].describe().loc[["min", "max", "std"]].round(2))
for nombre, modelo in [("sin escalar", KNeighborsClassifier(n_neighbors=5)),
("estandarizado", make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5)))]:
acc = cross_val_score(modelo, X_tr, y_tr, cv=cv)
print(f"5-NN {nombre:>14}: exactitud CV = {acc.mean():.3f} ± {acc.std():.3f}")
```
Sin escalar, la prolina domina la distancia y el clasificador apenas usa la información de las otras doce variables. Ahora elegimos $k$, el tipo de voto y la métrica con búsqueda en rejilla y validación cruzada, **solo sobre el entrenamiento**:
```{python}
#| label: fig-knn-wine-k
#| fig-cap: "Exactitud de validación cruzada (5 pliegues) del k-NN estandarizado según k, con voto uniforme y ponderado. El voto ponderado es menos sensible a k."
import plotly.graph_objects as go
pipe = make_pipeline(StandardScaler(), KNeighborsClassifier())
rejilla = {"kneighborsclassifier__n_neighbors": list(range(1, 32, 2)),
"kneighborsclassifier__weights": ["uniform", "distance"],
"kneighborsclassifier__p": [1, 2]}
busqueda = GridSearchCV(pipe, rejilla, cv=cv, scoring="accuracy").fit(X_tr, y_tr)
res = pd.DataFrame(busqueda.cv_results_)
fig = go.Figure()
for pesos, color in [("uniform", "#2563eb"), ("distance", "#ea580c")]:
d = res[(res.param_kneighborsclassifier__weights == pesos) & (res.param_kneighborsclassifier__p == 2)]
fig.add_trace(go.Scatter(x=d.param_kneighborsclassifier__n_neighbors, y=d.mean_test_score,
mode="lines+markers", name=f"voto {pesos} (euclidiana)", line=dict(color=color)))
fig.update_layout(xaxis_title="k", yaxis_title="exactitud CV", height=360, margin=dict(l=10, r=10, t=20, b=40))
fig.show()
print("Mejor configuración:", busqueda.best_params_, f"(CV = {busqueda.best_score_:.3f})")
print(f"Exactitud en prueba: {busqueda.score(X_te, y_te):.3f}")
```
### Variables irrelevantes y alta dimensión
Para ver la maldición de la dimensionalidad en un caso real, añadimos a *wine* columnas de puro ruido. Cada una entra en la distancia con el mismo peso que las variables químicas.
```{python}
#| label: fig-knn-ruido
#| fig-cap: "Exactitud de validación cruzada de 5-NN estandarizado al agregar variables de ruido a las 13 variables reales de wine. El árbol de decisión, que elige variables al partir, se degrada menos."
from sklearn.tree import DecisionTreeClassifier
rng = np.random.default_rng(0)
filas = []
for extra in [0, 5, 10, 25, 50, 100, 200]:
X_ruido = np.hstack([X_tr.values, rng.normal(size=(len(X_tr), extra))])
knn_acc = cross_val_score(make_pipeline(StandardScaler(), KNeighborsClassifier(5)), X_ruido, y_tr, cv=cv).mean()
arbol_acc = cross_val_score(DecisionTreeClassifier(max_depth=4, random_state=0), X_ruido, y_tr, cv=cv).mean()
filas.append({"variables de ruido": extra, "5-NN": knn_acc, "árbol (prof. 4)": arbol_acc})
ruido = pd.DataFrame(filas)
fig = go.Figure()
fig.add_trace(go.Scatter(x=ruido["variables de ruido"], y=ruido["5-NN"], name="5-NN", mode="lines+markers", line=dict(color="#2563eb")))
fig.add_trace(go.Scatter(x=ruido["variables de ruido"], y=ruido["árbol (prof. 4)"], name="árbol", mode="lines+markers", line=dict(color="#16a34a")))
fig.update_layout(xaxis_title="variables de ruido agregadas", yaxis_title="exactitud CV", yaxis_range=[0.3, 1.02],
height=360, margin=dict(l=10, r=10, t=20, b=40))
fig.show()
```
Finalmente, la concentración de distancias del laboratorio, simulada con `numpy`:
```{python}
#| label: knn-concentracion
rng = np.random.default_rng(1)
filas = []
for p in [1, 2, 10, 100, 1000]:
datos, q = rng.uniform(size=(500, p)), rng.uniform(size=p)
d = np.linalg.norm(datos - q, axis=1)
filas.append({"p": p, "d_min": d.min(), "d_max": d.max(), "d_min/d_max": d.min() / d.max(),
"lado para 10 %": 0.1 ** (1 / p)})
pd.DataFrame(filas).round(3)
```
## Ventajas y limitaciones
**Ventajas.**
- No supone ninguna forma para la frontera; con datos suficientes aproxima fronteras arbitrarias.
- Es multiclase de forma natural y sirve también para regresión.
- Incorporar datos nuevos es inmediato: basta agregarlos.
- Sus decisiones se explican con ejemplos: «se parece a estos cinco casos».
**Limitaciones.**
- Es sensible a la escala, a las variables irrelevantes y a la dimensión.
- Predecir es costoso en tiempo y memoria si no se usan estructuras de búsqueda.
- No produce un resumen de los datos: no dice qué variables importan.
- Sus probabilidades son burdas (múltiplos de $1/k$) y suelen necesitar calibración.
**Cuándo usarlo.** Como referencia rápida en problemas con pocas variables numéricas bien escaladas, en sistemas de recomendación y búsqueda por similitud (con búsqueda aproximada) y como componente de otros métodos, por ejemplo la imputación de valores faltantes con vecinos del capítulo de preprocesamiento.
## Resumen
- $k$-NN clasifica un punto con el voto de sus $k$ vecinos más cercanos; la proporción de votos estima $p(c \mid \mathbf{x})$.
- Qué significa «cercano» lo deciden la distancia y, sobre todo, el **escalamiento**, que se ajusta solo con el entrenamiento y forma parte del modelo.
- $k$ controla el compromiso sesgo–varianza y se elige con validación cruzada; el voto ponderado reduce la sensibilidad a $k$.
- Con infinitos datos, el error del 1-NN es a lo más el doble del error de Bayes, y $k$-NN con $k \to \infty$, $k/n \to 0$ lo alcanza.
- En alta dimensión las distancias se concentran y los vecindarios dejan de ser locales: $k$-NN necesita pocas variables relevantes.
El siguiente capítulo aborda una pregunta que ya apareció varias veces: cómo **medir** el desempeño de un clasificador cuando la exactitud no basta.
[^escalado]: Otra opción es la **estandarización**, $z = (x - \bar{x})/s$, que deja cada variable con media 0 y desviación estándar 1. Min-max garantiza el rango $[0, 1]$ pero es sensible a valores atípicos; la estandarización no acota el rango pero es más estable. Ambas se estudian en el capítulo de conversión de tipos.
[^gower]: La distancia de **Gower** promedia, variable por variable, una disimilitud entre 0 y 1: para numéricas, la diferencia absoluta dividida entre el rango; para categóricas, 0 si coinciden y 1 si no. Permite aplicar $k$-NN a tablas mixtas sin codificación *one-hot*.
## Referencias {.unnumbered}
::: {#refs}
:::