Calibration Curve — D3.js

A calibration curve (reliability diagram) visualizes how well the predicted probabilities of a binary classifier match actual outcomes. By plotting the fraction of positives against mean predicted probability in binned intervals, it reveals whether a model is well-calibrated, overconfident, or underconfident. A perfectly calibrated model follows the diagonal line where predicted probability equals observed frequency.

Calibration Curve rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// calibration-curve: Calibration Curve
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 90/100 | Created: 2026-09-02

const t = window.ANYPLOT_TOKENS;
const inkMuted = window.ANYPLOT_THEME === "dark" ? "#A8A79F" : "#6B6A63";
const { width, height } = window.ANYPLOT_SIZE;

// --- Data: a credit-default risk model's predicted probabilities ------------
// Deterministic LCG so the render is reproducible without a network call.
function lcg(seed) {
  let state = seed >>> 0;
  return () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rand = lcg(42);

const n = 3000;
const yProb = new Array(n);
const yTrue = new Array(n);
for (let i = 0; i < n; i++) {
  // Most borrowers score low risk; a thin tail scores high risk.
  const predicted = Math.pow(rand(), 1.7);
  // The model is overconfident: true default rates trail the predicted
  // probability, especially in the mid range.
  const truePositiveRate = Math.pow(predicted, 1.5);
  yProb[i] = predicted;
  yTrue[i] = rand() < truePositiveRate ? 1 : 0;
}

const binCount = 10;
const bins = Array.from({ length: binCount }, () => ({ sumProb: 0, sumTrue: 0, count: 0 }));
let sumSquaredError = 0;
for (let i = 0; i < n; i++) {
  const b = bins[Math.min(binCount - 1, Math.floor(yProb[i] * binCount))];
  b.sumProb += yProb[i];
  b.sumTrue += yTrue[i];
  b.count += 1;
  sumSquaredError += (yProb[i] - yTrue[i]) ** 2;
}
const reliability = bins
  .map((b, i) => ({
    binStart: i / binCount,
    binEnd: (i + 1) / binCount,
    meanPred: b.count > 0 ? b.sumProb / b.count : null,
    obsFreq: b.count > 0 ? b.sumTrue / b.count : null,
    count: b.count,
  }))
  .filter((d) => d.count > 0);

const brierScore = sumSquaredError / n;
const ece = reliability.reduce((acc, d) => acc + (d.count / n) * Math.abs(d.obsFreq - d.meanPred), 0);

// --- Layout: reliability plot on top, prediction histogram below ------------
const margin = { top: 90, right: 70, bottom: 60, left: 90 };
const plotWidth = width - margin.left - margin.right;
const calibHeight = 480;
const gap = 40;
const histHeight = height - margin.top - margin.bottom - calibHeight - gap;

const x = d3.scaleLinear().domain([0, 1]).range([0, plotWidth]);
const yCalib = d3.scaleLinear().domain([0, 1]).range([calibHeight, 0]);
const yHist = d3
  .scaleLinear()
  .domain([0, d3.max(reliability, (d) => d.count)])
  .nice()
  .range([histHeight, 0]);
const radius = d3
  .scaleSqrt()
  .domain([d3.min(reliability, (d) => d.count), d3.max(reliability, (d) => d.count)])
  .range([7, 18]);

const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);

// --- Title --------------------------------------------------------------
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 44)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px")
  .style("font-weight", "600")
  .text("calibration-curve · javascript · d3 · anyplot.ai");

// --- Reliability diagram --------------------------------------------------
const calib = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

calib
  .append("g")
  .attr("class", "grid")
  .call(d3.axisLeft(yCalib).tickValues([0, 0.2, 0.4, 0.6, 0.8, 1]).tickSize(-plotWidth).tickFormat(""))
  .call((g) => g.select(".domain").remove())
  .call((g) => g.selectAll("line").attr("stroke", t.grid));

const calibYAxis = calib.append("g").call(d3.axisLeft(yCalib).tickValues([0, 0.2, 0.4, 0.6, 0.8, 1]).tickFormat(d3.format(".1f")));
calibYAxis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
calibYAxis.selectAll("line").attr("stroke", t.inkSoft);
calibYAxis.select(".domain").attr("stroke", t.inkSoft);

calib
  .append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -calibHeight / 2)
  .attr("y", -62)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Observed default rate");

