---
title: "Árboles de regresión y ensambles: bagging, bosques aleatorios y AdaBoost"
pagetitle: "Árboles de regresión, bagging, bosques aleatorios y AdaBoost"
open-graph:
description: "Árboles de regresión y métodos de ensamble: por qué combinar modelos funciona, bootstrap y bagging, error fuera de la bolsa, bosques aleatorios, importancia de variables y AdaBoost, con desarrollo manual, laboratorios interactivos y Python."
twitter-card:
description: "Árboles de regresión y métodos de ensamble: por qué combinar modelos funciona, bootstrap y bagging, error fuera de la bolsa, bosques aleatorios, importancia de variables y AdaBoost, con desarrollo manual, laboratorios interactivos y Python."
description: "Árboles de regresión y métodos de ensamble: por qué combinar modelos funciona, bootstrap y bagging, error fuera de la bolsa, bosques aleatorios, importancia de variables y AdaBoost, con desarrollo manual, 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_fundamentos_clasificacion.bib, referencias_arboles_regresion_lineal.bib, ../../references.bib]
jupyter: python3
---
{{< include ../preprocesamiento/_labkit.qmd >}}
Los dos capítulos anteriores terminaron con la misma advertencia: un árbol de decisión es **inestable**. Basta cambiar unos cuantos ejemplos para que cambie la raíz y, con ella, todo el árbol. En el lenguaje del capítulo introductorio, los árboles profundos tienen poco sesgo y **mucha varianza**.
Este capítulo convierte esa debilidad en una fortaleza. Si un árbol es ruidoso, entrenemos muchos árboles distintos y combinemos sus respuestas: los errores de unos compensan los de otros. Esa es la idea de los **métodos de ensamble** [@dietterich2000ensemble], que con árboles como base están entre los clasificadores más eficaces para datos tabulares. Estudiamos dos familias:
- **Promediar modelos independientes** para reducir la varianza: *bagging* y **bosques aleatorios**.
- **Construir modelos en secuencia**, cada uno concentrado en los errores del anterior, para reducir el sesgo: **boosting**, con AdaBoost en este capítulo y *gradient boosting* y XGBoost en el siguiente.
Como varios de estos métodos se entienden mejor con una respuesta numérica, empezamos con los **árboles de regresión**, que además son la pieza básica del *gradient boosting*.
## Árboles de regresión
Un árbol de regresión parte el espacio de predictoras igual que CART, pero en cada hoja predice un **número**: el promedio de las respuestas de los ejemplos que llegan a ella. El modelo es una función escalonada
$$
\hat{f}(\mathbf{x}) = \sum_{h=1}^{J} c_h\,\mathbb{1}[\mathbf{x} \in R_h], \qquad c_h = \frac{1}{|R_h|}\sum_{\mathbf{x}_i \in R_h} y_i,
$$ {#eq-arbol-regresion}
donde $R_1, \ldots, R_J$ son las regiones (hojas). El promedio es la constante que minimiza el error cuadrático dentro de cada hoja, y la impureza de un nodo es su **suma de cuadrados** $\text{SSE} = \sum_i (y_i - \bar{y})^2$. Cada corte se elige para reducirla lo más posible.
### Ejemplo manual: precio de laptops según la RAM
| Laptop | A | B | C | D | E | F |
|---|---:|---:|---:|---:|---:|---:|
| RAM (GB) | 4 | 4 | 8 | 8 | 16 | 16 |
| Precio ($) | 6 500 | 7 200 | 10 500 | 12 000 | 21 000 | 24 000 |
: Seis laptops. {#tbl-laptops-ram}
Sin dividir, la predicción es el promedio, 13 533.33, y la suma de cuadrados es $266.43 \times 10^6$. Hay dos cortes posibles:
| Corte | Izquierda (promedio) | Derecha (promedio) | SSE después | Reducción |
|---|---|---|---:|---:|
| RAM ≤ 6 | A, B (6 850) | C, D, E, F (16 875) | $132.43 \times 10^6$ | 50.3 % |
| **RAM ≤ 12** | **A–D (9 050)** | **E, F (22 500)** | $\mathbf{25.23 \times 10^6}$ | **90.5 %** |
: Los dos cortes candidatos. {#tbl-cortes-ram}
Por ejemplo, para RAM ≤ 12: $(6500 - 9050)^2 + (7200 - 9050)^2 + (10500 - 9050)^2 + (12000 - 9050)^2 + (21000 - 22500)^2 + (24000 - 22500)^2 = 25.23 \times 10^6$. El árbol de profundidad 1 dice: «si la RAM es de 12 GB o menos, 9 050; si no, 22 500».
Todo lo visto en CART se aplica sin cambios: el crecimiento voraz, la poda de costo-complejidad (ahora con la suma de cuadrados como $R(T)$) y la tendencia a sobreajustar si se deja crecer el árbol hasta tener una observación por hoja. El laboratorio de bagging muestra este sobreajuste con 40 puntos.
## ¿Por qué funciona combinar modelos?
### Intuición: el voto de cinco jueces
Supongamos cinco clasificadores que aciertan cada uno el 70 % de las veces y cuyos errores son **independientes**. Si decidimos por mayoría, el ensamble acierta cuando al menos tres de los cinco aciertan:
$$
P(\text{mayoría correcta}) = \sum_{k=3}^{5}\binom{5}{k} 0.7^k\, 0.3^{5-k} = 0.309 + 0.360 + 0.168 = 0.837.
$$
Con 25 clasificadores así, la exactitud de la mayoría sube a 0.98. Es el **teorema del jurado de Condorcet**: si cada juez es mejor que el azar y sus errores son independientes, la mayoría se vuelve casi infalible. Dos condiciones hacen el trabajo: que cada modelo sea **mejor que el azar** y que sus errores sean **diversos**. Si los cinco cometieran exactamente los mismos errores, votar no serviría de nada.
### Formalización: la varianza de un promedio
Para predicciones numéricas, la misma idea se expresa con varianzas. Si $B$ modelos tienen cada uno varianza $\sigma^2$ y correlación $\rho$ entre sí, la varianza de su promedio es
$$
\operatorname{Var}\Big(\frac{1}{B}\sum_{b=1}^{B} \hat{f}_b(\mathbf{x})\Big) = \rho\,\sigma^2 + \frac{1 - \rho}{B}\,\sigma^2.
$$ {#eq-varianza-promedio}
Al aumentar $B$, el segundo término desaparece, pero el primero no: **la correlación entre modelos pone un piso** a la reducción de varianza. Toda la estrategia de los bosques aleatorios consiste en bajar $\rho$ sin subir demasiado $\sigma^2$. Como promediar no cambia el valor esperado, el **sesgo** del ensamble es el de sus modelos: por eso se promedian árboles profundos, de poco sesgo y mucha varianza.
## Bagging
Si tuviéramos muchos conjuntos de entrenamiento independientes, entrenaríamos un árbol en cada uno y los promediaríamos. Solo tenemos uno. *Bagging* (*bootstrap aggregating*), propuesto por @breiman1996bagging, simula esos conjuntos con **muestras bootstrap**: muestras de tamaño $n$ tomadas **con reemplazo** del conjunto original.
### Construcción paso a paso
**Paso 1. Una muestra bootstrap.** Con las seis laptops, un sorteo con reemplazo podría dar $\{A, A, C, D, F, F\}$: la laptop A y la F aparecen dos veces, y la B y la E **no aparecen**.
**Paso 2. Un árbol con esa muestra.** El mejor corte vuelve a ser RAM ≤ 12, pero las hojas cambian: a la izquierda $(6500 + 6500 + 10500 + 12000)/4 = 8\,875$ y a la derecha $24\,000$.
**Paso 3. Los ejemplos fuera de la bolsa.** B y E no participaron en este árbol, así que sirven para evaluarlo sin trampa. Sus errores son $7\,200 - 8\,875 = -1\,675$ y $21\,000 - 24\,000 = -3\,000$. Son los ejemplos **fuera de la bolsa** (*out-of-bag*, OOB).
**Paso 4. Repetir y promediar.** Con $B$ muestras bootstrap se obtienen $B$ árboles distintos. La predicción de *bagging* es el promedio (en regresión) o el voto mayoritario (en clasificación):
$$
\hat{f}_{\text{bag}}(\mathbf{x}) = \frac{1}{B}\sum_{b=1}^{B} \hat{f}^{*b}(\mathbf{x}).
$$ {#eq-bagging}
### El error fuera de la bolsa
La probabilidad de que una observación concreta **no** salga en ninguna de las $n$ extracciones es
$$
\Big(1 - \frac{1}{n}\Big)^n \xrightarrow[n \to \infty]{} e^{-1} \approx 0.368.
$$ {#eq-oob}
Con $n = 6$ es $(5/6)^6 = 0.335$. Es decir, cada árbol deja fuera alrededor de un tercio de los datos. Prediciendo cada observación solo con los árboles que **no** la vieron y promediando esos errores se obtiene el **error OOB**, una estimación del error de generalización casi gratuita, comparable a una validación cruzada, sin entrenar modelos adicionales.
## Bosques aleatorios
En *bagging* los árboles son distintos, pero no tanto: si hay una variable muy predictiva, casi todos la usan en la raíz y sus predicciones están muy correlacionadas, de modo que el piso $\rho\sigma^2$ de la @eq-varianza-promedio es alto. Los **bosques aleatorios** de @breiman2001random añaden una segunda fuente de azar: **en cada nodo**, el mejor corte se busca solo entre $m$ variables elegidas al azar de las $p$ disponibles. Así, otras variables tienen oportunidad de ocupar las preguntas importantes, los árboles se parecen menos y su promedio es más estable.
Los hiperparámetros principales son:
| Hiperparámetro | `scikit-learn` | Valor habitual | Efecto |
|---|---|---|---|
| Número de árboles $B$ | `n_estimators` | 200–1000 | Más árboles nunca sobreajustan; solo cuestan tiempo |
| Variables por nodo $m$ | `max_features` | $\sqrt{p}$ (clasificación), $p/3$ (regresión) | Menor $m$: árboles menos correlacionados pero más débiles |
| Tamaño de hoja | `min_samples_leaf` | 1–5 | Hojas más grandes: menos varianza por árbol |
| Profundidad | `max_depth` | sin límite | Normalmente se dejan crecer |
: Hiperparámetros de un bosque aleatorio. {#tbl-hiper-rf}
Con $m = p$ el bosque es exactamente *bagging*. Una propiedad práctica importante: aumentar $B$ no produce sobreajuste; el error converge y basta elegir $B$ suficientemente grande.
### Importancia de las variables
Un bosque pierde la interpretabilidad de un árbol único, pero ofrece dos medidas de importancia:
- **Disminución media de impureza** (MDI): la suma de las reducciones de Gini de cada variable en todos los árboles. Es gratuita, pero está sesgada a favor de variables numéricas o con muchas categorías, que tienen más cortes donde «encontrar» reducciones por azar [@strobl2007bias].
- **Importancia por permutación**: se desordena al azar una columna en los datos de prueba (o en los OOB) y se mide cuánto empeora el desempeño. Si la variable importa, desordenarla destruye información; si no, el error casi no cambia. Es más fiable, aunque reparte la importancia entre variables muy correlacionadas.
## Laboratorio: bagging de árboles de regresión
Cuarenta puntos generados con $y = 2\sin x + 0.3x + \varepsilon$, con ruido de desviación 0.6 (varianza 0.36, el error irreducible). El estado inicial es un **único** árbol sin límite de profundidad: memoriza los datos. Al pasar a *Bootstrap* y aumentar el número de árboles, el promedio se suaviza.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="bag-lab">
<p class="plab-eyebrow">Laboratorio · Bagging</p>
<h3 class="plab-title">¿Cuántos árboles ruidosos hacen falta para una buena predicción?</h3>
<p class="plab-sub">Cada línea tenue es un árbol entrenado con una muestra bootstrap; la línea gruesa es su promedio. Compara el error en prueba con el de un solo árbol y con el error irreducible.</p>
<div class="plab-controls">
<div class="plab-field">Muestreo
<div class="plab-seg" id="bag-mode"><button data-value="none" aria-pressed="true">Sin remuestreo</button><button data-value="boot">Bootstrap</button></div>
</div>
<label>Número de árboles B: <b id="bag-b-v"></b><input type="range" id="bag-b" min="1" max="100" step="1" value="1"></label>
<label>Profundidad máxima: <b id="bag-d-v"></b><input type="range" id="bag-d" min="1" max="9" step="1" value="9"></label>
<div class="plab-field">Azar<button class="plab-btn" id="bag-seed">Nuevas muestras</button></div>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>ECM entrenamiento</span><strong id="bag-tr"></strong></div>
<div class="plab-stat"><span>ECM prueba (300 puntos)</span><strong id="bag-te"></strong></div>
<div class="plab-stat"><span>ECM fuera de la bolsa</span><strong id="bag-oob"></strong></div>
<div class="plab-stat"><span>Error irreducible σ²</span><strong>0.360</strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4>Árboles y su promedio</h4>
<svg id="bag-svg" role="img" aria-label="Datos, función real, árboles individuales y su promedio"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>entrenamiento</span><span><i style="background:var(--lab-s3)"></i>función real</span><span><i style="background:var(--lab-s2);opacity:.4"></i>árboles</span><span><i style="background:var(--lab-s2)"></i>promedio</span></div>
</div>
<div class="plab-panel">
<h4>Error en prueba según el número de árboles</h4>
<svg id="bag-curve" role="img" aria-label="Error cuadrático medio en prueba del promedio de los primeros B árboles"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s4)"></i>promedio de B árboles</span><span><i style="background:var(--lab-axis)"></i>un árbol (todos los datos)</span><span><i style="background:var(--lab-s3)"></i>σ² irreducible</span></div>
</div>
</div>
<p class="plab-note" id="bag-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('bag-lab');
const $ = s => root.querySelector(s);
const D = {"tr":[[0.29,0.87],[0.7,1.65],[0.83,2.38],[1.29,1.54],[1.29,1.91],[1.3,1.81],[1.38,1.34],[1.48,2.51],[1.48,2.75],[2.02,1.96],[2.05,3.22],[2.17,2.8],[2.35,2.5],[2.75,1.83],[2.77,2.13],[3.53,-0.5],[3.69,0.43],[4.3,-0.18],[4.67,-1.66],[4.84,-0.32],[4.99,-0.58],[5.11,0.16],[5.12,-0.56],[5.49,0.21],[5.54,0.51],[5.92,0.54],[6.01,1.62],[6.22,1.68],[6.63,2.96],[6.7,2.51],[6.73,3.53],[7.88,4.73],[8.02,4.27],[8.17,4.73],[8.67,4.73],[8.96,4.66],[9.01,2.56],[9.28,3.6],[9.48,3.01],[9.81,2.14]],"te":[[0.02,0.33],[0.03,0.62],[0.03,0.42],[0.07,0.83],[0.07,0.39],[0.07,0.21],[0.1,-0.9],[0.13,0.03],[0.2,1.44],[0.23,0.65],[0.35,1.32],[0.36,0.74],[0.38,0.5],[0.41,2.2],[0.43,1.23],[0.46,1.97],[0.46,1.39],[0.54,1.06],[0.56,-0.07],[0.57,1.35],[0.57,0.36],[0.64,0.1],[0.68,2.26],[0.71,2.0],[0.71,1.95],[0.74,1.65],[0.77,1.65],[0.77,1.93],[0.78,1.78],[0.82,1.8],[0.82,2.2],[0.85,0.33],[0.91,2.43],[0.98,1.62],[0.99,2.53],[1.02,2.76],[1.02,1.55],[1.07,2.59],[1.08,2.6],[1.15,1.78],[1.21,1.94],[1.22,2.33],[1.24,0.94],[1.26,2.03],[1.27,2.41],[1.3,2.06],[1.34,1.4],[1.35,2.13],[1.35,3.27],[1.37,3.17],[1.42,2.34],[1.45,1.94],[1.5,2.44],[1.5,2.46],[1.63,2.08],[1.7,2.3],[1.71,2.87],[1.79,3.05],[1.8,1.03],[1.85,3.07],[1.88,2.55],[1.89,3.16],[1.89,2.46],[1.89,2.73],[1.89,3.45],[1.92,0.67],[1.93,3.41],[1.97,2.55],[1.99,1.33],[2.01,0.81],[2.02,2.6],[2.11,1.73],[2.19,2.89],[2.19,2.42],[2.22,2.18],[2.22,2.86],[2.24,2.36],[2.31,2.62],[2.36,2.62],[2.37,2.3],[2.41,2.53],[2.44,2.5],[2.45,1.19],[2.49,1.8],[2.51,1.86],[2.57,1.63],[2.58,0.89],[2.59,1.49],[2.6,2.02],[2.62,1.9],[2.71,2.56],[2.77,1.19],[2.83,1.2],[2.83,1.12],[2.86,1.22],[2.89,1.0],[2.91,0.21],[2.93,1.51],[2.94,1.88],[2.95,1.94],[2.95,0.61],[3.03,1.02],[3.1,2.12],[3.14,0.88],[3.24,0.99],[3.32,0.55],[3.42,1.08],[3.47,0.98],[3.5,1.75],[3.5,0.65],[3.69,0.33],[3.85,-0.87],[3.94,-0.5],[4.01,-0.74],[4.04,-0.55],[4.05,-1.15],[4.06,0.36],[4.08,-1.02],[4.09,-0.23],[4.13,-1.19],[4.17,-0.25],[4.19,0.52],[4.19,-0.97],[4.24,-0.29],[4.33,-0.6],[4.35,-0.39],[4.35,-0.31],[4.37,-0.95],[4.49,-0.71],[4.54,-0.96],[4.55,-1.1],[4.58,-1.35],[4.61,-0.03],[4.68,-1.04],[4.69,-0.19],[4.76,-0.24],[4.79,-1.16],[4.79,0.07],[4.8,-1.07],[4.85,-0.15],[4.85,0.35],[4.86,-0.95],[4.89,-1.06],[4.94,-1.03],[4.99,0.95],[4.99,-0.65],[5.0,-1.18],[5.04,0.11],[5.17,-0.35],[5.21,-0.22],[5.35,-0.6],[5.36,-1.33],[5.39,-0.38],[5.41,-0.54],[5.43,-0.85],[5.43,-0.54],[5.46,-0.4],[5.58,0.86],[5.58,0.8],[5.65,0.81],[5.66,0.2],[5.76,-0.03],[5.79,0.96],[5.8,1.08],[5.93,1.68],[6.0,1.22],[6.0,1.16],[6.03,1.49],[6.06,0.81],[6.1,0.98],[6.16,1.98],[6.2,0.93],[6.23,1.71],[6.47,2.78],[6.49,2.49],[6.53,2.7],[6.62,1.98],[6.62,2.57],[6.7,2.65],[6.71,3.2],[6.8,3.28],[6.81,3.07],[6.81,4.19],[6.81,3.38],[6.83,2.92],[6.83,2.73],[6.86,3.13],[6.87,2.83],[6.87,4.16],[6.91,3.6],[6.92,3.09],[6.95,2.94],[6.95,2.69],[6.96,3.19],[6.96,4.14],[6.97,3.88],[7.09,3.55],[7.11,3.7],[7.15,2.57],[7.21,4.23],[7.21,3.65],[7.21,4.6],[7.29,3.79],[7.4,3.75],[7.43,4.2],[7.47,3.73],[7.48,4.93],[7.51,4.33],[7.51,4.38],[7.53,4.38],[7.63,4.05],[7.64,4.56],[7.64,4.38],[7.65,5.33],[7.7,4.35],[7.72,4.55],[7.75,4.07],[7.78,4.14],[7.79,4.19],[7.8,5.15],[7.82,4.44],[7.85,4.98],[7.87,4.25],[7.9,4.2],[7.9,3.26],[7.91,4.92],[7.93,5.66],[7.93,4.29],[7.95,5.29],[8.01,4.94],[8.03,4.02],[8.04,4.89],[8.06,3.9],[8.06,4.25],[8.19,5.14],[8.25,4.48],[8.25,3.68],[8.28,5.21],[8.28,3.71],[8.31,4.25],[8.35,4.12],[8.36,4.22],[8.41,3.77],[8.54,3.19],[8.57,5.25],[8.58,3.56],[8.6,3.58],[8.66,2.58],[8.71,4.04],[8.72,4.74],[8.72,4.81],[8.79,5.54],[8.8,4.77],[8.8,4.12],[8.81,3.87],[8.82,4.71],[8.86,3.77],[8.86,3.91],[8.89,3.18],[8.89,3.81],[8.92,4.89],[8.93,3.65],[8.96,3.89],[8.97,2.19],[8.98,4.42],[8.98,3.84],[9.01,4.42],[9.03,3.68],[9.03,3.02],[9.06,2.67],[9.1,3.72],[9.15,3.32],[9.16,2.73],[9.19,3.22],[9.21,2.85],[9.23,3.62],[9.28,2.86],[9.34,2.26],[9.35,2.92],[9.37,2.6],[9.38,3.8],[9.42,2.57],[9.43,2.04],[9.46,2.74],[9.47,2.6],[9.47,2.91],[9.47,2.87],[9.49,2.09],[9.52,2.62],[9.62,2.42],[9.67,1.68],[9.68,2.03],[9.81,2.29],[9.83,1.18],[9.87,1.42],[9.9,1.74],[9.93,1.92],[9.95,2.25],[9.96,1.11],[9.96,2.1]]}, TR = D.tr, TE = D.te, n = TR.length;
const freal = x => 2 * Math.sin(x) + 0.3 * x;
// Árbol de regresión (cortes en puntos medios, mayor reducción de suma de cuadrados).
function build(pts, depth, maxD) {
const m = K.mean(pts.map(p => p[1]));
if (depth >= maxD || pts.length < 2) return { m };
const s = [...pts].sort((a, b) => a[0] - b[0]), N = s.length;
const tot = K.sum(s.map(p => p[1])), tot2 = K.sum(s.map(p => p[1] * p[1])), sse0 = tot2 - tot * tot / N;
let best = null, L = 0, L2 = 0;
for (let k = 0; k < N - 1; k++) {
L += s[k][1]; L2 += s[k][1] ** 2;
if (s[k][0] === s[k + 1][0]) continue;
const nl = k + 1, nr = N - nl, R = tot - L, R2 = tot2 - L2;
const sse = (L2 - L * L / nl) + (R2 - R * R / nr);
if (!best || sse < best.sse - 1e-9) best = { sse, t: (s[k][0] + s[k + 1][0]) / 2 };
}
if (!best || best.sse >= sse0 - 1e-12) return { m };
return { t: best.t, L: build(s.filter(p => p[0] <= best.t), depth + 1, maxD), R: build(s.filter(p => p[0] > best.t), depth + 1, maxD) };
}
const pred = (t, x) => { while (t.L) t = x <= t.t ? t.L : t.R; return t.m; };
const mse = (f, set) => K.mean(set.map(p => (f(p[0]) - p[1]) ** 2));
let seed = 3;
const modeSeg = K.segmented($('#bag-mode'), render);
const GRID = Array.from({ length: 201 }, (_, i) => i * 0.05);
function render() {
const B = +$('#bag-b').value, dv = +$('#bag-d').value, maxD = dv === 9 ? 99 : dv, boot = modeSeg.get() === 'boot';
$('#bag-b-v').textContent = B; $('#bag-d-v').textContent = dv === 9 ? 'sin límite' : dv;
const r = K.rng(seed), trees = [], bags = [];
for (let b = 0; b < 100; b++) {
const idx = boot ? Array.from({ length: n }, () => Math.floor(r() * n)) : TR.map((_, i) => i);
bags.push(new Set(idx)); trees.push(build(idx.map(i => TR[i]), 0, maxD));
}
const use = trees.slice(0, B), avg = x => K.mean(use.map(t => pred(t, x)));
const single = build(TR, 0, maxD), mseSingle = mse(x => pred(single, x), TE);
const mTr = mse(avg, TR), mTe = mse(avg, TE);
// Error fuera de la bolsa con los primeros B árboles.
let oobS = 0, oobN = 0;
TR.forEach((p, i) => { const ts = use.filter((t, b) => !bags[b].has(i)); if (ts.length) { oobS += (K.mean(ts.map(t => pred(t, p[0]))) - p[1]) ** 2; oobN++; } });
$('#bag-tr').textContent = K.fmt(mTr, 3); $('#bag-te').textContent = K.fmt(mTe, 3);
$('#bag-oob').textContent = boot && oobN ? `${K.fmt(oobS / oobN, 3)} (${oobN}/40)` : '—';
const c = K.chart($('#bag-svg'), { width: 380, height: 280, x: [0, 10], y: [-3, 6], margin: { left: 40, bottom: 40 } });
c.axes({ xTicks: 5, yTicks: 5, xLabel: 'x', yLabel: 'y', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 0) });
use.slice(0, 25).forEach(t => { const pth = c.path(GRID.map(x => [x, pred(t, x)]), 's2 line thin'); pth.style.strokeOpacity = B > 1 ? 0.22 : 0.9; });
c.path(GRID.map(x => [x, freal(x)]), 's3 line dash');
if (B > 1) c.path(GRID.map(x => [x, avg(x)]), 's2 line');
TR.forEach(p => c.dot(p[0], p[1], 3.5, 's1 dot'));
const curve = []; const acc = TE.map(() => 0);
trees.forEach((t, b) => { TE.forEach((p, i) => { acc[i] += pred(t, p[0]); }); curve.push([b + 1, K.mean(TE.map((p, i) => (acc[i] / (b + 1) - p[1]) ** 2))]); });
const ymax = Math.max(1.0, mseSingle * 1.15, ...curve.map(u => u[1]));
const cc = K.chart($('#bag-curve'), { width: 380, height: 280, x: [0, 100], y: [0, ymax], margin: { left: 44, bottom: 40 } });
cc.axes({ xTicks: 5, yTicks: 5, xLabel: 'número de árboles B', yLabel: 'ECM en prueba', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 1) });
cc.line(0, mseSingle, 100, mseSingle, 'muted line dash thin');
cc.line(0, 0.36, 100, 0.36, 's3 line dash thin');
cc.path(curve, 's4 line');
cc.dot(B, mTe, 6, 's2 dot');
const note = $('#bag-note');
if (!boot && B > 1) { note.className = 'plab-note bad'; note.textContent = `Sin remuestreo los ${B} árboles son idénticos: su promedio es el mismo árbol (ECM ${K.fmt(mTe, 3)}). Promediar solo ayuda si los modelos son diversos.`; }
else if (B === 1) { note.className = 'plab-note warn'; note.textContent = `Un solo árbol${dv === 9 ? ' sin límite de profundidad sigue cada punto (ECM de entrenamiento ' + K.fmt(mTr, 3) + ')' : ''}: su ECM en prueba es ${K.fmt(mTe, 3)}, lejos del irreducible 0.360. Cambia a Bootstrap y sube B.`; }
else { note.className = 'plab-note good'; note.textContent = `El promedio de ${B} árboles bootstrap tiene ECM ${K.fmt(mTe, 3)} en prueba, frente a ${K.fmt(mseSingle, 3)} del árbol único. El error fuera de la bolsa (${K.fmt(oobS / oobN, 3)}) lo estima sin usar la prueba. La curva se aplana: más árboles ya no ayudan, pero tampoco sobreajustan.`; }
}
root.querySelectorAll('input[type=range]').forEach(el => el.addEventListener('input', render));
$('#bag-seed').addEventListener('click', () => { seed++; render(); });
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio promedia árboles de regresión entrenados con muestras bootstrap. Valores de referencia: un único árbol sin límite de profundidad tiene ECM de prueba cercano a 0.62 (el error irreducible es 0.36); con profundidad 4, 0.554. El promedio de 100 árboles bootstrap profundos baja el ECM de prueba a cerca de 0.50, y el error fuera de la bolsa da una estimación parecida sin usar la prueba.
:::
**Guía del laboratorio.**
1. En el estado inicial, ¿por qué el ECM de entrenamiento es casi 0 y el de prueba ronda 0.62? Compara la escalera con la función real (verde).
2. Predice antes de mover: con *Sin remuestreo* y $B = 50$, ¿mejorará el error de prueba? Compruébalo y explica.
3. Cambia a *Bootstrap* y sube $B$ de 1 a 100. ¿Dónde se aplana la curva? ¿Llega al error irreducible?
4. Con $B = 100$, baja la profundidad a 1. ¿Por qué ahora el bagging casi no mejora al árbol único? Relaciónalo con la @eq-varianza-promedio y con el sesgo.
5. Compara el error fuera de la bolsa con el de prueba para varias semillas. ¿Es una buena estimación?
## Boosting y AdaBoost
*Bagging* entrena modelos en paralelo y los trata por igual. **Boosting** los entrena **en secuencia**: cada modelo nuevo se concentra en los casos que los anteriores clasificaron mal, y al final se combinan con pesos según su calidad. La pregunta que lo originó fue teórica [@schapire1990strength]: ¿se puede convertir un **clasificador débil**, apenas mejor que el azar, en uno arbitrariamente preciso? AdaBoost [@freund1997decision] fue la respuesta práctica.
### Construcción paso a paso
Diez puntos sobre una recta con etiquetas $+1$ y $-1$:
| $x$ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| $y$ | + | + | + | − | − | − | + | + | + | − |
: Diez puntos que ningún corte único separa. {#tbl-ada-datos}
El clasificador débil será un **tocón**: un solo corte «$x \le t$ ⇒ clase». Ningún tocón separa estos datos; el mejor comete 3 errores.
**Ronda 1.** Todos los pesos valen $w_i = 1/10$. El mejor tocón es «$x \le 3.5$ ⇒ +, si no −», que falla en $x = 7, 8, 9$. Su **error ponderado** es $\varepsilon_1 = 0.3$ y su **peso** en el ensamble es
$$
\alpha_1 = \tfrac{1}{2}\ln\frac{1 - \varepsilon_1}{\varepsilon_1} = \tfrac{1}{2}\ln\frac{0.7}{0.3} = 0.424.
$$
Ahora se **repesan** los ejemplos: los mal clasificados se multiplican por $e^{\alpha_1} = 1.528$ y los bien clasificados por $e^{-\alpha_1} = 0.655$; después se normaliza para que sumen 1. Los tres errores pasan a pesar $0.1667$ cada uno y los siete aciertos $0.0714$. Observa que los mal clasificados suman ahora exactamente $0.5$: para el siguiente tocón, el anterior es tan bueno como lanzar una moneda.
**Ronda 2.** Con esos pesos, el mejor tocón es «$x \le 9.5$ ⇒ +, si no −», que falla en $x = 4, 5, 6$: $\varepsilon_2 = 3 \times 0.0714 = 0.214$ y $\alpha_2 = \frac{1}{2}\ln(0.786/0.214) = 0.650$. Ese tocón casi no tendría sentido por sí solo; tiene sentido **porque** los puntos 7, 8 y 9 ahora pesan mucho.
**Ronda 3.** El mejor tocón es «$x \le 6.5$ ⇒ −, si no +», que falla en $x = 1, 2, 3$ y $10$, ahora livianos: $\varepsilon_3 = 0.182$ y $\alpha_3 = 0.752$.
**El ensamble.** La predicción es el signo de la suma ponderada de los tres tocones:
$$
F(x) = 0.424\,h_1(x) + 0.650\,h_2(x) + 0.752\,h_3(x), \qquad \hat{y} = \operatorname{signo}(F(x)).
$$
| $x$ | $h_1$ | $h_2$ | $h_3$ | $F(x)$ | $\hat{y}$ | $y$ |
|---|---:|---:|---:|---:|---|---|
| 1–3 | + | + | − | $0.424 + 0.650 - 0.752 = 0.321$ | + | + |
| 4–6 | − | + | − | $-0.424 + 0.650 - 0.752 = -0.526$ | − | − |
| 7–9 | − | + | + | $-0.424 + 0.650 + 0.752 = 0.978$ | + | + |
| 10 | − | − | + | $-0.424 - 0.650 + 0.752 = -0.321$ | − | − |
: El ensamble de tres tocones clasifica bien los diez puntos. {#tbl-ada-ensamble}
Tres clasificadores que fallan, cada uno, tres o cuatro de diez puntos producen juntos un clasificador perfecto en entrenamiento. Ninguno de ellos podía dibujar la frontera con tres cambios de clase; su suma sí.
### Formalización
Con etiquetas $y_i \in \{-1, +1\}$, pesos iniciales $w_i = 1/n$ y $M$ rondas, AdaBoost repite:
1. Entrenar el clasificador débil $h_m$ minimizando el error ponderado $\varepsilon_m = \sum_i w_i\,\mathbb{1}[h_m(\mathbf{x}_i) \neq y_i]$.
2. Calcular $\alpha_m = \frac{1}{2}\ln\frac{1 - \varepsilon_m}{\varepsilon_m}$, positivo si $\varepsilon_m < 0.5$.
3. Actualizar $w_i \leftarrow w_i \exp\!\big(-\alpha_m\, y_i\, h_m(\mathbf{x}_i)\big)$ y normalizar.
La predicción es $\operatorname{signo}\big(\sum_m \alpha_m h_m(\mathbf{x})\big)$. @friedman2000additive mostraron que AdaBoost minimiza, paso a paso, la **pérdida exponencial** $\sum_i e^{-y_i F(\mathbf{x}_i)}$ sobre modelos aditivos $F$; cada ronda agrega el término que más la reduce. Esta lectura como **optimización por etapas** es la que generaliza el *gradient boosting* del capítulo siguiente a cualquier pérdida.
Dos consecuencias prácticas:
- El error de entrenamiento cae exponencialmente rápido mientras cada $\varepsilon_m$ esté por debajo de 0.5. Pero la pérdida exponencial castiga sin límite a los puntos muy mal clasificados, de modo que AdaBoost es **sensible a etiquetas erróneas y atípicos**: termina concentrando todo el peso en ellos.
- Boosting reduce sobre todo el **sesgo** (combina modelos débiles, de mucho sesgo) y puede sobreajustar si se usan demasiadas rondas o modelos base demasiado fuertes. El número de rondas y la complejidad del modelo base se eligen con validación.
`scikit-learn` implementa la variante SAMME, que usa $\alpha_m = \ln\frac{1 - \varepsilon_m}{\varepsilon_m}$ (el doble) y solo aumenta el peso de los errores; tras normalizar, los pesos y las predicciones son idénticos a los de la versión clásica.
## Laboratorio: AdaBoost ronda por ronda
El tamaño de cada punto es proporcional a su peso **al inicio** de la ronda elegida. El modo *Diez puntos* reproduce las tablas anteriores. El modo *Círculo* usa 150 puntos de entrenamiento y 300 de prueba en el plano; la clase $+1$ está dentro de un círculo y un 3 % de las etiquetas están invertidas; los tocones solo pueden hacer cortes verticales u horizontales.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="ada-lab">
<p class="plab-eyebrow">Laboratorio · Boosting</p>
<h3 class="plab-title">¿Cómo aprenden juntos clasificadores que por separado fallan?</h3>
<p class="plab-sub">Avanza las rondas. Observa qué puntos ganan peso, qué tocón se elige y cómo cambia la frontera del ensamble.</p>
<div class="plab-controls">
<div class="plab-field">Datos
<div class="plab-seg" id="ada-mode"><button data-value="d1" aria-pressed="true">Diez puntos</button><button data-value="d2">Círculo (2D)</button></div>
</div>
<label>Ronda m: <b id="ada-m-v"></b><input type="range" id="ada-m" min="1" max="10" step="1" value="1"></label>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>Tocón de la ronda</span><strong id="ada-h"></strong></div>
<div class="plab-stat"><span>Error ponderado ε</span><strong id="ada-eps"></strong></div>
<div class="plab-stat"><span>Peso α</span><strong id="ada-alpha"></strong></div>
<div class="plab-stat"><span>Errores del ensamble</span><strong id="ada-err"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4 id="ada-plot-t">Pesos y ensamble</h4>
<svg id="ada-svg" role="img" aria-label="Puntos con tamaño proporcional a su peso, tocón actual y predicción del ensamble"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s1)"></i>clase +1</span><span><i class="dot" style="background:var(--lab-s2)"></i>clase −1</span><span><i style="background:var(--lab-s5)"></i>tocón de la ronda</span><span id="ada-leg-f"><i style="background:var(--lab-s4)"></i>F(x) del ensamble</span></div>
</div>
<div class="plab-panel">
<h4>Error del ensamble por ronda</h4>
<svg id="ada-curve" role="img" aria-label="Error de entrenamiento y de prueba del ensamble según el número de rondas"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s1)"></i>entrenamiento</span><span id="ada-leg-te"><i style="background:var(--lab-s4)"></i>prueba</span><span><i class="dot" style="background:var(--lab-s2)"></i>ronda actual</span></div>
</div>
</div>
<div class="plab-panel" style="margin-top:1rem"><h4>Rondas</h4><div id="ada-tab" class="plab-scroll"></div></div>
<p class="plab-note" id="ada-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('ada-lab');
const $ = s => root.querySelector(s);
const C = {"tr":[[6.9,6.4,1],[1.3,1.1,-1],[6.5,8.5,-1],[2.0,2.2,-1],[7.2,4.7,1],[4.2,3.5,1],[0.6,4.5,-1],[3.0,3.9,1],[5.4,6.8,1],[6.2,7.4,1],[0.2,6.5,-1],[5.4,8.5,-1],[9.4,0.1,-1],[8.3,2.5,-1],[6.2,7.6,1],[8.5,9.4,-1],[6.3,8.6,-1],[5.0,2.3,1],[1.6,5.0,-1],[7.5,4.5,1],[6.8,1.3,-1],[9.7,4.7,-1],[8.3,7.3,-1],[7.8,3.8,-1],[1.1,4.6,-1],[3.3,4.0,1],[9.9,3.1,-1],[7.8,1.7,-1],[5.9,3.5,1],[2.4,3.9,1],[9.3,5.8,-1],[5.2,8.0,-1],[5.0,1.1,1],[1.3,7.2,-1],[4.7,2.1,1],[9.7,2.7,-1],[7.8,3.2,-1],[7.5,5.3,1],[0.4,7.1,-1],[2.9,4.3,1],[7.1,10.0,-1],[4.5,6.7,1],[4.9,6.7,1],[1.6,9.9,-1],[1.4,6.7,-1],[0.7,0.9,-1],[8.7,7.5,-1],[1.0,2.8,-1],[0.7,9.4,-1],[8.5,7.8,-1],[7.0,6.7,1],[0.3,0.6,-1],[3.0,5.8,1],[8.3,4.4,-1],[2.5,6.3,-1],[9.5,4.9,-1],[5.6,2.9,1],[7.5,1.5,-1],[6.7,2.2,-1],[6.8,5.0,1],[8.2,5.7,-1],[9.1,4.5,-1],[0.2,9.8,-1],[7.7,8.2,-1],[8.3,1.3,-1],[8.7,0.8,-1],[3.1,1.6,-1],[4.8,1.9,-1],[2.1,5.8,-1],[1.6,4.8,-1],[6.9,0.2,-1],[8.8,7.5,-1],[7.5,7.2,-1],[9.6,1.4,-1],[0.8,7.4,-1],[0.2,6.3,-1],[6.2,9.2,-1],[1.0,3.5,-1],[7.6,3.5,-1],[2.9,1.8,1],[0.2,5.3,-1],[0.6,4.7,-1],[7.3,4.2,1],[6.1,9.5,-1],[1.8,6.4,-1],[6.7,5.1,1],[3.4,1.6,-1],[3.8,5.5,1],[9.7,2.7,-1],[8.6,7.9,-1],[3.7,5.8,1],[0.3,9.8,-1],[4.9,1.5,-1],[7.9,8.7,-1],[7.4,9.8,-1],[2.8,5.5,1],[4.4,6.4,1],[2.8,6.8,1],[4.7,3.8,1],[7.0,2.7,-1],[3.3,10.0,-1],[3.9,3.4,1],[9.6,9.4,-1],[9.2,1.0,-1],[8.0,2.1,-1],[6.5,8.6,-1],[7.5,0.6,-1],[5.9,1.7,-1],[8.3,2.3,-1],[2.1,3.6,-1],[5.5,9.0,-1],[1.8,8.4,-1],[4.2,9.9,-1],[6.4,2.2,-1],[5.1,7.4,-1],[8.3,4.7,-1],[3.8,7.4,1],[1.0,7.1,-1],[4.0,6.9,1],[0.9,2.5,-1],[2.9,3.0,1],[9.7,8.5,-1],[0.9,5.8,-1],[9.5,8.1,-1],[0.5,7.4,-1],[7.8,9.0,-1],[5.8,4.4,1],[1.8,5.9,-1],[2.8,2.9,-1],[5.3,0.7,-1],[4.9,2.5,1],[4.6,1.9,-1],[7.5,8.6,-1],[6.9,8.9,-1],[8.8,5.9,-1],[7.5,1.4,-1],[5.6,9.7,-1],[7.4,9.7,-1],[3.1,1.5,-1],[0.6,0.2,-1],[5.9,7.1,1],[1.7,9.5,-1],[2.8,2.4,-1],[7.1,0.3,-1],[4.7,6.7,1],[5.6,2.3,1],[8.1,5.5,-1],[5.7,3.9,1],[9.8,0.6,-1],[2.5,7.2,-1]],"te":[[3.7,2.0,-1],[0.9,6.5,-1],[4.6,9.9,-1],[8.5,8.4,-1],[0.5,5.6,-1],[6.1,0.5,-1],[4.8,3.3,1],[2.2,8.0,1],[4.2,1.0,-1],[3.7,9.1,-1],[3.9,1.9,-1],[0.7,5.1,-1],[6.7,4.1,1],[8.5,5.2,-1],[8.0,5.7,-1],[5.9,4.1,1],[1.1,7.0,-1],[8.6,7.3,-1],[4.2,0.9,-1],[1.9,5.5,-1],[6.6,0.3,1],[9.4,7.1,-1],[7.2,9.9,-1],[6.2,4.9,1],[2.2,6.9,-1],[7.0,9.5,-1],[9.3,4.9,-1],[8.5,9.6,-1],[4.2,6.1,1],[5.9,4.8,1],[4.7,2.2,1],[1.9,3.1,-1],[5.9,6.6,1],[8.2,2.9,-1],[5.2,4.5,1],[1.7,9.8,-1],[3.4,6.9,1],[6.9,7.7,-1],[8.3,9.5,-1],[3.2,6.7,1],[3.2,3.0,1],[7.9,7.5,-1],[7.3,1.2,-1],[9.7,6.3,-1],[4.6,9.1,-1],[1.1,3.0,-1],[1.4,3.3,-1],[7.9,1.5,-1],[7.6,6.0,1],[3.3,8.5,-1],[0.4,2.4,-1],[5.0,4.0,1],[3.8,10.0,-1],[5.5,6.6,1],[4.6,2.0,-1],[1.0,7.6,-1],[7.2,9.9,-1],[2.5,3.9,1],[3.8,5.7,1],[0.5,8.0,-1],[7.3,0.9,-1],[7.1,3.4,1],[4.3,2.0,-1],[6.9,5.8,-1],[0.2,1.5,-1],[7.3,1.3,-1],[4.7,6.7,1],[1.7,5.0,-1],[4.2,1.4,-1],[7.9,0.8,-1],[8.3,3.3,-1],[3.6,2.2,-1],[8.5,10.0,-1],[1.9,6.3,-1],[0.4,4.0,-1],[2.2,6.3,-1],[2.0,6.1,-1],[9.6,2.5,-1],[1.3,8.6,-1],[0.5,7.4,-1],[8.9,1.3,-1],[5.2,9.8,-1],[1.5,8.3,-1],[6.7,0.7,-1],[1.7,9.2,-1],[0.7,3.6,-1],[0.3,2.6,-1],[2.1,7.9,-1],[0.8,1.1,-1],[5.4,2.0,-1],[5.2,0.5,-1],[1.9,3.4,-1],[7.2,8.3,-1],[1.9,5.1,-1],[8.6,5.3,-1],[7.9,8.5,-1],[3.3,6.1,1],[5.4,2.0,-1],[7.2,8.7,-1],[0.4,9.5,-1],[0.5,4.0,-1],[0.8,3.8,-1],[5.7,5.0,1],[3.5,5.2,-1],[7.0,6.5,1],[1.5,5.6,-1],[0.7,6.4,-1],[0.4,5.6,-1],[2.3,0.5,-1],[4.9,3.1,1],[0.6,7.8,-1],[2.3,6.6,-1],[4.2,7.9,-1],[3.0,1.3,-1],[3.4,6.8,1],[3.1,8.2,1],[3.5,1.9,-1],[5.8,5.7,1],[5.8,0.3,-1],[5.3,5.9,1],[1.5,0.5,-1],[4.7,6.3,-1],[0.3,7.9,-1],[3.3,2.7,1],[5.8,0.9,-1],[8.1,9.0,-1],[0.2,3.4,1],[0.8,1.2,-1],[7.9,6.9,-1],[2.3,0.0,-1],[6.3,4.6,1],[7.2,7.7,-1],[8.3,4.5,-1],[6.0,9.3,-1],[4.1,5.0,1],[3.4,6.5,1],[5.8,7.4,1],[7.8,1.2,-1],[8.7,7.5,-1],[1.1,4.0,-1],[2.9,7.0,1],[10.0,4.1,-1],[3.4,4.9,1],[8.6,1.8,-1],[7.2,3.1,1],[6.9,8.7,-1],[3.6,8.7,-1],[6.3,5.6,1],[6.2,1.3,-1],[1.9,6.3,-1],[9.0,0.9,-1],[5.8,9.3,-1],[6.8,7.8,-1],[8.5,5.0,-1],[5.5,8.9,-1],[0.7,6.9,-1],[7.5,8.4,-1],[5.4,8.2,1],[9.3,0.5,-1],[0.1,1.5,-1],[6.9,1.6,-1],[0.3,6.4,-1],[2.1,2.1,-1],[6.8,5.5,1],[7.0,3.5,1],[6.3,6.4,1],[0.1,5.9,-1],[4.8,5.1,1],[4.4,10.0,-1],[5.6,2.0,-1],[9.5,0.4,-1],[2.9,7.9,-1],[6.7,1.9,-1],[7.4,3.0,-1],[6.0,3.9,1],[4.8,6.2,1],[0.8,7.5,-1],[2.8,0.6,-1],[2.1,1.1,-1],[1.1,7.3,-1],[1.2,6.1,-1],[8.5,0.4,-1],[3.7,8.3,-1],[3.6,5.0,1],[3.6,7.4,1],[7.5,4.2,1],[0.0,8.7,-1],[2.1,7.1,-1],[8.6,3.7,-1],[1.2,4.0,-1],[1.7,1.9,-1],[9.3,8.5,-1],[2.1,5.2,1],[4.9,0.4,-1],[9.8,1.6,-1],[0.5,9.2,-1],[5.9,10.0,-1],[0.3,3.4,-1],[6.9,4.3,1],[9.7,1.0,-1],[7.4,5.2,1],[1.4,3.5,-1],[2.6,8.1,-1],[3.5,6.9,-1],[7.7,9.3,-1],[3.3,3.8,1],[9.2,1.0,-1],[2.9,8.9,-1],[2.6,4.0,1],[0.5,7.7,-1],[2.9,4.2,1],[0.7,0.4,-1],[1.7,3.6,-1],[2.5,3.3,-1],[2.4,9.4,-1],[2.5,6.2,1],[5.1,6.9,1],[8.7,7.8,-1],[2.9,4.4,1],[3.1,7.7,-1],[2.5,4.7,1],[7.6,4.4,1],[7.0,6.1,1],[2.9,3.8,1],[5.8,4.7,1],[7.2,6.8,1],[1.4,1.5,-1],[0.9,3.2,-1],[0.2,5.0,-1],[3.4,1.0,-1],[8.4,0.7,-1],[6.9,2.2,-1],[7.1,8.6,-1],[2.7,7.1,-1],[4.7,7.4,1],[1.2,0.1,-1],[5.6,5.5,1],[0.5,5.5,-1],[5.4,1.1,-1],[6.0,2.3,1],[9.2,5.8,-1],[3.8,4.5,1],[4.2,4.7,1],[6.8,7.9,-1],[4.4,8.6,-1],[8.3,9.7,-1],[6.2,6.1,1],[1.0,0.9,-1],[9.1,5.2,-1],[7.6,6.7,-1],[9.9,3.3,-1],[7.0,7.4,-1],[0.2,6.7,-1],[1.6,3.8,-1],[1.8,0.2,-1],[2.4,5.0,1],[7.6,5.1,-1],[8.4,2.3,-1],[1.0,7.9,-1],[3.1,1.9,-1],[1.3,0.9,1],[7.2,1.6,-1],[8.1,5.4,-1],[0.4,4.8,-1],[2.8,5.6,-1],[9.3,5.9,-1],[9.5,6.8,1],[7.6,8.3,-1],[3.4,5.5,1],[3.6,8.4,-1],[8.3,6.1,-1],[5.7,2.7,1],[3.9,2.5,1],[6.6,1.2,-1],[2.0,6.9,-1],[5.1,6.2,-1],[0.7,3.9,-1],[9.9,4.7,-1],[7.8,2.9,-1],[8.7,3.6,-1],[2.4,0.3,-1],[0.5,3.8,-1],[9.0,2.2,-1],[4.2,8.8,-1],[9.6,7.6,-1],[3.3,6.3,1],[2.1,6.1,-1],[5.6,8.2,-1],[4.1,4.2,1],[3.6,9.0,-1],[9.1,1.9,-1],[8.7,2.4,-1],[7.0,5.1,1],[9.5,1.8,-1],[0.9,8.6,-1],[3.0,2.7,-1],[4.0,3.1,1],[6.9,6.0,1],[9.3,7.8,-1],[9.3,7.5,-1]]};
const SETS = {
d1: { tr: [1, 1, 1, -1, -1, -1, 1, 1, 1, -1].map((y, i) => [i + 1, 0, y]), te: null, dims: [0], M: 10 },
d2: { tr: C.tr, te: C.te, dims: [0, 1], M: 100 }
};
// Mejor tocón ponderado: recorre variables, cortes (incluidos los extremos) y sentido.
function stump(pts, w, dims) {
let best = null;
dims.forEach(f => {
const v = [...new Set(pts.map(p => p[f]))].sort((a, b) => a - b);
const cuts = [v[0] - 0.5, ...v.slice(0, -1).map((a, i) => (a + v[i + 1]) / 2), v[v.length - 1] + 0.5];
cuts.forEach(t => [1, -1].forEach(s => {
let e = 0; pts.forEach((p, i) => { const h = p[f] <= t ? s : -s; if (h !== p[2]) e += w[i]; });
if (!best || e < best.e - 1e-12) best = { f, t, s, e };
}));
});
return best;
}
const hval = (st, p) => (p[st.f] <= st.t ? st.s : -st.s);
const cache = {};
function run(key) {
if (cache[key]) return cache[key];
const S = SETS[key], n = S.tr.length; let w = Array(n).fill(1 / n); const rounds = [];
for (let m = 0; m < S.M; m++) {
const st = stump(S.tr, w, S.dims), e = Math.max(st.e, 1e-10), a = 0.5 * Math.log((1 - e) / e);
rounds.push({ ...st, e: st.e, a, w: [...w] });
w = w.map((wi, i) => wi * Math.exp(-a * S.tr[i][2] * hval(st, S.tr[i]))); const z = K.sum(w); w = w.map(x => x / z);
}
const F = (p, m) => { let s = 0; for (let k = 0; k < m; k++) s += rounds[k].a * hval(rounds[k], p); return s; };
const errs = set => rounds.map((_, m) => set.filter(p => Math.sign(F(p, m + 1)) !== p[2]).length / set.length);
return (cache[key] = { rounds, F, errTr: errs(S.tr), errTe: S.te ? errs(S.te) : null });
}
const modeSeg = K.segmented($('#ada-mode'), () => { const S = SETS[modeSeg.get()]; $('#ada-m').max = S.M; if (+$('#ada-m').value > S.M) $('#ada-m').value = 3; render(); });
const fmtSt = (st, key) => key === 'd1' ? `x ≤ ${K.fmt(st.t, 1)} ⇒ ${st.s > 0 ? '+' : '−'}` : `x${st.f ? '₂' : '₁'} ≤ ${K.fmt(st.t, 2)} ⇒ ${st.s > 0 ? '+' : '−'}`;
function render() {
const key = modeSeg.get(), S = SETS[key], m = +$('#ada-m').value, R = run(key), rd = R.rounds[m - 1];
$('#ada-m-v').textContent = m;
$('#ada-h').textContent = fmtSt(rd, key); $('#ada-eps').textContent = K.fmt(rd.e, 3); $('#ada-alpha').textContent = K.fmt(rd.a, 3);
const nErr = Math.round(R.errTr[m - 1] * S.tr.length);
$('#ada-err').textContent = `${nErr}/${S.tr.length}` + (S.te ? ` · prueba ${K.fmt(R.errTe[m - 1], 3)}` : '');
$('#ada-leg-te').style.display = S.te ? '' : 'none';
const wmax = Math.max(...rd.w);
if (key === 'd1') {
$('#ada-plot-t').textContent = 'Pesos (tamaño) y puntaje F(x) del ensamble';
const c = K.chart($('#ada-svg'), { width: 380, height: 280, x: [0.3, 10.7], y: [-2.2, 2.2], margin: { left: 40, bottom: 40 } });
c.axes({ xTicks: 10, yTicks: 4, xLabel: 'x', yLabel: 'F(x)', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 1) });
c.zeroLines(); c.line(0.3, 0, 10.7, 0, 'muted line thin');
const steps = []; for (let x = 0.5; x <= 10.5; x += 0.5) steps.push([x, R.F([x], m)]);
const fp = []; steps.forEach((s, i) => { fp.push(s); if (i + 1 < steps.length) fp.push([steps[i + 1][0], s[1]]); });
c.path(fp, 's4 line');
c.line(rd.t, -2.2, rd.t, 2.2, 's5 line dash');
c.text(rd.t, 2.0, rd.s > 0 ? '← + − →' : '← − + →', { 'text-anchor': 'middle', class: 'strong' });
S.tr.forEach((p, i) => { const d = c.dot(p[0], p[2] > 0 ? 1.2 : -1.2, 3 + 14 * Math.sqrt(rd.w[i] / wmax) , (p[2] > 0 ? 's1' : 's2') + ' dot');
d.style.fillOpacity = 0.75; if (Math.sign(R.F(p, m)) !== p[2]) { d.style.stroke = 'var(--lab-text)'; d.style.strokeWidth = 2; } });
$('#ada-leg-f').style.display = '';
} else {
$('#ada-plot-t').textContent = 'Región del ensamble y pesos de los puntos';
const c = K.chart($('#ada-svg'), { width: 380, height: 380, x: [0, 10], y: [0, 10], margin: { left: 40, right: 12, top: 12, bottom: 40 } });
const G = 40, st = 10 / G;
for (let i = 0; i < G; i++) for (let j = 0; j < G; j++) { const p = [(i + 0.5) * st, (j + 0.5) * st]; const r = c.rect(p[0] - st / 2, p[1] - st / 2, p[0] + st / 2, p[1] + st / 2, R.F(p, m) >= 0 ? 's1' : 's2'); r.style.fillOpacity = 0.14; r.style.stroke = 'none'; }
c.axes({ xTicks: 5, yTicks: 5, xLabel: 'x₁', yLabel: 'x₂', grid: false, xFmt: v => K.fmt(v, 0) });
const circ = []; for (let k = 0; k <= 64; k++) circ.push([5 + 3 * Math.cos(k / 32 * Math.PI), 5 + 3 * Math.sin(k / 32 * Math.PI)]); c.path(circ, 's3 line dash thin');
if (rd.f === 0) c.line(rd.t, 0, rd.t, 10, 's5 line dash'); else c.line(0, rd.t, 10, rd.t, 's5 line dash');
S.tr.forEach((p, i) => { const d = c.dot(p[0], p[1], 2 + 9 * Math.sqrt(rd.w[i] / wmax), (p[2] > 0 ? 's1' : 's2') + ' dot');
if (Math.sign(R.F(p, m)) !== p[2]) { d.style.stroke = 'var(--lab-text)'; d.style.strokeWidth = 1.8; } });
$('#ada-leg-f').style.display = 'none';
}
const M = S.M, ymax = Math.max(0.4, ...R.errTr, ...(R.errTe || [])) * 1.05;
const cc = K.chart($('#ada-curve'), { width: 380, height: 260, x: [0, M + 0.5], y: [0, ymax], margin: { left: 44, bottom: 40 } });
cc.axes({ xTicks: Math.min(M, 5), yTicks: 4, xLabel: 'rondas', yLabel: 'error', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 2) });
cc.path(R.errTr.map((e, k) => [k + 1, e]), 's1 line');
if (R.errTe) cc.path(R.errTe.map((e, k) => [k + 1, e]), 's4 line');
cc.dot(m, R.errTr[m - 1], 6, 's2 dot');
const show = R.rounds.slice(Math.max(0, m - 8), m);
$('#ada-tab').innerHTML = '<table class="plab-table"><thead><tr><th>Ronda</th><th>Tocón</th><th>ε</th><th>α</th><th>Errores del ensamble</th></tr></thead><tbody>' +
show.map((r, k) => { const idx = Math.max(0, m - 8) + k; return `<tr${idx === m - 1 ? ' class="hl"' : ''}><td>${idx + 1}</td><td>${fmtSt(r, key)}</td><td>${K.fmt(r.e, 4)}</td><td>${K.fmt(r.a, 4)}</td><td>${Math.round(R.errTr[idx] * S.tr.length)}/${S.tr.length}</td></tr>`; }).join('') + '</tbody></table>';
const note = $('#ada-note');
if (key === 'd1') { note.className = nErr ? 'plab-note warn' : 'plab-note good';
note.textContent = nErr ? `Tras ${m} ronda(s) el ensamble aún falla ${nErr} punto(s). Los puntos grandes son los que la ronda ${m} debe atender: los errores anteriores pesan en conjunto 0.5.` : `Con ${m} tocones el ensamble clasifica bien los diez puntos, aunque cada tocón falla tres o cuatro. Las rondas posteriores siguen repesando, pero el error ya no cambia.`; }
else { const best = R.errTe.indexOf(Math.min(...R.errTe)); note.className = 'plab-note ' + (m > 60 && R.errTe[m - 1] > Math.min(...R.errTe) + 0.02 ? 'warn' : 'good');
note.textContent = `Ronda ${m}: error de entrenamiento ${K.fmt(R.errTr[m - 1], 3)} y de prueba ${K.fmt(R.errTe[m - 1], 3)}. La frontera circular se aproxima con escalones. Los puntos con etiqueta invertida acumulan peso ronda tras ronda: AdaBoost es sensible al ruido.`; }
}
$('#ada-m').addEventListener('input', render);
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio recorre AdaBoost ronda por ronda. Valores de referencia con los diez puntos: ronda 1, tocón $x \le 3.5 ⇒ +$, $\varepsilon = 0.300$, $\alpha = 0.424$; ronda 2, $x \le 9.5 ⇒ +$, $\varepsilon = 0.214$, $\alpha = 0.650$; ronda 3, $x \le 6.5 ⇒ −$, $\varepsilon = 0.182$, $\alpha = 0.752$, y el ensamble ya no comete errores. En el modo *Círculo* el error de prueba baja de 0.27 con un tocón a cerca de 0.08 con 30 rondas y después se estabiliza, mientras el de entrenamiento sigue bajando.
:::
**Guía del laboratorio.**
1. Con *Diez puntos*, recorre las rondas 1 a 3 y comprueba $\varepsilon$, $\alpha$ y los pesos contra la construcción paso a paso.
2. En la ronda 2, ¿por qué el tocón elegido deja mal a los puntos 4, 5 y 6? ¿Cuánto pesan en ese momento?
3. Predice antes de mover: en la ronda 4, ¿qué tocón se elegirá? Pista: compara los pesos de los grupos.
4. Cambia a *Círculo*. ¿Cuántas rondas hacen falta para que la región se parezca al círculo? ¿El error de prueba sigue bajando todo el tiempo?
5. Localiza un punto con etiqueta invertida (un punto azul lejos del círculo o naranja dentro) y observa su tamaño en rondas avanzadas. ¿Qué dice esto de AdaBoost con datos ruidosos?
## Bagging frente a boosting
| | Bagging y bosques aleatorios | Boosting |
|---|---|---|
| Entrenamiento | En paralelo, independiente | En secuencia, dependiente |
| Modelo base | Fuerte e inestable (árbol profundo) | Débil (tocón, árbol poco profundo) |
| Qué reduce | Varianza | Sesgo (y algo de varianza) |
| Combinación | Promedio o voto simple | Suma ponderada |
| Riesgo de sobreajuste al agregar modelos | No | Sí: se controla el número de rondas |
| Ruido en las etiquetas | Robusto | Sensible (AdaBoost) |
| Ajuste de hiperparámetros | Poco necesario | Importante |
: Comparación de las dos familias de ensambles. {#tbl-bagging-boosting}
Como regla práctica, un bosque aleatorio con valores por defecto es una referencia excelente y difícil de batir con poco esfuerzo; el *boosting* bien ajustado (capítulo siguiente) suele ganar por un margen pequeño cuando hay tiempo para validar sus hiperparámetros.
## Implementación en Python
### Árbol de regresión y bagging
```{python}
#| label: ens-laptops
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeRegressor, export_text
ram = pd.DataFrame({"RAM": [4, 4, 8, 8, 16, 16]}, index=list("ABCDEF"))
precio = np.array([6500, 7200, 10500, 12000, 21000, 24000])
arbol = DecisionTreeRegressor(max_depth=1).fit(ram, precio)
print(export_text(arbol, feature_names=["RAM"]))
# La muestra bootstrap del texto: {A, A, C, D, F, F}; fuera de la bolsa: B y E
boot = ["A", "A", "C", "D", "F", "F"]
arbol_b = DecisionTreeRegressor(max_depth=1).fit(ram.loc[boot], pd.Series(precio, index=ram.index).loc[boot])
oob = ["B", "E"]
print("Predicciones OOB:", dict(zip(oob, arbol_b.predict(ram.loc[oob]))))
print(f"Probabilidad de quedar fuera de una muestra bootstrap: n=6 → {(5/6)**6:.3f}; n grande → {np.exp(-1):.3f}")
```
Los datos del laboratorio y el efecto de promediar:
```{python}
#| label: fig-ens-bagging
#| fig-cap: "Error cuadrático medio en prueba de bagging y de un bosque aleatorio según el número de árboles, frente a un árbol único y al error irreducible (0.36). En una sola variable, el bosque aleatorio coincide con bagging."
import plotly.graph_objects as go
from sklearn.ensemble import BaggingRegressor
from sklearn.metrics import mean_squared_error
def f_real(x):
return 2 * np.sin(x) + 0.3 * x
def generar_reg(n, semilla):
rng = np.random.default_rng(semilla)
x = np.sort(rng.uniform(0, 10, n)).round(2)
return x, (f_real(x) + rng.normal(0, 0.6, n)).round(2)
x_tr, y_tr = generar_reg(40, 11)
x_te, y_te = generar_reg(300, 12)
X_tr, X_te = x_tr.reshape(-1, 1), x_te.reshape(-1, 1)
for prof in [1, 2, 4, None]:
t = DecisionTreeRegressor(max_depth=prof, random_state=0).fit(X_tr, y_tr)
print(f"Árbol único (profundidad {prof}): ECM entrenamiento {mean_squared_error(y_tr, t.predict(X_tr)):.3f}, "
f"prueba {mean_squared_error(y_te, t.predict(X_te)):.3f}")
Bs = [1, 2, 5, 10, 20, 50, 100, 200]
ecm_bag = [mean_squared_error(y_te, BaggingRegressor(DecisionTreeRegressor(), n_estimators=B, random_state=0)
.fit(X_tr, y_tr).predict(X_te)) for B in Bs]
bag_oob = BaggingRegressor(DecisionTreeRegressor(), n_estimators=200, oob_score=True, random_state=0).fit(X_tr, y_tr)
print(f"Bagging con 200 árboles: ECM prueba {ecm_bag[-1]:.3f}, ECM fuera de la bolsa "
f"{mean_squared_error(y_tr, bag_oob.oob_prediction_):.3f}")
fig = go.Figure(go.Scatter(x=Bs, y=ecm_bag, mode="lines+markers", name="bagging", line=dict(color="#7c3aed")))
fig.add_hline(y=mean_squared_error(y_te, DecisionTreeRegressor(random_state=0).fit(X_tr, y_tr).predict(X_te)),
line=dict(color="gray", dash="dash"), annotation_text="árbol único")
fig.add_hline(y=0.36, line=dict(color="#16a34a", dash="dot"), annotation_text="error irreducible")
fig.update_layout(xaxis=dict(type="log", title="número de árboles"), yaxis_title="ECM en prueba",
height=340, margin=dict(l=10, r=10, t=20, b=40))
fig.show()
```
### AdaBoost: el ejemplo manual
`AdaBoostClassifier` con tocones reproduce las tres rondas. Sus `estimator_weights_` son el doble de los $\alpha_m$ clásicos (variante SAMME).
```{python}
#| label: ens-adaboost-manual
from sklearn.ensemble import AdaBoostClassifier
from sklearn.tree import DecisionTreeClassifier
x10 = np.arange(1, 11).reshape(-1, 1)
y10 = np.array([1, 1, 1, -1, -1, -1, 1, 1, 1, -1])
ada = AdaBoostClassifier(DecisionTreeClassifier(max_depth=1), n_estimators=3, random_state=0).fit(x10, y10)
pd.DataFrame({
"corte": [e.tree_.threshold[0] for e in ada.estimators_],
"ε": ada.estimator_errors_,
"α clásico (½ ln)": ada.estimator_weights_ / 2,
"errores del ensamble": [int((p != y10).sum()) for p in ada.staged_predict(x10)],
}, index=[1, 2, 3]).round(4)
```
### Comparación en un problema real
Comparamos un árbol, *bagging*, un bosque aleatorio, AdaBoost y *gradient boosting* en el conjunto *breast cancer* (569 tumores, 30 variables) con validación cruzada estratificada repetida.
```{python}
#| label: ens-comparacion
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import BaggingClassifier, RandomForestClassifier, GradientBoostingClassifier
from sklearn.model_selection import RepeatedStratifiedKFold, cross_validate, train_test_split
X, y = load_breast_cancer(return_X_y=True, as_frame=True)
cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=0)
modelos = {
"Árbol (sin poda)": DecisionTreeClassifier(random_state=0),
"Bagging (300 árboles)": BaggingClassifier(DecisionTreeClassifier(), n_estimators=300, random_state=0, n_jobs=-1),
"Bosque aleatorio (300)": RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1),
"AdaBoost (300 tocones)": AdaBoostClassifier(DecisionTreeClassifier(max_depth=1), n_estimators=300, random_state=0),
"Gradient boosting": GradientBoostingClassifier(random_state=0),
}
filas = []
for nombre, m in modelos.items():
r = cross_validate(m, X, y, cv=cv, scoring=["accuracy", "roc_auc"])
filas.append({"modelo": nombre, "exactitud": r["test_accuracy"].mean(), "desv.": r["test_accuracy"].std(),
"AUC": r["test_roc_auc"].mean()})
pd.DataFrame(filas).set_index("modelo").round(3)
```
### Error fuera de la bolsa e importancia de variables
```{python}
#| label: fig-ens-importancia
#| fig-cap: "Diez variables más importantes del bosque aleatorio según la disminución de impureza (MDI, en el entrenamiento) y según la permutación (en prueba). Ambas coinciden en las variables principales, pero la MDI reparte importancia entre variables muy correlacionadas que la permutación considera casi intercambiables."
from sklearn.inspection import permutation_importance
from plotly.subplots import make_subplots
X_tr2, X_te2, y_tr2, y_te2 = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0)
rf = RandomForestClassifier(n_estimators=500, oob_score=True, random_state=0, n_jobs=-1).fit(X_tr2, y_tr2)
print(f"Exactitud fuera de la bolsa: {rf.oob_score_:.3f} · exactitud en prueba: {rf.score(X_te2, y_te2):.3f}")
mdi = pd.Series(rf.feature_importances_, index=X.columns)
perm = pd.Series(permutation_importance(rf, X_te2, y_te2, n_repeats=20, random_state=0).importances_mean, index=X.columns)
top = mdi.sort_values(ascending=False).index[:10][::-1]
fig = make_subplots(rows=1, cols=2, subplot_titles=("MDI (impureza)", "Permutación (prueba)"), shared_yaxes=True)
fig.add_trace(go.Bar(x=mdi[top], y=top, orientation="h", marker_color="#2563eb"), row=1, col=1)
fig.add_trace(go.Bar(x=perm[top], y=top, orientation="h", marker_color="#ea580c"), row=1, col=2)
fig.update_layout(height=420, showlegend=False, margin=dict(l=10, r=10, t=40, b=30))
fig.show()
```
## Resumen
- Un **árbol de regresión** predice el promedio de cada hoja y elige cortes que reducen la suma de cuadrados.
- Combinar modelos funciona si cada uno es mejor que el azar y sus errores son **diversos**; la correlación entre modelos limita la reducción de varianza ($\rho\sigma^2$).
- **Bagging** promedia árboles entrenados con muestras bootstrap; cada árbol deja fuera ≈ 36.8 % de los datos, lo que da el **error fuera de la bolsa** sin costo adicional.
- Los **bosques aleatorios** eligen cada corte entre $m$ variables al azar para decorrelacionar los árboles; más árboles no sobreajustan. La importancia por permutación es más fiable que la MDI.
- **AdaBoost** entrena clasificadores débiles en secuencia, repesando los errores y combinándolos con pesos $\alpha_m = \frac{1}{2}\ln\frac{1 - \varepsilon_m}{\varepsilon_m}$; minimiza la pérdida exponencial y es sensible al ruido.
- *Bagging* reduce varianza; *boosting*, sesgo. El capítulo siguiente generaliza el *boosting* a cualquier pérdida con el **gradient boosting** y su implementación más popular, XGBoost.
## Referencias {.unnumbered}
::: {#refs}
:::