Bias-Variance Tradeoff Curve — D3.js

A theoretical visualization of the bias-variance tradeoff showing how total prediction error decomposes into bias squared, variance, and irreducible noise as a function of model complexity. The plot displays multiple curves: bias squared (decreasing with complexity), variance (increasing with complexity), irreducible error (constant), and total error (U-shaped). This is one of the most fundamental conceptual plots in machine learning for understanding model selection, overfitting, and underfitting.

Bias-Variance Tradeoff Curve rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// curve-bias-variance-tradeoff: Bias-Variance Tradeoff Curve
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-08-24

//# anyplot-orientation: landscape

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 170, right: 210, bottom: 90, left: 95 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data (theoretical decomposition, deterministic) -------------------------
const modelComplexity = d3.range(0, 20.001, 0.25);
const biasSquared = modelComplexity.map((c) => 5 / (1 + 0.4 * c));
const variance = modelComplexity.map((c) => 0.011 * c * c);
const irreducibleError = modelComplexity.map(() => 1.0);
const totalError = modelComplexity.map(
  (c, i) => biasSquared[i] + variance[i] + irreducibleError[i]
);

let optimalIdx = 0;
for (let i = 1; i < totalError.length; i++) {
  if (totalError[i] < totalError[optimalIdx]) optimalIdx = i;
}
const optimalComplexity = modelComplexity[optimalIdx];
const optimalError = totalError[optimalIdx];

// --- Scales --------------------------------------------------------------------
const x = d3.scaleLinear().domain([0, 20]).range([0, iw]);
const y = d3.scaleLinear().domain([0, d3.max(totalError) * 1.1]).nice().range([ih, 0]);

// --- SVG mount -------------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Underfitting / overfitting zones (drawn first, behind everything) -----------
g.append("rect")
  .attr("x", 0).attr("y", 0)
  .attr("width", x(optimalComplexity)).attr("height", ih)
  .attr("fill", t.palette[1]).attr("opacity", 0.08);

g.append("rect")
  .attr("x", x(optimalComplexity)).attr("y", 0)
  .attr("width", iw - x(optimalComplexity)).attr("height", ih)
  .attr("fill", t.palette[2]).attr("opacity", 0.08);

g.append("text")
  .attr("x", x(optimalComplexity) / 2).attr("y", 26)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft).style("font-size", "15px").style("font-style", "italic")
  .text("Underfitting zone");

g.append("text")
  .attr("x", x(optimalComplexity) + (iw - x(optimalComplexity)) / 2).attr("y", 26)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft).style("font-size", "15px").style("font-style", "italic")
  .text("Overfitting zone");

// --- Y-axis grid (subtle, full-width) ---------------------------------------------
const grid = g.append("g").call(d3.axisLeft(y).tickSize(-iw).tickFormat(""));
grid.selectAll("line").attr("stroke", t.grid);
grid.select(".domain").remove();

// --- Axes ----------------------------------------------------------------------
const xAxis = g.append("g").attr("transform", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(5));
const yAxis = g.append("g").call(d3.axisLeft(y).ticks(6));
for (const ax of [xAxis, yAxis]) {
  ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
  ax.selectAll("line").attr("stroke", t.inkSoft);
  ax.select(".domain").attr("stroke", t.inkSoft);
}

// --- Optimal-complexity marker ---------------------------------------------------
g.append("line")
  .attr("x1", x(optimalComplexity)).attr("x2", x(optimalComplexity))
  .attr("y1", 0).attr("y2", ih)
  .attr("stroke", t.ink).attr("stroke-width", 1.5)
  .attr("stroke-dasharray", "5,4").attr("opacity", 0.6);

g.append("circle")
  .attr("cx", x(optimalComplexity)).attr("cy", y(optimalError)).attr("r", 7)
  .attr("fill", t.palette[0]).attr("stroke", t.pageBg).attr("stroke-width", 2);

const optimalAnchor = optimalComplexity > 12 ? "end" : "start";
g.append("text")
  .attr("x", x(optimalComplexity) + (optimalAnchor === "end" ? -12 : 12)).attr("y", 46)
  .attr("text-anchor", optimalAnchor)
  .attr("fill", t.ink).style("font-size", "15px").style("font-weight", "600")
  .text(`Optimal complexity ≈ ${optimalComplexity.toFixed(1)}`);

// --- Curves ----------------------------------------------------------------------
const line = d3.line().x((d) => x(d.c)).y((d) => y(d.v)).curve(d3.curveMonotoneX);
const toSeries = (arr) => modelComplexity.map((c, i) => ({ c, v: arr[i] }));

const curves = [
  { label: "Bias²", data: toSeries(biasSquared), color: t.palette[1], width: 3, dash: "10,5" },
  { label: "Variance", data: toSeries(variance), color: t.palette[2], width: 3, dash: "3,3" },
  { label: "Irreducible error", data: toSeries(irreducibleError), color: t.ink, width: 2.5, dash: "2,6" },
  { label: "Total error", data: toSeries(totalError), color: t.palette[0], width: 4, dash: "none" },
];

for (const s of curves) {
  const path = g.append("path")
    .datum(s.data)
    .attr("fill", "none")
    .attr("stroke", s.color)
    .attr("stroke-width", s.width)
    .attr("d", line);
  if (s.dash !== "none") path.attr("stroke-dasharray", s.dash);
}

// --- Direct end-of-line labels (collision-avoided) --------------------------------
const endLabels = curves
  .map((s) => ({ label: s.label, color: s.color, y: y(s.data[s.data.length - 1].v) }))
  .sort((a, b) => a.y - b.y);

const minGap = 22;
for (let i = 1; i < endLabels.length; i++) {
  if (endLabels[i].y - endLabels[i - 1].y < minGap) {
    endLabels[i].y = endLabels[i - 1].y + minGap;
  }
}

g.selectAll(".end-label")
  .data(endLabels)
  .join("text")
  .attr("x", iw + 12).attr("y", (d) => d.y + 5)
  .attr("fill", (d) => d.color).style("font-size", "16px").style("font-weight", "600")
  .text((d) => d.label);

// --- Formula annotation -----------------------------------------------------------
svg.append("text")
  .attr("x", width / 2).attr("y", 88).attr("text-anchor", "middle")
  .attr("fill", t.inkSoft).style("font-size", "18px").style("font-style", "italic")
  .text("Total Error = Bias² + Variance + Irreducible Error");

// --- Axis labels -------------------------------------------------------------------
g.append("text")
  .attr("x", iw / 2).attr("y", ih + 60).attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "18px")
  .text("Model Complexity (Low → High)");

g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -ih / 2).attr("y", -65).attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "18px")
  .text("Prediction Error");

// --- Title ------------------------------------------------------------------------
const titleText = "curve-bias-variance-tradeoff · javascript · d3 · anyplot.ai";
const titleRatio = titleText.length > 67 ? 67 / titleText.length : 1;
const titleFontSize = Math.round(22 * titleRatio);
svg.append("text")
  .attr("x", width / 2).attr("y", 44).attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", `${titleFontSize}px`).style("font-weight", "600")
  .text(titleText);

Part of Bias-Variance Tradeoff Curve on anyplot.ai.

Other implementations