The chart below shows eight data points (blue dots). The model starts with the mean prediction (dashed line). Each round you click Add one tree fits a tiny stump to the current residuals and adds it to the ensemble. Watch the red residual bars collapse toward zero.
<!-- Gradient Boosting demo — additive ensemble with residual bars -->
<div class="gb-wrap">
<div class="gb-top">
<div class="chart-label">Ensemble prediction vs. data</div>
<canvas id="gbCanvas" width="480" height="220"></canvas>
</div>
<div class="residual-section">
<div class="chart-label">Residuals (errors) after each round</div>
<canvas id="resCanvas" width="480" height="100"></canvas>
</div>
<div class="info-row">
<span class="round-badge">Round: <span id="roundNum">0</span></span>
<span class="rmse-badge">RMSE: <span id="rmseVal">—</span></span>
</div>
<div class="btns">
<button id="btnAdd" type="button">Add one tree</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div id="treeLog" class="tree-log"></div>
</div>
/* Layout and chart styling for gradient boosting demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.gb-wrap { padding: .5rem; max-width: 500px; }
.chart-label { font-size: .78rem; color: #666; margin-bottom: .2rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
canvas { display: block; border: 1px solid #dde3ea; border-radius: 6px; background: #f8fafc; width: 100%; }
.info-row { display: flex; gap: 1rem; align-items: center; margin: .5rem 0; flex-wrap: wrap; }
.round-badge, .rmse-badge { font-size: .88rem; font-weight: 700; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.tree-log { font-size: .8rem; color: #444; line-height: 1.6; max-height: 80px; overflow-y: auto; }
.tree-log .entry { display: flex; gap: .5rem; }
.tree-log .t-round { color: #1d3557; font-weight: 700; min-width: 3ch; }
.tree-log .t-split { color: #555; }
.tree-log .t-rmse { color: #0a7d33; font-weight: 600; margin-left: auto; }
/* Gradient Boosting — sequential stumps fitted to residuals */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Fixed 1-D training data: x in [1..8], y = noisy linear signal */
const DATA_X = [1, 2, 3, 4, 5, 6, 7, 8];
const DATA_Y = [2.5, 3.8, 3.2, 5.1, 4.6, 6.3, 5.8, 7.4];
const MAX_ROUNDS = 8;
const LR = 0.5; // learning rate: scale each tree's contribution
/* Model state — residuals, cumulative predictions, round counter */
let residuals = DATA_Y.slice();
let predictions = new Array(DATA_X.length).fill(0);
let round = 0;
let basePred = 0;
/* Root mean squared error of the current residuals */
function rmse(res) {
const mse = res.reduce((s, r) => s + r * r, 0) / res.length;
return Math.sqrt(mse).toFixed(3);
}
/* Fit a depth-1 decision stump to minimize squared loss on (xs, ys) */
function fitStump(xs, ys) {
let bestSplit = null, bestLoss = Infinity;
for (let i = 0; i < xs.length - 1; i++) {
const thresh = (xs[i] + xs[i + 1]) / 2;
const left = ys.filter((_, j) => xs[j] <= thresh);
const right = ys.filter((_, j) => xs[j] > thresh);
if (!left.length || !right.length) continue;
const lMean = left.reduce((a, b) => a + b, 0) / left.length;
const rMean = right.reduce((a, b) => a + b, 0) / right.length;
const loss = left.reduce((s, v) => s + (v - lMean) ** 2, 0)
+ right.reduce((s, v) => s + (v - rMean) ** 2, 0);
if (loss < bestLoss) { bestLoss = loss; bestSplit = { thresh, lMean, rMean }; }
}
return bestSplit;
}
/* Predict with a fitted stump */
function predictStump(stump, x) {
return x <= stump.thresh ? stump.lMean : stump.rMean;
}
function init() {
basePred = DATA_Y.reduce((a, b) => a + b, 0) / DATA_Y.length;
residuals = DATA_Y.map(y => y - basePred);
predictions = new Array(DATA_X.length).fill(basePred);
round = 0;
document.getElementById('roundNum').textContent = '0';
document.getElementById('rmseVal').textContent = rmse(residuals);
document.getElementById('treeLog').innerHTML = '';
document.getElementById('btnAdd').disabled = false;
drawMain();
drawResiduals();
}
/* Draw the main chart showing data points and ensemble curve */
function drawMain() {
const cv = document.getElementById('gbCanvas');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const PAD = { l: 36, r: 12, t: 14, b: 26 };
const xMin = 0, xMax = 9, yMin = 0, yMax = 9;
function px(x) { return PAD.l + (x - xMin) / (xMax - xMin) * (W - PAD.l - PAD.r); }
function py(y) { return H - PAD.b - (y - yMin) / (yMax - yMin) * (H - PAD.t - PAD.b); }
ctx.clearRect(0, 0, W, H);
/* Grid lines and y-axis labels */
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
for (let v = 1; v <= 8; v++) {
ctx.beginPath(); ctx.moveTo(PAD.l, py(v)); ctx.lineTo(W - PAD.r, py(v));
ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText(v, PAD.l - 4, py(v) + 3);
}
/* Dashed line = base prediction (mean) */
ctx.strokeStyle = '#a0b4c8'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(px(xMin), py(basePred)); ctx.lineTo(px(xMax), py(basePred)); ctx.stroke();
ctx.setLineDash([]);
/* Solid blue line = current ensemble prediction */
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2;
ctx.beginPath();
DATA_X.forEach((x, i) => {
i === 0 ? ctx.moveTo(px(x), py(predictions[i])) : ctx.lineTo(px(x), py(predictions[i]));
});
ctx.stroke();
/* Blue dots = training data */
DATA_X.forEach((x, i) => {
ctx.beginPath();
ctx.arc(px(x), py(DATA_Y[i]), 5, 0, 2 * Math.PI);
ctx.fillStyle = '#2979c4'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
});
}
/* Draw the residual bar chart below the main chart */
function drawResiduals() {
const cv = document.getElementById('resCanvas');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const PAD = { l: 36, r: 12, t: 10, b: 22 };
const absMax = Math.max(3, ...residuals.map(Math.abs));
function px(x) { return PAD.l + (x - 1) / 7 * (W - PAD.l - PAD.r); }
function py(y) { return (H - PAD.t - PAD.b) / 2 + PAD.t - y / absMax * ((H - PAD.t - PAD.b) / 2); }
ctx.clearRect(0, 0, W, H);
/* Zero baseline */
const mid = py(0);
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PAD.l, mid); ctx.lineTo(W - PAD.r, mid); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText('0', PAD.l - 4, mid + 3);
/* Residual bars (red = positive, orange = negative) */
const barW = Math.max(12, (W - PAD.l - PAD.r) / DATA_X.length - 8);
DATA_X.forEach((x, i) => {
const bx = px(x) - barW / 2;
const by = Math.min(mid, py(residuals[i]));
const bh = Math.abs(py(residuals[i]) - mid);
ctx.fillStyle = residuals[i] >= 0 ? '#e06060' : '#e09030';
ctx.fillRect(bx, by, barW, bh);
ctx.fillStyle = '#555'; ctx.textAlign = 'center'; ctx.font = '9px system-ui';
ctx.fillText(residuals[i].toFixed(1), px(x), residuals[i] >= 0 ? by - 2 : by + bh + 10);
});
}
/* Fit one stump to current residuals and update the ensemble */
function addTree() {
if (round >= MAX_ROUNDS) return;
const stump = fitStump(DATA_X, residuals);
if (!stump) return;
/* Apply learning rate and update predictions and residuals */
const newPred = predictions.map((p, i) => p + LR * predictStump(stump, DATA_X[i]));
const newRes = DATA_Y.map((y, i) => y - newPred[i]);
predictions = newPred;
residuals = newRes;
round++;
const r = rmse(residuals);
document.getElementById('roundNum').textContent = round;
document.getElementById('rmseVal').textContent = r;
/* Append a log entry for this round */
const log = document.getElementById('treeLog');
const entry = document.createElement('div');
entry.className = 'entry';
entry.innerHTML = `<span class="t-round">${round}</span>`
+ `<span class="t-split">${i18nFmt('x ≤ %thresh% → +%lv%, else +%rv%', { thresh: stump.thresh.toFixed(1), lv: (LR * stump.lMean).toFixed(2), rv: (LR * stump.rMean).toFixed(2) })}</span>`
+ `<span class="t-rmse">RMSE ${r}</span>`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
drawMain();
drawResiduals();
if (round >= MAX_ROUNDS) document.getElementById('btnAdd').disabled = true;
}
document.getElementById('btnAdd').addEventListener('click', addTree);
document.getElementById('btnReset').addEventListener('click', init);
init();
<!-- Gradient Boosting demo — additive ensemble with residual bars -->
<div class="gb-wrap">
<div class="gb-top">
<div class="chart-label">Ensemble prediction vs. data</div>
<canvas id="gbCanvas" width="480" height="220"></canvas>
</div>
<div class="residual-section">
<div class="chart-label">Residuals (errors) after each round</div>
<canvas id="resCanvas" width="480" height="100"></canvas>
</div>
<div class="info-row">
<span class="round-badge">Round: <span id="roundNum">0</span></span>
<span class="rmse-badge">RMSE: <span id="rmseVal">—</span></span>
</div>
<div class="btns">
<button id="btnAdd" type="button">Add one tree</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div id="treeLog" class="tree-log"></div>
</div>
/* Layout and chart styling for gradient boosting demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.gb-wrap { padding: .5rem; max-width: 500px; }
.chart-label { font-size: .78rem; color: #666; margin-bottom: .2rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
canvas { display: block; border: 1px solid #dde3ea; border-radius: 6px; background: #f8fafc; width: 100%; }
.info-row { display: flex; gap: 1rem; align-items: center; margin: .5rem 0; flex-wrap: wrap; }
.round-badge, .rmse-badge { font-size: .88rem; font-weight: 700; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.tree-log { font-size: .8rem; color: #444; line-height: 1.6; max-height: 80px; overflow-y: auto; }
.tree-log .entry { display: flex; gap: .5rem; }
.tree-log .t-round { color: #1d3557; font-weight: 700; min-width: 3ch; }
.tree-log .t-split { color: #555; }
.tree-log .t-rmse { color: #0a7d33; font-weight: 600; margin-left: auto; }
/* Gradient Boosting — sequential stumps fitted to residuals */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Fixed 1-D training data: x in [1..8], y = noisy linear signal */
const DATA_X = [1, 2, 3, 4, 5, 6, 7, 8];
const DATA_Y = [2.5, 3.8, 3.2, 5.1, 4.6, 6.3, 5.8, 7.4];
const MAX_ROUNDS = 8;
const LR = 0.5; // learning rate: scale each tree's contribution
/* Model state — residuals, cumulative predictions, round counter */
let residuals = DATA_Y.slice();
let predictions = new Array(DATA_X.length).fill(0);
let round = 0;
let basePred = 0;
/* Root mean squared error of the current residuals */
function rmse(res) {
const mse = res.reduce((s, r) => s + r * r, 0) / res.length;
return Math.sqrt(mse).toFixed(3);
}
/* Fit a depth-1 decision stump to minimize squared loss on (xs, ys) */
function fitStump(xs, ys) {
let bestSplit = null, bestLoss = Infinity;
for (let i = 0; i < xs.length - 1; i++) {
const thresh = (xs[i] + xs[i + 1]) / 2;
const left = ys.filter((_, j) => xs[j] <= thresh);
const right = ys.filter((_, j) => xs[j] > thresh);
if (!left.length || !right.length) continue;
const lMean = left.reduce((a, b) => a + b, 0) / left.length;
const rMean = right.reduce((a, b) => a + b, 0) / right.length;
const loss = left.reduce((s, v) => s + (v - lMean) ** 2, 0)
+ right.reduce((s, v) => s + (v - rMean) ** 2, 0);
if (loss < bestLoss) { bestLoss = loss; bestSplit = { thresh, lMean, rMean }; }
}
return bestSplit;
}
/* Predict with a fitted stump */
function predictStump(stump, x) {
return x <= stump.thresh ? stump.lMean : stump.rMean;
}
function init() {
basePred = DATA_Y.reduce((a, b) => a + b, 0) / DATA_Y.length;
residuals = DATA_Y.map(y => y - basePred);
predictions = new Array(DATA_X.length).fill(basePred);
round = 0;
document.getElementById('roundNum').textContent = '0';
document.getElementById('rmseVal').textContent = rmse(residuals);
document.getElementById('treeLog').innerHTML = '';
document.getElementById('btnAdd').disabled = false;
drawMain();
drawResiduals();
}
/* Draw the main chart showing data points and ensemble curve */
function drawMain() {
const cv = document.getElementById('gbCanvas');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const PAD = { l: 36, r: 12, t: 14, b: 26 };
const xMin = 0, xMax = 9, yMin = 0, yMax = 9;
function px(x) { return PAD.l + (x - xMin) / (xMax - xMin) * (W - PAD.l - PAD.r); }
function py(y) { return H - PAD.b - (y - yMin) / (yMax - yMin) * (H - PAD.t - PAD.b); }
ctx.clearRect(0, 0, W, H);
/* Grid lines and y-axis labels */
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
for (let v = 1; v <= 8; v++) {
ctx.beginPath(); ctx.moveTo(PAD.l, py(v)); ctx.lineTo(W - PAD.r, py(v));
ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText(v, PAD.l - 4, py(v) + 3);
}
/* Dashed line = base prediction (mean) */
ctx.strokeStyle = '#a0b4c8'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(px(xMin), py(basePred)); ctx.lineTo(px(xMax), py(basePred)); ctx.stroke();
ctx.setLineDash([]);
/* Solid blue line = current ensemble prediction */
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2;
ctx.beginPath();
DATA_X.forEach((x, i) => {
i === 0 ? ctx.moveTo(px(x), py(predictions[i])) : ctx.lineTo(px(x), py(predictions[i]));
});
ctx.stroke();
/* Blue dots = training data */
DATA_X.forEach((x, i) => {
ctx.beginPath();
ctx.arc(px(x), py(DATA_Y[i]), 5, 0, 2 * Math.PI);
ctx.fillStyle = '#2979c4'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
});
}
/* Draw the residual bar chart below the main chart */
function drawResiduals() {
const cv = document.getElementById('resCanvas');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const PAD = { l: 36, r: 12, t: 10, b: 22 };
const absMax = Math.max(3, ...residuals.map(Math.abs));
function px(x) { return PAD.l + (x - 1) / 7 * (W - PAD.l - PAD.r); }
function py(y) { return (H - PAD.t - PAD.b) / 2 + PAD.t - y / absMax * ((H - PAD.t - PAD.b) / 2); }
ctx.clearRect(0, 0, W, H);
/* Zero baseline */
const mid = py(0);
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PAD.l, mid); ctx.lineTo(W - PAD.r, mid); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText('0', PAD.l - 4, mid + 3);
/* Residual bars (red = positive, orange = negative) */
const barW = Math.max(12, (W - PAD.l - PAD.r) / DATA_X.length - 8);
DATA_X.forEach((x, i) => {
const bx = px(x) - barW / 2;
const by = Math.min(mid, py(residuals[i]));
const bh = Math.abs(py(residuals[i]) - mid);
ctx.fillStyle = residuals[i] >= 0 ? '#e06060' : '#e09030';
ctx.fillRect(bx, by, barW, bh);
ctx.fillStyle = '#555'; ctx.textAlign = 'center'; ctx.font = '9px system-ui';
ctx.fillText(residuals[i].toFixed(1), px(x), residuals[i] >= 0 ? by - 2 : by + bh + 10);
});
}
/* Fit one stump to current residuals and update the ensemble */
function addTree() {
if (round >= MAX_ROUNDS) return;
const stump = fitStump(DATA_X, residuals);
if (!stump) return;
/* Apply learning rate and update predictions and residuals */
const newPred = predictions.map((p, i) => p + LR * predictStump(stump, DATA_X[i]));
const newRes = DATA_Y.map((y, i) => y - newPred[i]);
predictions = newPred;
residuals = newRes;
round++;
const r = rmse(residuals);
document.getElementById('roundNum').textContent = round;
document.getElementById('rmseVal').textContent = r;
/* Append a log entry for this round */
const log = document.getElementById('treeLog');
const entry = document.createElement('div');
entry.className = 'entry';
entry.innerHTML = `<span class="t-round">${round}</span>`
+ `<span class="t-split">${i18nFmt('x ≤ %thresh% → +%lv%, else +%rv%', { thresh: stump.thresh.toFixed(1), lv: (LR * stump.lMean).toFixed(2), rv: (LR * stump.rMean).toFixed(2) })}</span>`
+ `<span class="t-rmse">RMSE ${r}</span>`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
drawMain();
drawResiduals();
if (round >= MAX_ROUNDS) document.getElementById('btnAdd').disabled = true;
}
document.getElementById('btnAdd').addEventListener('click', addTree);
document.getElementById('btnReset').addEventListener('click', init);
init();
Notice how the first few trees make the biggest dent — residuals that once spanned several units shrink rapidly. Later trees do progressively finer correction. This is the bias-variance trade-off in action: early rounds attack bias; too many rounds on noisy data start fitting noise instead of signal (overfitting). The learning rate η controls how aggressively each tree is trusted.
Comments
Loading comments...