Calibration Curve — MUI X Charts

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 MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// calibration-curve: Calibration Curve
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 90/100 | Created: 2026-09-02
import { LineChart } from "@mui/x-charts/LineChart";
import { BarChart } from "@mui/x-charts/BarChart";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";

const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------

// mulberry32 PRNG — small, fast, fixed-seed (the browser has no seeded RNG)
function mulberry32(seed: number) {
  return function random() {
    seed = (seed + 0x6d2b79f5) | 0;
    let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}

// Standard normal via Box-Muller, driven by the same PRNG stream
function gaussian(random: () => number): number {
  const u = Math.max(random(), 1e-9);
  const v = random();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}

const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
const logit = (p: number) => Math.log(p / (1 - p));
const sigmoid = (z: number) => 1 / (1 + Math.exp(-z));

const N_SAMPLES = 3000;
const NUM_BINS = 10;
const random = mulberry32(42);

// Latent true disease risk for each patient, and the observed diagnosis
// drawn from a Bernoulli trial at that risk — this is the ground truth
// both classifiers below are scored against.
const trueRisk = Array.from({ length: N_SAMPLES }, () => random());
const yTrue = trueRisk.map((risk) => (random() < risk ? 1 : 0));

// Model A: well-calibrated screening classifier — small prediction noise.
const predA = trueRisk.map((risk) => clamp01(risk + 0.05 * gaussian(random)));

// Model B: overconfident classifier — pushes predictions toward 0/1 by
// sharpening the log-odds, so mid-risk patients get extreme scores.
const OVERCONFIDENCE_SCALE = 2.4;
const predB = trueRisk.map((risk) =>
  sigmoid(logit(clamp01(risk) * 0.998 + 0.001) * OVERCONFIDENCE_SCALE),
);

type Bin = { center: number; fracPos: number | null; count: number };

function calibrationBins(yProb: number[]): Bin[] {
  const sums = Array.from({ length: NUM_BINS }, () => ({ count: 0, sumTrue: 0 }));
  yProb.forEach((p, i) => {
    const idx = Math.min(NUM_BINS - 1, Math.floor(p * NUM_BINS));
    sums[idx].count += 1;
    sums[idx].sumTrue += yTrue[i];
  });
  return sums.map((b, i) => ({
    center: (i + 0.5) / NUM_BINS,
    fracPos: b.count > 0 ? b.sumTrue / b.count : null,
    count: b.count,
  }));
}

function brierScore(yProb: number[]): number {
  const sse = yProb.reduce((acc, p, i) => acc + (p - yTrue[i]) ** 2, 0);
  return sse / yProb.length;
}

function expectedCalibrationError(bins: Bin[], total: number): number {
  return bins.reduce((acc, b) => {
    if (b.fracPos === null) return acc;
    return acc + (b.count / total) * Math.abs(b.fracPos - b.center);
  }, 0);
}

const binsA = calibrationBins(predA);
const binsB = calibrationBins(predB);
const eceA = expectedCalibrationError(binsA, N_SAMPLES);
const eceB = expectedCalibrationError(binsB, N_SAMPLES);
const brierA = brierScore(predA);
const brierB = brierScore(predB);

// Shared x-axis: bin centers bracketed by 0 and 1 so the diagonal reaches
// both corners; the bracket points are `null` on the model series (gaps),
// so only the 10 real bins draw markers.
const xValues = [0, ...binsA.map((b) => b.center), 1];
const modelASeries = [null, ...binsA.map((b) => b.fracPos), null];
const modelBSeries = [null, ...binsB.map((b) => b.fracPos), null];
const diagonalSeries = xValues;

const binLabels = binsA.map((b) => {
  const lo = Math.round((b.center - 0.05) * 100);
  const hi = Math.round((b.center + 0.05) * 100);
  return `${lo}–${hi}%`;
});

const TITLE_HEIGHT = 78;

// --- Chart (default-exported component — the harness mounts it) ------------

export default function Chart() {
  const { width, height } = window.ANYPLOT_SIZE;
  const chartAreaHeight = height - TITLE_HEIGHT;
  const mainHeight = Math.round(chartAreaHeight * 0.62);
  const histHeight = chartAreaHeight - mainHeight;

  return (
    <Box
      sx={{
        width,
        height,
        display: "flex",
        flexDirection: "column",
        paddingTop: "16px",
      }}
    >
      <Typography
        sx={{
          color: t.ink,
          fontSize: 22,
          fontWeight: 500,
          textAlign: "center",
          lineHeight: 1.2,
        }}
      >
        calibration-curve · javascript · muix · anyplot.ai
      </Typography>
      <Typography
        sx={{
          color: t.inkSoft,
          fontSize: 14,
          textAlign: "center",
          lineHeight: 1.4,
          marginTop: "4px",
        }}
      >
        {`Model A — ECE ${eceA.toFixed(3)}, Brier ${brierA.toFixed(3)}   ·   Model B — ECE ${eceB.toFixed(3)}, Brier ${brierB.toFixed(3)}`}
      </Typography>

      <LineChart
        width={width}
        height={mainHeight}
        skipAnimation
        colors={t.palette}
        xAxis={[
          {
            data: xValues,
            scaleType: "linear",
            min: 0,
            max: 1,
            label: "Mean Predicted Probability",
            valueFormatter: (v: number) => `${Math.round(v * 100)}%`,
          },
        ]}
        yAxis={[
          {
            min: 0,
            max: 1,
            label: "Fraction of Positives",
            valueFormatter: (v: number) => `${Math.round(v * 100)}%`,
            // tickFontSize drives the label's reserved offset from the tick
            // text (MUI X sizes that gap off this prop, not tickLabelStyle),
            // so it must stay wide enough for a 4-char "100%" tick.
            tickFontSize: 32,
            labelFontSize: 16,
            tickLabelStyle: { fontSize: 14 },
          },
        ]}
        series={[
          {
            id: "diagonal",
            data: diagonalSeries,
            label: "Perfect calibration",
            color: t.ink,
            showMark: false,
            curve: "linear",
          },
          {
            id: "modelA",
            data: modelASeries,
            label: "Model A (well-calibrated)",
            color: t.palette[0],
            showMark: true,
            curve: "linear",
          },
          {
            id: "modelB",
            data: modelBSeries,
            label: "Model B (overconfident)",
            color: t.palette[1],
            showMark: true,
            curve: "linear",
          },
        ]}
        margin={{ left: 90, right: 40, top: 20, bottom: 70 }}
        sx={{
          "& .MuiChartsAxis-tickLabel": { fontSize: "14px" },
          "& .MuiChartsAxis-label": { fontSize: "16px" },
          "& .MuiChartsLegend-label": { fontSize: "14px" },
          "& .MuiLineElement-series-modelA": { strokeWidth: 3 },
          "& .MuiLineElement-series-modelB": { strokeWidth: 3 },
          "& .MuiLineElement-series-diagonal": { strokeWidth: 2, strokeDasharray: "8 5" },
          "& .MuiChartsGrid-line": { stroke: t.grid },
        }}
      >
        <ChartsGrid horizontal />
      </LineChart>

      <BarChart
        width={width}
        height={histHeight}
        skipAnimation
        borderRadius={2}
        xAxis={[
          {
            scaleType: "band",
            data: binLabels,
            label: "Predicted Probability Bin",
          },
        ]}
        yAxis={[
          {
            label: "Count",
            tickFontSize: 40,
            labelFontSize: 14,
            tickLabelStyle: { fontSize: 12 },
          },
        ]}
        series={[
          { data: binsA.map((b) => b.count), label: "Model A", color: t.palette[0] },
          { data: binsB.map((b) => b.count), label: "Model B", color: t.palette[1] },
        ]}
        margin={{ left: 90, right: 40, top: 10, bottom: 60 }}
        slotProps={{ legend: { hidden: true } }}
        sx={{
          "& .MuiChartsAxis-tickLabel": { fontSize: "12px" },
          "& .MuiChartsAxis-label": { fontSize: "14px" },
        }}
      />
    </Box>
  );
}

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/calibration-curve/muix/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": "muix",
  "page": "https://anyplot.ai/calibration-curve/javascript/muix",
  "hub": "https://anyplot.ai/calibration-curve",
  "code_json": "https://api.anyplot.ai/specs/calibration-curve/muix/code",
  "spec_json": "https://api.anyplot.ai/specs/calibration-curve",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/muix/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/muix/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/muix/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/calibration-curve/javascript/muix/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Calibration Curve on anyplot.ai.

Other implementations