Introduction

For most of the twentieth century, statistics taught a clean story: fit your data too closely and your model breaks on new examples. The bias-variance tradeoff said error follows a U-shape — underfitting on the left, overfitting on the right, and a sweet spot of complexity in the middle. Graduate students memorized it. Textbooks printed it in color.

Then deep neural networks arrived with millions, then billions, of parameters — wildly more than the number of training examples — and kept getting better. The U-curve predicted catastrophe. Reality handed out state-of-the-art accuracy.

In 2019, Mikhail Belkin and colleagues named the pattern double descent: test error drops in the classical regime, spikes sharply at the interpolation threshold (where the model can just barely memorize all training data), then descends a second time as the model grows even larger. The curve is not a U — it is a W, or a double U, and it demands a new theory.

Watch the Curve Appear

The slider below controls model complexity — think of it as adding more parameters to a polynomial fit. The blue curve is training error (always falling), and the red curve is test error on held-out data.

<p class="hint">Drag the slider to increase model complexity. Watch the <b style="color:#2563eb">training error</b> (blue) fall monotonically, while the <b style="color:#dc2626">test error</b> (red) first drops, then spikes at the <b>interpolation threshold</b>, then descends a second time into the overparameterized regime.</p>
<div class="ctrl">
  <label for="complexity">Model complexity: <span id="cval">1</span></label>
  <input type="range" id="complexity" min="1" max="60" value="1" step="1">
