|
| 1 | +// anyplot.ai |
| 2 | +// roc-curve: ROC Curve with AUC |
| 3 | +// Library: d3 7.9.0 | JavaScript 22.23.2 |
| 4 | +// Quality: 91/100 | Created: 2026-09-05 |
| 5 | +//# anyplot-orientation: square |
| 6 | + |
| 7 | +const t = window.ANYPLOT_TOKENS; |
| 8 | +const isDark = window.ANYPLOT_THEME === "dark"; |
| 9 | +const muted = isDark ? "#A8A79F" : "#6B6A63"; // Imprint semantic anchor: muted |
| 10 | +const { width, height } = window.ANYPLOT_SIZE; |
| 11 | +const margin = { top: 110, right: 90, bottom: 100, left: 120 }; |
| 12 | +const iw = width - margin.left - margin.right; |
| 13 | +const ih = height - margin.top - margin.bottom; |
| 14 | + |
| 15 | +// --- Data: three synthetic diagnostic-test classifiers of varying skill, each |
| 16 | +// built from a deterministic LCG (own seed per model, so runs stay reproducible |
| 17 | +// and independent of each other) --------------------------------------------- |
| 18 | +function makeLcg(seed) { |
| 19 | + let state = seed >>> 0; |
| 20 | + return () => { |
| 21 | + state = (state * 1664525 + 1013904223) >>> 0; |
| 22 | + return state / 4294967296; |
| 23 | + }; |
| 24 | +} |
| 25 | +const clamp01 = (v) => Math.min(1, Math.max(0, v)); |
| 26 | + |
| 27 | +function buildRoc({ seed, muDiseased, muHealthy, sd }) { |
| 28 | + const rand = makeLcg(seed); |
| 29 | + function randNormal() { |
| 30 | + const u1 = Math.max(rand(), 1e-9); |
| 31 | + const u2 = rand(); |
| 32 | + return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); |
| 33 | + } |
| 34 | + |
| 35 | + const nDiseased = 150; |
| 36 | + const nHealthy = 150; |
| 37 | + const diseasedScores = Array.from({ length: nDiseased }, () => clamp01(muDiseased + sd * randNormal())); |
| 38 | + const healthyScores = Array.from({ length: nHealthy }, () => clamp01(muHealthy + sd * randNormal())); |
| 39 | + |
| 40 | + const labeledScores = [ |
| 41 | + ...diseasedScores.map((score) => ({ score, isDiseased: true })), |
| 42 | + ...healthyScores.map((score) => ({ score, isDiseased: false })), |
| 43 | + ].sort((a, b) => b.score - a.score); |
| 44 | + |
| 45 | + // Sweep the decision threshold from high to low, accumulating hits/misses — |
| 46 | + // the same construction sklearn.metrics.roc_curve uses on predicted scores. |
| 47 | + let truePositives = 0; |
| 48 | + let falsePositives = 0; |
| 49 | + const points = [{ fpr: 0, tpr: 0 }]; |
| 50 | + for (const { isDiseased } of labeledScores) { |
| 51 | + if (isDiseased) truePositives += 1; |
| 52 | + else falsePositives += 1; |
| 53 | + points.push({ fpr: falsePositives / nHealthy, tpr: truePositives / nDiseased }); |
| 54 | + } |
| 55 | + |
| 56 | + let auc = 0; |
| 57 | + for (let i = 1; i < points.length; i++) { |
| 58 | + const a = points[i - 1]; |
| 59 | + const b = points[i]; |
| 60 | + auc += ((b.fpr - a.fpr) * (a.tpr + b.tpr)) / 2; |
| 61 | + } |
| 62 | + return { points, auc }; |
| 63 | +} |
| 64 | + |
| 65 | +const models = [ |
| 66 | + { name: "Strong classifier", seed: 42, muDiseased: 0.66, muHealthy: 0.34, sd: 0.16 }, |
| 67 | + { name: "Moderate classifier", seed: 7, muDiseased: 0.6, muHealthy: 0.4, sd: 0.2 }, |
| 68 | + { name: "Weak classifier", seed: 99, muDiseased: 0.56, muHealthy: 0.44, sd: 0.24 }, |
| 69 | +].map((spec) => ({ ...spec, ...buildRoc(spec) })); |
| 70 | + |
| 71 | +const color = d3 |
| 72 | + .scaleOrdinal() |
| 73 | + .domain(models.map((m) => m.name)) |
| 74 | + .range(t.palette); |
| 75 | + |
| 76 | +// --- SVG mount --------------------------------------------------------------- |
| 77 | +const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height); |
| 78 | +const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`); |
| 79 | + |
| 80 | +// --- Scales — equal-aspect: iw === ih so an FPR unit spans the same pixels |
| 81 | +// as a TPR unit, per the spec's "equal aspect ratio preferred" note --------- |
| 82 | +const x = d3.scaleLinear().domain([0, 1]).range([0, iw]); |
| 83 | +const y = d3.scaleLinear().domain([0, 1]).range([ih, 0]); |
| 84 | + |
| 85 | +// --- Gridlines ----------------------------------------------------------- |
| 86 | +g.append("g") |
| 87 | + .selectAll("line") |
| 88 | + .data(x.ticks(5)) |
| 89 | + .join("line") |
| 90 | + .attr("x1", (d) => x(d)) |
| 91 | + .attr("x2", (d) => x(d)) |
| 92 | + .attr("y1", 0) |
| 93 | + .attr("y2", ih) |
| 94 | + .attr("stroke", t.grid); |
| 95 | +g.append("g") |
| 96 | + .selectAll("line") |
| 97 | + .data(y.ticks(5)) |
| 98 | + .join("line") |
| 99 | + .attr("x1", 0) |
| 100 | + .attr("x2", iw) |
| 101 | + .attr("y1", (d) => y(d)) |
| 102 | + .attr("y2", (d) => y(d)) |
| 103 | + .attr("stroke", t.grid); |
| 104 | + |
| 105 | +// --- Diagonal reference line (random classifier, y = x) --------------------- |
| 106 | +g.append("line") |
| 107 | + .attr("x1", x(0)) |
| 108 | + .attr("y1", y(0)) |
| 109 | + .attr("x2", x(1)) |
| 110 | + .attr("y2", y(1)) |
| 111 | + .attr("stroke", muted) |
| 112 | + .attr("stroke-width", 2.5) |
| 113 | + .attr("stroke-dasharray", "10,8"); |
| 114 | + |
| 115 | +// --- Area fill under the strongest curve only, to keep a single focal point, |
| 116 | +// then the ROC curve for each model in its own Imprint color ----------------- |
| 117 | +const area = d3 |
| 118 | + .area() |
| 119 | + .x((d) => x(d.fpr)) |
| 120 | + .y0(ih) |
| 121 | + .y1((d) => y(d.tpr)); |
| 122 | +g.append("path").datum(models[0].points).attr("fill", color(models[0].name)).attr("opacity", 0.1).attr("d", area); |
| 123 | + |
| 124 | +const line = d3 |
| 125 | + .line() |
| 126 | + .x((d) => x(d.fpr)) |
| 127 | + .y((d) => y(d.tpr)); |
| 128 | +g.selectAll(".roc-line") |
| 129 | + .data(models) |
| 130 | + .join("path") |
| 131 | + .attr("class", "roc-line") |
| 132 | + .attr("fill", "none") |
| 133 | + .attr("stroke", (d) => color(d.name)) |
| 134 | + .attr("stroke-width", 4) |
| 135 | + .attr("stroke-linejoin", "round") |
| 136 | + .attr("stroke-linecap", "round") |
| 137 | + .attr("d", (d) => line(d.points)); |
| 138 | + |
| 139 | +// --- Axes ------------------------------------------------------------------- |
| 140 | +const xAxis = g |
| 141 | + .append("g") |
| 142 | + .attr("transform", `translate(0,${ih})`) |
| 143 | + .call(d3.axisBottom(x).ticks(5).tickFormat(d3.format(".1f"))); |
| 144 | +const yAxis = g.append("g").call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(".1f"))); |
| 145 | +for (const ax of [xAxis, yAxis]) { |
| 146 | + ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px"); |
| 147 | + ax.selectAll("line").attr("stroke", t.inkSoft); |
| 148 | + ax.select(".domain").attr("stroke", t.inkSoft); |
| 149 | +} |
| 150 | + |
| 151 | +// --- Axis labels -------------------------------------------------------------- |
| 152 | +g.append("text") |
| 153 | + .attr("x", iw / 2) |
| 154 | + .attr("y", ih + 65) |
| 155 | + .attr("text-anchor", "middle") |
| 156 | + .attr("fill", t.ink) |
| 157 | + .style("font-size", "18px") |
| 158 | + .text("False Positive Rate"); |
| 159 | +g.append("text") |
| 160 | + .attr("transform", "rotate(-90)") |
| 161 | + .attr("x", -ih / 2) |
| 162 | + .attr("y", -90) |
| 163 | + .attr("text-anchor", "middle") |
| 164 | + .attr("fill", t.ink) |
| 165 | + .style("font-size", "18px") |
| 166 | + .text("True Positive Rate"); |
| 167 | + |
| 168 | +// --- Legend (bottom-right — every ROC curve stays at/above the diagonal, so |
| 169 | +// the low-TPR/high-FPR corner below it stays clear of the data) -------------- |
| 170 | +const legendEntries = [ |
| 171 | + ...models.map((m) => ({ label: `${m.name} (AUC = ${m.auc.toFixed(2)})`, stroke: color(m.name), dash: null })), |
| 172 | + { label: "Random classifier (AUC = 0.50)", stroke: muted, dash: "8,6" }, |
| 173 | +]; |
| 174 | +const legend = g.append("g").attr("transform", `translate(${iw - 460}, ${ih - 160})`); |
| 175 | +const rows = legend |
| 176 | + .selectAll(".legend-row") |
| 177 | + .data(legendEntries) |
| 178 | + .join("g") |
| 179 | + .attr("class", "legend-row") |
| 180 | + .attr("transform", (_, i) => `translate(0, ${i * 34})`); |
| 181 | +rows |
| 182 | + .append("line") |
| 183 | + .attr("x1", 0) |
| 184 | + .attr("x2", 36) |
| 185 | + .attr("y1", 0) |
| 186 | + .attr("y2", 0) |
| 187 | + .attr("stroke", (d) => d.stroke) |
| 188 | + .attr("stroke-width", (d) => (d.dash ? 2.5 : 4)) |
| 189 | + .attr("stroke-dasharray", (d) => d.dash); |
| 190 | +rows |
| 191 | + .append("text") |
| 192 | + .attr("x", 48) |
| 193 | + .attr("y", 5) |
| 194 | + .attr("fill", (d, i) => (i === legendEntries.length - 1 ? t.inkSoft : t.ink)) |
| 195 | + .style("font-size", "15px") |
| 196 | + .text((d) => d.label); |
| 197 | + |
| 198 | +// --- Title -------------------------------------------------------------------- |
| 199 | +svg |
| 200 | + .append("text") |
| 201 | + .attr("x", width / 2) |
| 202 | + .attr("y", 55) |
| 203 | + .attr("text-anchor", "middle") |
| 204 | + .attr("fill", t.ink) |
| 205 | + .style("font-size", "24px") |
| 206 | + .style("font-weight", "600") |
| 207 | + .text("roc-curve · javascript · d3 · anyplot.ai"); |
0 commit comments