diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4fb30..db8d971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,16 @@ different event from one that moved because it was wrong. with `plumbline report` prints the same Dataset section, with the read, loaded, and refused counts, as the one written at run time (#58). Artifacts written before this still report, without the section. - +- The site scores your own predictions (#56). Paste a probability and an + outcome per line, as CSV, tabs, or spaces, with or without a header, and the + page computes the ECE, the floor for exactly those predictions, and the + verdict a report would print, word for word. A row that cannot be read is + named by its line, and nothing is scored until every row reads. Nothing + pasted leaves the browser: it is not uploaded, stored, or put in the address. + The parity check holds the path to `ece_figure` on pasted text in four + formats, two of them on and beside a rounding tie, and pins the parser's + refusals; the smoke test pastes a bad row and the worked example's rows, + which must reproduce the report's line. - A browser calculator for the ECE floor (`site/`), a JavaScript port of `synthetic_floor` that reproduces numpy's seeded random stream draw for draw. `scripts/floor_golden.py` exports golden values from the Python and diff --git a/scripts/check_floor_parity.mjs b/scripts/check_floor_parity.mjs index 4d4981f..ee6f5dd 100644 --- a/scripts/check_floor_parity.mjs +++ b/scripts/check_floor_parity.mjs @@ -53,6 +53,58 @@ for (const c of fixture.cases) { console.log(`${label.padEnd(36)} mean ${band.mean.toFixed(6)} p95 ${band.p95.toFixed(6)} ${elapsed}ms`); } +// Pasted predictions: the page parses the text, then scores it as a report +// does. Each case must parse to the rows the Python scored and print the same +// line, character for character. +for (const c of fixture.pasted) { + const label = `pasted, ${c.label}`; + const parsed = floor.parsePredictions(c.text); + if (parsed.errors.length) { + failures.push(`${label}: refused: ${JSON.stringify(parsed.errors.slice(0, 2))}`); + continue; + } + if (parsed.rows !== c.n) failures.push(`${label}: read ${parsed.rows} rows, the Python scored ${c.n}`); + const measured = floor.ece(parsed.probabilities, parsed.correct, c.n_bins); + const band = floor.calibrationFloor(parsed.probabilities, c.n_bins); + for (const [key, actual] of [["ece", measured], ["mean", band.mean], ["p95", band.p95]]) { + const gap = Math.abs(actual - c[key]); + worst = Math.max(worst, gap); + if (!(gap <= tolerance)) failures.push(`${label}: ${key} ${actual} against Python ${c[key]} (off by ${gap})`); + } + const line = floor.statement(measured, band); + if (line !== c.statement) failures.push(`${label}: verdict differs\n js: ${line}\n python: ${c.statement}`); +} +console.log(`${fixture.pasted.length} pasted texts parsed and scored`); + +// What the parser refuses, and the line it names. There is no Python to hold +// these to: the report never reads pasted text. They pin the page's own rules. +const refusals = [ + ["0.5,1\n1.2,0", [2, "the probability 1.2 is outside 0 to 1"]], + ["probability,outcome\n0.5,1\nabc,1", [3, 'the probability "abc" is not a number']], + ["NaN,1\n0.5,1", [1, 'the probability "NaN" is not a number']], + ["0.5,2", [1, 'the outcome "2" is not 0 or 1']], + ["0.5\n0.5,1", [1, "expected two values, a probability and an outcome, and found 1"]], + ["0.5,1,0.3", [1, "expected two values, a probability and an outcome, and found 3"]], + ["", [null, "there are no rows to score. Paste one row per line: a probability, then 0 or 1"]], + ["probability,outcome\n\n", [null, "there are no rows to score. Paste one row per line: a probability, then 0 or 1"]], +]; +for (const [text, [line, message]] of refusals) { + const { errors } = floor.parsePredictions(text); + const got = errors[0]; + if (!got || got.line !== line || got.message !== message) { + failures.push(`parser: ${JSON.stringify(text)} gave ${JSON.stringify(got)}, expected line ${line}: ${message}`); + } +} +const capped = floor.parsePredictions("0.5,1\n0.5,0\n0.5,1", { maxRows: 2 }).errors; +if (!(capped.length === 1 && capped[0].line === null && capped[0].message.startsWith("3 rows is more than this page scores"))) { + failures.push(`parser: a paste over the row cap gave ${JSON.stringify(capped)}`); +} +const lenient = floor.parsePredictions(' 0.5, 1,\n\n"0.25"\tfalse \n1e-1 TRUE\n'); +if (lenient.errors.length || lenient.probabilities.join() !== "0.5,0.25,0.1" || lenient.correct.join() !== "true,false,true") { + failures.push(`parser: lenient input read as ${JSON.stringify(lenient)}`); +} +console.log(`${refusals.length + 2} parser cases checked`); + // Every figure on the page is printed through fixed4, which must round as // Python's "%.4f" does, including on and beside a tie. const formats = Object.entries(fixture.fixed4); diff --git a/scripts/floor_golden.py b/scripts/floor_golden.py index 2129fe2..020290a 100644 --- a/scripts/floor_golden.py +++ b/scripts/floor_golden.py @@ -25,7 +25,8 @@ import numpy as np -from plumbline.metrics.calibration import CalibrationFigure, synthetic_floor +from plumbline.metrics.calibration import CalibrationFigure, ece_figure, synthetic_floor +from plumbline.types import ProbabilitySeries FIXTURE = Path(__file__).resolve().parent.parent / "site" / "floor-golden.json" @@ -93,6 +94,81 @@ def _formatting_cases() -> dict[str, str]: return {repr(value): f"{value:.4f}" for value in sorted(values)} +def _pasted_cases() -> list[dict[str, Any]]: + """Pasted text in the formats the page reads, with what a report prints for it. + + The page parses the text itself, so each case is written the way a person + might paste it, and the Python scores the rows the text was written from + through ``ece_figure``, the call a report makes. Probabilities are written + with ``repr``, which round-trips exactly, so both sides score the same + doubles. Two cases put the measured ECE on and beside a rounding tie. + """ + rng = np.random.default_rng(56) + confident = [float(p) for p in rng.beta(5.0, 2.0, 300)] + confident_hits = [bool(u < p * 0.9) for u, p in zip(rng.random(300), confident, strict=True)] + spread = [float(p) for p in rng.uniform(0.0, 1.0, 150)] + spread_hits = [bool(u < p) for u, p in zip(rng.random(150), spread, strict=True)] + tie = [0.75] * 32 + tie_hits = [True] * 23 + [False] * 9 # 23/32 against 0.75: ECE is exactly 0.03125 + near = [0.80125] * 400 + near_hits = [True] * 320 + [False] * 80 # 0.8 against 0.80125: beside 0.00125 + + def rows(ps: list[float], hits: list[bool], line: str) -> list[str]: + return [ + line.format(p=repr(p), y=int(y), word=str(y).lower()) + for p, y in zip(ps, hits, strict=True) + ] + + spread_lines = rows(spread, spread_hits, "{p}\t{word}") + spread_lines.insert(40, "") + series = [ + ( + "comma separated, with a header", + 10, + confident, + confident_hits, + "probability,outcome\n" + "\n".join(rows(confident, confident_hits, "{p},{y}")) + "\n", + ), + ( + "tab separated, CRLF line ends, true and false, a blank line", + 15, + spread, + spread_hits, + "\r\n".join(spread_lines), + ), + ( + "space separated, the ECE exactly on a rounding tie", + 10, + tie, + tie_hits, + "\n".join(rows(tie, tie_hits, " {p} {y}")), + ), + ( + "semicolons and quotes, the ECE beside a tie", + 10, + near, + near_hits, + 'p;"correct"\n' + "\n".join(rows(near, near_hits, '"{p}";"{y}"')), + ), + ] + cases = [] + for label, n_bins, ps, hits, text in series: + figure = ece_figure(ProbabilitySeries(tuple(ps), "calibrated_claim"), hits, n_bins=n_bins) + cases.append( + { + "label": label, + "n_bins": n_bins, + "text": text, + "n": figure.n, + "ece": figure.value, + "mean": figure.floor.mean, + "p95": figure.floor.p95, + "statement": figure.statement(), + } + ) + return cases + + def _pcg64_state(seed: int) -> dict[str, str]: state = np.random.default_rng(seed).bit_generator.state["state"] return {"state": str(state["state"]), "inc": str(state["inc"])} @@ -125,8 +201,9 @@ def build() -> dict[str, Any]: ) return { "about": ( - "Golden values from plumbline.metrics.calibration.synthetic_floor, " - "which site/floor.js must reproduce. Written by scripts/floor_golden.py." + "Golden values from plumbline.metrics.calibration (synthetic_floor for the " + "calculator, ece_figure for pasted rows), which site/floor.js must " + "reproduce. Written by scripts/floor_golden.py." ), "function": "synthetic_floor(n, n_bins, accuracy), all other arguments default", "defaults": {"binning": "equal_width", "n_boot": 2000, "concentration": 6.0, "seed": 0}, @@ -135,6 +212,7 @@ def build() -> dict[str, Any]: "pcg64_initial_state": {"0": _pcg64_state(0), "1": _pcg64_state(1)}, "cases": cases, "fixed4": _formatting_cases(), + "pasted": _pasted_cases(), } @@ -147,6 +225,16 @@ def _check(fresh: dict[str, Any]) -> list[str]: problems.append("numpy's seeded PCG64 states differ from the fixture") if committed.get("fixed4") != fresh["fixed4"]: problems.append("the :.4f formatting cases differ from the fixture") + old_pasted = committed.get("pasted", []) + if [c["text"] for c in old_pasted] != [c["text"] for c in fresh["pasted"]]: + problems.append("the pasted-text cases differ from the fixture; regenerate it") + else: + for old, new in zip(old_pasted, fresh["pasted"], strict=True): + for key in ("ece", "mean", "p95"): + if abs(old[key] - new[key]) > TOLERANCE: + problems.append(f"pasted, {new['label']}: {key} is {new[key]!r} now") + if old["statement"] != new["statement"]: + problems.append(f"pasted, {new['label']}: verdict wording changed") if len(committed["cases"]) != len(fresh["cases"]): problems.append("the fixture holds a different set of cases; regenerate it") return problems diff --git a/scripts/smoke_site.mjs b/scripts/smoke_site.mjs index 8a46e0d..c0df8ac 100644 --- a/scripts/smoke_site.mjs +++ b/scripts/smoke_site.mjs @@ -17,6 +17,8 @@ // field invalid; // - the calculator runs, and writes its inputs into the address; // - a link carrying those inputs runs the calculator as the page opens; +// - pasted predictions with a bad row name the line and score nothing, and the +// worked example's rows, pasted, reproduce the report's line; // - search, opened from the header on a doc page, finds a section of that doc. import { spawn } from "node:child_process"; @@ -313,6 +315,27 @@ await check("a link carrying the inputs runs the calculator as the page opens", await violations(); }); +await check("a pasted row that cannot be read names its line and scores nothing", async () => { + await until(`!${$("paste-fill")}.hidden`, "the example's rows to be offered"); + await evaluate(`${$("paste-data")}.value = "probability,outcome\\n0.5,1\\n1.4,0"; ${$("paste-go")}.click();`); + const status = await evaluate(`${$("paste-status")}.textContent`); + expect(status.startsWith("Line 3: the probability 1.4 is outside 0 to 1."), `status reads ${JSON.stringify(status)}`); + expect((await evaluate(`${$("paste-data")}.getAttribute("aria-invalid")`)) === "true", "the box is not marked invalid"); + expect(await evaluate(`${$("paste-result")}.hidden`), "a result was shown for rows that did not read"); +}); + +await check("the worked example's rows, pasted, give the report's line", async () => { + const before = await evaluate("location.search"); + await evaluate(`${$("paste-fill")}.click(); ${$("paste-go")}.click();`); + await until(`!${$("paste-result")}.hidden && !${$("paste-go")}.disabled`, "a scored paste"); + const verdict = await evaluate(`${$("paste-verdict")}.querySelector("span").textContent`); + const report = await evaluate(`${$("ex-report-line")}.lastElementChild.textContent`); + expect(verdict === report, `pasted verdict ${JSON.stringify(verdict.slice(0, 80))} is not the report's ${JSON.stringify(report.slice(0, 80))}`); + expect(!(await evaluate(`${$("paste-data")}.hasAttribute("aria-invalid")`)), "the box is still marked invalid"); + expect((await evaluate("location.search")) === before, "scoring pasted rows changed the address"); + await violations(); +}); + await check("search on a doc page finds a section of it", async () => { await open("docs/methodology.html"); await evaluate("document.querySelector('.search-open').click()"); diff --git a/site/explainer.css b/site/explainer.css index 9193a0f..ba4016b 100644 --- a/site/explainer.css +++ b/site/explainer.css @@ -6,12 +6,16 @@ section { padding: 2rem 0; border-top: 1px solid var(--line); } form { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 1rem; margin: 1.25rem 0 1rem; } label { display: block; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem; } .hint { display: block; font-size: 0.8rem; font-weight: 400; color: var(--muted); } -input { width: 100%; font: 1rem var(--mono); padding: 0.5rem 0.6rem; color: var(--fg); background: var(--bg); border: 1px solid var(--control); border-radius: 4px; } +input, textarea { width: 100%; font: 1rem var(--mono); padding: 0.5rem 0.6rem; color: var(--fg); background: var(--bg); border: 1px solid var(--control); border-radius: 4px; } .actions { grid-column: 1 / -1; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } button { font: 600 1rem var(--sans); padding: 0.55rem 1.1rem; color: var(--bg); background: var(--fg); border: 0; border-radius: 4px; cursor: pointer; } button[disabled] { opacity: 0.55; cursor: progress; } progress { flex: 1 1 8rem; height: 0.5rem; } .error { color: var(--error); } +textarea { display: block; min-height: 9rem; resize: vertical; font-size: 0.9rem; line-height: 1.4; } +form .wide { grid-column: 1 / -1; } +.note { border-left: 3px solid var(--line); padding: 0.2rem 0 0.2rem 0.8rem; } +.paste-errors { margin: 0 0 1rem; padding-left: 1.25rem; font-size: 0.9rem; } .result { border: 1px solid var(--line); border-radius: 6px; padding: 1rem; background: var(--panel); } .result[hidden], [hidden] { display: none !important; } @@ -98,5 +102,5 @@ a.button.secondary, button.secondary { color: var(--fg); background: var(--bg); a.button.secondary:hover, button.secondary:hover { border-color: var(--fg); } button.small-button { font-size: 0.85rem; font-weight: 400; padding: 0.3rem 0.7rem; } .share { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem; } -input[aria-invalid="true"] { border-color: var(--error); outline: 1px solid var(--error); } +input[aria-invalid="true"], textarea[aria-invalid="true"] { border-color: var(--error); outline: 1px solid var(--error); } @media print { .rail, .cta, .share, .actions { display: none !important; } } diff --git a/site/explainer.js b/site/explainer.js index fd0356e..a0a0f6f 100644 --- a/site/explainer.js +++ b/site/explainer.js @@ -38,8 +38,11 @@ function runHere(job) { setTimeout(function () { try { - if (job.message.kind === "synthetic") { - job.resolve(F.syntheticFloor(job.message.n, job.message.nBins, job.message.accuracy)); + var m = job.message; + if (m.kind === "synthetic") { + job.resolve(F.syntheticFloor(m.n, m.nBins, m.accuracy)); + } else if (m.kind === "observed") { + job.resolve({ measured: F.ece(m.probabilities, m.correct, m.nBins), band: F.calibrationFloor(m.probabilities, m.nBins) }); } else { job.resolve(F.planRows(job.message.target, job.message.nBins, job.message.accuracy, { onStep: job.onStep })); } @@ -107,7 +110,7 @@ }, }; } - var runners = { calc: makeRunner(), plan: makeRunner() }; + var runners = { calc: makeRunner(), plan: makeRunner(), paste: makeRunner() }; /* ---- forms ------------------------------------------------------------ */ @@ -140,7 +143,7 @@ } function clearInvalid(form) { - Array.prototype.forEach.call(form.querySelectorAll("input"), function (input) { + Array.prototype.forEach.call(form.querySelectorAll("input, textarea"), function (input) { input.removeAttribute("aria-invalid"); input.removeAttribute("aria-describedby"); }); @@ -205,7 +208,7 @@ }); } - var lastResult = { calc: "", plan: "" }; + var lastResult = { calc: "", plan: "", paste: "" }; ["calc", "plan"].forEach(function (prefix) { function copyButton(id, what, valueOf) { $(id).addEventListener("click", function () { @@ -220,6 +223,16 @@ copyButton(prefix + "-copy-result", "Result", function () { return lastResult[prefix]; }); $(prefix + "-cancel").addEventListener("click", function () { runners[prefix].cancel(); }); }); + // Pasted rows are never put in the address, so this tool shares a result + // and not a link. + $("paste-cancel").addEventListener("click", function () { runners.paste.cancel(); }); + $("paste-copy-result").addEventListener("click", function () { + var status = $("paste-status"); + copy(lastResult.paste).then( + function () { status.classList.remove("error"); status.textContent = "Result copied."; }, + function () { status.textContent = "Could not copy; select the text instead."; } + ); + }); /* ---- calculator ------------------------------------------------------ */ @@ -348,6 +361,8 @@ } function showExample(ex) { + exampleRows = ex; + $("paste-fill").hidden = false; var p = ex.probabilities, correct = ex.correct, nBins = ex.n_bins, n = p.length; $("ex-report-line").replaceChildren(el("p", ex.report.accuracy_line), el("p", ex.report.ece_line)); @@ -405,6 +420,91 @@ $("ex-lesson").hidden = false; } + /* ---- your own predictions --------------------------------------------- */ + + // Nothing pasted here leaves the page: it is read below, scored in the + // worker, and never written to the address, storage, or the network. + var exampleRows = null; + var PASTE_ERRORS_SHOWN = 10; + + $("paste-fill").addEventListener("click", function () { + if (!exampleRows) return; + var lines = ["probability,outcome"]; + exampleRows.probabilities.forEach(function (p, i) { lines.push(p + "," + (exampleRows.correct[i] ? 1 : 0)); }); + $("paste-data").value = lines.join("\n") + "\n"; + $("paste-bins").value = String(exampleRows.n_bins); + busy("paste", false, "Filled in the worked example's " + exampleRows.probabilities.length + + " rows. Score them to see the report's line again."); + }); + + function showPasteErrors(errors) { + var list = $("paste-errors"); + list.replaceChildren(); + errors.slice(0, PASTE_ERRORS_SHOWN).forEach(function (e) { + list.appendChild(el("li", (e.line === null ? "" : "Line " + e.line + ": ") + e.message + ".")); + }); + if (errors.length > PASTE_ERRORS_SHOWN) { + list.appendChild(el("li", (errors.length - PASTE_ERRORS_SHOWN) + " more not shown.", "muted")); + } + list.hidden = !errors.length; + } + + $("paste").addEventListener("submit", function (event) { + event.preventDefault(); + var form = event.target; + clearInvalid(form); + showPasteErrors([]); + var bins = number(form.bins, isWhole(1, 1000), "Bins must be a whole number from 1 to 1,000."); + if (bins.message) { busy("paste", false); fail("paste", bins); return; } + var parsed = F.parsePredictions(form.data.value); + if (parsed.errors.length) { + // Nothing is scored until every row reads. The first problem is said in + // the status line; the list holds the rest, and the box points at both. + var first = parsed.errors[0]; + var more = parsed.errors.length - 1; + busy("paste", false); + fail("paste", { + message: (first.line === null ? "" : "Line " + first.line + ": ") + first.message + "." + + (more ? " " + more + " more " + (more === 1 ? "problem" : "problems") + " below." : "") + + " Nothing was scored.", + }); + showPasteErrors(parsed.errors); + form.data.setAttribute("aria-invalid", "true"); + form.data.setAttribute("aria-describedby", "paste-status paste-errors"); + form.data.focus(); + $("paste-result").hidden = true; + return; + } + var running = runners.paste.run( + { kind: "observed", probabilities: parsed.probabilities, correct: parsed.correct, nBins: bins.value }, + function (f) { $("paste-progress").value = f; }); + busy("paste", true, "Scoring " + parsed.rows.toLocaleString("en-US") + " rows and running 2,000 resamples..."); + running + .then(function (result) { busy("paste", false, showPaste(result, parsed)); }) + .catch(function (error) { busy("paste", false); fail("paste", error); }); + }); + + // Draws the result, and returns the one line the status reads out. + function showPaste(result, parsed) { + var band = result.band, measured = result.measured; + var clears = F.isDistinguishable(measured, band); + var statement = F.statement(measured, band); + text("paste-n", band.n.toLocaleString("en-US") + (parsed.header ? " (a header line was skipped)" : "")); + text("paste-ece", fmt(measured)); + text("paste-mean", fmt(band.mean)); + text("paste-p95", fmt(band.p95)); + var verdict = $("paste-verdict"); + verdict.className = "verdict " + (clears ? "distinguishable" : "inconclusive"); + verdict.replaceChildren( + el("strong", clears ? "Distinguishable from sampling noise" : "Inconclusive, which is not a pass"), + el("span", statement) + ); + lastResult.paste = statement; + $("paste-result").hidden = false; + return "ECE " + fmt(measured) + " against a floor 95th percentile of " + fmt(band.p95) + + (clears ? ": it clears it." : ": inconclusive."); + } + /* ---- planner --------------------------------------------------------- */ $("plan").addEventListener("submit", function (event) { diff --git a/site/floor-golden.json b/site/floor-golden.json index 20b0296..95ebb37 100644 --- a/site/floor-golden.json +++ b/site/floor-golden.json @@ -1,5 +1,5 @@ { - "about": "Golden values from plumbline.metrics.calibration.synthetic_floor, which site/floor.js must reproduce. Written by scripts/floor_golden.py.", + "about": "Golden values from plumbline.metrics.calibration (synthetic_floor for the calculator, ece_figure for pasted rows), which site/floor.js must reproduce. Written by scripts/floor_golden.py.", "function": "synthetic_floor(n, n_bins, accuracy), all other arguments default", "defaults": { "binning": "equal_width", @@ -1371,5 +1371,47 @@ "1.84375": "1.8438", "1.90625": "1.9062", "1.96875": "1.9688" - } + }, + "pasted": [ + { + "label": "comma separated, with a header", + "n_bins": 10, + "text": "probability,outcome\n0.3701902842671547,0\n0.6636748580851755,0\n0.4854677254876745,1\n0.9809095860229241,1\n0.5402041081123293,1\n0.5878420282243031,1\n0.6429783201852302,1\n0.6914959785439542,1\n0.8429774994285216,1\n0.6053664379951705,0\n0.5505226687317148,1\n0.47873665214418765,1\n0.9186624277149896,0\n0.32830736621921897,1\n0.8046851732099684,0\n0.8983728404473525,1\n0.7176103346111447,0\n0.6845520598937217,1\n0.8754085153219575,1\n0.8274990808507586,1\n0.8165098359026878,0\n0.7370462486190534,1\n0.8136063683786394,1\n0.8120125108755948,1\n0.7847554230365376,1\n0.7890675244463915,1\n0.5442676637935917,1\n0.7571337761499041,1\n0.9021548876500897,1\n0.637880062408436,1\n0.7557549754401213,1\n0.5083763821624885,1\n0.9141689143748283,1\n0.8253786551448449,1\n0.9130937777894381,1\n0.9339506644871263,1\n0.8784293563597917,1\n0.895670527899472,1\n0.8430529077285147,0\n0.9555244758421513,1\n0.6562334841941971,0\n0.44347198079416006,0\n0.5882951048748661,0\n0.6632882208074936,1\n0.7193471554059294,1\n0.4876146079345244,1\n0.953168038895694,0\n0.7174536271438119,0\n0.7623244598883582,1\n0.9509176055447596,1\n0.8831960622867566,1\n0.7383262444068864,1\n0.6705386587124225,1\n0.6742124297478073,0\n0.7473350702698447,1\n0.5874704389192519,1\n0.744815008138343,1\n0.8734280898226684,1\n0.8297216201264507,0\n0.500944448282882,1\n0.6082688643601383,1\n0.8503070978000848,1\n0.6915965862987412,1\n0.7453015147962215,1\n0.9149539864420778,0\n0.6881209119592449,0\n0.43798699977734107,1\n0.8914830297995384,0\n0.4105129789272475,0\n0.7472626135336042,1\n0.8677678395354927,0\n0.9018897593429304,1\n0.5551233908380523,1\n0.9424249586699289,1\n0.7490780290998588,0\n0.6472160899498884,1\n0.8677347123628618,1\n0.7553091985144603,1\n0.8320194462604729,0\n0.8075970235316438,0\n0.6339841866183518,1\n0.49726004536795126,0\n0.4105371902108986,1\n0.8417525930304077,1\n0.7536828171041157,1\n0.8064127928769447,1\n0.7854594038727644,1\n0.5963890082318981,0\n0.5529445130913578,0\n0.9267909229693727,1\n0.7515970304169407,1\n0.5593384722006013,0\n0.805210866278916,1\n0.7966683402613025,1\n0.5155310158049526,0\n0.4365883968646004,0\n0.7823467361944703,1\n0.7613503500913443,1\n0.626144209182107,1\n0.6694521261435745,1\n0.5866183003387513,1\n0.6000598269757904,0\n0.7172361276561078,1\n0.5527992496181331,0\n0.8590012221015682,1\n0.535433262307073,0\n0.8682061118295181,0\n0.9357722771399521,0\n0.8119736186824921,1\n0.7986123343128968,1\n0.8107896870003097,1\n0.8956489957173016,1\n0.7528890472068774,1\n0.7820916134883324,1\n0.9180589086435903,1\n0.7024068363271042,0\n0.7178566909297954,1\n0.7987040746674912,1\n0.8422369874987513,1\n0.7990381593486006,1\n0.734168410981497,1\n0.9148037540012764,0\n0.9512786598755222,1\n0.5115165194732113,0\n0.6680665924216662,0\n0.9035603892225401,1\n0.6278994800190909,1\n0.8197891964984755,0\n0.9647400144317287,0\n0.6972562919176344,1\n0.7684046697253971,0\n0.669647327062451,1\n0.7008342235525218,0\n0.5493029774934077,1\n0.829711646861401,0\n0.672083575156045,1\n0.8280829924700488,1\n0.7404069235501143,1\n0.9165172488447596,1\n0.2282370958780986,0\n0.5386581972300077,1\n0.7457243489462131,0\n0.5392385577908511,1\n0.7282403747702962,1\n0.8815371641998792,1\n0.9317419977093483,1\n0.6428026789281851,1\n0.430810850276538,0\n0.6506688477927165,1\n0.42472556381855225,0\n0.5521720366772187,0\n0.4962777211926458,0\n0.5521532981257298,0\n0.7796066865227138,0\n0.7969958630189081,0\n0.7912668283237764,1\n0.8779876427333928,1\n0.6233181788415615,1\n0.8342941272432719,1\n0.348248971986688,0\n0.836354674601855,0\n0.7486363732096073,1\n0.9022350531555352,1\n0.6235948805828943,0\n0.7412447353591232,1\n0.9513810859673139,1\n0.9228817311905471,1\n0.64916096179032,0\n0.9456648149743587,1\n0.5061343952388129,0\n0.8887209746062371,0\n0.79197745415576,0\n0.9066895906936023,0\n0.8363569154967893,1\n0.6975598059422804,0\n0.9110310794481414,1\n0.8845099497931554,1\n0.797249804568639,1\n0.8234974141447502,1\n0.8731182067678682,1\n0.6710415703971537,0\n0.6899353665103408,1\n0.9056899904434397,1\n0.5584019550584538,0\n0.6935218256066912,0\n0.5781141365894732,1\n0.5842441620215233,0\n0.8909719408973987,1\n0.47497656651502757,0\n0.30628057890831156,0\n0.6613257539649979,1\n0.9182098689670549,1\n0.8689256027144716,1\n0.7778303880886164,1\n0.6605842495341648,1\n0.7070577979094427,1\n0.5150620613284645,0\n0.7983389970158196,1\n0.44855480238672674,0\n0.6508648099224633,1\n0.9255033777884485,1\n0.394853735533111,0\n0.7867356781046114,1\n0.5862323242027035,1\n0.742574501218065,1\n0.8233157601980059,1\n0.9776754616159198,1\n0.8308139894160536,1\n0.6312823438927516,1\n0.7495943521641198,1\n0.6330588838243157,1\n0.805864860227732,0\n0.8887378106808479,1\n0.7164518065643302,1\n0.7346928331243497,1\n0.7964994861585364,0\n0.6452092959454038,1\n0.7887232244762341,1\n0.859688093786934,1\n0.7877313919164622,0\n0.8357431010713455,1\n0.761159160822158,1\n0.8012002285350062,0\n0.8342390553977881,0\n0.37358065987559713,0\n0.8399620235697624,0\n0.6093418214146425,1\n0.869359968523917,1\n0.7429978373115638,1\n0.9857316567739101,0\n0.8990737823380992,1\n0.7658433201169123,1\n0.8388790588565389,1\n0.13923361900684222,0\n0.7750684420668394,1\n0.5948658555396185,1\n0.4688512407957305,1\n0.9500019505154772,1\n0.6554731834099224,1\n0.7468502782246492,1\n0.6133090129809938,0\n0.6471700420621249,0\n0.9015034427957023,1\n0.918475057393014,1\n0.823568284769587,1\n0.8254780969183326,1\n0.8512121810403324,1\n0.7700545197165005,1\n0.715605522111546,1\n0.8236374691025015,0\n0.505662620472717,1\n0.7442785127318075,0\n0.5024241043670978,0\n0.5323628354510287,0\n0.49575715066601667,1\n0.8668514453238375,1\n0.6242494830337679,1\n0.7071417673637219,0\n0.46186034198055237,1\n0.8056259126568305,1\n0.3525147771800355,0\n0.842252928121914,1\n0.4873924680795274,1\n0.6706392345533406,0\n0.7386804567934326,1\n0.9086960236859598,1\n0.7764830732374907,1\n0.4095110358667931,1\n0.6654872908264021,1\n0.8415880166083428,1\n0.8703776906494938,0\n0.7233215805008749,0\n0.8981798649057454,1\n0.6446564490475859,1\n0.7307904831484313,1\n0.9337474280437011,1\n0.6230267811255795,1\n0.5360793418892547,1\n0.8660855378904423,1\n0.5271394905857312,0\n0.765759728799988,1\n0.6524696445069339,1\n0.9257168343443426,1\n0.5461358606182308,0\n0.7480755178612511,1\n0.8404996243858934,0\n0.46216000140207303,0\n0.939382629974081,1\n0.5308856836821055,1\n0.4263112730497274,1\n0.6318270693469151,0\n0.76546862303657,1\n0.8357280501480471,0\n0.710861165612637,1\n0.6217916985673697,0\n0.836028146464499,1\n0.8369846237927154,0\n0.8743917960956923,1\n0.8264623961107267,0\n0.8569061872208475,1\n", + "n": 300, + "ece": 0.08256759382915613, + "mean": 0.05060766943179064, + "p95": 0.07520325441503889, + "statement": "ECE 0.0826 over 300 rows (10 equal width bins), against a calibrated-model floor of 0.0506 (95th percentile 0.0752): miscalibration is distinguishable from sampling noise." + }, + { + "label": "tab separated, CRLF line ends, true and false, a blank line", + "n_bins": 15, + "text": "0.8092251254673093\tfalse\r\n0.4810475108402493\tfalse\r\n0.6150968308958921\ttrue\r\n0.16901035542560794\tfalse\r\n0.06109131264098788\tfalse\r\n0.12262722928878389\tfalse\r\n0.260205205042753\tfalse\r\n0.15746477725918107\tfalse\r\n0.09811156131315213\tfalse\r\n0.965616171921477\ttrue\r\n0.64024646917968\tfalse\r\n0.06283530440957164\tfalse\r\n0.6232950439881982\tfalse\r\n0.8174599894568946\ttrue\r\n0.4662516651391754\ttrue\r\n0.73528215473066\tfalse\r\n0.8560957030307116\ttrue\r\n0.7809314624426905\ttrue\r\n0.37469093722066327\tfalse\r\n0.8063052210769309\ttrue\r\n0.6797309842087644\tfalse\r\n0.5441444257254635\ttrue\r\n0.6640869675530805\ttrue\r\n0.8323301563131899\ttrue\r\n0.5994946338452651\ttrue\r\n0.3427956490611287\tfalse\r\n0.5574401539739183\ttrue\r\n0.936080333049103\ttrue\r\n0.6087827358989107\ttrue\r\n0.15745125025268203\tfalse\r\n0.6360353529084468\tfalse\r\n0.6914338569413809\ttrue\r\n0.42126265304268395\ttrue\r\n0.7833959783104201\ttrue\r\n0.8705602046249002\ttrue\r\n0.507211294929106\tfalse\r\n0.21790444828803646\tfalse\r\n0.6881032519474287\tfalse\r\n0.00411233163437319\tfalse\r\n0.3502617240759307\tfalse\r\n\r\n0.9352696943166048\ttrue\r\n0.6645994624576257\tfalse\r\n0.610127098529173\tfalse\r\n0.8208248356314631\ttrue\r\n0.92366253639984\ttrue\r\n0.6730912403808006\ttrue\r\n0.51129127814052\tfalse\r\n0.6817005717995038\ttrue\r\n0.8899861184206933\ttrue\r\n0.7140194045085091\tfalse\r\n0.9498130543981782\ttrue\r\n0.34520656461313304\tfalse\r\n0.21614137962877666\tfalse\r\n0.4783647743967546\ttrue\r\n0.8843747222028043\tfalse\r\n0.4061608268263315\tfalse\r\n0.0471967793994339\tfalse\r\n0.7220830451450202\ttrue\r\n0.7312581199981368\tfalse\r\n0.15257757557359208\tfalse\r\n0.6906773861917685\ttrue\r\n0.8159109793597631\tfalse\r\n0.9388029785643608\ttrue\r\n0.5196187225053817\ttrue\r\n0.005260178281515815\tfalse\r\n0.45596229708928215\tfalse\r\n0.6982282138608734\ttrue\r\n0.685603418704395\ttrue\r\n0.9719211064669567\ttrue\r\n0.15998676726080874\tfalse\r\n0.7850514335124563\ttrue\r\n0.5287084362267819\tfalse\r\n0.5118535986122918\tfalse\r\n0.3642922284946962\tfalse\r\n0.12612145219641568\tfalse\r\n0.8856591785934856\ttrue\r\n0.3500651481128494\tfalse\r\n0.8890678391009302\ttrue\r\n0.4778501255227805\tfalse\r\n0.2885141595192058\tfalse\r\n0.18314668398887002\tfalse\r\n0.8261608151809642\tfalse\r\n0.0670082083353638\tfalse\r\n0.699881437809391\tfalse\r\n0.000734218650418339\tfalse\r\n0.4763010765379868\ttrue\r\n0.7251867150355418\ttrue\r\n0.7643861262351269\ttrue\r\n0.3689060891729379\tfalse\r\n0.8407039905274829\ttrue\r\n0.1779477506507431\tfalse\r\n0.6525856250479789\ttrue\r\n0.11093291317605714\tfalse\r\n0.08169699773110173\tfalse\r\n0.8584770844072255\ttrue\r\n0.22227571522325595\tfalse\r\n0.37222805287748184\tfalse\r\n0.04131405978541758\tfalse\r\n0.18490454741039097\tfalse\r\n0.49893622010934036\tfalse\r\n0.5308800623040114\ttrue\r\n0.2124453901395038\tfalse\r\n0.8594821883376382\ttrue\r\n0.6141412143428835\ttrue\r\n0.049753841472731763\tfalse\r\n0.1270379908640027\tfalse\r\n0.3857686994077191\tfalse\r\n0.5290646649467213\ttrue\r\n0.23426514867427006\ttrue\r\n0.9721774387851962\ttrue\r\n0.8292813126608648\ttrue\r\n0.5643581363939282\tfalse\r\n0.7279323259932716\ttrue\r\n0.1733617511617992\tfalse\r\n0.5677147297527553\ttrue\r\n0.8417937724914958\ttrue\r\n0.7109214107389936\ttrue\r\n0.03928094042693042\ttrue\r\n0.5969846402370829\tfalse\r\n0.7096180600849119\ttrue\r\n0.06537837025450433\tfalse\r\n0.6958775599796297\ttrue\r\n0.1953942156617785\tfalse\r\n0.5557920253825632\ttrue\r\n0.9364734927925796\ttrue\r\n0.13308504047817282\tfalse\r\n0.9384531847054718\ttrue\r\n0.8059143590756168\ttrue\r\n0.1933381413865768\tfalse\r\n0.6408979420254313\ttrue\r\n0.09697128926361853\ttrue\r\n0.6062934627402057\tfalse\r\n0.31638374702764194\tfalse\r\n0.24657104375987926\tfalse\r\n0.05299896777931312\tfalse\r\n0.41718266278269156\tfalse\r\n0.9078211084000235\ttrue\r\n0.5898374349703307\tfalse\r\n0.2277324778059242\tfalse\r\n0.757064035744464\ttrue\r\n0.5688307708568273\tfalse\r\n0.00454309077941728\tfalse\r\n0.17939433257263737\tfalse\r\n0.08728479124377542\tfalse\r\n0.9373635851491866\ttrue\r\n0.21206081712181002\tfalse\r\n0.29868775220491417\tfalse\r\n0.6028032693583263\ttrue\r\n0.8043238663654723\tfalse\r\n0.9659887379506182\ttrue", + "n": 150, + "ece": 0.08763532023460592, + "mean": 0.097457440489558, + "p95": 0.13177332613847564, + "statement": "ECE 0.0876 over 150 rows (15 equal width bins), against a calibrated-model floor of 0.0975 (95th percentile 0.1318): INCONCLUSIVE at this sample size. A perfectly calibrated model would often score this badly on this many rows, so this dataset cannot tell the two apart. This is not a clean bill of health: nothing was established either way. Collect more rows to make the question answerable." + }, + { + "label": "space separated, the ECE exactly on a rounding tie", + "n_bins": 10, + "text": " 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 1\n 0.75 0\n 0.75 0\n 0.75 0\n 0.75 0\n 0.75 0\n 0.75 0\n 0.75 0\n 0.75 0\n 0.75 0", + "n": 32, + "ece": 0.03125, + "mean": 0.059703125, + "p95": 0.15625, + "statement": "ECE 0.0312 over 32 rows (10 equal width bins), against a calibrated-model floor of 0.0597 (95th percentile 0.1562): INCONCLUSIVE at this sample size. A perfectly calibrated model would often score this badly on this many rows, so this dataset cannot tell the two apart. This is not a clean bill of health: nothing was established either way. Collect more rows to make the question answerable." + }, + { + "label": "semicolons and quotes, the ECE beside a tie", + "n_bins": 10, + "text": "p;\"correct\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"1\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"\n\"0.80125\";\"0\"", + "n": 400, + "ece": 0.0012499999999998623, + "mean": 0.016173749999999997, + "p95": 0.03875000000000006, + "statement": "ECE 0.0012 over 400 rows (10 equal width bins), against a calibrated-model floor of 0.0162 (95th percentile 0.0388): INCONCLUSIVE at this sample size. A perfectly calibrated model would often score this badly on this many rows, so this dataset cannot tell the two apart. This is not a clean bill of health: nothing was established either way. Collect more rows to make the question answerable." + } + ] } diff --git a/site/floor-worker.js b/site/floor-worker.js index af0cc6f..7fca2d8 100644 --- a/site/floor-worker.js +++ b/site/floor-worker.js @@ -12,6 +12,11 @@ self.onmessage = function (event) { var result; if (job.kind === "synthetic") { result = F.syntheticFloor(job.n, job.nBins, job.accuracy, { onProgress: progress }); + } else if (job.kind === "observed") { + result = { + measured: F.ece(job.probabilities, job.correct, job.nBins), + band: F.calibrationFloor(job.probabilities, job.nBins, { onProgress: progress }), + }; } else if (job.kind === "plan") { result = F.planRows(job.target, job.nBins, job.accuracy, { onProgress: progress, diff --git a/site/floor.js b/site/floor.js index aed8aa1..f6b8e11 100644 --- a/site/floor.js +++ b/site/floor.js @@ -517,6 +517,111 @@ return { rows: rows, band: band, evaluations: evaluations }; } + /* ---------------------------------------------------------- pasted rows */ + + // The most rows the page scores. Each row is redrawn 2,000 times for the + // floor, so past this the wait grows long enough that the tool itself is the + // better place to run it. + var MAX_PASTED_ROWS = 20000; + var NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/; + + function unquoted(raw) { + var value = raw.trim(); + if (value.length >= 2 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') { + value = value.slice(1, -1).trim(); + } + return value; + } + + // A plain decimal or exponent number, or null. Number() alone would accept + // "", "0x1", and "Infinity", none of which is a probability someone meant. + function strictNumber(value) { + return NUMBER.test(value) ? Number(value) : null; + } + + function outcome(value) { + var lower = value.toLowerCase(); + if (lower === "true") return true; + if (lower === "false") return false; + var number = strictNumber(value); + return number === 0 || number === 1 ? number === 1 : null; + } + + // A first line is a header when it names its columns: some field is not a + // number, not an outcome, and not a spelling of NaN or infinity, which are + // mistakes to report rather than names to skip. + function isHeader(fields) { + return fields.some(function (value) { + return strictNumber(value) === null && outcome(value) === null && + /[a-z]/i.test(value) && !/^[+-]?(nan|inf|infinity)$/i.test(value); + }); + } + + function shown(value) { + return '"' + (value.length > 24 ? value.slice(0, 24) + "..." : value) + '"'; + } + + /** + * Reads pasted predictions: one row per line, a probability and an outcome + * (1 or true when the prediction was right, 0 or false when it was not), + * separated by a comma, semicolon, tab, or spaces. A first line that names + * its columns is skipped, and so are blank lines. + * + * Returns { probabilities, correct, rows, header, errors }. Each error is + * { line, message }, with line null for a problem with the whole paste. Rows + * are for scoring only when errors is empty. + * + * options: maxRows (default 20,000). + */ + function parsePredictions(text, options) { + options = options || {}; + var maxRows = options.maxRows || MAX_PASTED_ROWS; + var lines = String(text).split(/\r\n|\r|\n/); + var probabilities = []; + var correct = []; + var errors = []; + var header = false; + var first = true; + for (var i = 0; i < lines.length; i++) { + if (lines[i].trim() === "") continue; + var fields = lines[i].trim().split(/\s*[,;\t]\s*|\s+/).map(unquoted); + if (fields.length > 1 && fields[fields.length - 1] === "") fields.pop(); // a trailing separator + if (first) { + first = false; + if (isHeader(fields)) { header = true; continue; } + } + var line = i + 1; + if (fields.length !== 2) { + errors.push({ line: line, message: "expected two values, a probability and an outcome, and found " + fields.length }); + continue; + } + var p = strictNumber(fields[0]); + var y = outcome(fields[1]); + if (p === null) { + errors.push({ line: line, message: "the probability " + shown(fields[0]) + " is not a number" }); + } else if (!(p >= 0 && p <= 1)) { + errors.push({ line: line, message: "the probability " + fields[0] + " is outside 0 to 1" }); + } else if (y === null) { + errors.push({ line: line, message: "the outcome " + shown(fields[1]) + " is not 0 or 1" }); + } else { + probabilities.push(p); + correct.push(y); + } + } + var rows = probabilities.length; + if (!errors.length && !rows) { + errors.push({ line: null, message: "there are no rows to score. Paste one row per line: a probability, then 0 or 1" }); + } + if (rows > maxRows) { + errors.push({ + line: null, + message: rows.toLocaleString("en-US") + " rows is more than this page scores (" + + maxRows.toLocaleString("en-US") + "). Run plumbline itself on a set this large", + }); + } + return { probabilities: probabilities, correct: correct, rows: rows, header: header, errors: errors }; + } + /* -------------------------------------------------------------- wording */ // Python's "%.4f": round the exact binary value, half to even. toFixed also @@ -1064,6 +1169,8 @@ judgment: judgment, isDistinguishable: isDistinguishable, fixed4: fixed4, + parsePredictions: parsePredictions, + maxPastedRows: MAX_PASTED_ROWS, // Exposed for the parity check, not for the page. _internal: { Pcg64: Pcg64, diff --git a/site/index.html b/site/index.html index 74f7fd5..a88e8dd 100644 --- a/site/index.html +++ b/site/index.html @@ -52,6 +52,7 @@
This is an estimate from summary numbers. Your real predictions can give a different floor at the same row count; the worked example - shows why.
+ shows why, and you can score your own predictions instead. @@ -191,7 +192,57 @@This is why plumbline needs your actual predictions. The calculator gives - an estimate from summary numbers. The tool gives the real floor, from your data.
+ an estimate from summary numbers. The tool gives the real floor, from your data, and so does + the next section. + + + +Paste the predictions a model made and whether each one was right, and this computes their + ECE, the floor for exactly those predictions, and the verdict a plumbline report would print, + word for word.
+Nothing you paste leaves this page. It is read and scored by + the script on this page, in your browser. It is not uploaded, not stored, and not put in the + address, so reloading the page clears it.
+ + +A binary classifier's probability of the positive class, with the true + label as the outcome, can be scored the same way. That measures whether the positive class's + probabilities hold, which is a different figure from the report's ECE for the same classifier: + the report scores the confidence in whichever answer was chosen.
ece_figure, the call a report makes: the
+ ECE, then calibration_floor on the same rows. The check holds it to the Python
+ on pasted text, including a value that sits exactly on a rounding tie.