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
<span class="dot test"></span> Test error
<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));
<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
<span class="dot test"></span> Test error
<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));
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.
Comments
Loading comments...