Introduction

Imagine a committee where each expert only speaks up about what the previous expert got wrong. Each correction is small and imperfect, but after enough rounds the committee's collective answer is surprisingly accurate. That is the core idea behind gradient boosting.

The algorithm starts with a single prediction — typically the mean of the training targets. It then measures the residual errors (how far each prediction is from the truth), fits a shallow decision tree to those residuals, and adds a scaled version of that tree to the model. The residuals shrink. A new tree is fitted to the new residuals, and the cycle repeats.

After TT rounds the final prediction is the sum of TT small trees:

y^=F0+η∑t=1Tht(x)\hat{y} = F_0 + \eta \sum_{t=1}^{T} h_t(x)

where F0F_0 is the initial guess, η\eta is the learning rate (a small shrinkage factor like 0.10.1), and each hth_t is a shallow tree fitted to the residuals at round tt.

Algorithms like XGBoost (Chen & Guestrin, 2016) and LightGBM (Ke et al., 2017) are refined, highly optimised versions of this idea. They dominate structured-data competitions and underpin systems from fraud detection to medical diagnosis.

Watch Residuals Shrink

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();
Result
Console

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 η\eta controls how aggressively each tree is trusted.

The Real Complexity

The word gradient is not decoration. Gradient boosting is gradient descent in function space, a framing due to Jerome Friedman (2001).

  • Loss function: for regression we minimise mean squared error L(y,y^)=12(y−y^)2L(y, \hat{y}) = \frac{1}{2}(y - \hat{y})^2. The negative gradient with respect to the current prediction is exactly y−y^y - \hat{y} — the residual.
  • Gradient step: fitting a tree to the residuals and adding it to the model is one step of steepest descent. The learning rate η\eta is the step size.
  • Any differentiable loss: swap in logistic loss and you get gradient boosting for classification; use custom loss functions for ranking, survival analysis, or any task where a gradient can be computed.
  • Regularisation: XGBoost adds an ℓ2\ell_2 penalty on the leaf weights and a term on the number of leaves, automatically controlling overfitting without needing a separate validation loop.

Training TT trees each of depth dd on nn samples costs O(T⋅n⋅d⋅2d)O(T \cdot n \cdot d \cdot 2^d) — polynomial in all parameters. Prediction is O(T⋅2d)O(T \cdot 2^d) per sample. Both are fast enough that XGBoost runs on millions of rows on a laptop.

Compare this with neural network training, where gradient descent happens in weight space and can require billions of steps. Gradient boosting's key advantage on structured data is that each step fits an interpretable tree rather than adjusting millions of anonymous weights.

Where It Matters

Gradient boosting sits behind a remarkable range of real systems:

  • Competitive machine learning: XGBoost won or featured in the majority of Kaggle structured-data competitions between 2014 and 2020. It remains the first model practitioners reach for on tabular data.
  • Credit and fraud scoring: banks and fintechs use gradient boosting to score loan applications and flag suspicious transactions in real time, because it handles missing values, mixed feature types, and skewed distributions naturally.
  • Search and recommendation: click-through rate (CTR) models at major tech companies are often gradient-boosted ensembles trained on billions of examples with LightGBM for speed.
  • Medical risk prediction: clinical decision-support tools use gradient boosting to predict readmission, sepsis onset, or cancer recurrence from electronic health records.
  • Scientific computing: dimensionality reduction and feature selection pipelines often gate downstream models with a gradient-boosted importance score to strip irrelevant columns before fitting heavier models.

The common thread is structured, heterogeneous data — rows and columns with a mix of continuous, categorical, and missing values — where gradient boosting consistently outperforms simpler models and often matches deep networks at a fraction of the training cost.

Conclusion

Gradient boosting's elegance lies in its humility: no single tree needs to be right, only less wrong than the last model. By pointing each tree at the residuals left by all the previous ones, the algorithm performs gradient descent in function space and assembles an ensemble that is routinely among the most accurate methods for structured data.

The idea generalises far beyond regression. Any differentiable loss function — classification, ranking, survival — plugs in by swapping the residuals for the corresponding negative gradient. XGBoost and LightGBM add regularisation, histogram-based splits, and parallelism on top, but the core loop remains: fit, measure error, fit the error, repeat.

Next time you see a Kaggle leaderboard dominated by gradient-boosted ensembles, or a credit model that "just works," remember: underneath is a long chain of weak trees, each one quietly correcting the mistakes of the last.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/gradient-boosting/Content licensed under CC BY-NC 4.0.