</div>
<div class="regime-label" id="regime">Regime: <strong id="regime-name">Classical (underfit)</strong></div>
<canvas id="chart" width="540" height="280"></canvas>
<div class="legend">
  <span class="dot train"></span> Training error &nbsp;
  <span class="dot test"></span> Test error &nbsp;
  <span class="dot thresh"></span> Interpolation threshold
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.ctrl { display: flex; align-items: center; gap: .7rem; margin-bottom: .4rem; font-size: .92rem; font-weight: 600; }
#complexity { flex: 1; accent-color: #1d3557; }
.regime-label { font-size: .85rem; margin-bottom: .5rem; color: #555; }
canvas { display: block; max-width: 100%; border: 1px solid #e2e8f0; border-radius: 8px; }
.legend { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; font-size: .82rem; margin-top: .45rem; color: #555; }
.dot { display: inline-block; width: 12px; height: 4px; border-radius: 2px; }
.dot.train { background: #2563eb; }
.dot.test  { background: #dc2626; }
.dot.thresh { background: #f59e0b; width: 2px; height: 12px; border-radius: 1px; }
// Double descent demo.
// We synthesise smooth train/test error curves that exhibit the classic double-descent shape:
//   1. Classical regime: test error U-shape — falls from underfitting to a minimum, then rises sharply back toward the interpolation threshold.
//
//   2. Interpolation threshold: test error spikes (model is brittle).
//   3. Over-parameterised regime: test error descends a second time, eventually reaching near the classical minimum.
//
// Formulas are chosen to mimic the qualitative behaviour documented in Belkin et al. (2019) and Nakkiran et al. (2020).  All values are
// normalised to [0, 1].

const THRESHOLD = 30; // complexity where model just fits the training set
const MAX_C = 60;

// --- curve functions ---------------------------------------------------
// x in [1, MAX_C], returns value in [0, 1]

function trainError(x) {
  // Monotonically decreasing: starts ~0.91, approaches ~0.01
  return 0.9 * Math.exp(-0.085 * (x - 1)) + 0.01;
}

function testError(x) {
  const t = THRESHOLD;
  if (x <= t) {
    // Classical U-shape: underfitting decay → minimum ~x=14 → sharp rise to spike
    const norm = x / t; // 0 → 1
    const decay  = 0.60 * Math.exp(-8 * norm);   // initial underfitting
    const floor  = 0.18;                          // classical minimum floor
    const rise   = 0.55 * Math.pow(norm, 5);     // sharp rise toward threshold
    return decay + floor + rise;
  } else {
    // Spike at threshold, then second descent
    const dist = x - t;
    const spike = 0.55 * Math.exp(-0.08 * dist); // rapid decay from spike
    const floor  = 0.18 + 0.05 * Math.exp(-0.04 * dist); // asymptote ~0.18
    return spike + floor;
  }
}

// --- canvas drawing ----------------------------------------------------
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 20, right: 20, bottom: 40, left: 48 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;

function toCanvasX(c) { return PAD.left + (c - 1) / (MAX_C - 1) * plotW; }
function toCanvasY(v) { return PAD.top + (1 - v) * plotH; }

function drawAxes() {
  ctx.clearRect(0, 0, W, H);
  ctx.strokeStyle = '#cbd5e1';
  ctx.lineWidth = 1;
  // Y axis
  ctx.beginPath(); ctx.moveTo(PAD.left, PAD.top); ctx.lineTo(PAD.left, PAD.top + plotH); ctx.stroke();
  // X axis
  ctx.beginPath(); ctx.moveTo(PAD.left, PAD.top + plotH); ctx.lineTo(PAD.left + plotW, PAD.top + plotH); ctx.stroke();
  // Y grid lines + labels
  ctx.fillStyle = '#94a3b8'; ctx.font = '11px system-ui';
  ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
  [0, 0.25, 0.5, 0.75, 1.0].forEach(v => {
    const y = toCanvasY(v);
    ctx.fillText((v * 100).toFixed(0) + '%', PAD.left - 5, y);
    ctx.strokeStyle = '#f1f5f9'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + plotW, y); ctx.stroke();
  });
  // X axis label
  ctx.fillStyle = '#64748b'; ctx.font = '11px system-ui';
  ctx.textAlign = 'center'; ctx.textBaseline = 'top';
  ctx.fillText('Model Complexity →', PAD.left + plotW / 2, PAD.top + plotH + 8);
  // Y axis label
  ctx.save();
  ctx.translate(13, PAD.top + plotH / 2);
  ctx.rotate(-Math.PI / 2);
  ctx.fillText('Error', 0, 0);
  ctx.restore();
}

function drawFullCurve(fn, color, dashed) {
  ctx.beginPath();
  ctx.strokeStyle = color; ctx.lineWidth = 2.2;
  if (dashed) ctx.setLineDash([5, 4]); else ctx.setLineDash([]);
  for (let c = 1; c <= MAX_C; c++) {
    const x = toCanvasX(c), y = toCanvasY(fn(c));
    c === 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.stroke();
  ctx.setLineDash([]);
}

function drawThresholdLine() {
  const x = toCanvasX(THRESHOLD);
  ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.8;
  ctx.setLineDash([4, 3]);
  ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, PAD.top + plotH); ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = '#f59e0b'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
  ctx.fillText('Interp. threshold', x, PAD.top + 4);
}

function drawCurrentDot(c, fn, color) {
  const x = toCanvasX(c), y = toCanvasY(fn(c));
  ctx.beginPath(); ctx.arc(x, y, 5, 0, 2 * Math.PI);
  ctx.fillStyle = color; ctx.fill();
  ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
}

function getRegime(c) {
  if (c < THRESHOLD * 0.7) return 'Classical (underfit)';
  if (c < THRESHOLD - 1) return 'Classical (near threshold)';
  if (c <= THRESHOLD + 2) return 'Interpolation threshold ⚠️';
  if (c < THRESHOLD + 12) return 'Overparameterized (descent begins)';
  return 'Overparameterized (second descent)';
}

function render(c) {
  drawAxes();
  drawThresholdLine();
  // Draw ghost full curves (faint) so user can see shape ahead
  ctx.globalAlpha = 0.18;
  drawFullCurve(trainError, '#2563eb', false);
  drawFullCurve(testError, '#dc2626', false);
  ctx.globalAlpha = 1;
  // Draw curves up to current complexity
  ctx.beginPath();
  ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 2.5; ctx.setLineDash([]);
  for (let i = 1; i <= c; i++) {
    const x = toCanvasX(i), y = toCanvasY(trainError(i));
    i === 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.stroke();
  ctx.beginPath();
  ctx.strokeStyle = '#dc2626'; ctx.lineWidth = 2.5;
  for (let i = 1; i <= c; i++) {
    const x = toCanvasX(i), y = toCanvasY(testError(i));
    i === 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.stroke();
  // Current position dots
  drawCurrentDot(c, trainError, '#2563eb');
  drawCurrentDot(c, testError, '#dc2626');
  // Labels
  document.getElementById('cval').textContent = c;
  document.getElementById('regime-name').textContent = getRegime(c);
}

const slider = document.getElementById('complexity');
slider.addEventListener('input', () => render(+slider.value));
render(1);

// Verify curve properties (console assertions — checked by node test)
console.assert(trainError(1) > 0.8, 'train starts high');
console.assert(trainError(MAX_C) < 0.05, 'train ends low');
// Find the classical minimum (x in [1, THRESHOLD-1])
let classMin = Infinity, classMinX = 1;
for (let c = 1; c < THRESHOLD; c++) { const v = testError(c); if (v < classMin) { classMin = v; classMinX = c; } }
const testAtThreshold = testError(THRESHOLD);
const testAfterMax = testError(MAX_C);
console.assert(testAtThreshold > classMin, 'spike exceeds classical minimum');
console.assert(testAfterMax < testAtThreshold, 'second descent below spike');
console.assert(testAfterMax < classMin + 0.05, 'second descent reaches near classical minimum');
console.log('Double-descent curve assertions passed.');
console.log('trainError(1)=' + trainError(1).toFixed(3),
  'trainError(60)=' + trainError(MAX_C).toFixed(3),
  'classMin=' + classMin.toFixed(3) + ' at x=' + classMinX,
  'testError(threshold)=' + testAtThreshold.toFixed(3),
  'testError(max)=' + testAfterMax.toFixed(3));
Result
Console

Drag slowly from left to right. In the classical regime test error falls as the model learns real signal. At the interpolation threshold the model can just barely memorize every training point — it becomes brittle and test error spikes. Keep dragging into the overparameterized regime and test error descends a second time: the model is so large it finds a smooth interpolant almost for free, generalizing better than a model half its size.

The Real Complexity

Why does a model that memorizes all its training data generalize at all? The short answer is: not all interpolants are equal.

  • Minimum-norm interpolation. When a model is overparameterized it has infinitely many ways to fit the training set exactly. Gradient descent tends to find the minimum-norm solution — the one that wiggles least in function space. Low norm means smooth, and smooth functions generalize.
  • Implicit regularization. Stochastic gradient descent and other optimizers implicitly prefer simple solutions even without an explicit penalty term. This implicit bias does the work that classical regularization was supposed to do consciously.
  • Benign overfitting (proven for linear models by Bartlett et al., 2020). For certain distributions, a model can memorize noisy labels on training data and still converge to the correct decision boundary as the number of features grows. The noise is "absorbed" by the extra directions in parameter space.
  • Not yet a complete theorem. For general neural networks, double descent is a robust empirical phenomenon documented by Preetum Nakkiran et al. (2020) across ResNets, transformers, and random features models. A fully rigorous proof for all model families and data distributions remains open.

So the status is: empirically well-established, partially explained in linear and kernel settings, open in full generality. It overturns the classical bias-variance picture but replaces it with something more subtle — a landscape of risk that depends on model size, dataset size, and training time simultaneously. See also non-convex optimization and PAC learning for related tensions between theory and practice.

Where It Matters

Double descent is not a curiosity — it rewires several everyday ML decisions:

  • Model sizing. Classical advice: stop before overfitting. Modern reality: if you can afford to cross the interpolation threshold by a wide margin, a much larger model may generalize better than one chosen by cross-validation in the classical regime.
  • Early stopping is not free. Nakkiran et al. showed that epoch-wise double descent also exists: as training runs longer, test error can peak mid-training and then fall again. Stopping at the first valley may abandon a better minimum.
  • Dataset augmentation shifts the threshold. Adding more training data pushes the interpolation threshold to larger model sizes, which can temporarily worsen test error for a fixed model — a counterintuitive data-hurts region.
  • Foundation models and scaling laws. Empirical neural scaling laws (Kaplan et al., 2020) show loss falling smoothly as parameters and data grow together. Double descent explains why the scaling regime is stable: you are deep in the overparameterized zone where more is reliably better.
  • Regularization reimagined. Weight decay and dropout still help, but their role is now understood as shifting which interpolant gradient descent finds, not simply fighting overfitting in the classical sense.

Understanding double descent helps explain why the neural network training intuition "just make it bigger" so often works in practice — and when it might not.

Conclusion

The double descent curve is one of the most surprising empirical findings in modern machine learning. A century of statistical wisdom said: more complexity, more overfitting. Double descent says: keep going past the danger zone and a second, smoother valley awaits.

The intuition is almost poetic. A model that can just barely memorize its data is stuck in a brittle equilibrium — any perturbation breaks it. A model vastly larger than its data has so many degrees of freedom that it can spread the memorization across a vast parameter space, keeping the solution smooth and generalizable.

We have partial theory — minimum-norm interpolation, implicit regularization, benign overfitting in linear settings — but a complete picture for general neural networks remains open. What we do know is that the classical U-shaped intuition is a special case, valid only in the underparameterized regime. PAC learning and classical statistics describe one side of the mountain. Double descent describes the other side — and the mountain is higher than anyone expected.

Share this article

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

Comments

Loading comments...

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