Kolmogorov-Smirnov Plot for Distribution Comparison — MUI X Charts

A Kolmogorov-Smirnov (K-S) plot compares two empirical cumulative distribution functions (ECDFs) and visualizes the K-S statistic, which is the maximum vertical distance between the two distributions. The plot displays both ECDFs as step functions, highlights the point of maximum divergence, and typically reports the K-S statistic value and p-value for hypothesis testing. This visualization is essential for determining whether two samples come from the same underlying distribution.

Kolmogorov-Smirnov Plot for Distribution Comparison rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// ks-test-comparison: Kolmogorov-Smirnov Plot for Distribution Comparison
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-08-26

import { LineChart } from "@mui/x-charts/LineChart";
import { ChartsReferenceLine } from "@mui/x-charts/ChartsReferenceLine";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";

const t = window.ANYPLOT_TOKENS;

// --- Deterministic PRNG (LCG) + Box-Muller normal sampler --------------------
let seed = 42;
function nextUniform(): number {
  seed = (seed * 1103515245 + 12345) & 0x7fffffff;
  return seed / 0x7fffffff;
}
function nextNormal(mean: number, std: number): number {
  const u1 = nextUniform() || 1e-9;
  const u2 = nextUniform();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + std * z;
}

// --- Data: credit-scoring samples for a model-discrimination check -----------
// Good-standing vs. bad-standing customers should show separated score
// distributions if the scoring model discriminates well.
const SAMPLE_SIZE = 400;
const goodScores = Array.from({ length: SAMPLE_SIZE }, () => nextNormal(680, 55));
const badScores = Array.from({ length: SAMPLE_SIZE }, () => nextNormal(590, 70));

const sortedGood = [...goodScores].sort((a, b) => a - b);
const sortedBad = [...badScores].sort((a, b) => a - b);

// Fraction of a sorted sample that is ≤ x (binary search)
function ecdfAt(sorted: number[], x: number): number {
  let lo = 0;
  let hi = sorted.length;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (sorted[mid] <= x) lo = mid + 1;
    else hi = mid;
  }
  return lo / sorted.length;
}

// Evaluate both ECDFs at every observed score so the step jumps land exactly
// where either sample has a data point.
const xGrid = [...goodScores, ...badScores].sort((a, b) => a - b);
const ecdfGood = xGrid.map((x) => ecdfAt(sortedGood, x));
const ecdfBad = xGrid.map((x) => ecdfAt(sortedBad, x));

let ksStatistic = 0;
let ksLocation = xGrid[0];
xGrid.forEach((x, i) => {
  const distance = Math.abs(ecdfGood[i] - ecdfBad[i]);
  if (distance > ksStatistic) {
    ksStatistic = distance;
    ksLocation = x;
  }
});

// Asymptotic two-sample K-S p-value (Kolmogorov distribution tail probability)
const effectiveN = (SAMPLE_SIZE * SAMPLE_SIZE) / (SAMPLE_SIZE + SAMPLE_SIZE);
const lambda = (Math.sqrt(effectiveN) + 0.12 + 0.11 / Math.sqrt(effectiveN)) * ksStatistic;
let pValue = 0;
for (let k = 1; k <= 100; k += 1) {
  pValue += 2 * (-1) ** (k - 1) * Math.exp(-2 * k * k * lambda * lambda);
}
pValue = Math.min(1, Math.max(0, pValue));
const pLabel = pValue < 0.001 ? "p < 0.001" : `p = ${pValue.toFixed(3)}`;

const HEADER_HEIGHT = 96;

// --- Chart (default-exported component — the harness mounts it) -------------
export default function Chart() {
  const { width, height } = window.ANYPLOT_SIZE;

  return (
    <Box sx={{ width, height, display: "flex", flexDirection: "column", paddingTop: "20px" }}>
      <Typography
        sx={{ color: t.ink, fontSize: 22, fontWeight: 500, textAlign: "center", lineHeight: 1.2 }}
      >
        ks-test-comparison · javascript · muix · anyplot.ai
      </Typography>
      <Typography
        sx={{ color: t.inkSoft, fontSize: 16, textAlign: "center", lineHeight: 1.4, marginTop: "6px" }}
      >
        K-S statistic D = {ksStatistic.toFixed(3)} · {pLabel}
      </Typography>
      <LineChart
        width={width}
        height={height - HEADER_HEIGHT}
        skipAnimation
        series={[
          {
            data: ecdfGood,
            label: "Good-standing customers (n=400)",
            color: t.palette[0],
            curve: "stepAfter" as const,
            showMark: false,
          },
          {
            data: ecdfBad,
            label: "Bad-standing customers (n=400)",
            color: t.palette[4],
            curve: "stepAfter" as const,
            showMark: false,
          },
        ]}
        xAxis={[
          {
            data: xGrid,
            scaleType: "linear",
            label: "Credit Score",
            valueFormatter: (v: number) => `${Math.round(v)}`,
          },
        ]}
        yAxis={[
          {
            label: "Cumulative Proportion",
            min: 0,
            max: 1,
          },
        ]}
        grid={{ horizontal: true }}
        margin={{ left: 90, right: 60, top: 20, bottom: 80 }}
        sx={{
          "& .MuiChartsAxis-tickLabel": { fontSize: "14px" },
          "& .MuiChartsAxis-label": { fontSize: "16px" },
          "& .MuiChartsLegend-label": { fontSize: "14px" },
          "& .MuiLineElement-root": { strokeWidth: 2.5 },
        }}
      >
        <ChartsReferenceLine
          x={ksLocation}
          label={`max |ΔF| at ${Math.round(ksLocation)}`}
          labelAlign="end"
          lineStyle={{ stroke: t.ink, strokeDasharray: "6 4", strokeWidth: 1.5 }}
          labelStyle={{ fill: t.ink, fontSize: 13 }}
        />
      </LineChart>
    </Box>
  );
}

Part of Kolmogorov-Smirnov Plot for Distribution Comparison on anyplot.ai.

Other implementations