// Perfect-calibration reference line — the neutral/baseline semantic anchor.
calib
  .append("line")
  .attr("x1", x(0))
  .attr("y1", yCalib(0))
  .attr("x2", x(1))
  .attr("y2", yCalib(1))
  .attr("stroke", t.ink)
  .attr("stroke-width", 2)
  .attr("stroke-dasharray", "7 6")
  .attr("opacity", 0.55);

const line = d3
  .line()
  .x((d) => x(d.meanPred))
  .y((d) => yCalib(d.obsFreq));

calib
  .append("path")
  .datum(reliability)
  .attr("fill", "none")
  .attr("stroke", t.palette[0])
  .attr("stroke-width", 3)
  .attr("d", line);

calib
  .selectAll("circle")
  .data(reliability)
  .join("circle")
  .attr("cx", (d) => x(d.meanPred))
  .attr("cy", (d) => yCalib(d.obsFreq))
  .attr("r", (d) => radius(d.count))
  .attr("fill", t.palette[0])
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 2);

// Legend
const legend = calib.append("g").attr("transform", "translate(16,16)");
legend.append("line").attr("x1", 0).attr("x2", 28).attr("y1", 0).attr("y2", 0).attr("stroke", t.palette[0]).attr("stroke-width", 3);
legend.append("circle").attr("cx", 14).attr("cy", 0).attr("r", 6).attr("fill", t.palette[0]);
legend
  .append("text")
  .attr("x", 38)
  .attr("y", 5)
  .attr("fill", t.inkSoft)
  .style("font-size", "14px")
  .text("Model calibration (marker size = bin sample count)");
legend
  .append("line")
  .attr("x1", 0)
  .attr("x2", 28)
  .attr("y1", 26)
  .attr("y2", 26)
  .attr("stroke", t.ink)
  .attr("stroke-width", 2)
  .attr("stroke-dasharray", "7 6")
  .attr("opacity", 0.55);
legend.append("text").attr("x", 38).attr("y", 31).attr("fill", t.inkSoft).style("font-size", "14px").text("Perfect calibration");

// Summary metrics — required by the specification. Placed in the empty
// lower-right corner so it never collides with the curve or reference line,
// both of which run through the top-right corner in this scenario.
calib
  .append("text")
  .attr("x", plotWidth - 10)
  .attr("y", calibHeight - 18)
  .attr("text-anchor", "end")
  .attr("fill", inkMuted)
  .style("font-size", "15px")
  .text(`Brier score: ${brierScore.toFixed(3)}  ·  ECE: ${ece.toFixed(3)}`);

// --- Prediction-distribution histogram --------------------------------------
const hist = svg.append("g").attr("transform", `translate(${margin.left},${margin.top + calibHeight + gap})`);

const histYAxis = hist.append("g").call(d3.axisLeft(yHist).ticks(4));
histYAxis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "13px");
histYAxis.selectAll("line").attr("stroke", t.inkSoft);
histYAxis.select(".domain").attr("stroke", t.inkSoft);

hist
  .append("g")
  .attr("class", "grid")
  .call(d3.axisLeft(yHist).ticks(4).tickSize(-plotWidth).tickFormat(""))
  .call((g) => g.select(".domain").remove())
  .call((g) => g.selectAll("line").attr("stroke", t.grid));

hist
  .selectAll("rect")
  .data(reliability)
  .join("rect")
  .attr("x", (d) => x(d.binStart) + 2)
  .attr("width", (d) => Math.max(0, x(d.binEnd) - x(d.binStart) - 4))
  .attr("y", (d) => yHist(d.count))
  .attr("height", (d) => histHeight - yHist(d.count))
  .attr("fill", inkMuted);

const histXAxis = hist.append("g").attr("transform", `translate(0,${histHeight})`).call(d3.axisBottom(x).tickValues([0, 0.2, 0.4, 0.6, 0.8, 1]).tickFormat(d3.format(".1f")));
histXAxis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
histXAxis.selectAll("line").attr("stroke", t.inkSoft);
histXAxis.select(".domain").attr("stroke", t.inkSoft);

hist
  .append("text")
  .attr("x", plotWidth / 2)
  .attr("y", histHeight + 46)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Mean predicted probability");

hist
  .append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -histHeight / 2)
  .attr("y", -62)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "14px")
  .text("Count");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/calibration-curve/d3/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "calibration-curve",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/calibration-curve/javascript/d3",
  "hub": "https://anyplot.ai/calibration-curve",
  "code_json": "https://api.anyplot.ai/specs/calibration-curve/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/calibration-curve",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/d3/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Calibration Curve on anyplot.ai.

Other implementations