Kolmogorov-Smirnov Plot for Distribution Comparison — D3.js

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 D3.js

Renders

JavaScript source (D3.js)

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

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 120, right: 70, bottom: 90, left: 100 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

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

// --- Data: credit scores for Good vs Bad loan customers ---------------------
const n1 = 260;
const n2 = 220;
const goodScores = Array.from({ length: n1 }, () => nextNormal(680, 55));
const badScores = Array.from({ length: n2 }, () => nextNormal(590, 65));

const sorted1 = goodScores.slice().sort((a, b) => a - b);
const sorted2 = badScores.slice().sort((a, b) => a - b);

// --- Two-sample K-S statistic (merge sweep over both sorted samples) -------
let i = 0;
let j = 0;
let ksD = 0;
let ksX = sorted1[0];
let ksD1 = 0;
let ksD2 = 0;
while (i < n1 && j < n2) {
  const x = sorted1[i] <= sorted2[j] ? sorted1[i] : sorted2[j];
  if (sorted1[i] <= sorted2[j]) i += 1;
  else j += 1;
  const d1 = i / n1;
  const d2 = j / n2;
  const diff = Math.abs(d1 - d2);
  if (diff > ksD) {
    ksD = diff;
    ksX = x;
    ksD1 = d1;
    ksD2 = d2;
  }
}

// Asymptotic two-sample K-S p-value (Kolmogorov distribution series)
const nEff = (n1 * n2) / (n1 + n2);
const lambda = (Math.sqrt(nEff) + 0.12 + 0.11 / Math.sqrt(nEff)) * ksD;
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(Math.max(pValue, 0), 1);
const pLabel = pValue < 0.001 ? "p < 0.001" : `p = ${pValue.toFixed(3)}`;

// --- Scales -------------------------------------------------------------------
const allScores = sorted1.concat(sorted2);
const scoreMin = d3.min(allScores);
const scoreMax = d3.max(allScores);
const scorePad = (scoreMax - scoreMin) * 0.05;
const xDomain = [scoreMin - scorePad, scoreMax + scorePad];

const x = d3.scaleLinear().domain(xDomain).range([0, iw]);
const y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);

// --- ECDF step-function point series -----------------------------------------
function ecdfPoints(sorted, n) {
  const pts = [[xDomain[0], 0]];
  sorted.forEach((value, idx) => pts.push([value, (idx + 1) / n]));
  pts.push([xDomain[1], 1]);
  return pts;
}
const goodEcdf = ecdfPoints(sorted1, n1);
const badEcdf = ecdfPoints(sorted2, n2);
const stepLine = d3
  .line()
  .x((d) => x(d[0]))
  .y((d) => y(d[1]))
  .curve(d3.curveStepAfter);

// --- SVG mount ----------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Gridlines (y-axis only) ---------------------------------------------------
g.append("g")
  .selectAll("line")
  .data(y.ticks(5))
  .join("line")
  .attr("x1", 0)
  .attr("x2", iw)
  .attr("y1", (d) => y(d))
  .attr("y2", (d) => y(d))
  .attr("stroke", t.grid)
  .attr("stroke-width", 1);

// --- Axes -----------------------------------------------------------------------
const xAxis = g
  .append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(x).ticks(8).tickFormat(d3.format("d")));
const yAxis = g.append("g").call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(".1f")));
for (const axisGroup of [xAxis, yAxis]) {
  axisGroup.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
  axisGroup.selectAll("line").attr("stroke", t.inkSoft);
  axisGroup.select(".domain").attr("stroke", t.inkSoft);
}
xAxis.selectAll("line").attr("stroke", t.inkSoft);
yAxis.selectAll("line").remove();

g.append("text")
  .attr("x", iw / 2)
  .attr("y", ih + 60)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "17px")
  .text("Credit Score");

g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -ih / 2)
  .attr("y", -70)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "17px")
  .text("Cumulative Proportion");

// --- ECDF step curves -----------------------------------------------------------
g.append("path").datum(badEcdf).attr("fill", "none").attr("stroke", t.palette[4]).attr("stroke-width", 3).attr("d", stepLine);
g.append("path").datum(goodEcdf).attr("fill", "none").attr("stroke", t.palette[0]).attr("stroke-width", 3).attr("d", stepLine);

// --- Maximum divergence marker ---------------------------------------------------
g.append("line")
  .attr("x1", x(ksX))
  .attr("x2", x(ksX))
  .attr("y1", y(ksD1))
  .attr("y2", y(ksD2))
  .attr("stroke", t.ink)
  .attr("stroke-width", 2.5)
  .attr("stroke-dasharray", "7,5");
g.append("circle").attr("cx", x(ksX)).attr("cy", y(ksD1)).attr("r", 5.5).attr("fill", t.ink);
g.append("circle").attr("cx", x(ksX)).attr("cy", y(ksD2)).attr("r", 5.5).attr("fill", t.ink);

// --- Info panel: legend + K-S statistic -----------------------------------------
const panelWidth = 340;
const panelHeight = 138;
const panelX = iw - panelWidth - 16;
const panelY = ih - panelHeight - 16;
const panel = g.append("g").attr("transform", `translate(${panelX},${panelY})`);
panel
  .append("rect")
  .attr("width", panelWidth)
  .attr("height", panelHeight)
  .attr("rx", 8)
  .attr("fill", t.elevatedBg)
  .attr("stroke", t.grid)
  .attr("stroke-width", 1);

const legendRows = [
  { label: `Good customers (n=${n1})`, color: t.palette[0] },
  { label: `Bad customers (n=${n2})`, color: t.palette[4] },
];
legendRows.forEach((row, idx) => {
  const rowY = 32 + idx * 32;
  panel.append("line").attr("x1", 20).attr("x2", 50).attr("y1", rowY).attr("y2", rowY).attr("stroke", row.color).attr("stroke-width", 4);
  panel
    .append("text")
    .attr("x", 60)
    .attr("y", rowY + 5)
    .attr("fill", t.inkSoft)
    .style("font-size", "14px")
    .text(row.label);
});

panel
  .append("text")
  .attr("x", 20)
  .attr("y", panelHeight - 24)
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .style("font-weight", "600")
  .text(`D = ${ksD.toFixed(3)}, ${pLabel}`);

// --- Title -----------------------------------------------------------------------
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 54)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "26px")
  .style("font-weight", "600")
  .text("ks-test-comparison · javascript · d3 · anyplot.ai");

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

Other implementations