{"spec_id":"calibration-curve","library":"echarts","language":"javascript","code":"// anyplot.ai\n// calibration-curve: Calibration Curve\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst MUTED = t.theme === \"light\" ? \"#6B6A63\" : \"#A8A79F\";\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// A deliberately overconfident fraud-detection classifier: the latent risk\n// score z sets the true positive rate via a mild sigmoid, but the model\n// reports a steeper sigmoid, pushing predicted probabilities toward 0/1\n// further than reality warrants.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction randNormal() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\nfunction sigmoid(x) {\n  return 1 / (1 + Math.exp(-x));\n}\n\nconst sampleCount = 3000;\nconst yTrue = [];\nconst yProb = [];\nfor (let i = 0; i < sampleCount; i++) {\n  const z = randNormal() * 1.2;\n  const trueProb = sigmoid(z);\n  yTrue.push(rand() < trueProb ? 1 : 0);\n  yProb.push(sigmoid(1.8 * z)); // overconfident: steeper than the true relationship\n}\n\n// Bin into 10 equal-width probability intervals.\nconst binCount = 10;\nconst binSumProb = new Array(binCount).fill(0);\nconst binSumPos = new Array(binCount).fill(0);\nconst binN = new Array(binCount).fill(0);\nfor (let i = 0; i < sampleCount; i++) {\n  const bin = Math.min(binCount - 1, Math.floor(yProb[i] * binCount));\n  binSumProb[bin] += yProb[i];\n  binSumPos[bin] += yTrue[i];\n  binN[bin] += 1;\n}\n\nconst calibrationPoints = [];\nconst bubbleSizes = [];\nconst histCounts = [];\nconst binLabels = [];\nlet worstGapIndex = -1;\nlet worstGap = 0;\nfor (let b = 0; b < binCount; b++) {\n  binLabels.push((b / binCount).toFixed(1));\n  histCounts.push(binN[b]);\n  if (binN[b] > 0) {\n    const meanPred = binSumProb[b] / binN[b];\n    const fracPos = binSumPos[b] / binN[b];\n    calibrationPoints.push([meanPred, fracPos]);\n    bubbleSizes.push(Math.round(8 + 22 * Math.sqrt(binN[b] / sampleCount)));\n    const gap = fracPos - meanPred;\n    if (Math.abs(gap) > Math.abs(worstGap)) {\n      worstGap = gap;\n      worstGapIndex = calibrationPoints.length - 1;\n    }\n  }\n}\n\n// Summary metrics.\nlet brierSum = 0;\nfor (let i = 0; i < sampleCount; i++) {\n  brierSum += (yProb[i] - yTrue[i]) ** 2;\n}\nconst brierScore = brierSum / sampleCount;\n\nlet ece = 0;\nfor (let b = 0; b < binCount; b++) {\n  if (binN[b] > 0) {\n    const meanPred = binSumProb[b] / binN[b];\n    const fracPos = binSumPos[b] / binN[b];\n    ece += (binN[b] / sampleCount) * Math.abs(fracPos - meanPred);\n  }\n}\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"calibration-curve · javascript · echarts · anyplot.ai\",\n    subtext: `Fraud-detection classifier · Brier score ${brierScore.toFixed(3)} · ECE ${ece.toFixed(3)}`,\n    left: \"center\",\n    top: 22,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },\n    subtextStyle: { color: t.inkSoft, fontSize: 15 },\n  },\n  legend: {\n    data: [\"Model calibration\", \"Perfect calibration\"],\n    top: 70,\n    right: 140,\n    itemWidth: 22,\n    itemHeight: 12,\n    textStyle: { color: t.inkSoft, fontSize: 14 },\n  },\n  grid: [\n    { left: 140, right: 110, top: 130, height: 480 },\n    { left: 140, right: 110, top: 650, height: 140 },\n  ],\n  xAxis: [\n    {\n      gridIndex: 0,\n      type: \"value\",\n      min: 0,\n      max: 1,\n      axisLabel: { color: t.inkSoft, fontSize: 14 },\n      axisLine: { lineStyle: { color: t.inkSoft } },\n      axisTick: { show: false },\n      splitLine: { show: true, lineStyle: { color: t.grid } },\n    },\n    {\n      gridIndex: 1,\n      type: \"category\",\n      data: binLabels,\n      name: \"Mean Predicted Probability\",\n      nameLocation: \"middle\",\n      nameGap: 46,\n      nameTextStyle: { color: t.ink, fontSize: 16 },\n      axisLabel: { color: t.inkSoft, fontSize: 13 },\n      axisLine: { lineStyle: { color: t.inkSoft } },\n      axisTick: { show: false },\n      splitLine: { show: false },\n    },\n  ],\n  yAxis: [\n    {\n      gridIndex: 0,\n      type: \"value\",\n      min: 0,\n      max: 1,\n      name: \"Fraction of Positives\",\n      nameLocation: \"middle\",\n      nameGap: 55,\n      nameTextStyle: { color: t.ink, fontSize: 16 },\n      axisLabel: { color: t.inkSoft, fontSize: 14 },\n      axisLine: { show: false },\n      axisTick: { show: false },\n      splitLine: { show: true, lineStyle: { color: t.grid } },\n    },\n    {\n      gridIndex: 1,\n      type: \"value\",\n      name: \"Count\",\n      nameLocation: \"middle\",\n      nameGap: 45,\n      nameTextStyle: { color: t.ink, fontSize: 14 },\n      axisLabel: { color: t.inkSoft, fontSize: 12 },\n      axisLine: { show: false },\n      axisTick: { show: false },\n      splitLine: { show: false },\n    },\n  ],\n  series: [\n    {\n      name: \"Perfect calibration\",\n      type: \"line\",\n      xAxisIndex: 0,\n      yAxisIndex: 0,\n      data: [\n        [0, 0],\n        [1, 1],\n      ],\n      showSymbol: false,\n      lineStyle: { color: t.ink, width: 2, type: \"dashed\" },\n      z: 1,\n    },\n    {\n      name: \"Model calibration\",\n      type: \"line\",\n      xAxisIndex: 0,\n      yAxisIndex: 0,\n      data: calibrationPoints,\n      symbol: \"circle\",\n      symbolSize: (val, params) => bubbleSizes[params.dataIndex],\n      lineStyle: { color: t.palette[0], width: 3 },\n      itemStyle: { color: t.palette[0], borderColor: t.pageBg, borderWidth: 1.5 },\n      markPoint: {\n        symbol: \"pin\",\n        symbolSize: 46,\n        itemStyle: { color: t.amber },\n        label: {\n          color: t.pageBg,\n          fontSize: 11,\n          fontWeight: 600,\n          formatter: () => (worstGap >= 0 ? \"+\" : \"\") + worstGap.toFixed(2),\n        },\n        data:\n          worstGapIndex >= 0\n            ? [{ name: \"Largest gap\", coord: calibrationPoints[worstGapIndex], value: worstGap }]\n            : [],\n      },\n      z: 2,\n    },\n    {\n      name: \"Predicted probability distribution\",\n      type: \"bar\",\n      xAxisIndex: 1,\n      yAxisIndex: 1,\n      data: histCounts,\n      barWidth: \"72%\",\n      itemStyle: { color: MUTED },\n    },\n  ],\n});\n"}