---
title: "Regresión lineal y clasificadores lineales: Fisher y el perceptrón"
pagetitle: "Regresión lineal, discriminante de Fisher y perceptrón"
open-graph:
description: "Modelos lineales para predecir y clasificar: mínimos cuadrados, métricas de regresión, geometría de un clasificador lineal, discriminante lineal de Fisher y perceptrón, con desarrollos manuales, laboratorios interactivos y Python."
twitter-card:
description: "Modelos lineales para predecir y clasificar: mínimos cuadrados, métricas de regresión, geometría de un clasificador lineal, discriminante lineal de Fisher y perceptrón, con desarrollos manuales, laboratorios interactivos y Python."
description: "Modelos lineales para predecir y clasificar: mínimos cuadrados, métricas de regresión, geometría de un clasificador lineal, discriminante lineal de Fisher y perceptrón, con desarrollos manuales, laboratorios interactivos y 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_regresion_clasificadores.bib, ../../references.bib]
jupyter: python3
---
{{< include ../preprocesamiento/_labkit.qmd >}}
Los árboles y los vecinos cercanos construyen fronteras flexibles a partir de reglas locales. Los **modelos lineales** hacen la apuesta contraria: resumen toda la información en un **puntaje** que es una suma ponderada de las variables,
$$
g(\mathbf{x}) = w_0 + w_1 x_1 + \cdots + w_p x_p = \mathbf{w}^\top\mathbf{x} + w_0,
$$
y deciden con ese puntaje. Para predecir un número, el puntaje es la predicción; para clasificar, basta mirar su **signo**. Su frontera de decisión es una recta en el plano y un hiperplano en general. Son menos flexibles que un árbol profundo, pero tienen poca varianza, se entrenan rápido, sus coeficientes se interpretan y son la base de la regresión logística, las máquinas de soporte vectorial y las redes neuronales.
Este capítulo recorre tres maneras de elegir $\mathbf{w}$: **mínimos cuadrados**, que ajusta números; el **discriminante de Fisher**, que busca la dirección que mejor separa dos clases; y el **perceptrón**, que corrige sus errores uno a uno. El capítulo siguiente presenta la cuarta y más usada, la regresión logística.
## Regresión lineal
### Intuición: la recta que menos se equivoca
Cinco estudiantes registraron sus horas de estudio $x$ y su calificación $y$ (sobre 10):
| Estudiante | 1 | 2 | 3 | 4 | 5 |
|---|---:|---:|---:|---:|---:|
| Horas $x$ | 1 | 2 | 3 | 4 | 5 |
| Calificación $y$ | 2 | 3 | 5 | 4 | 6 |
: Horas de estudio y calificación de cinco estudiantes. {#tbl-ols-datos}
Ninguna recta pasa por los cinco puntos. Para cada recta candidata $\hat{y} = \beta_0 + \beta_1 x$, cada estudiante tiene un **residuo** $e_i = y_i - \hat{y}_i$. El criterio de **mínimos cuadrados** [@legendre1805nouvelles; @gauss1809theoria] elige la recta que minimiza la suma de los residuos al cuadrado, $\text{SSE} = \sum_i e_i^2$. Elevar al cuadrado impide que errores positivos y negativos se cancelen y castiga más los errores grandes.
### Construcción paso a paso
**Paso 1. Centrar.** Las medias son $\bar{x} = 3$ y $\bar{y} = 20/5 = 4$.
**Paso 2. Pendiente.** Para una variable, la solución de mínimos cuadrados es el cociente entre la covariación de $x$ con $y$ y la variación de $x$:
| $x_i - \bar{x}$ | −2 | −1 | 0 | 1 | 2 | Suma |
|---|---:|---:|---:|---:|---:|---:|
| $y_i - \bar{y}$ | −2 | −1 | 1 | 0 | 2 | |
| $(x_i - \bar{x})(y_i - \bar{y})$ | 4 | 1 | 0 | 0 | 4 | $S_{xy} = 9$ |
| $(x_i - \bar{x})^2$ | 4 | 1 | 0 | 1 | 4 | $S_{xx} = 10$ |
: Cálculo de la pendiente. {#tbl-ols-pendiente}
$$
\hat{\beta}_1 = \frac{S_{xy}}{S_{xx}} = \frac{9}{10} = 0.9, \qquad \hat{\beta}_0 = \bar{y} - \hat{\beta}_1\bar{x} = 4 - 0.9 \cdot 3 = 1.3.
$$
Cada hora adicional de estudio se asocia con 0.9 puntos más de calificación.
**Paso 3. Predicciones y residuos.** Con $\hat{y} = 1.3 + 0.9x$:
| $x$ | 1 | 2 | 3 | 4 | 5 |
|---|---:|---:|---:|---:|---:|
| $\hat{y}$ | 2.2 | 3.1 | 4.0 | 4.9 | 5.8 |
| $e = y - \hat{y}$ | −0.2 | −0.1 | 1.0 | −0.9 | 0.2 |
| $e^2$ | 0.04 | 0.01 | 1.00 | 0.81 | 0.04 |
: Predicciones y residuos de la recta de mínimos cuadrados. {#tbl-ols-residuos}
**Paso 4. Métricas.** $\text{SSE} = 1.90$, de modo que el **error cuadrático medio** es $\text{MSE} = 1.90/5 = 0.38$ y su raíz $\text{RMSE} = 0.616$ puntos. El **error absoluto medio** es $\text{MAE} = (0.2 + 0.1 + 1.0 + 0.9 + 0.2)/5 = 0.48$. La variación total respecto a la media es $\text{SST} = \sum_i (y_i - \bar{y})^2 = 4 + 1 + 1 + 0 + 4 = 10$, así que
$$
R^2 = 1 - \frac{\text{SSE}}{\text{SST}} = 1 - \frac{1.9}{10} = 0.81:
$$
la recta explica el 81 % de la variación de las calificaciones respecto a predecir siempre el promedio.
### Formalización
Con $p$ variables, el modelo es $y_i = \beta_0 + \sum_j \beta_j x_{ij} + \varepsilon_i$. Apilando las observaciones en la **matriz de diseño** $\mathbf{X} \in \mathbb{R}^{n \times (p+1)}$, cuya primera columna es de unos, y en el vector $\mathbf{y}$, el problema es
$$
\hat{\boldsymbol{\beta}} = \arg\min_{\boldsymbol{\beta}} \|\mathbf{y} - \mathbf{X}\boldsymbol{\beta}\|^2.
$$ {#eq-ols}
Derivando e igualando a cero se obtienen las **ecuaciones normales** $\mathbf{X}^\top\mathbf{X}\hat{\boldsymbol{\beta}} = \mathbf{X}^\top\mathbf{y}$ y, si $\mathbf{X}^\top\mathbf{X}$ es invertible,
$$
\hat{\boldsymbol{\beta}} = (\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top\mathbf{y}.
$$ {#eq-ols-cerrada}
Geométricamente, $\hat{\mathbf{y}} = \mathbf{X}\hat{\boldsymbol{\beta}}$ es la **proyección ortogonal** de $\mathbf{y}$ sobre el espacio generado por las columnas de $\mathbf{X}$, y por eso los residuos son perpendiculares a cada columna: suman cero y no están correlacionados con ninguna variable. Si dos variables son (casi) linealmente dependientes, $\mathbf{X}^\top\mathbf{X}$ es (casi) singular y los coeficientes se vuelven inestables (**multicolinealidad**); la solución práctica es eliminar variables redundantes o **regularizar** sumando $\lambda\|\boldsymbol{\beta}\|^2$ a la pérdida (*ridge*) [@hoerl1970ridge], tema que se retoma con la regresión logística.
| Métrica | Fórmula | Unidades | Sensible a atípicos |
|---|---|---|---|
| MSE | $\frac{1}{n}\sum e_i^2$ | cuadrado de $y$ | mucho |
| RMSE | $\sqrt{\text{MSE}}$ | las de $y$ | mucho |
| MAE | $\frac{1}{n}\sum \lvert e_i \rvert$ | las de $y$ | poco |
| $R^2$ | $1 - \text{SSE}/\text{SST}$ | sin unidades | mucho |
: Métricas de regresión. {#tbl-metricas-regresion}
### Laboratorio: ajusta la recta
Mueve la pendiente y el intercepto para minimizar la suma de cuadrados; cada residuo se dibuja como un cuadrado cuya área es su contribución a la SSE. El panel derecho muestra la SSE de todas las rectas posibles: el mínimo es la solución de mínimos cuadrados. Arrastra un punto para ver cuánto puede mover la recta un solo dato atípico.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="ols-lab">
<p class="plab-eyebrow">Laboratorio · Mínimos cuadrados</p>
<h3 class="plab-title">¿Puedes encontrar la recta de mínima suma de cuadrados?</h3>
<p class="plab-sub">Ajusta β₀ y β₁ con los controles, o pulsa el botón para ver la solución. Arrastra los puntos para cambiar los datos.</p>
<div class="plab-controls">
<label>Intercepto β₀: <b id="ols-b0-v"></b><input type="range" id="ols-b0" min="-2" max="6" step="0.05" value="3"></label>
<label>Pendiente β₁: <b id="ols-b1-v"></b><input type="range" id="ols-b1" min="-1" max="2.5" step="0.01" value="0.3"></label>
<div class="plab-field">Acciones
<div style="display:flex;gap:.4rem;flex-wrap:wrap"><button class="plab-btn primary" id="ols-fit">Mínimos cuadrados</button><button class="plab-btn" id="ols-reset">Datos originales</button></div>
</div>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>SSE de tu recta</span><strong id="ols-sse"></strong></div>
<div class="plab-stat"><span>SSE mínima posible</span><strong id="ols-min"></strong></div>
<div class="plab-stat"><span>R² de tu recta</span><strong id="ols-r2"></strong></div>
<div class="plab-stat"><span>Recta óptima</span><strong id="ols-opt"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4>Datos, recta y cuadrados de los residuos</h4>
<svg id="ols-svg" role="img" aria-label="Puntos arrastrables, recta propuesta y residuos al cuadrado"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>datos</span><span><i style="background:var(--lab-s2)"></i>tu recta</span><span><i style="background:var(--lab-s3)"></i>mínimos cuadrados</span><span><i style="background:var(--lab-s4);opacity:.4"></i>residuo²</span></div>
</div>
<div class="plab-panel">
<h4>SSE para cada (β₀, β₁)</h4>
<svg id="ols-map" role="img" aria-label="Mapa de la suma de cuadrados en el plano de coeficientes"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s2)"></i>tu recta</span><span><i class="dot" style="background:var(--lab-s3)"></i>mínimo</span><span>más oscuro = menor SSE</span></div>
</div>
</div>
<p class="plab-note" id="ols-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('ols-lab');
const $ = s => root.querySelector(s);
const ORIG = [[1, 2], [2, 3], [3, 5], [4, 4], [5, 6]];
let P = ORIG.map(p => p.slice());
const fit = () => { const mx = K.mean(P.map(p => p[0])), my = K.mean(P.map(p => p[1]));
const sxy = K.sum(P.map(p => (p[0] - mx) * (p[1] - my))), sxx = K.sum(P.map(p => (p[0] - mx) ** 2));
const b1 = sxx > 1e-12 ? sxy / sxx : 0; return [my - b1 * mx, b1]; };
const sse = (b0, b1) => K.sum(P.map(p => (p[1] - b0 - b1 * p[0]) ** 2));
function render() {
const b0 = +$('#ols-b0').value, b1 = +$('#ols-b1').value, [o0, o1] = fit(), my = K.mean(P.map(p => p[1]));
const s = sse(b0, b1), smin = sse(o0, o1), sst = K.sum(P.map(p => (p[1] - my) ** 2));
$('#ols-b0-v').textContent = K.fmt(b0, 2); $('#ols-b1-v').textContent = K.fmt(b1, 2);
$('#ols-sse').textContent = K.fmt(s, 3); $('#ols-min').textContent = K.fmt(smin, 3);
$('#ols-r2').textContent = K.fmt(1 - s / sst, 3); $('#ols-opt').textContent = `ŷ = ${K.fmt(o0, 2)} + ${K.fmt(o1, 2)}x`;
const c = K.chart($('#ols-svg'), { width: 380, height: 380, x: [0, 7], y: [0, 9], margin: { left: 40, bottom: 40, right: 10, top: 10 } });
c.axes({ xTicks: 7, yTicks: 9, xLabel: 'horas x', yLabel: 'calificación y', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 0) });
P.forEach(p => { const yh = b0 + b1 * p[0], side = Math.abs(c.Y(p[1]) - c.Y(yh));
const r = K.el('rect', { x: c.X(p[0]), y: Math.min(c.Y(p[1]), c.Y(yh)), width: side, height: side, class: 's4' }, c.g); r.style.fillOpacity = 0.18; r.style.strokeOpacity = 0.5;
c.line(p[0], p[1], p[0], yh, 's4 line thin'); });
c.line(0, o0, 7, o0 + 7 * o1, 's3 line dash');
c.line(0, b0, 7, b0 + 7 * b1, 's2 line');
P.forEach((p, i) => { const d = c.dot(p[0], p[1], 7, 's1 dot drag'); d.dataset.i = i; });
// Mapa de la SSE en el plano (β₀, β₁).
const m = K.chart($('#ols-map'), { width: 380, height: 380, x: [-2, 6], y: [-1, 2.5], margin: { left: 44, bottom: 40, right: 10, top: 10 } });
const G = 40, dx = 8 / G, dy = 3.5 / G, vals = [];
for (let i = 0; i < G; i++) for (let j = 0; j < G; j++) vals.push([-2 + (i + 0.5) * dx, -1 + (j + 0.5) * dy]);
const ls = vals.map(v => Math.log(sse(v[0], v[1]) + 1e-9)), lo = Math.min(...ls), hi = Math.max(...ls);
vals.forEach((v, k) => { const r = m.rect(v[0] - dx / 2, v[1] - dy / 2, v[0] + dx / 2, v[1] + dy / 2, 's1'); r.style.stroke = 'none'; r.style.fillOpacity = 0.85 * (1 - (ls[k] - lo) / (hi - lo)) + 0.03; });
m.axes({ xTicks: 4, yTicks: 4, xLabel: 'β₀ (intercepto)', yLabel: 'β₁ (pendiente)', grid: false, xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 1) });
m.dot(K.clamp(o0, -2, 6), K.clamp(o1, -1, 2.5), 7, 's3 dot'); m.dot(b0, b1, 6, 's2 dot');
const note = $('#ols-note'), gap = s - smin;
note.className = gap < 0.02 ? 'plab-note good' : 'plab-note';
note.textContent = gap < 0.02 ? `¡Encontraste la recta de mínimos cuadrados! SSE = ${K.fmt(s, 3)} y R² = ${K.fmt(1 - s / sst, 3)}. Ninguna otra recta tiene menor suma de cuadrados.`
: `Tu recta tiene SSE ${K.fmt(s, 3)}, ${K.fmt(gap, 3)} por encima del mínimo. El valle del mapa es alargado: la pendiente y el intercepto se compensan entre sí (si subes uno, conviene bajar el otro).`;
}
const svg = $('#ols-svg'); let drag = -1;
svg.addEventListener('pointerdown', e => { const t = e.target.closest('[data-i]'); if (t) { drag = +t.dataset.i; svg.setPointerCapture(e.pointerId); } });
svg.addEventListener('pointermove', e => { if (drag < 0) return; const [x, y] = svg.__chart.pointer(e); P[drag] = [K.clamp(Math.round(x * 10) / 10, 0.2, 6.8), K.clamp(Math.round(y * 10) / 10, 0.2, 8.8)]; render(); });
svg.addEventListener('pointerup', () => { drag = -1; });
$('#ols-fit').addEventListener('click', () => { const [o0, o1] = fit(); $('#ols-b0').value = K.clamp(o0, -2, 6); $('#ols-b1').value = K.clamp(o1, -1, 2.5); render(); });
$('#ols-reset').addEventListener('click', () => { P = ORIG.map(p => p.slice()); render(); });
root.querySelectorAll('input[type=range]').forEach(el => el.addEventListener('input', render));
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio permite mover la recta y los puntos. Valores de referencia con los cinco estudiantes: la recta de mínimos cuadrados es $\hat{y} = 1.3 + 0.9x$, con SSE = 1.90 y $R^2 = 0.81$. Si el quinto estudiante tuviera calificación 9 en lugar de 6, la recta pasaría a $\hat{y} = 0.1 + 1.5x$: un solo punto extremo cambia la pendiente en 67 %.
:::
**Guía del laboratorio.**
1. Sin pulsar el botón, intenta llegar a una SSE menor que 2. Después compara con la solución y con la @tbl-ols-residuos.
2. Observa el mapa: ¿por qué el valle es una franja diagonal y no un círculo?
3. Predice antes de mover: si arrastras al quinto estudiante hasta una calificación de 9, ¿la pendiente subirá mucho o poco? Pulsa *Mínimos cuadrados* y compruébalo.
4. ¿Qué residuo aporta más a la SSE en la solución original? ¿Por qué un error de 1 punto pesa 25 veces más que uno de 0.2?
## De la regresión a la clasificación lineal
### La geometría de un clasificador lineal
Con dos clases codificadas como $y \in \{-1, +1\}$, un clasificador lineal decide
$$
\hat{y} = \operatorname{signo}\big(g(\mathbf{x})\big), \qquad g(\mathbf{x}) = \mathbf{w}^\top\mathbf{x} + w_0.
$$ {#eq-clasificador-lineal}
La frontera $g(\mathbf{x}) = 0$ es un hiperplano. Tres hechos geométricos se usarán en este capítulo y en el de máquinas de soporte vectorial:
- $\mathbf{w}$ es **perpendicular** a la frontera: apunta hacia la región positiva.
- $w_0$ **desplaza** la frontera sin girarla.
- La **distancia con signo** de un punto a la frontera es $g(\mathbf{x})/\|\mathbf{w}\|$. El puntaje no solo dice de qué lado está el punto, sino qué tan lejos.
Una primera idea para elegir $\mathbf{w}$ es usar mínimos cuadrados con $y \in \{-1, +1\}$ como respuesta. Funciona sorprendentemente bien cuando las clases son nubes compactas, pero tiene un defecto: castiga también a los puntos **bien clasificados y lejos** de la frontera, porque su puntaje se aleja de $\pm 1$. Un grupo de puntos correctos pero extremos puede arrastrar la frontera y provocar errores. Las dos secciones siguientes, y la regresión logística, son maneras de evitar ese problema.
## El discriminante lineal de Fisher
### Intuición: la mejor sombra
Proyectemos todos los puntos sobre una recta que pasa por el origen con dirección $\mathbf{w}$: cada punto se convierte en un número, su «sombra» $z = \mathbf{w}^\top\mathbf{x}$. Una buena dirección es aquella en que las sombras de las dos clases quedan **lejos entre sí** y **apretadas dentro de cada clase**. @fisher1936use propuso medirlo con el cociente
$$
J(\mathbf{w}) = \frac{(\bar{z}_A - \bar{z}_B)^2}{s_A^2 + s_B^2},
$$ {#eq-fisher-J}
donde $\bar{z}$ y $s^2 = \sum (z - \bar{z})^2$ son la media y la dispersión de las sombras de cada clase. La dirección que une las medias parece la elección obvia, pero no considera la forma de las nubes: si una clase está estirada en diagonal, sus sombras sobre esa dirección se dispersan.
### Construcción paso a paso
Dos clases de tres puntos: $A = \{(1, 2), (2, 3), (3, 3)\}$ y $B = \{(4, 5), (5, 6), (6, 5)\}$.
**Paso 1. Medias.** $\mathbf{m}_A = (2, 2.67)$ y $\mathbf{m}_B = (5, 5.33)$.
**Paso 2. Dispersión dentro de cada clase.** Con las desviaciones respecto a la media de cada clase, $\mathbf{S}_A = \sum (\mathbf{x} - \mathbf{m}_A)(\mathbf{x} - \mathbf{m}_A)^\top$:
$$
\mathbf{S}_A = \begin{pmatrix} 2 & 1 \\ 1 & 0.67 \end{pmatrix}, \qquad
\mathbf{S}_B = \begin{pmatrix} 2 & 0 \\ 0 & 0.67 \end{pmatrix}, \qquad
\mathbf{S}_W = \mathbf{S}_A + \mathbf{S}_B = \begin{pmatrix} 4 & 1 \\ 1 & 1.33 \end{pmatrix}.
$$
La clase $A$ tiene covarianza positiva (sube en diagonal); la $B$, no.
**Paso 3. Dirección de Fisher.** La dirección que maximiza $J$ es
$$
\mathbf{w}^{*} \propto \mathbf{S}_W^{-1}(\mathbf{m}_A - \mathbf{m}_B) = \frac{1}{4.33}\begin{pmatrix} 1.33 & -1 \\ -1 & 4 \end{pmatrix}\begin{pmatrix} -3 \\ -2.67 \end{pmatrix} = \begin{pmatrix} -0.31 \\ -1.77 \end{pmatrix}.
$$
Normalizada, $\mathbf{w}^{*} \approx (-0.17, -0.99)$: casi vertical, muy distinta de la diferencia de medias $(-3, -2.67)$, que apunta en diagonal.
**Paso 4. Comparar.** Proyectando sobre cada dirección (normalizada):
| Dirección | Sombras de A | Sombras de B | $J$ |
|---|---|---|---:|
| Diferencia de medias | −2.08, −3.49, −4.24 | −6.31, −7.72, −7.81 | 4.22 |
| Fisher | −2.14, −3.30, −3.47 | −5.61, −6.77, −5.95 | **5.64** |
: Proyecciones sobre la diferencia de medias y sobre la dirección de Fisher. {#tbl-fisher}
Con la dirección de Fisher, las sombras de $A$ quedan más juntas (la diagonal de $A$ casi desaparece al proyectar) y el cociente sube un 34 %. La clasificación se completa con un **umbral** sobre $z$; el más simple es el punto medio de las medias proyectadas, $(-2.97 - 6.11)/2 = -4.54$.
### Formalización
Con las matrices de dispersión **entre clases** $\mathbf{S}_B = (\mathbf{m}_A - \mathbf{m}_B)(\mathbf{m}_A - \mathbf{m}_B)^\top$ y **dentro de las clases** $\mathbf{S}_W$, el criterio es un cociente de Rayleigh generalizado:
$$
J(\mathbf{w}) = \frac{\mathbf{w}^\top\mathbf{S}_B\mathbf{w}}{\mathbf{w}^\top\mathbf{S}_W\mathbf{w}}, \qquad
\mathbf{w}^{*} = \arg\max_{\mathbf{w}} J(\mathbf{w}) \propto \mathbf{S}_W^{-1}(\mathbf{m}_A - \mathbf{m}_B).
$$ {#eq-fisher}
Es la misma estructura que el PCA del capítulo de preprocesamiento, con una diferencia crucial: PCA busca la dirección de **máxima varianza** sin mirar las clases; Fisher, la de **máxima separación** entre clases. Con $K$ clases, Fisher encuentra hasta $K - 1$ direcciones, lo que lo convierte también en una técnica de reducción de dimensión supervisada. Cuando las clases son normales con la misma covarianza, la dirección de Fisher coincide con la del clasificador bayesiano óptimo, el **análisis discriminante lineal** (LDA) que se estudia en el capítulo de clasificadores bayesianos.
### Laboratorio: la dirección de Fisher
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="fis-lab">
<p class="plab-eyebrow">Laboratorio · Discriminante de Fisher</p>
<h3 class="plab-title">¿Desde qué ángulo se separan mejor las clases?</h3>
<p class="plab-sub">Gira la dirección de proyección y observa las «sombras» de cada clase y el cociente J. Los botones llevan a la dirección de Fisher o a la diferencia de medias.</p>
<div class="plab-controls">
<div class="plab-field">Datos
<div class="plab-seg" id="fis-data"><button data-value="seis" aria-pressed="true">Seis puntos</button><button data-value="nubes">Dos nubes alargadas</button></div>
</div>
<label>Ángulo de la dirección: <b id="fis-t-v"></b><input type="range" id="fis-t" min="0" max="179" step="1" value="0"></label>
<div class="plab-field">Ir a
<div style="display:flex;gap:.4rem;flex-wrap:wrap"><button class="plab-btn primary" id="fis-go-f">Dirección de Fisher</button><button class="plab-btn" id="fis-go-m">Diferencia de medias</button></div>
</div>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>J de esta dirección</span><strong id="fis-j"></strong></div>
<div class="plab-stat"><span>J de Fisher (máximo)</span><strong id="fis-jf"></strong></div>
<div class="plab-stat"><span>J de la diferencia de medias</span><strong id="fis-jm"></strong></div>
<div class="plab-stat"><span>Errores con umbral en el punto medio</span><strong id="fis-err"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4>Datos y dirección de proyección</h4>
<svg id="fis-svg" style="overflow:hidden" role="img" aria-label="Puntos de dos clases, recta de proyección y sus proyecciones"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>clase A</span><span><i class="dot" style="background:var(--lab-s2)"></i>clase B</span><span><i style="background:var(--lab-axis)"></i>dirección w</span></div>
</div>
<div class="plab-panel">
<h4>Sombras sobre la dirección</h4>
<svg id="fis-proj" role="img" aria-label="Valores proyectados de cada clase con sus medias y el umbral"></svg>
<h4 style="margin-top:.6rem">J según el ángulo</h4>
<svg id="fis-curve" role="img" aria-label="Cociente de Fisher en función del ángulo"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s4)"></i>J(ángulo)</span><span><i class="dot" style="background:var(--lab-s2)"></i>actual</span><span><i class="dot" style="background:var(--lab-s3)"></i>Fisher</span><span><i class="dot" style="background:var(--lab-axis)"></i>diferencia de medias</span></div>
</div>
</div>
<p class="plab-note" id="fis-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('fis-lab');
const $ = s => root.querySelector(s);
const r = K.rng(7), nube = (mx, my, a, b, ang, n) => Array.from({ length: n }, () => { const u = r.normal() * a, v = r.normal() * b, c = Math.cos(ang), s = Math.sin(ang); return [mx + u * c - v * s, my + u * s + v * c]; });
const SETS = {
seis: { A: [[1, 2], [2, 3], [3, 3]], B: [[4, 5], [5, 6], [6, 5]], lim: [-1, 8] },
nubes: { A: nube(2.9, 3.4, 1.6, 0.35, 0.7, 25), B: nube(4.4, 3.4, 1.6, 0.35, 0.7, 25), lim: [-1, 8] }
};
const mean = P => [K.mean(P.map(p => p[0])), K.mean(P.map(p => p[1]))];
function stats(S) {
const mA = mean(S.A), mB = mean(S.B), sw = [[0, 0], [0, 0]];
[[S.A, mA], [S.B, mB]].forEach(([P, m]) => P.forEach(p => { const d = [p[0] - m[0], p[1] - m[1]]; sw[0][0] += d[0] * d[0]; sw[0][1] += d[0] * d[1]; sw[1][1] += d[1] * d[1]; }));
sw[1][0] = sw[0][1];
const det = sw[0][0] * sw[1][1] - sw[0][1] ** 2, dm = [mA[0] - mB[0], mA[1] - mB[1]];
const wf = [(sw[1][1] * dm[0] - sw[0][1] * dm[1]) / det, (-sw[1][0] * dm[0] + sw[0][0] * dm[1]) / det];
return { mA, mB, wf, dm };
}
const angOf = w => ((Math.atan2(w[1], w[0]) * 180 / Math.PI) % 180 + 180) % 180;
function J(S, t) { const w = [Math.cos(t * Math.PI / 180), Math.sin(t * Math.PI / 180)], zA = S.A.map(p => w[0] * p[0] + w[1] * p[1]), zB = S.B.map(p => w[0] * p[0] + w[1] * p[1]);
const a = K.mean(zA), b = K.mean(zB); return { J: (a - b) ** 2 / (K.sum(zA.map(z => (z - a) ** 2)) + K.sum(zB.map(z => (z - b) ** 2))), zA, zB, a, b, w }; }
const dataSeg = K.segmented($('#fis-data'), render);
$('#fis-go-f').addEventListener('click', () => { $('#fis-t').value = Math.round(angOf(stats(SETS[dataSeg.get()]).wf)); render(); });
$('#fis-go-m').addEventListener('click', () => { $('#fis-t').value = Math.round(angOf(stats(SETS[dataSeg.get()]).dm)); render(); });
function render() {
const S = SETS[dataSeg.get()], t = +$('#fis-t').value, st = stats(S), R = J(S, t);
const tf = angOf(st.wf), tm = angOf(st.dm), jf = J(S, tf).J, jm = J(S, tm).J;
$('#fis-t-v').textContent = `${t}°`; $('#fis-j').textContent = K.fmt(R.J, 3); $('#fis-jf').textContent = `${K.fmt(jf, 3)} (${K.fmt(tf, 0)}°)`; $('#fis-jm').textContent = `${K.fmt(jm, 3)} (${K.fmt(tm, 0)}°)`;
const thr = (R.a + R.b) / 2, sideA = R.a > thr ? 1 : -1;
const errs = R.zA.filter(z => Math.sign(z - thr) !== sideA).length + R.zB.filter(z => Math.sign(z - thr) === sideA).length;
$('#fis-err').textContent = `${errs} de ${S.A.length + S.B.length}`;
const c = K.chart($('#fis-svg'), { width: 380, height: 380, x: S.lim, y: S.lim, margin: { left: 36, bottom: 36, right: 10, top: 10 } });
c.axes({ xTicks: 5, yTicks: 5, xLabel: 'x₁', yLabel: 'x₂', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 0) });
const L = 12; c.line(-L * R.w[0], -L * R.w[1], L * R.w[0], L * R.w[1], 'muted line');
c.arrow(0, 0, 1.5 * R.w[0], 1.5 * R.w[1], 's2');
[[S.A, 's1'], [S.B, 's2']].forEach(([P, cl]) => P.forEach(p => { const z = R.w[0] * p[0] + R.w[1] * p[1];
c.line(p[0], p[1], z * R.w[0], z * R.w[1], cl + ' line thin dash').style.strokeOpacity = 0.45; c.dot(z * R.w[0], z * R.w[1], 2.5, cl + ' dot'); c.dot(p[0], p[1], 5, cl + ' dot'); }));
const all = [...R.zA, ...R.zB], lo = Math.min(...all) - 0.5, hi = Math.max(...all) + 0.5;
const cp = K.chart($('#fis-proj'), { width: 380, height: 120, x: [lo, hi], y: [0, 3], margin: { left: 36, bottom: 30, right: 10, top: 6 } });
cp.axes({ xTicks: 5, yTicks: 0, xLabel: 'z = wᵀx', grid: false, xFmt: v => K.fmt(v, 1) });
R.zA.forEach(z => cp.dot(z, 2, 5, 's1 dot')); R.zB.forEach(z => cp.dot(z, 1, 5, 's2 dot'));
cp.line(R.a, 1.6, R.a, 2.4, 's1 line'); cp.line(R.b, 0.6, R.b, 1.4, 's2 line'); cp.line(thr, 0.2, thr, 2.8, 'muted line dash');
const pts = []; for (let a = 0; a < 180; a++) pts.push([a, J(S, a).J]);
const jmax = Math.max(...pts.map(p => p[1])) * 1.1;
const cc = K.chart($('#fis-curve'), { width: 380, height: 150, x: [0, 180], y: [0, jmax], margin: { left: 36, bottom: 30, right: 10, top: 6 } });
cc.axes({ xTicks: 6, yTicks: 3, xLabel: 'ángulo (grados)', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 1) });
cc.path(pts, 's4 line'); cc.dot(tm, jm, 5, 'muted dot'); cc.dot(tf, jf, 6, 's3 dot'); cc.dot(t, R.J, 6, 's2 dot');
const note = $('#fis-note');
note.className = R.J >= jf * 0.98 ? 'plab-note good' : 'plab-note';
note.textContent = R.J >= jf * 0.98 ? `Esta es (casi) la dirección de Fisher: J = ${K.fmt(R.J, 3)}. Las sombras de cada clase quedan lo más apretadas posible en relación con la distancia entre sus medias.`
: `J = ${K.fmt(R.J, 3)}, frente al máximo ${K.fmt(jf, 3)} en ${K.fmt(tf, 0)}°. La diferencia de medias (${K.fmt(tm, 0)}°) alcanza ${K.fmt(jm, 3)}: ignora la forma alargada de las nubes.`;
}
$('#fis-t').addEventListener('input', render);
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio gira la dirección de proyección. Con los seis puntos, la diferencia de medias (42°) da $J = 4.22$ y la dirección de Fisher (80°) da el máximo $J = 5.64$, como en la @tbl-fisher. Con dos nubes alargadas en diagonal la ventaja de Fisher es mucho mayor: la diferencia de medias deja ambas clases traslapadas.
:::
**Guía del laboratorio.**
1. Con *Seis puntos*, pulsa *Diferencia de medias* y luego *Dirección de Fisher*. Comprueba los valores de $J$ de la @tbl-fisher.
2. ¿Por qué con la dirección de Fisher las sombras de la clase A quedan más juntas?
3. Cambia a *Dos nubes alargadas*. Predice antes de pulsar: ¿la diferencia de medias separará bien las clases? Compara los errores con el umbral en el punto medio.
4. Busca un ángulo con $J$ casi cero. ¿Qué tienen de especial las sombras en esa dirección?
## El perceptrón
### Intuición: aprender de los errores, uno a la vez
El perceptrón de @rosenblatt1958perceptron, inspirado en una neurona, no resuelve ninguna ecuación: recorre los ejemplos y, cada vez que clasifica uno mal, **gira la frontera hacia él**. Si un punto positivo cae del lado negativo, se suma su vector a $\mathbf{w}$; si un negativo cae del lado positivo, se resta. Es el antecedente directo de las redes neuronales y del descenso de gradiente estocástico.
### Construcción paso a paso
Usamos el **vector aumentado** $\tilde{\mathbf{x}} = (1, x_1, x_2)$ y $\tilde{\mathbf{w}} = (w_0, w_1, w_2)$, de modo que $g = \tilde{\mathbf{w}}^\top\tilde{\mathbf{x}}$. Cuatro puntos, recorridos en orden:
| Punto | $\mathbf{x}$ | $y$ |
|---|---|---:|
| P1 | (2, 1) | +1 |
| P2 | (3, 4) | −1 |
| P3 | (1, 3) | −1 |
| P4 | (4, 2) | +1 |
: Cuatro puntos linealmente separables. {#tbl-perceptron-datos}
Empezamos con $\tilde{\mathbf{w}} = (0, 0, 0)$. Un punto está **mal clasificado** si $y\,g \le 0$ (con $\mathbf{w} = 0$ todos lo están). La regla, con tasa de aprendizaje 1, es $\tilde{\mathbf{w}} \leftarrow \tilde{\mathbf{w}} + y\,\tilde{\mathbf{x}}$:
| Época | Punto | $g$ antes | ¿Error? | $\tilde{\mathbf{w}}$ después |
|---:|---|---:|---|---|
| 1 | P1 | 0 | sí | (0,0,0) + (1,2,1) = **(1, 2, 1)** |
| 1 | P2 | 1 + 6 + 4 = 11 | sí ($y = -1$) | (1,2,1) − (1,3,4) = **(0, −1, −3)** |
| 1 | P3 | 0 − 1 − 9 = −10 | no | (0, −1, −3) |
| 1 | P4 | 0 − 4 − 6 = −10 | sí | (0,−1,−3) + (1,4,2) = **(1, 3, −1)** |
| 2 | P1 | 1 + 6 − 1 = 6 | no | (1, 3, −1) |
| 2 | P2 | 1 + 9 − 4 = 6 | sí | **(0, 0, −5)** |
| 2 | P4 | 0 + 0 − 10 = −10 | sí | **(1, 4, −3)** |
| 3 | P2 | 1 + 12 − 12 = 1 | sí | **(0, 1, −7)** |
| 3 | P4 | 0 + 4 − 14 = −10 | sí | **(1, 5, −5)** |
| 4 | — | | ninguno | (1, 5, −5) |
: Traza del perceptrón (en las épocas 2 y 3 se omiten los puntos sin error). {#tbl-perceptron-traza}
Tras siete actualizaciones, la época 4 no comete errores: la frontera es $1 + 5x_1 - 5x_2 = 0$, es decir, $x_2 = x_1 + 0.2$, y los positivos quedan debajo. Observa que el algoritmo oscila: P2 y P4 se «pelean» la frontera durante tres épocas porque cada corrección para uno estropea al otro.
### Formalización
La regla de actualización ante un error es
$$
\tilde{\mathbf{w}} \leftarrow \tilde{\mathbf{w}} + \eta\, y_i\, \tilde{\mathbf{x}}_i \quad \text{si } y_i\, \tilde{\mathbf{w}}^\top\tilde{\mathbf{x}}_i \le 0.
$$ {#eq-perceptron}
Tras la actualización, $y_i\,\tilde{\mathbf{w}}^\top\tilde{\mathbf{x}}_i$ aumenta en $\eta\|\tilde{\mathbf{x}}_i\|^2 > 0$: el punto queda «menos mal». Es un descenso de gradiente estocástico sobre la **pérdida del perceptrón** $\max(0, -y\,g(\mathbf{x}))$, que vale cero para los puntos bien clasificados y crece linealmente con lo mal clasificados que están.
**Teorema de convergencia** [@novikoff1962convergence]. Si los datos son **linealmente separables** con margen $\gamma$ (existe un $\mathbf{w}^{*}$ unitario con $y_i\,\mathbf{w}^{*\top}\tilde{\mathbf{x}}_i \ge \gamma$ para todo $i$) y $\|\tilde{\mathbf{x}}_i\| \le R$, el perceptrón comete a lo más $(R/\gamma)^2$ errores antes de encontrar un separador. Cuanto más «holgada» es la separación, más rápido converge.
Tres limitaciones motivan los modelos de los capítulos siguientes:
- Si los datos **no son separables**, el perceptrón nunca se detiene: la frontera cicla indefinidamente. El caso clásico es el **XOR** [@minsky1969perceptrons], que ninguna recta separa y que llevó a las redes multicapa.
- Si son separables, encuentra **una** frontera cualquiera, dependiente del orden de los datos; las máquinas de soporte vectorial buscan la de **margen máximo**.
- No produce probabilidades; la regresión logística sí.
### Laboratorio: el perceptrón paso a paso
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="per-lab">
<p class="plab-eyebrow">Laboratorio · Perceptrón</p>
<h3 class="plab-title">¿Cuántas correcciones necesita la frontera?</h3>
<p class="plab-sub">Avanza punto por punto o época por época. El punto con anillo es el que se evalúa; si está mal clasificado, la frontera gira hacia él.</p>
<div class="plab-controls">
<div class="plab-field">Datos
<div class="plab-seg" id="per-data"><button data-value="cuatro" aria-pressed="true">Cuatro puntos</button><button data-value="sep">Separable (20)</button><button data-value="xor">XOR</button></div>
</div>
<div class="plab-field">Avanzar
<div style="display:flex;gap:.4rem;flex-wrap:wrap"><button class="plab-btn primary" id="per-step">Siguiente punto</button><button class="plab-btn" id="per-epoch">Época completa</button><button class="plab-btn" id="per-run">Hasta converger</button><button class="plab-btn" id="per-reset">Reiniciar</button></div>
</div>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>w̃ = (w₀, w₁, w₂)</span><strong id="per-w"></strong></div>
<div class="plab-stat"><span>Época</span><strong id="per-ep"></strong></div>
<div class="plab-stat"><span>Actualizaciones</span><strong id="per-up"></strong></div>
<div class="plab-stat"><span>Errores con w̃ actual</span><strong id="per-err"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4>Frontera actual</h4>
<svg id="per-svg" style="overflow:hidden" role="img" aria-label="Puntos de dos clases, frontera del perceptrón y vector w"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>y = +1</span><span><i class="dot" style="background:var(--lab-s2)"></i>y = −1</span><span><i style="background:var(--lab-s4)"></i>frontera g = 0</span><span><i style="background:var(--lab-s5)"></i>w (hacia +1)</span></div>
</div>
<div class="plab-panel">
<h4>Últimas actualizaciones</h4>
<div id="per-log" class="plab-scroll"></div>
<h4 style="margin-top:.6rem">Errores por época</h4>
<svg id="per-curve" role="img" aria-label="Número de errores en cada época"></svg>
</div>
</div>
<p class="plab-note" id="per-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('per-lab');
const $ = s => root.querySelector(s);
const r = K.rng(4), sep = []; while (sep.length < 20) { const a = r() * 6, b = r() * 6, g = a - b + 0.4 * Math.sin(a); if (Math.abs(g) > 0.5) sep.push([+a.toFixed(1), +b.toFixed(1), g > 0 ? 1 : -1]); }
const SETS = { cuatro: [[2, 1, 1], [3, 4, -1], [1, 3, -1], [4, 2, 1]], sep, xor: [[1, 1, -1], [4, 4, -1], [1, 4, 1], [4, 1, 1], [1.5, 1.6, -1], [3.6, 3.4, -1], [1.4, 3.6, 1], [3.5, 1.5, 1]] };
let S, w, i, ep, ups, epErr, hist, log;
const g = (w, p) => w[0] + w[1] * p[0] + w[2] * p[1];
const dataSeg = K.segmented($('#per-data'), () => { reset(); render(); });
function reset() { S = SETS[dataSeg.get()]; w = [0, 0, 0]; i = 0; ep = 1; ups = 0; epErr = 0; hist = []; log = []; }
function step() {
if (hist.length && hist[hist.length - 1] === 0) return false;
const p = S[i], gv = g(w, p), err = p[2] * gv <= 0;
if (err) { const before = w.slice(); w = [w[0] + p[2], w[1] + p[2] * p[0], w[2] + p[2] * p[1]]; ups++; epErr++; log.push({ ep, i, gv, before, after: w.slice() }); }
i++;
if (i === S.length) { hist.push(epErr); epErr = 0; i = 0; if (hist[hist.length - 1] > 0) ep++; }
return true;
}
$('#per-step').addEventListener('click', () => { step(); render(); });
$('#per-epoch').addEventListener('click', () => { const e0 = hist.length; let k = 0; while (hist.length === e0 && k++ < 1000 && step()); render(); });
$('#per-run').addEventListener('click', () => { let k = 0; while (k++ < 100 * S.length && step()); render(); });
$('#per-reset').addEventListener('click', () => { reset(); render(); });
const fmtW = v => `(${v.map(x => K.fmt(x, 1)).join(', ')})`;
function render() {
const conv = hist.length && hist[hist.length - 1] === 0;
$('#per-w').textContent = fmtW(w); $('#per-ep').textContent = conv ? `${ep} (convergió)` : ep; $('#per-up').textContent = ups;
const nErr = S.filter(p => p[2] * g(w, p) <= 0).length; $('#per-err').textContent = `${nErr} de ${S.length}`;
const c = K.chart($('#per-svg'), { width: 380, height: 380, x: [-0.5, 6.5], y: [-0.5, 6.5], margin: { left: 36, bottom: 36, right: 10, top: 10 } });
c.axes({ xTicks: 6, yTicks: 6, xLabel: 'x₁', yLabel: 'x₂', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 0) });
if (Math.abs(w[1]) + Math.abs(w[2]) > 1e-9) {
const pts = []; if (Math.abs(w[2]) > 1e-9) { [-0.5, 6.5].forEach(x => pts.push([x, -(w[0] + w[1] * x) / w[2]])); } else { [-0.5, 6.5].forEach(y => pts.push([-w[0] / w[1], y])); }
c.line(pts[0][0], pts[0][1], pts[1][0], pts[1][1], 's4 line');
const nrm = Math.hypot(w[1], w[2]), t = -w[0] / (nrm * nrm), cx = K.clamp(t * w[1], 0, 6), cy = K.clamp(t * w[2], 0, 6);
const fx = Math.abs(w[2]) > 1e-9 ? 3 : -w[0] / w[1], fy = Math.abs(w[2]) > 1e-9 ? -(w[0] + w[1] * 3) / w[2] : 3;
c.arrow(fx, fy, fx + w[1] / nrm, fy + w[2] / nrm, 's5');
}
S.forEach((p, k) => { const d = c.dot(p[0], p[1], 6, (p[2] > 0 ? 's1' : 's2') + ' dot'); if (p[2] * g(w, p) <= 0) { d.style.stroke = 'var(--lab-text)'; d.style.strokeWidth = 2; } });
if (!conv && dataSeg.get() !== 'sep') { const p = S[i]; const ring = c.dot(p[0], p[1], 11, 'muted'); ring.style.fill = 'none'; ring.style.strokeWidth = 2; }
if (dataSeg.get() === 'cuatro') S.forEach((p, k) => c.text(p[0], p[1], 'P' + (k + 1), { dx: 9, dy: -7, class: 'strong' }));
$('#per-log').innerHTML = log.length ? '<table class="plab-table"><thead><tr><th>Época</th><th>Punto</th><th>g antes</th><th>w̃ después</th></tr></thead><tbody>' +
log.slice(-7).map(l => `<tr><td>${l.ep}</td><td>${dataSeg.get() === 'cuatro' ? 'P' + (l.i + 1) : '#' + (l.i + 1)}</td><td>${K.fmt(l.gv, 1)}</td><td>${fmtW(l.after)}</td></tr>`).join('') + '</tbody></table>' : '<p class="plab-sub">Aún no hay actualizaciones.</p>';
const H = hist.length ? hist : [0], hm = Math.max(1, ...H);
const cc = K.chart($('#per-curve'), { width: 380, height: 150, x: [0.5, Math.max(5, hist.length) + 0.5], y: [0, hm * 1.15], margin: { left: 36, bottom: 30, right: 10, top: 6 } });
cc.axes({ xTicks: 5, yTicks: 3, xLabel: 'época', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 0) });
hist.forEach((e, k) => { cc.rect(k + 0.65, 0, k + 1.35, e, 's4').style.fillOpacity = 0.6; });
const note = $('#per-note');
if (conv) { note.className = 'plab-note good'; note.textContent = `Convergió: una época completa sin errores, tras ${ups} actualizaciones. La frontera es ${K.fmt(w[0], 1)} + ${K.fmt(w[1], 1)}x₁ + ${K.fmt(w[2], 1)}x₂ = 0.`; }
else if (dataSeg.get() === 'xor' && hist.length >= 20) { note.className = 'plab-note bad'; note.textContent = `Tras ${hist.length} épocas el perceptrón sigue cometiendo errores: los datos XOR no son linealmente separables y la frontera cicla sin fin.`; }
else { note.className = 'plab-note'; note.textContent = `Época ${ep}, punto ${i + 1} de ${S.length}. Con la frontera actual hay ${nErr} punto(s) mal clasificado(s) (con borde grueso).`; }
}
reset(); render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio ejecuta el perceptrón paso a paso. Con los cuatro puntos reproduce la @tbl-perceptron-traza: siete actualizaciones, convergencia en la época 4 con $\tilde{\mathbf{w}} = (1, 5, -5)$. Con datos XOR nunca converge.
:::
**Guía del laboratorio.**
1. Con *Cuatro puntos*, avanza punto por punto y comprueba cada fila de la @tbl-perceptron-traza.
2. ¿Por qué después de corregir P2 en la época 1, el punto P4 queda mal clasificado?
3. Predice antes de pulsar: con *Separable (20)*, ¿cuántas épocas crees que necesitará? Usa *Hasta converger*.
4. Con *XOR*, pulsa *Hasta converger*. Observa la gráfica de errores por época: ¿se estabiliza en cero?
## Comparación
| | Mínimos cuadrados | Fisher | Perceptrón |
|---|---|---|---|
| Qué optimiza | Suma de cuadrados del puntaje | Separación de las proyecciones | Pérdida del perceptrón |
| Solución | Cerrada (ecuaciones normales) | Cerrada ($\mathbf{S}_W^{-1}\Delta\mathbf{m}$) | Iterativa |
| Datos no separables | Sí | Sí | No converge |
| Sensible a puntos lejanos bien clasificados | Sí | Poco | No |
| Probabilidades | No | Con supuestos gaussianos (LDA) | No |
: Tres maneras de elegir un clasificador lineal. {#tbl-comparacion-lineales}
## Implementación en Python
### Regresión lineal
```{python}
#| label: lin-ols
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
x = np.array([1, 2, 3, 4, 5], dtype=float)
y = np.array([2, 3, 5, 4, 6], dtype=float)
# Ecuaciones normales con la matriz de diseño
X = np.column_stack([np.ones_like(x), x])
beta = np.linalg.solve(X.T @ X, X.T @ y)
print("β (ecuaciones normales):", beta.round(3))
modelo = LinearRegression().fit(x.reshape(-1, 1), y)
pred = modelo.predict(x.reshape(-1, 1))
print(f"β (scikit-learn): {modelo.intercept_:.3f}, {modelo.coef_[0]:.3f}")
pd.Series({"SSE": ((y - pred) ** 2).sum(), "MSE": mean_squared_error(y, pred),
"RMSE": mean_squared_error(y, pred) ** 0.5, "MAE": mean_absolute_error(y, pred),
"R²": r2_score(y, pred)}).round(3)
```
Con varias variables, la interpretación de cada coeficiente es «manteniendo las demás constantes». Con el conjunto *diabetes* de `scikit-learn` (10 variables clínicas estandarizadas):
```{python}
#| label: fig-lin-diabetes
#| fig-cap: "Residuos contra valores ajustados de la regresión lineal en el conjunto de prueba de diabetes. Una nube sin forma alrededor de cero es lo esperable; un embudo indicaría varianza no constante y una curva, una relación no lineal."
import plotly.graph_objects as go
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
Xd, yd = load_diabetes(return_X_y=True, as_frame=True)
X_tr, X_te, y_tr, y_te = train_test_split(Xd, yd, test_size=0.3, random_state=0)
lr = LinearRegression().fit(X_tr, y_tr)
print(f"R² prueba: {r2_score(y_te, lr.predict(X_te)):.3f} · RMSE prueba: {mean_squared_error(y_te, lr.predict(X_te)) ** 0.5:.1f}")
print(pd.Series(lr.coef_, index=Xd.columns).round(1).sort_values().to_string())
fig = go.Figure(go.Scatter(x=lr.predict(X_te), y=y_te - lr.predict(X_te), mode="markers", marker=dict(color="#2563eb", size=6, opacity=0.7)))
fig.add_hline(y=0, line=dict(color="gray", dash="dash"))
fig.update_layout(xaxis_title="valor ajustado", yaxis_title="residuo", height=340, margin=dict(l=10, r=10, t=20, b=40))
fig.show()
```
### Discriminante de Fisher
```{python}
#| label: lin-fisher
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
A = np.array([[1, 2], [2, 3], [3, 3]], dtype=float)
B = np.array([[4, 5], [5, 6], [6, 5]], dtype=float)
mA, mB = A.mean(axis=0), B.mean(axis=0)
SW = (A - mA).T @ (A - mA) + (B - mB).T @ (B - mB)
w = np.linalg.solve(SW, mA - mB)
def J(direccion):
d = direccion / np.linalg.norm(direccion)
zA, zB = A @ d, B @ d
return (zA.mean() - zB.mean()) ** 2 / (((zA - zA.mean()) ** 2).sum() + ((zB - zB.mean()) ** 2).sum())
print("S_W =\n", SW.round(3))
print("w de Fisher (sin normalizar):", w.round(3), "· normalizada:", (w / np.linalg.norm(w)).round(3))
print(f"J(Fisher) = {J(w):.3f} J(diferencia de medias) = {J(mA - mB):.3f}")
lda = LinearDiscriminantAnalysis().fit(np.vstack([A, B]), [0, 0, 0, 1, 1, 1])
coef = lda.coef_[0]
print("Dirección de LDA en scikit-learn (normalizada):", (coef / np.linalg.norm(coef)).round(3))
```
La dirección de `LinearDiscriminantAnalysis` es la misma (con el signo opuesto, porque apunta hacia la clase 1).
### El perceptrón
```{python}
#| label: lin-perceptron
def perceptron(X, y, eta=1.0, max_epocas=100):
Xa = np.column_stack([np.ones(len(X)), X])
w, traza = np.zeros(Xa.shape[1]), []
for epoca in range(1, max_epocas + 1):
errores = 0
for i, (xi, yi) in enumerate(zip(Xa, y)):
if yi * (w @ xi) <= 0:
w = w + eta * yi * xi
errores += 1
traza.append({"época": epoca, "punto": f"P{i + 1}", "w después": tuple(w)})
if errores == 0:
return w, epoca, pd.DataFrame(traza)
return w, None, pd.DataFrame(traza)
Xp = np.array([[2, 1], [3, 4], [1, 3], [4, 2]], dtype=float)
yp = np.array([1, -1, -1, 1])
w, epocas, traza = perceptron(Xp, yp)
print(f"Convergió en la época {epocas} con w̃ = {w}")
traza
```
`scikit-learn` incluye `Perceptron`. Con datos XOR no converge, como ningún clasificador lineal:
```{python}
#| label: lin-xor
from sklearn.linear_model import Perceptron
X_xor = np.array([[0, 0], [1, 1], [0, 1], [1, 0]])
y_xor = np.array([-1, -1, 1, 1])
for nombre, (Xs, ys) in {"cuatro puntos separables": (Xp, yp), "XOR": (X_xor, y_xor)}.items():
p = Perceptron(max_iter=1000, tol=None, random_state=0).fit(Xs, ys)
print(f"{nombre:>26}: exactitud en entrenamiento = {p.score(Xs, ys):.2f}")
```
## Resumen
- Un modelo lineal resume cada caso en un puntaje $\mathbf{w}^\top\mathbf{x} + w_0$; para clasificar se mira su signo y la frontera es un hiperplano perpendicular a $\mathbf{w}$.
- **Mínimos cuadrados** minimiza la suma de residuos al cuadrado; tiene solución cerrada $(\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top\mathbf{y}$ y se evalúa con MSE, RMSE, MAE y $R^2$. Es sensible a puntos extremos.
- **Fisher** proyecta sobre la dirección $\mathbf{S}_W^{-1}(\mathbf{m}_A - \mathbf{m}_B)$, que maximiza la separación de las medias en relación con la dispersión dentro de las clases; no es la diferencia de medias cuando las clases están correlacionadas.
- El **perceptrón** corrige la frontera con cada error; converge si los datos son linealmente separables, en a lo más $(R/\gamma)^2$ errores, y cicla si no lo son (XOR).
- La regresión logística (capítulo siguiente) resuelve las limitaciones de los tres: da probabilidades, converge siempre y no castiga a los puntos bien clasificados.
## Referencias {.unnumbered}
::: {#refs}
:::