{"spec_id":"calibration-curve","library":"d3","language":"javascript","code":"// anyplot.ai\n// calibration-curve: Calibration Curve\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst inkMuted = window.ANYPLOT_THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: a credit-default risk model's predicted probabilities ------------\n// Deterministic LCG so the render is reproducible without a network call.\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst n = 3000;\nconst yProb = new Array(n);\nconst yTrue = new Array(n);\nfor (let i = 0; i < n; i++) {\n  // Most borrowers score low risk; a thin tail scores high risk.\n  const predicted = Math.pow(rand(), 1.7);\n  // The model is overconfident: true default rates trail the predicted\n  // probability, especially in the mid range.\n  const truePositiveRate = Math.pow(predicted, 1.5);\n  yProb[i] = predicted;\n  yTrue[i] = rand() < truePositiveRate ? 1 : 0;\n}\n\nconst binCount = 10;\nconst bins = Array.from({ length: binCount }, () => ({ sumProb: 0, sumTrue: 0, count: 0 }));\nlet sumSquaredError = 0;\nfor (let i = 0; i < n; i++) {\n  const b = bins[Math.min(binCount - 1, Math.floor(yProb[i] * binCount))];\n  b.sumProb += yProb[i];\n  b.sumTrue += yTrue[i];\n  b.count += 1;\n  sumSquaredError += (yProb[i] - yTrue[i]) ** 2;\n}\nconst reliability = bins\n  .map((b, i) => ({\n    binStart: i / binCount,\n    binEnd: (i + 1) / binCount,\n    meanPred: b.count > 0 ? b.sumProb / b.count : null,\n    obsFreq: b.count > 0 ? b.sumTrue / b.count : null,\n    count: b.count,\n  }))\n  .filter((d) => d.count > 0);\n\nconst brierScore = sumSquaredError / n;\nconst ece = reliability.reduce((acc, d) => acc + (d.count / n) * Math.abs(d.obsFreq - d.meanPred), 0);\n\n// --- Layout: reliability plot on top, prediction histogram below ------------\nconst margin = { top: 90, right: 70, bottom: 60, left: 90 };\nconst plotWidth = width - margin.left - margin.right;\nconst calibHeight = 480;\nconst gap = 40;\nconst histHeight = height - margin.top - margin.bottom - calibHeight - gap;\n\nconst x = d3.scaleLinear().domain([0, 1]).range([0, plotWidth]);\nconst yCalib = d3.scaleLinear().domain([0, 1]).range([calibHeight, 0]);\nconst yHist = d3\n  .scaleLinear()\n  .domain([0, d3.max(reliability, (d) => d.count)])\n  .nice()\n  .range([histHeight, 0]);\nconst radius = d3\n  .scaleSqrt()\n  .domain([d3.min(reliability, (d) => d.count), d3.max(reliability, (d) => d.count)])\n  .range([7, 18]);\n\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Title --------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"calibration-curve · javascript · d3 · anyplot.ai\");\n\n// --- Reliability diagram --------------------------------------------------\nconst calib = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\ncalib\n  .append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(yCalib).tickValues([0, 0.2, 0.4, 0.6, 0.8, 1]).tickSize(-plotWidth).tickFormat(\"\"))\n  .call((g) => g.select(\".domain\").remove())\n  .call((g) => g.selectAll(\"line\").attr(\"stroke\", t.grid));\n\nconst calibYAxis = calib.append(\"g\").call(d3.axisLeft(yCalib).tickValues([0, 0.2, 0.4, 0.6, 0.8, 1]).tickFormat(d3.format(\".1f\")));\ncalibYAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\ncalibYAxis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\ncalibYAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\ncalib\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -calibHeight / 2)\n  .attr(\"y\", -62)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Observed default rate\");\n\n// Perfect-calibration reference line — the neutral/baseline semantic anchor.\ncalib\n  .append(\"line\")\n  .attr(\"x1\", x(0))\n  .attr(\"y1\", yCalib(0))\n  .attr(\"x2\", x(1))\n  .attr(\"y2\", yCalib(1))\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"7 6\")\n  .attr(\"opacity\", 0.55);\n\nconst line = d3\n  .line()\n  .x((d) => x(d.meanPred))\n  .y((d) => yCalib(d.obsFreq));\n\ncalib\n  .append(\"path\")\n  .datum(reliability)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 3)\n  .attr(\"d\", line);\n\ncalib\n  .selectAll(\"circle\")\n  .data(reliability)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => x(d.meanPred))\n  .attr(\"cy\", (d) => yCalib(d.obsFreq))\n  .attr(\"r\", (d) => radius(d.count))\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// Legend\nconst legend = calib.append(\"g\").attr(\"transform\", \"translate(16,16)\");\nlegend.append(\"line\").attr(\"x1\", 0).attr(\"x2\", 28).attr(\"y1\", 0).attr(\"y2\", 0).attr(\"stroke\", t.palette[0]).attr(\"stroke-width\", 3);\nlegend.append(\"circle\").attr(\"cx\", 14).attr(\"cy\", 0).attr(\"r\", 6).attr(\"fill\", t.palette[0]);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 38)\n  .attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Model calibration (marker size = bin sample count)\");\nlegend\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 28)\n  .attr(\"y1\", 26)\n  .attr(\"y2\", 26)\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"7 6\")\n  .attr(\"opacity\", 0.55);\nlegend.append(\"text\").attr(\"x\", 38).attr(\"y\", 31).attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").text(\"Perfect calibration\");\n\n// Summary metrics — required by the specification. Placed in the empty\n// lower-right corner so it never collides with the curve or reference line,\n// both of which run through the top-right corner in this scenario.\ncalib\n  .append(\"text\")\n  .attr(\"x\", plotWidth - 10)\n  .attr(\"y\", calibHeight - 18)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", inkMuted)\n  .style(\"font-size\", \"15px\")\n  .text(`Brier score: ${brierScore.toFixed(3)}  ·  ECE: ${ece.toFixed(3)}`);\n\n// --- Prediction-distribution histogram --------------------------------------\nconst hist = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top + calibHeight + gap})`);\n\nconst histYAxis = hist.append(\"g\").call(d3.axisLeft(yHist).ticks(4));\nhistYAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nhistYAxis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nhistYAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nhist\n  .append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(yHist).ticks(4).tickSize(-plotWidth).tickFormat(\"\"))\n  .call((g) => g.select(\".domain\").remove())\n  .call((g) => g.selectAll(\"line\").attr(\"stroke\", t.grid));\n\nhist\n  .selectAll(\"rect\")\n  .data(reliability)\n  .join(\"rect\")\n  .attr(\"x\", (d) => x(d.binStart) + 2)\n  .attr(\"width\", (d) => Math.max(0, x(d.binEnd) - x(d.binStart) - 4))\n  .attr(\"y\", (d) => yHist(d.count))\n  .attr(\"height\", (d) => histHeight - yHist(d.count))\n  .attr(\"fill\", inkMuted);\n\nconst 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\")));\nhistXAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nhistXAxis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nhistXAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nhist\n  .append(\"text\")\n  .attr(\"x\", plotWidth / 2)\n  .attr(\"y\", histHeight + 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Mean predicted probability\");\n\nhist\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -histHeight / 2)\n  .attr(\"y\", -62)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .text(\"Count\");\n"}