<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maya Andersson</title>
    <description>The latest articles on DEV Community by Maya Andersson (@maya_andersson_dev).</description>
    <link>https://dev.to/maya_andersson_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3940866%2F5582fb73-6689-457f-92ac-b4e833ce5f1d.png</url>
      <title>DEV Community: Maya Andersson</title>
      <link>https://dev.to/maya_andersson_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maya_andersson_dev"/>
    <language>en</language>
    <item>
      <title>Run forty experiments against one eval set and you will find an improvement that is not there</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Wed, 12 Aug 2026 14:07:15 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/run-forty-experiments-against-one-eval-set-and-you-will-find-an-improvement-that-is-not-there-57oj</link>
      <guid>https://dev.to/maya_andersson_dev/run-forty-experiments-against-one-eval-set-and-you-will-find-an-improvement-that-is-not-there-57oj</guid>
      <description>&lt;p&gt;You iterate on a prompt, run the eval set, keep the best variant. Repeat for a quarter. The number that survives is the maximum of forty noisy measurements, and the maximum of noisy measurements is an upward-biased estimate of the thing you care about. The size of that bias is computable, it is larger than most of the "wins" teams celebrate, and there is a decade-old literature on how to reuse a holdout without fooling yourself that I almost never see cited in LLM eval writeups.&lt;/p&gt;

&lt;h2&gt;
  
  
  The selection machine
&lt;/h2&gt;

&lt;p&gt;The workflow looks responsible. A fixed eval set of 250 examples, a binary pass judgment per example, every candidate prompt measured on the same set. Best score ships. It looks like discipline.&lt;/p&gt;

&lt;p&gt;The problem is the word "best." Each measurement is the true pass rate plus sampling noise. Pick the maximum of forty measurements and you have preferentially picked positive noise. The winner's measured score overstates its true score even when some candidates are genuinely better than others; the statistics community has called this the winner's curse for decades.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big the bias is
&lt;/h2&gt;

&lt;p&gt;For a pass rate near p on n examples, one measurement has standard error sqrt(p(1-p)/n). At p = 0.85 and n = 250 that is 2.26 points. Now suppose, worst case, all k candidates are actually identical: every measured difference is pure noise. The expected maximum of k standard normal draws is easy to compute numerically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5 experiments: expected max 1.16 sigma, so +2.6 points at n = 250&lt;/li&gt;
&lt;li&gt;10 experiments: 1.54 sigma, +3.5 points&lt;/li&gt;
&lt;li&gt;40 experiments: 2.16 sigma, +4.9 points&lt;/li&gt;
&lt;li&gt;100 experiments: 2.51 sigma, +5.7 points&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Forty tries against a 250-example set buys you an expected phantom improvement of about five points. If your quarterly review says the prompt work moved the pass rate from 85 to 89, the null hypothesis says: that is what selection looks like when nothing improved at all.&lt;/p&gt;

&lt;p&gt;Twelve lines to check my arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;

&lt;span class="c1"&gt;# k identical candidates, each measured once on n examples
&lt;/span&gt;&lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;binomial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true rate: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean best-of-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inflation: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; points&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prints about +4.7, just under the normal-approximation figure above for k = 40 (the binomial's discreteness and ties shave off a couple of tenths). Change k and re-run: the bias keeps growing, roughly like sqrt(2 ln k), so it slows with persistence but never stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part the field already solved
&lt;/h2&gt;

&lt;p&gt;Dwork, Feldman, Hardt, Pitassi, Reingold, and Roth worked out the general problem in 2015 under the name adaptive data analysis (the reusable holdout line of work; arXiv:1506.02629). Their observation: the moment your next experiment depends on the last measurement, the holdout stops being fresh, and classical guarantees quietly void themselves. Their mechanism, Thresholdout, answers holdout queries through a noise-adding gate so the set survives many more adaptive looks.&lt;/p&gt;

&lt;p&gt;You do not need to implement Thresholdout to benefit from the diagnosis. Four practices capture most of the value:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Two sets, two roles. Iterate freely against a dev set. Confirm on a frozen set you touch only when shipping. The confirm set's job is to be boring: it answers a handful of times per quarter, not forty.&lt;/li&gt;
&lt;li&gt;Selection-adjusted bars. If you compared k variants, the winner must clear the baseline by more than the expected max of k noise draws, not by more than zero. For k = 40 at n = 250, that bar is about five points, which is sobering and correct.&lt;/li&gt;
&lt;li&gt;Count your touches. k is the one quantity in this whole analysis you control and can log exactly. Teams version their prompts and their datasets and keep no record of how many times the eval set has been queried. Make k a first-class number in the eval report.&lt;/li&gt;
&lt;li&gt;Retire burned sets. After enough adaptive looks, refresh the eval set from new traffic and re-baseline. Not because the data drifted (that is a separate failure with its own essay) but because your process has memorized this particular sample's noise.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Does a bigger eval set fix this? It helps: sigma scales as 1/sqrt(n), so quadrupling the set halves the inflation. It does not change the shape: the bias still grows with k. A bigger set lowers the noise floor. The selection effect sitting on top of it remains.&lt;/p&gt;

&lt;p&gt;Is this just p-hacking with a new name? It is the same statistical family. P-hacking selects among analyses of one experiment; this selects among experiments measured on one reused set. The adaptive data analysis literature treats both as instances of the same leakage.&lt;/p&gt;

&lt;p&gt;Has anyone measured whether this happens in practice, or is it theory? Measured, with a twist. Recht, Roelofs, Schmidt, and Shankar built a fresh CIFAR-10 test set in 2018 (arXiv:1806.00451) expecting to catch years of test-set reuse as overfitting. The drop they found pointed at distribution shift instead, and adaptivity-driven overfitting was smaller than theory allows. Two honest readings: benchmark-scale selection has correction mechanisms (many teams, public leaderboards, diverse models), and a single team hammering a private 250-example set with forty variants of one prompt is a much purer selection machine than ImageNet ever was. The math above is the worst case; your k and your n decide how close you sit to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;Is there a principled touch budget for an eval set: a k beyond which refresh is mandatory, stated in advance the way significance levels are? I have a heuristic (refresh when the selection-adjusted bar exceeds the effect sizes you care about) but I have not seen a treatment that turns it into a rule teams actually adopt. If you have seen one in the wild, I want to read it.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>testing</category>
    </item>
    <item>
      <title>I read the metric libraries of five widely-used eval tools. The metric was never the hard part.</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Tue, 11 Aug 2026 18:53:06 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/i-read-the-metric-libraries-of-five-widely-used-eval-tools-the-metric-was-never-the-hard-part-5hap</link>
      <guid>https://dev.to/maya_andersson_dev/i-read-the-metric-libraries-of-five-widely-used-eval-tools-the-metric-was-never-the-hard-part-5hap</guid>
      <description>&lt;p&gt;Every LLM eval tool sells you the same headline: a big bag of ready-made metrics. Fifty of them. Seventy. Pick one, call evaluate(), get a number. The pitch works because it is true, and because it quietly relocates the hard part of evaluation to somewhere you cannot see it.&lt;/p&gt;

&lt;p&gt;I spent a week reading the actual metric catalogs of five widely-used eval libraries, not the landing pages. Arize Phoenix, DeepEval, Future AGI, Langfuse, and Ragas. I wanted to know two things. What do you get for free, and what does the free part let you stop thinking about. The answer to the second question is: almost nothing. The metric is the easy 20 percent. The two hard parts, choosing a metric that matches your failure mode and putting an error bar on the result, are still yours, and none of these tools does them for you.&lt;/p&gt;

&lt;p&gt;Here is the survey, then the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  What five catalogs actually ship
&lt;/h2&gt;

&lt;p&gt;I ordered these alphabetically on purpose. I am not ranking them, and I built a comparison table precisely so I would not be tempted to.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Built-in metrics (documented)&lt;/th&gt;
&lt;th&gt;LLM-as-judge&lt;/th&gt;
&lt;th&gt;Aggregation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Arize Phoenix&lt;/td&gt;
&lt;td&gt;Elastic License 2.0 (source-available)&lt;/td&gt;
&lt;td&gt;~16 pre-built evaluators: Faithfulness, Correctness, Q&amp;amp;A, Tool Selection, Hallucination, Toxicity&lt;/td&gt;
&lt;td&gt;Yes, judge templates use function calling to extract structured verdicts&lt;/td&gt;
&lt;td&gt;Datasets and Experiments: per-example scores, aggregate metrics, cross-experiment comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepEval&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;"50+" metrics: G-Eval, DAG, Answer Relevancy, Faithfulness, Tool Correctness, Hallucination, Bias&lt;/td&gt;
&lt;td&gt;Yes, most metrics are LLM-judged (QAG, DAG, G-Eval)&lt;/td&gt;
&lt;td&gt;Pytest-style pass/fail against thresholds; &lt;code&gt;-r&lt;/code&gt; flag repeats a test case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Future AGI&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;72 local metrics: BLEU, ROUGE, embedding similarity, JSON-schema, plus judged faithfulness and function-call accuracy&lt;/td&gt;
&lt;td&gt;Yes, local heuristics augmentable with a judge via &lt;code&gt;augment=True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Aggregated metric type; datasets in the platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse&lt;/td&gt;
&lt;td&gt;MIT (core; &lt;code&gt;ee/&lt;/code&gt; is commercial)&lt;/td&gt;
&lt;td&gt;Managed evaluator catalog: Hallucination, Context-Relevance, Toxicity, Helpfulness (some maintained with Ragas)&lt;/td&gt;
&lt;td&gt;Yes, numeric, categorical, or boolean judge scores&lt;/td&gt;
&lt;td&gt;Dataset runs scored at aggregate level, side-by-side experiment comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ragas&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;"30+" metrics: Faithfulness, Context Precision, Context Recall, Response Relevancy, Tool Call Accuracy&lt;/td&gt;
&lt;td&gt;Yes, LLM-based metrics issue one or more judge calls&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;evaluate()&lt;/code&gt; returns mean scores across the dataset; &lt;code&gt;to_pandas()&lt;/code&gt; for per-sample&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few honest notes before anyone quotes this table at me.&lt;/p&gt;

&lt;p&gt;The metric counts are each project's own stated approximation, not a hand count. "50+", "30+", and "~16" are marketing-adjacent numbers from their docs, and "72" is a figure from Future AGI's SDK README. I did not recount anyone's catalog line by line, so treat the column as order-of-magnitude, not a leaderboard. Phoenix is worth a license asterisk: Elastic License 2.0 is source-available, not OSI-approved open source, which matters if you are redistributing, less so if you are just running it. Langfuse is MIT at the core with a separately licensed enterprise directory. The other three are Apache-2.0.&lt;/p&gt;

&lt;p&gt;The overlap is the real story. Faithfulness shows up in four of the five. Tool-calling correctness shows up in four. Every one of them supports LLM-as-a-judge, and every one of them returns you a score. If you were choosing on metric coverage alone, you would have a hard time telling them apart, because for the common cases they are converging on the same list. That convergence is a signal. The catalog is becoming a commodity. Which means the catalog is not where your evaluation succeeds or fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part number one: the metric that matches your failure
&lt;/h2&gt;

&lt;p&gt;A built-in metric encodes a definition somebody else wrote. "Faithfulness" in a RAG library means the answer is grounded in the retrieved context. That is a real and useful thing to measure. It is also completely silent about whether your agent picked the right document to retrieve in the first place, whether it called the refund tool instead of the lookup tool, or whether it was confidently, groundedly wrong because the context itself was stale.&lt;/p&gt;

&lt;p&gt;I have watched teams adopt a library, wire up five of its metrics, get a dashboard full of 0.9s, and ship a regression anyway, because none of the five metrics measured the thing that actually broke. The tool did its job. The scores were accurate. They were just answers to questions nobody's users were asking.&lt;/p&gt;

&lt;p&gt;The move that works is unglamorous and the tools cannot do it for you. Write down your failure taxonomy first. Go read fifty real production traces, cluster the ways your system actually fails, and only then go shopping in the catalog for metrics that map onto those clusters. Most of the time you will find that two or three built-in metrics cover your top failure modes and the rest are noise you were about to pay judge-call money to compute. Occasionally you will find that your worst failure mode has no built-in metric at all, and you will write a custom judge. Every one of these five tools lets you write a custom metric, which tells you the authors already know the catalog does not cover you.&lt;/p&gt;

&lt;p&gt;So the first hard part is selection, and selection is a function of your traces, not their docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part number two: the error bar nobody ships
&lt;/h2&gt;

&lt;p&gt;Here is the finding that sent me down this whole path. I read the aggregation behavior of all five tools carefully, because aggregation is where statistics is supposed to happen. What I found is that all five will happily give you a mean. Ragas returns {'faithfulness': 0.892}. Phoenix and Langfuse show aggregate scores across an experiment. DeepEval gives you a pass rate against a threshold. Future AGI has an aggregated-metric type.&lt;/p&gt;

&lt;p&gt;Not one of them, as of July 2026, computes a confidence interval for you by default.&lt;/p&gt;

&lt;p&gt;I want to be precise, because this is the kind of claim that gets misquoted into "tool X can't do statistics." That is not what I am saying. I am saying I went looking in the documented, out-of-the-box behavior of each library and did not find confidence-interval computation in any of them, Future AGI included. DeepEval's -r repeat flag gets you closest to the raw material, since you can run a case multiple times, but the docs stop at repeating, not at turning the repeats into an interval. The others aggregate to a mean and stop.&lt;/p&gt;

&lt;p&gt;This matters more than the metric-count column everyone stares at. A faithfulness pass rate of 0.88 measured on 50 examples (44 of 50 above threshold) is not 0.88. It is 0.88 with a 95 percent Wilson interval running from roughly 0.76 to 0.94 (Wilson, 1927, the standard small-sample interval for a proportion). If your last release scored 0.82 (41 of 50) on the same 50 and you are about to write "faithfulness improved," you are reading noise as signal. The tool told you 0.82 and 0.88. It did not tell you those two numbers are statistically indistinguishable, because computing that was never its job, and it did not warn you that you were about to assume it was.&lt;/p&gt;

&lt;p&gt;Here are the ten lines the catalog leaves to you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;statsmodels.stats.proportion&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;proportion_confint&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pass_rate_with_ci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;passes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;proportion_confint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wilson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 44 of 50 examples passed the metric's threshold
&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pass_rate_with_ci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  95% CI [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 0.88  95% CI [0.76, 0.94]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That interval is the difference between "we improved" and "we cannot tell yet, run more examples." For continuous judge scores rather than pass/fail, bootstrap the mean instead of using Wilson, but the discipline is identical: never report a point estimate from an eval set without the interval around it, and never compare two releases whose intervals overlap as if one won.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what is the tool for
&lt;/h2&gt;

&lt;p&gt;This is not an argument against any of these libraries. I use them. The catalog genuinely saves you from reimplementing BLEU, ROUGE, embedding similarity, and a dozen judge prompts, and standardizing on one of them is a good decision. Phoenix rides OpenTelemetry if that is your stack. DeepEval's pytest ergonomics are the cleanest if you live in CI. Future AGI if you want eval, tracing, and guardrails in one place. Langfuse if you want tracing and evals in one product. Ragas is still the sharpest on RAG. They are all fine, and they are all converging.&lt;/p&gt;

&lt;p&gt;The mistake is thinking the tool finished your evaluation. It started it. It computed the metric, which was the part a library can do, and it handed you back a number stripped of the two things that make the number mean anything: whether the metric matches your failure, and how much of the number is noise. Those two stay on your side of the API no matter which logo is on the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Which of these five should I pick?&lt;br&gt;
Whichever fits your stack and license constraints, because on metric coverage they are close to interchangeable for common cases. Decide on ergonomics (pytest vs platform vs OTel), license (Apache-2.0 vs Elastic vs MIT-core), and whether you need the surrounding tracing and datasets, not on the metric count.&lt;/p&gt;

&lt;p&gt;Is a bigger metric library better?&lt;br&gt;
No. A bigger library raises the chance that a metric matching your failure mode exists, but it does nothing to raise the chance that you picked it. Ten well-chosen metrics beat seventy defaults every time.&lt;/p&gt;

&lt;p&gt;Do I really need confidence intervals for eval?&lt;br&gt;
If you are making ship/no-ship decisions on the difference between two scores, yes, always. The interval is what tells you whether the difference is real. Ten extra lines of code, and it is the highest-leverage code in your eval suite.&lt;/p&gt;

&lt;p&gt;Does any tool do the interval for me?&lt;br&gt;
Not by default, in any of the five I read, as of July 2026. If that changes I will happily update this. Until then, wrap your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;If the metric catalog is commoditizing, the differentiation moves up a level, to selection and uncertainty. So why does no eval tool ship a "your last two runs are statistically indistinguishable" warning yet? It is a small feature. It would prevent a large fraction of the false "we improved" claims I see in eval writeups. My guess is that a tool that keeps telling you your result is inconclusive is a harder product to sell than one that always returns a confident number. I would love to be proven wrong by whichever project ships it first.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>opensource</category>
      <category>statistics</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your eval monitor fired on four days this week. At your sample size, that was the most likely count</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Tue, 11 Aug 2026 18:50:05 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/your-eval-monitor-fired-on-four-days-this-week-at-your-sample-size-that-was-the-most-likely-count-14a6</link>
      <guid>https://dev.to/maya_andersson_dev/your-eval-monitor-fired-on-four-days-this-week-at-your-sample-size-that-was-the-most-likely-count-14a6</guid>
      <description>&lt;p&gt;A team I compared notes with recently runs continuous evals on production traffic: an LLM judge scores a sample of traces, the scores land on a dashboard, and a monitor pages when the hourly pass rate drops below 88 percent. Their baseline is around 92. The monitor paged on four separate days last week. Four investigations, four shrugs, four "it recovered on its own."&lt;/p&gt;

&lt;p&gt;Nobody chose a false-alarm rate for that monitor. But it has one, and it is not small. With roughly 150 judge scores an hour, a fresh window each check, and a true pass rate of 0.92, the chance that at least one hourly check dips below 0.88 at some point in a day is 53 percent. That makes the expected number of alert days in a week 3.7, and the single most likely weekly count exactly four. Their monitor was not detecting regressions. It was sampling noise on a schedule, and the on-call rotation was the readout.&lt;/p&gt;

&lt;p&gt;An alert threshold on an eval score is a hypothesis test. Every tool that ships eval monitoring runs that test for you. What none of the configuration surfaces I read this month asks you for is the one number the test depends on: how many scores are in the window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test you are actually running
&lt;/h2&gt;

&lt;p&gt;The setup is ordinary binomial arithmetic, which is what makes it checkable. Each check looks at a fresh window of n judge verdicts. The true pass rate is p. The monitor fires when the observed rate falls below a threshold t. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alpha_per_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;P(false alarm): windowed rate &amp;lt; t although nothing changed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;          &lt;span class="c1"&gt;# largest count that fires
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;p_fire_today&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;alpha_per_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt;      &lt;span class="c1"&gt;# tumbling windows, independent checks
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;power_per_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# P(one check catches a real drop)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that for a 0.88 threshold against a 0.92 baseline, across realistic window sizes, and add the probability that a single check catches a genuine regression to 0.85:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scores per window&lt;/th&gt;
&lt;th&gt;Fires per check on noise&lt;/th&gt;
&lt;th&gt;Fires some time today&lt;/th&gt;
&lt;th&gt;Expected alert days per week&lt;/th&gt;
&lt;th&gt;Catches a real drop to 0.85, per check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;13.5%&lt;/td&gt;
&lt;td&gt;96.9%&lt;/td&gt;
&lt;td&gt;6.8&lt;/td&gt;
&lt;td&gt;52.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;10.2%&lt;/td&gt;
&lt;td&gt;92.4%&lt;/td&gt;
&lt;td&gt;6.5&lt;/td&gt;
&lt;td&gt;63.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;5.6%&lt;/td&gt;
&lt;td&gt;74.8%&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;75.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;td&gt;53.3%&lt;/td&gt;
&lt;td&gt;3.7&lt;/td&gt;
&lt;td&gt;81.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;35.0%&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;86.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;td&gt;4.9%&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;94.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the 25-per-window row twice. That monitor pages nearly every day on pure noise, and a single check still misses a real seven-point regression about half the time. It manages to be trigger-happy and insensitive simultaneously, which is what happens when the threshold is chosen as a round number four points under baseline instead of as a quantile of anything.&lt;/p&gt;

&lt;p&gt;The window size is not an abstract parameter. It is your traffic times your sampling rate. Sample 10 percent of 500 traces an hour and n is 50. The sampling knob every tool gives you for cost control is also, silently, the monitor's sensitivity knob: cutting sampling from 100 percent to 10 percent widens the score's confidence interval by a factor of about 3.2, the square root of ten. None of this is exotic. It is the standard behaviour of binomial proportions, catalogued in detail in Brown, Cai and DasGupta's "Interval Estimation for a Binomial Proportion" (Statistical Science, 2001).&lt;/p&gt;

&lt;p&gt;So the question I brought to the current tooling was not "who has alerting." Most do. It was three narrower questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question one: which traces does the score come from?
&lt;/h2&gt;

&lt;p&gt;This decides n, so it decides everything above.&lt;/p&gt;

&lt;p&gt;Langfuse runs judge evaluators at ingest time on observations that match your filters, with a sampling percentage you set; matching observations enter an evaluation queue and are scored asynchronously. Filters cover observation type, trace name, tags, user, session and metadata, so the scored population is well defined, and the docs are explicit that sampling exists to manage evaluation cost.&lt;/p&gt;

&lt;p&gt;LangSmith does the equivalent through automation rules: a filter selects runs, a sampling rate decides what fraction of the filtered runs the evaluator sees, and the scores attach back to the traces.&lt;/p&gt;

&lt;p&gt;Opik's online evaluation rules score live production traces with LLM-as-judge metrics and write results back as feedback scores on each trace.&lt;/p&gt;

&lt;p&gt;Future AGI's continuous eval tasks take the same two controls; a forward-only cursor means history is never backfilled.&lt;/p&gt;

&lt;p&gt;Phoenix evaluates production traces when you run evals over them by SDK or in the app; for continuously scheduled scoring of live traffic its own docs point you to Arize AX, the commercial platform. DeepEval sits at the same boundary from the other side: the framework is offline-first, and production scoring is the companion Confident AI platform's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question two: what turns a score into a page?
&lt;/h2&gt;

&lt;p&gt;Langfuse has the most developed vocabulary here of the set I read. Monitors watch numeric, categorical or boolean scores; you set an operator, an alert threshold and an optional warning threshold over a lookback window, and route through Slack, webhooks or GitHub Actions. It is also the only config I found that makes you decide what an empty window means: treat missing data as zero, hold the previous severity, record NO_DATA silently, or page after sustained NO_DATA. How many monitors you can create depends on plan tier; the features themselves do not.&lt;/p&gt;

&lt;p&gt;LangSmith alerts on five metric types, run count, cost, errors, latency and feedback score, that last one being where online eval results live, and it routes to Slack, PagerDuty, Dynatrace or any webhook.&lt;/p&gt;

&lt;p&gt;Future AGI's monitors take a static or percentage-change threshold, or learn one from the historical mean, with separate warning and critical levels and a check frequency in minutes.&lt;/p&gt;

&lt;p&gt;Opik writes rule scores onto traces and tracks them on dashboards; its online-rules documentation describes score computation and display, and no notification configuration appears on that page.&lt;/p&gt;

&lt;p&gt;Phoenix routes threshold-based triggers on production traffic to Arize AX, per its own docs. I did not audit AX's alert configuration, nor Confident AI's, so the two commercial companions stay out of the comparison below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question three: where does sample size enter?
&lt;/h2&gt;

&lt;p&gt;I read three alerting configuration surfaces end to end this month: Langfuse's monitor settings, LangSmith's alert settings and Future AGI's monitor model. In all three, the vocabulary is drawn from the same short list: a value, a direction, a window, sometimes a check frequency. None of the three has a field for the number of scores the window is expected to contain, and none derives the threshold from one. The window is specified in time, five minutes, an hour, a day, and how many scores fall inside it is whatever traffic and sampling happen to produce.&lt;/p&gt;

&lt;p&gt;Two of the threshold types deserve a specific caution. A percentage-change threshold compares two noisy window estimates, and the difference of two independent windowed rates carries roughly twice the variance of either one, so at small n it is noisier than the static threshold it replaces. And an auto or anomaly threshold that learns the historical mean solves a real problem, baseline drift, but the alert it fires is still a point-estimate comparison; learning where the baseline sits is not the same as knowing how far a healthy window wanders from it.&lt;/p&gt;

&lt;p&gt;To be precise about scope: this is what the configuration surfaces expose as of early August 2026, from each vendor's public docs and, where the code is public, its source tree. Any of them could ship a sample-size-aware policy tomorrow, and the gap is easy to work around today, which is the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting the threshold from n instead
&lt;/h2&gt;

&lt;p&gt;You do not need any vendor to fix this. Invert the binomial: decide the false-alarm rate you can staff, then compute the threshold your window size implies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;threshold_from_n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Largest rate threshold whose per-check false alarm stays &amp;lt;= alpha.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ppf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scores per window&lt;/th&gt;
&lt;th&gt;Page below (1% per check)&lt;/th&gt;
&lt;th&gt;Warn below (10% per check)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;0.720&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;0.870&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;0.860&lt;/td&gt;
&lt;td&gt;0.887&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0.870&lt;/td&gt;
&lt;td&gt;0.890&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.885&lt;/td&gt;
&lt;td&gt;0.900&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things fall out. First, at 25 scores a window, an honest 1-percent page threshold is 0.72, a full sixteen points below the 0.88 the team "felt" was right; the small-n rows are exactly where intuition overshoots most. Second, the pairing maps cleanly onto the warning-plus-critical structure that Langfuse and Future AGI both expose and that LangSmith approximates with two rules: put the warning at the 10 percent quantile and the page at the 1 percent quantile, and both levels inherit a false-alarm ceiling you actually chose.&lt;/p&gt;

&lt;p&gt;If your platform only takes time-based windows, fix the count instead: check every N scores rather than every hour. A count-based window makes n a constant, which makes the table above exact instead of approximate, and it stops quiet hours from paging you simply because n collapsed overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Is 0.92 a magic baseline? No. Every number above recomputes for your baseline and your window in the four lines of code shown; the shape of the conclusion, thresholds must move with n, survives any realistic parameter choice.&lt;/p&gt;

&lt;p&gt;My monitor uses a rolling window checked every five minutes. Does the daily 53 percent still apply? Not directly, and the direction matters: the five-minute checks include the hourly windows among them, so the daily probability is at least the 53 percent, not less. Plugging 288 checks into the independence formula overstates it badly, though; overlapping windows are correlated, the per-check false alarm is unchanged, and correlated checks cluster their false alarms into the same bad hour.&lt;/p&gt;

&lt;p&gt;Why not just require two consecutive breaches? That is a legitimate sample-size-aware policy, and on tumbling windows it squares the per-check false-alarm rate. You pay in detection delay, one extra check period, and in power against short-lived regressions. It is the cheapest fix on this page; the quantile threshold is the principled one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;Auto thresholds that learn the baseline mean already read the score history. The same history contains everything needed to learn the window-to-window variance and set the alert line at a chosen quantile of it, which would make the monitor's false-alarm rate a configured property instead of an accident of traffic. None of the docs I read says whether any of the auto modes does this today. If someone from one of these teams can point me at a sample-size-aware or variance-aware alert policy in their product, mine is exactly the kind of dashboard it would quiet, and I will happily run the comparison again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources for the tool claims, all read in the first week of August 2026: Langfuse docs, "Monitors and Alerts" and the LLM-as-a-judge evaluator pages; LangSmith docs, "Online evaluations" and "Alerts"; Opik repository README and its online evaluation rules doc (comet-ml/opik); Phoenix repository docs, llm-evals page (Arize-ai/phoenix); DeepEval repository README (confident-ai/deepeval); Future AGI repository, tracer models eval_task.py and monitor.py (future-agi/future-agi). The binomial arithmetic is standard; see Brown, Cai and DasGupta, "Interval Estimation for a Binomial Proportion," Statistical Science 16(2), 2001.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>statistics</category>
      <category>observability</category>
    </item>
    <item>
      <title>Your eval monitor fired on four days this week. At your sample size, that was the most likely count</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Fri, 07 Aug 2026 19:01:00 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/your-eval-monitor-fired-on-four-days-this-week-at-your-sample-size-that-was-the-most-likely-count-1jna</link>
      <guid>https://dev.to/maya_andersson_dev/your-eval-monitor-fired-on-four-days-this-week-at-your-sample-size-that-was-the-most-likely-count-1jna</guid>
      <description>&lt;p&gt;A team I compared notes with recently runs continuous evals on production traffic: an LLM judge scores a sample of traces, the scores land on a dashboard, and a monitor pages when the hourly pass rate drops below 88 percent. Their baseline is around 92. The monitor paged on four separate days last week. Four investigations, four shrugs, four "it recovered on its own."&lt;/p&gt;

&lt;p&gt;Nobody chose a false-alarm rate for that monitor. But it has one, and it is not small. With roughly 150 judge scores an hour, a fresh window each check, and a true pass rate of 0.92, the chance that at least one hourly check dips below 0.88 at some point in a day is 53 percent. That makes the expected number of alert days in a week 3.7, and the single most likely weekly count exactly four. Their monitor was not detecting regressions. It was sampling noise on a schedule, and the on-call rotation was the readout.&lt;/p&gt;

&lt;p&gt;An alert threshold on an eval score is a hypothesis test. Every tool that ships eval monitoring runs that test for you. What none of the configuration surfaces I read this month asks you for is the one number the test depends on: how many scores are in the window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test you are actually running
&lt;/h2&gt;

&lt;p&gt;The setup is ordinary binomial arithmetic, which is what makes it checkable. Each check looks at a fresh window of n judge verdicts. The true pass rate is p. The monitor fires when the observed rate falls below a threshold t. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alpha_per_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;P(false alarm): windowed rate &amp;lt; t although nothing changed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;          &lt;span class="c1"&gt;# largest count that fires
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;p_fire_today&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;alpha_per_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt;      &lt;span class="c1"&gt;# tumbling windows, independent checks
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;power_per_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# P(one check catches a real drop)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that for a 0.88 threshold against a 0.92 baseline, across realistic window sizes, and add the probability that a single check catches a genuine regression to 0.85:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scores per window&lt;/th&gt;
&lt;th&gt;Fires per check on noise&lt;/th&gt;
&lt;th&gt;Fires some time today&lt;/th&gt;
&lt;th&gt;Expected alert days per week&lt;/th&gt;
&lt;th&gt;Catches a real drop to 0.85, per check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;13.5%&lt;/td&gt;
&lt;td&gt;96.9%&lt;/td&gt;
&lt;td&gt;6.8&lt;/td&gt;
&lt;td&gt;52.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;10.2%&lt;/td&gt;
&lt;td&gt;92.4%&lt;/td&gt;
&lt;td&gt;6.5&lt;/td&gt;
&lt;td&gt;63.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;5.6%&lt;/td&gt;
&lt;td&gt;74.8%&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;75.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;td&gt;53.3%&lt;/td&gt;
&lt;td&gt;3.7&lt;/td&gt;
&lt;td&gt;81.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;35.0%&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;86.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;td&gt;4.9%&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;94.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the 25-per-window row twice. That monitor pages nearly every day on pure noise, and a single check still misses a real seven-point regression about half the time. It manages to be trigger-happy and insensitive simultaneously, which is what happens when the threshold is chosen as a round number four points under baseline instead of as a quantile of anything.&lt;/p&gt;

&lt;p&gt;The window size is not an abstract parameter. It is your traffic times your sampling rate. Sample 10 percent of 500 traces an hour and n is 50. The sampling knob every tool gives you for cost control is also, silently, the monitor's sensitivity knob: cutting sampling from 100 percent to 10 percent widens the score's confidence interval by a factor of about 3.2, the square root of ten. None of this is exotic. It is the standard behaviour of binomial proportions, catalogued in detail in Brown, Cai and DasGupta's "Interval Estimation for a Binomial Proportion" (Statistical Science, 2001).&lt;/p&gt;

&lt;p&gt;So the question I brought to the current tooling was not "who has alerting." Most do. It was three narrower questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question one: which traces does the score come from?
&lt;/h2&gt;

&lt;p&gt;This decides n, so it decides everything above.&lt;/p&gt;

&lt;p&gt;Langfuse runs judge evaluators at ingest time on observations that match your filters, with a sampling percentage you set; matching observations enter an evaluation queue and are scored asynchronously. Filters cover observation type, trace name, tags, user, session and metadata, so the scored population is well defined, and the docs are explicit that sampling exists to manage evaluation cost.&lt;/p&gt;

&lt;p&gt;LangSmith does the equivalent through automation rules: a filter selects runs, a sampling rate decides what fraction of the filtered runs the evaluator sees, and the scores attach back to the traces.&lt;/p&gt;

&lt;p&gt;Opik's online evaluation rules score live production traces with LLM-as-judge metrics and write results back as feedback scores on each trace.&lt;/p&gt;

&lt;p&gt;Future AGI's continuous eval tasks take the same two controls; a forward-only cursor means history is never backfilled.&lt;/p&gt;

&lt;p&gt;Phoenix evaluates production traces when you run evals over them by SDK or in the app; for continuously scheduled scoring of live traffic its own docs point you to Arize AX, the commercial platform. DeepEval sits at the same boundary from the other side: the framework is offline-first, and production scoring is the companion Confident AI platform's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question two: what turns a score into a page?
&lt;/h2&gt;

&lt;p&gt;Langfuse has the most developed vocabulary here of the set I read. Monitors watch numeric, categorical or boolean scores; you set an operator, an alert threshold and an optional warning threshold over a lookback window, and route through Slack, webhooks or GitHub Actions. It is also the only config I found that makes you decide what an empty window means: treat missing data as zero, hold the previous severity, record NO_DATA silently, or page after sustained NO_DATA. How many monitors you can create depends on plan tier; the features themselves do not.&lt;/p&gt;

&lt;p&gt;LangSmith alerts on five metric types, run count, cost, errors, latency and feedback score, that last one being where online eval results live, and it routes to Slack, PagerDuty, Dynatrace or any webhook.&lt;/p&gt;

&lt;p&gt;Future AGI's monitors take a static or percentage-change threshold, or learn one from the historical mean, with separate warning and critical levels and a check frequency in minutes.&lt;/p&gt;

&lt;p&gt;Opik writes rule scores onto traces and tracks them on dashboards; its online-rules documentation describes score computation and display, and no notification configuration appears on that page.&lt;/p&gt;

&lt;p&gt;Phoenix routes threshold-based triggers on production traffic to Arize AX, per its own docs. I did not audit AX's alert configuration, nor Confident AI's, so the two commercial companions stay out of the comparison below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question three: where does sample size enter?
&lt;/h2&gt;

&lt;p&gt;I read three alerting configuration surfaces end to end this month: Langfuse's monitor settings, LangSmith's alert settings and Future AGI's monitor model. In all three, the vocabulary is drawn from the same short list: a value, a direction, a window, sometimes a check frequency. None of the three has a field for the number of scores the window is expected to contain, and none derives the threshold from one. The window is specified in time, five minutes, an hour, a day, and how many scores fall inside it is whatever traffic and sampling happen to produce.&lt;/p&gt;

&lt;p&gt;Two of the threshold types deserve a specific caution. A percentage-change threshold compares two noisy window estimates, and the difference of two independent windowed rates carries roughly twice the variance of either one, so at small n it is noisier than the static threshold it replaces. And an auto or anomaly threshold that learns the historical mean solves a real problem, baseline drift, but the alert it fires is still a point-estimate comparison; learning where the baseline sits is not the same as knowing how far a healthy window wanders from it.&lt;/p&gt;

&lt;p&gt;To be precise about scope: this is what the configuration surfaces expose as of early August 2026, from each vendor's public docs and, where the code is public, its source tree. Any of them could ship a sample-size-aware policy tomorrow, and the gap is easy to work around today, which is the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting the threshold from n instead
&lt;/h2&gt;

&lt;p&gt;You do not need any vendor to fix this. Invert the binomial: decide the false-alarm rate you can staff, then compute the threshold your window size implies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;threshold_from_n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Largest rate threshold whose per-check false alarm stays &amp;lt;= alpha.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ppf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scores per window&lt;/th&gt;
&lt;th&gt;Page below (1% per check)&lt;/th&gt;
&lt;th&gt;Warn below (10% per check)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;0.720&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;0.870&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;0.860&lt;/td&gt;
&lt;td&gt;0.887&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0.870&lt;/td&gt;
&lt;td&gt;0.890&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.885&lt;/td&gt;
&lt;td&gt;0.900&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things fall out. First, at 25 scores a window, an honest 1-percent page threshold is 0.72, a full sixteen points below the 0.88 the team "felt" was right; the small-n rows are exactly where intuition overshoots most. Second, the pairing maps cleanly onto the warning-plus-critical structure that Langfuse and Future AGI both expose and that LangSmith approximates with two rules: put the warning at the 10 percent quantile and the page at the 1 percent quantile, and both levels inherit a false-alarm ceiling you actually chose.&lt;/p&gt;

&lt;p&gt;If your platform only takes time-based windows, fix the count instead: check every N scores rather than every hour. A count-based window makes n a constant, which makes the table above exact instead of approximate, and it stops quiet hours from paging you simply because n collapsed overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Is 0.92 a magic baseline? No. Every number above recomputes for your baseline and your window in the four lines of code shown; the shape of the conclusion, thresholds must move with n, survives any realistic parameter choice.&lt;/p&gt;

&lt;p&gt;My monitor uses a rolling window checked every five minutes. Does the daily 53 percent still apply? Not directly, and the direction matters: the five-minute checks include the hourly windows among them, so the daily probability is at least the 53 percent, not less. Plugging 288 checks into the independence formula overstates it badly, though; overlapping windows are correlated, the per-check false alarm is unchanged, and correlated checks cluster their false alarms into the same bad hour.&lt;/p&gt;

&lt;p&gt;Why not just require two consecutive breaches? That is a legitimate sample-size-aware policy, and on tumbling windows it squares the per-check false-alarm rate. You pay in detection delay, one extra check period, and in power against short-lived regressions. It is the cheapest fix on this page; the quantile threshold is the principled one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;Auto thresholds that learn the baseline mean already read the score history. The same history contains everything needed to learn the window-to-window variance and set the alert line at a chosen quantile of it, which would make the monitor's false-alarm rate a configured property instead of an accident of traffic. None of the docs I read says whether any of the auto modes does this today. If someone from one of these teams can point me at a sample-size-aware or variance-aware alert policy in their product, mine is exactly the kind of dashboard it would quiet, and I will happily run the comparison again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources for the tool claims, all read in the first week of August 2026: Langfuse docs, "Monitors and Alerts" and the LLM-as-a-judge evaluator pages; LangSmith docs, "Online evaluations" and "Alerts"; Opik repository README and its online evaluation rules doc (comet-ml/opik); Phoenix repository docs, llm-evals page (Arize-ai/phoenix); DeepEval repository README (confident-ai/deepeval); Future AGI repository, tracer models eval_task.py and monitor.py (future-agi/future-agi). The binomial arithmetic is standard; see Brown, Cai and DasGupta, "Interval Estimation for a Binomial Proportion," Statistical Science 16(2), 2001.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmevaluation</category>
      <category>monitoring</category>
      <category>statistics</category>
      <category>observability</category>
    </item>
    <item>
      <title>Upgrading the judge ends one score series and starts another</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Thu, 06 Aug 2026 19:17:16 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/upgrading-the-judge-ends-one-score-series-and-starts-another-3169</link>
      <guid>https://dev.to/maya_andersson_dev/upgrading-the-judge-ends-one-score-series-and-starts-another-3169</guid>
      <description>&lt;p&gt;There is a mature literature on what happens when you swap one measuring instrument for another, and it is not in machine learning.&lt;/p&gt;

&lt;p&gt;The standard treatment is Bland and Altman, "Statistical methods for assessing agreement between two methods of clinical measurement", Lancet 1986; i: 307-310. Their core move is the one worth borrowing: do not correlate the two instruments. Plot their &lt;em&gt;difference&lt;/em&gt; against their &lt;em&gt;average&lt;/em&gt;, and look at the shape.&lt;/p&gt;

&lt;p&gt;Correlation is the wrong tool because it measures association rather than agreement, and it inflates with the spread of whatever sample you happened to use. Two instruments can correlate at 1.00 while one of them reads a fifth of the scale high everywhere it has room to. A constant offset is a perfect linear relationship, so correlation reports agreement that is not there.&lt;/p&gt;

&lt;p&gt;An LLM-as-judge is an instrument. When we replaced ours with its successor and the weekly quality score moved from 0.78 to 0.83 in the same week, we had changed the system being measured and the thing measuring it at once, and the honest position was that we did not know which had moved. Our scores run 0 to 1.&lt;/p&gt;

&lt;p&gt;The usual response is to estimate an offset and subtract it. That is a reasonable instinct and it is also a model, with assumptions, and the assumptions are checkable. Three checks, and the third one I got wrong the first time I ran it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The apparatus
&lt;/h2&gt;

&lt;p&gt;An anchor set: a fixed collection of (input, output) pairs, with the outputs frozen, scored under both judges. Paired, same items, same text, two raters.&lt;/p&gt;

&lt;p&gt;Ours had 60 items. On it, sd(d) was 0.12, where d is the per-item difference, new judge minus old. Both judges had a score sd of 0.22 on the anchor set, so the ratio sd(new)/sd(old) came out at 1.0 to the precision I can report from two figures rounded to two decimals. That ratio matters more than it looks, and check two is where I explain why. The scores themselves are continuous, a weighted rubric rolled up to 0 to 1, not a grid of discrete levels; that turns out to matter for check three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check one: the offset, and how badly you know it
&lt;/h2&gt;

&lt;p&gt;The offset is the mean of d, and the useful number is not the offset but its confidence interval:&lt;/p&gt;

&lt;p&gt;half-width = 1.96 x sd(d) / sqrt(n)&lt;/p&gt;

&lt;p&gt;Rearranged, that gives the anchor set size you need for a given precision:&lt;/p&gt;

&lt;p&gt;±0.05 on the offset: 10 items at sd(d) = 0.08, 23 at 0.12, 50 at 0.18&lt;br&gt;
±0.03 on the offset: 28 items at sd(d) = 0.08, 62 at 0.12, 139 at 0.18&lt;br&gt;
±0.02 on the offset: 62 items at sd(d) = 0.08, 139 at 0.12, 312 at 0.18&lt;br&gt;
±0.01 on the offset: 246 items at sd(d) = 0.08, 554 at 0.12, 1,245 at 0.18&lt;/p&gt;

&lt;p&gt;That table uses z = 1.96, the large-sample form. Under the t distribution, which is what Bland and Altman specify because sd(d) is estimated rather than known, every cell rises by two or three items. Proportionally it only matters at the top: the 10 becomes 13 and the 23 becomes 25, while the 554 becomes 556.&lt;/p&gt;

&lt;p&gt;At 60 items and sd(d) = 0.12, our offset is pinned to ±0.030.&lt;/p&gt;

&lt;p&gt;Now put the two numbers next to each other. We were trying to detect a product improvement of about 0.05, using a correction whose own uncertainty was ±0.030. The error bar on the correction was about 60% of the thing being measured. Subtracting that offset relabels the confounding rather than removing it, and it does so in a way that looks precise because a single number has been subtracted.&lt;/p&gt;

&lt;p&gt;Note which interval that is, because I conflated the two for longer than I would like. The ±0.030 is the precision of the &lt;em&gt;mean&lt;/em&gt; offset, which is the right number for correcting an aggregate. Bland and Altman's headline statistic is the limits of agreement, mean ± 1.96 x sd(d), which for us is ±0.235, and at 60 items each of those limits is itself uncertain by roughly ±0.05. Any &lt;em&gt;individual&lt;/em&gt; rescaled score carries the wide interval, not the narrow one. If you plan to correct a per-item score and act on it, you are working with something close to eight times the width you computed.&lt;/p&gt;

&lt;p&gt;A word on why the paired design is worth the trouble, since the reason usually given is wrong. Scoring the same frozen outputs twice does not merely make sd(d) small. Against two disjoint samples of frozen outputs, one per judge, the paired design needs 2/(1 - rho) times fewer &lt;em&gt;items&lt;/em&gt; and 1/(1 - rho) times fewer &lt;em&gt;judge calls&lt;/em&gt;, so it wins on items even when the two judges are uncorrelated. What rho buys is the judge calls. Our sd(d) of 0.12 against a score sd of 0.22 implies rho of about 0.85, and at ±0.01 that is 3,719 items per arm unpaired, 7,438 in total, against 554 scored by both judges: thirteen times the items, or 6.7 times the judge calls.&lt;/p&gt;

&lt;p&gt;(The identity sd(d)² = 2 x sd(score)² x (1 - rho) assumes both judges have the same score sd, which ours did. If yours do not, use rho = (sd_new² + sd_old² - sd_d²) / (2 x sd_new x sd_old), because the shortcut will quietly mis-state rho and everything downstream of it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Check two: did the spread change, or only the location?
&lt;/h2&gt;

&lt;p&gt;Subtracting an offset assumes the new judge is the old judge shifted. In what I have seen, it is at least as common for a new judge to be &lt;em&gt;compressed&lt;/em&gt;: more agreeable, more clustered in the middle, fewer confident lows. The mean moves and the standard deviation shrinks.&lt;/p&gt;

&lt;p&gt;The diagnostic is the ratio sd(new) / sd(old) on the anchor set, plus the difference-against-average plot. If the difference d trends with the average score, or fans out at one end, the relationship is not a shift and no single constant will fix it.&lt;/p&gt;

&lt;p&gt;Ours came back at 1.00, which is the boring answer and the one that let me use the simple rho identity above. When it does not come back near 1, the consequence is specific: a compressed judge attenuates every future comparison toward zero, your real improvements shrink, your real regressions shrink, and the series goes quiet. This is the same shape as the attenuation I wrote about last week, arriving by a different route. There the effect shrank because a binary judge's flips are bounded. Here it would shrink because the judge's scale is narrower.&lt;/p&gt;

&lt;p&gt;I owe this check the same precision arithmetic I gave check one, and I have not done it. There is a proper paired-variance test (Pitman and Morgan's, which works by testing the correlation between the sum and the difference), and I have not worked out what interval 60 items buys on the ratio. So the point estimate is 1.00 and I am not going to pretend I know how tightly. I would rather say that than quote a number I have not derived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check three: did the ranking change, and what would "unchanged" have looked like?
&lt;/h2&gt;

&lt;p&gt;The offset and the spread are both about the scale. The third question is about order: does the new judge rank items the way the old one did? Kendall's tau or Spearman's rho on the pairs.&lt;/p&gt;

&lt;p&gt;Here is the mistake I made, and it is the reason this section exists.&lt;/p&gt;

&lt;p&gt;I computed tau, got 0.65, and read that as substantial rank movement. It is not. Before reading tau you have to work out what tau you would get if nothing but noise had changed. For jointly normal scores, tau = (2/pi) x arcsin(rho), and our implied rho of 0.85 gives tau = 0.6483 on its own. So the gap I thought I was reading was 0.002, which at 60 items is not a gap at all. The arcsin value is the floor and I had mistaken it for a finding.&lt;/p&gt;

&lt;p&gt;And then the uncomfortable corollary, which is the reason I now treat this check as the weakest of the three. Under joint normality, rho and tau carry the same information, so comparing observed tau against the rho-implied floor is not really a rank-change detector at all. It is a test of the normality and linearity assumption. It fires when the relationship between the judges is monotone but curved, or when a handful of extreme pairs are propping up the Pearson correlation.&lt;/p&gt;

&lt;p&gt;One caveat on the floor itself, which I got wrong on the first attempt. The arcsin identity is a continuous-normal result. Our anchor scores are continuous, so it applies. If yours sit on a discrete rubric grid, ties break it in both directions at once: tau-a is dragged down by tied pairs, while tau-b's tie correction over-restores and lands above the continuous value. On a grid the arcsin number is simply the wrong null, and the floor has to be recomputed on your actual grid before any of this means anything. Say which variant you computed, too.&lt;/p&gt;

&lt;p&gt;So the check is worth running, and what it earns you is a reason to look harder at the difference-against-average plot. It does not on its own earn you the conclusion that you have two series.&lt;/p&gt;

&lt;p&gt;When you do conclude the ranks have moved, it decides whether splicing is possible. If ranks are preserved, some monotone transformation maps one scale to the other, and with enough anchor items you can estimate it. If they have moved, there is no &lt;em&gt;item-level&lt;/em&gt; rescaling that makes a score from March comparable to a score from August. You can still equate the two score distributions and keep the aggregate series roughly comparable, at the cost of no individual rescaled score being trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anchor-set construction: freeze the outputs
&lt;/h2&gt;

&lt;p&gt;The outputs must be frozen artefacts, not re-generated.&lt;/p&gt;

&lt;p&gt;It is tempting to take last quarter's 200 eval inputs, run today's system on them, and score that with both judges. It feels like the same thing. It is not: the model, the prompt and the retrieval index have all moved, so the paired differences now contain both judge drift and system drift, and the whole point of the exercise was to separate those. Store the text, score the text, and accept that the anchor set stops being a test suite the moment you freeze it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the ranks have moved
&lt;/h2&gt;

&lt;p&gt;I have found only one honest option, and it is expensive: run both judges in parallel on a sample of traffic for a transition window, publish both series side by side, and let the old series end rather than pretending it continues. Annotate the chart at the switch date. Anyone who reads across the boundary is then doing so knowingly.&lt;/p&gt;

&lt;p&gt;The alternative, which I have watched happen, is that the offset gets subtracted, the chart stays smooth, and six weeks later a genuine regression is invisible because it is the same size as the correction nobody re-examined.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How often does the judge actually change? More often than the calendar suggests. A version pin protects you from silent provider updates but not from your own team changing the rubric text, the temperature, the few-shot examples, or the parser. Every one of those is a new instrument by this argument, and the rubric edit is the most frequent and least announced.&lt;/p&gt;

&lt;p&gt;Can I skip this if I only look at relative comparisons within a week? Partly. The judge's &lt;em&gt;offset&lt;/em&gt; cancels out of a comparison where both arms were scored by the same judge in the same window. Its scale does not: a compressed judge attenuates the contrast by the same factor in both weeks, so the comparison stays valid in sign and direction but not in magnitude. That is still a good reason to structure reporting around within-week contrasts rather than a long-running level, because the level is where the trouble is worst, and levels are what end up on dashboards.&lt;/p&gt;

&lt;p&gt;Is 554 anchor items realistic? For ±0.01, at our sd(d), that is what the arithmetic says. Most teams do not need ±0.01, and the table above lets you buy the precision you actually need.&lt;/p&gt;

&lt;p&gt;Does a deterministic metric avoid all of this? For the metric itself, yes. Exact match does not drift. But most people are not upgrading exact match, they are upgrading the thing they use because exact match was inadequate, and that thing has a version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;I have been assuming the anchor set is representative of production traffic, and everything above inherits that assumption. But an anchor set is frozen by construction, and traffic is not. Six months in, the anchor set describes a distribution that no longer arrives, so the offset you measured is the offset &lt;em&gt;on old traffic&lt;/em&gt;, which may not be the offset on current traffic.&lt;/p&gt;

&lt;p&gt;Refreshing the anchor set restores representativeness and destroys comparability with everything measured before the refresh, which is the same problem one level up.&lt;/p&gt;

&lt;p&gt;I do not have a clean answer. The best I have managed is overlapping anchor sets, retiring and adding a slice at a time so consecutive versions share most of their items, which is the common-item nonequivalent-groups design from the test-equating literature (Kolen and Brennan, &lt;em&gt;Test Equating, Scaling, and Linking&lt;/em&gt;). Whether that is worth the machinery for an internal quality metric, I genuinely do not know.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>testing</category>
    </item>
    <item>
      <title>A noisy judge does not just add error bars. It shrinks the effect you are trying to measure.</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Wed, 05 Aug 2026 19:28:06 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/a-noisy-judge-does-not-just-add-error-bars-it-shrinks-the-effect-you-are-trying-to-measure-4jbh</link>
      <guid>https://dev.to/maya_andersson_dev/a-noisy-judge-does-not-just-add-error-bars-it-shrinks-the-effect-you-are-trying-to-measure-4jbh</guid>
      <description>&lt;p&gt;Most of what I have written about LLM judges argues in one direction: your improvement is probably not real. Small eval sets, selection across many experiments, position bias, clustered examples. All of that pushes toward scepticism and I still believe it.&lt;/p&gt;

&lt;p&gt;This post argues the other way, because there is a failure mode that runs opposite and I do not see it discussed. Judge noise biases your measured improvement toward zero. The interval gets wider, which everyone expects, and the point estimate also moves, systematically, in the same direction every time. A change that genuinely helped can read as flat, you will record a null result, and the noisier your judge the more confident that mistake becomes.&lt;/p&gt;

&lt;p&gt;This is old statistics wearing new clothes. Spearman named it attenuation in 1904: an observed correlation is bounded above by the geometric mean of the reliabilities of the two instruments measuring it, so unreliable measurement makes real relationships look weaker than they are ("The proof and measurement of association between two things", American Journal of Psychology 15, 72-101, whose section II.2 is titled "'Attenuation' by errors"). An LLM judge is an instrument with reliability well under 1, and we report its output as though it were a ruler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic, which is embarrassingly simple
&lt;/h2&gt;

&lt;p&gt;Take a binary judge: pass or fail. Suppose its flip probability is the same conditional on either true label, so it is as likely to turn a true pass into a reported fail as the reverse. Call that flip probability e.&lt;/p&gt;

&lt;p&gt;If a model's true pass rate is p, the rate your judge reports is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_observed = p(1 - e) + (1 - p)e
           = e + p(1 - 2e)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now measure two models, true rates p_A and p_B, with the same judge. Subtract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_observed(B) - p_observed(A) = (1 - 2e) * (p_B - p_A)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The e term cancels. What survives is a multiplier on your effect: every measured gap is the true gap times (1 - 2e).&lt;/p&gt;

&lt;p&gt;That factor bites at values of e that are entirely ordinary:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Judge flip rate e&lt;/th&gt;
&lt;th&gt;Disagreement with your labels&lt;/th&gt;
&lt;th&gt;Attenuation factor&lt;/th&gt;
&lt;th&gt;A true 5-point gap measures as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;5.0 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;4.0 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.15&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;td&gt;3.5 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;3.0 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;2.5 points&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I checked the algebra against simulation rather than trusting it: 6,000 simulated eval runs of 1,600 examples each, true rates 0.70 and 0.75, e = 0.15, mean observed gap 3.5 points against a predicted 3.5. Note what that does and does not establish. The bias is the same at every eval-set size, so it confirms the algebra and says nothing about how many examples you need. That question comes next, and it is worse than the table suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  It costs you twice, and the second cost is a noise term with two sources
&lt;/h2&gt;

&lt;p&gt;Shrinking the effect is the visible cost. The second depends on which test you are running, and I had it wrong at first.&lt;/p&gt;

&lt;p&gt;The intuitive story is that noise pushes both observed rates toward 0.5, since p_observed - 0.5 = (1 - 2e)(p - 0.5), and 0.5 is where per-example variance is greatest. That is true and it is the right picture to hold. But it is the noise term of an unpaired comparison, and unpaired is the wrong test here. Eval examples are not independent draws: some prompts are hard for every system, that shared difficulty correlates the two arms, and I have argued before that comparing two runs by their average pass rate is the wrong test for exactly this reason.&lt;/p&gt;

&lt;p&gt;So what does judge noise do to a paired comparison? The arithmetic is prettier than I expected. Write the reported label as A' = A + F_A(1 - 2A), with F_A a Bernoulli flip at rate e independent of everything else. Expand E[A'B'] and E[A']E[B'] and the cross terms are identical, so they cancel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cov(A', B') = (1 - 2e)^2 * Cov(A, B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factor that attenuates your effect appears squared in the covariance between arms. That is an identity rather than a simulation result, and the ratio does not depend on how correlated your arms were to begin with. I checked it at three different difficulty spreads and got covariance ratios of 0.495, 0.490 and 0.493 against a predicted (1 - 2e)^2 = 0.490, with the correlation itself falling from 0.27 to 0.12 in the middle case.&lt;/p&gt;

&lt;p&gt;It is worth saying up front what that identity is for, because it does more work later than it looks like it is doing here. Pairing helps you only through the covariance, so a judge flipping labels at rate e keeps exactly (1 - 2e)^2 of whatever pairing was buying you. That one fact is why every number in this post moves when your examples get more or less uniform in difficulty, and it is the last section of this piece.&lt;/p&gt;

&lt;p&gt;Which is why there are two costs on the statistic and not three. McNemar's test looks only at the discordant pairs, so its noise term is the discordant-pair rate, and that rate is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_discordant = p_A + p_B - 2 p_A p_B - 2 Cov(A, B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so judge noise inflates it from both directions at once: the marginal rates slide toward 0.5, and the covariance is destroyed. I decomposed it one factor at a time, in both orders, and they come out close to even: of the total inflation, about 48 percent is the marginal shift and about 52 percent is the covariance loss. Which means the "toward 0.5" intuition is not superseded by the covariance identity, it is one of two roughly equal ingredients in the same noise term. On my model that rate went from 0.294 to 0.400 at e = 0.15, and the whole effect on the test statistic is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z' / z = (1 - 2e) * sqrt(p_discordant / p_discordant')
       = 0.70 * sqrt(0.294 / 0.400)  =  0.60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two factors on the statistic: the effect shrinks, and the discordance inflates. What I got wrong at first was adding a third, by counting the marginal-variance story separately from the discordance it is half responsible for.&lt;/p&gt;

&lt;p&gt;So here is the power calculation for the design this actually is: a per-example difficulty term, marginal pass rates calibrated to 0.70 and 0.75 (0.699 and 0.749 after calibration), McNemar's exact test on the discordant pairs, 6,000 simulated runs per cell.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Eval set size&lt;/th&gt;
&lt;th&gt;Power, perfect judge&lt;/th&gt;
&lt;th&gt;Power, judge at e = 0.15&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.41&lt;/td&gt;
&lt;td&gt;0.19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;td&gt;0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,600&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,200&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those cells carry about a point of Monte Carlo error, so read them to two figures and no further. To reach 80 percent power on that effect I needed roughly 1,000 examples with a perfect judge and roughly 2,750 with the noisy one, about 2.75 times the eval set to buy back what a 15 percent flip rate cost.&lt;/p&gt;

&lt;p&gt;At n = 800, a size that felt generous to me for years, that puts a real 5-point improvement below a one-in-three chance of surfacing. Most teams who try that change will shelve it on the evidence.&lt;/p&gt;

&lt;p&gt;One caveat, because that table has a knob in it I nearly published without stating. Everything above assumes one level of between-arm correlation, which is to say one answer to how much your examples vary in difficulty. Change it and the whole table moves: at n = 800 a perfect judge's power runs from 0.59 at uncorrelated arms to 0.90 at a correlation of 0.57.&lt;/p&gt;

&lt;p&gt;I expected the ratio between the two columns to be the stable thing. It is not, and it moves in the direction that makes this worse rather than better. Eval-set sizes for 80 percent power, across the same range:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Between-arm correlation&lt;/th&gt;
&lt;th&gt;Perfect judge&lt;/th&gt;
&lt;th&gt;Judge at e = 0.15&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;td&gt;1,254&lt;/td&gt;
&lt;td&gt;2,888&lt;/td&gt;
&lt;td&gt;2.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;td&gt;1,129&lt;/td&gt;
&lt;td&gt;2,763&lt;/td&gt;
&lt;td&gt;2.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.27&lt;/td&gt;
&lt;td&gt;918&lt;/td&gt;
&lt;td&gt;2,551&lt;/td&gt;
&lt;td&gt;2.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;693&lt;/td&gt;
&lt;td&gt;2,327&lt;/td&gt;
&lt;td&gt;3.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.57&lt;/td&gt;
&lt;td&gt;544&lt;/td&gt;
&lt;td&gt;2,178&lt;/td&gt;
&lt;td&gt;4.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are closed-form rather than simulated, so the trend is exact rather than approximate. The first row is worth checking by hand, because at zero correlation pairing buys nothing and McNemar reduces to the unpaired test: the ratio there is just the variance inflation times the attenuation term, 1.13 * 2.04 = 2.3, which is the two factors from the previous section multiplied together.&lt;/p&gt;

&lt;p&gt;So the more your examples differ in difficulty, the more a noisy judge costs you, and the covariance identity says why: pairing pays off precisely when the arms are correlated, and (1 - 2e)^2 is the fraction of that payoff you keep. At a correlation of 0.57 a perfect judge clears 80 percent power on about 540 examples and a judge flipping 15 percent of labels needs almost exactly four times as many. The 2.75x I quoted above is the middle of that range, not a constant. Measure your own arm correlation, which you can do directly from a paired run, and read the ratio off the row you land on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do about it
&lt;/h2&gt;

&lt;p&gt;Estimate e, and be honest about what you are estimating. You probably have a human-labelled set from validating the judge. What that gives you is the disagreement between two fallible instruments, which is a different quantity from e. If your humans flip at rate e_h and the judge at e_j, then for the observed disagreement d:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 - 2d = (1 - 2*e_h) * (1 - 2*e_j)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed that on four million simulated labels and it holds to four decimal places. Which means dividing your gap by (1 - 2d) over-corrects by exactly 1/(1 - 2*e_h). Humans flipping 5 percent of labels inflates your effect by 11 percent; at 10 percent human error you are inflating by 25 percent. Two annotators agreeing does not make them right, which I have written about before, and it is precisely the assumption this correction smuggles in. So treat the corrected number as an upper bound unless you truly believe your labels are error-free.&lt;/p&gt;

&lt;p&gt;Report the disattenuated effect only next to the raw one. Divide the observed gap by (1 - 2e): an observed 3.5 points at e = 0.15 implies a true gap near 5. The uncertainty is computable and belongs in the report. The standard error on (1 - 2e) from m human labels is 2*sqrt(e(1-e)/m), so at e = 0.15 with 100 labels the correction factor alone carries about 20 percent relative error, putting your corrected 3.5 points somewhere near 4.2 to 6.3 before any sampling error in the gap itself. At 500 labels that tightens to roughly 4.6 to 5.5.&lt;/p&gt;

&lt;p&gt;Spearman's own worked example in the 1904 paper takes an observed 0.38 and corrects it to approximately 1, which he calls absolute and complete. The abuse is as old as the method.&lt;/p&gt;

&lt;p&gt;Check whether you have noise or bias, and use the right null. Attenuation assumes the judge's errors do not depend on what you are measuring. If it prefers longer answers and model B is more verbose, that is a confound with a direction, and dividing by reliability amplifies it. I will not re-derive that argument here beyond the one thing people get wrong in code: the expected split of the disagreements is not even. Under symmetric noise E[flip_up] = (1-p)*e*n and E[flip_down] = p*e*n, so the expected ratio is (1-p) : p. At a pass rate of 0.70, perfectly symmetric noise produces a 30:70 split, and any test against 50/50 will call that lopsided and wrongly tell you not to correct. I ran exactly that mistake against simulated symmetric noise at pass rates from 0.5 to 0.9 and it produced a false "do not correct" verdict at every rate above 0.5.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binomtest&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;diagnose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;human, judge: equal-length boolean arrays of pass/fail labels.
    Returns e_hat, its standard error, and whether the disagreement is
    consistent with symmetric noise (attenuation) rather than a directional bias.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="n"&gt;flip_up&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;# human fail, judge pass
&lt;/span&gt;    &lt;span class="n"&gt;flip_down&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;# human pass, judge fail
&lt;/span&gt;    &lt;span class="n"&gt;e_hat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flip_up&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;flip_down&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
    &lt;span class="n"&gt;se_e&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e_hat&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;e_hat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# The null is the HUMAN FAIL RATE, not 0.5: symmetric noise flips
&lt;/span&gt;    &lt;span class="c1"&gt;# (1-p) of the failures up and p of the passes down.
&lt;/span&gt;    &lt;span class="n"&gt;n_disagree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flip_up&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;flip_down&lt;/span&gt;
    &lt;span class="n"&gt;pval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;binomtest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flip_up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_disagree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;pvalue&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n_disagree&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e_hat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e_hat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;se_e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;se_e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attenuation_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pval&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p_value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pval&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;                          &lt;span class="c1"&gt;# symmetric noise, e = 0.15
&lt;/span&gt;&lt;span class="n"&gt;judge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;diagnose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# e_hat ~ 0.15, attenuation_ok True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not assume cutting e beats buying examples. I believed this and it is wrong. Going from e = 0.15 to e = 0.08 is worth about 1.7 times the eval set, and at a fixed budget doubling the examples beat cutting e at every size I tested: at n = 800 that is 0.57 power against 0.49. The honest reason to attack e is that tightening a rubric or splitting a compound criterion is usually cheaper than labelling another 800 cases, and it also recovers some of the pairing benefit noise destroyed. Both interventions are legitimate. And the sample-size requirement scales as the inverse square of (1 - 2e), not of e, which is worth getting right before quoting it at anyone.&lt;/p&gt;

&lt;p&gt;Stop reading a flat result as evidence of no effect unless you know your power at the effect size you cared about. A null with unknown power carries no information, and attenuation biases even that absence toward the conclusion you are about to draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Does this apply to continuous scores, not just pass or fail?&lt;br&gt;
Not in the same way, and this is the part I had wrong until I simulated it. Under classical additive error, where the measured score is the true score plus independent noise, a raw mean difference is not attenuated at all, because the noise has mean zero. What attenuates is a correlation or a standardised effect size, and there the correction is division by the square root of the reliability when the other variable is measured without error, or by the geometric mean of both reliabilities when neither is. The binary case above is not a special case of that. It attenuates the raw gap precisely because a flip is bounded rather than additive, so the expected judge score is 0.64 where the true rate is 0.70. Identify your error process before reaching for a correction.&lt;/p&gt;

&lt;p&gt;My judge disagrees with humans 5 percent of the time. Am I fine?&lt;br&gt;
Mostly. The factor is 0.90, so you lose about a tenth of every effect. Worth knowing when you are arguing over a 1-point difference, not worth restructuring around. Confirm the 5 percent was measured on traffic-like cases rather than your cleanest examples.&lt;/p&gt;

&lt;p&gt;Can I average several judge calls and skip the maths?&lt;br&gt;
Averaging independent calls reduces the random component of e, so it is a real intervention, and it recovers some of the lost pairing correlation as well. The systematic component is untouched by averaging, and it was a bias rather than attenuation to begin with.&lt;/p&gt;

&lt;p&gt;Does a paired test fix it?&lt;br&gt;
No, and that is the finding that surprised me most. Pairing remains the right design and it still helps. But the (1 - 2e) multiplier is a property of the instrument rather than of the comparison, so pairing cannot reach it, and per-judgment noise actively erodes the between-arm correlation pairing depends on.&lt;/p&gt;

&lt;p&gt;Were my past null results wrong?&lt;br&gt;
Some, and you can work out which. Take the effect you were hoping for, your e, and your eval-set size, and compute the power you actually had. Any null where power was under about 0.5 tells you nothing in either direction. Three of mine qualified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;The awkward case is a judge whose error rate depends on what it is reading. Everything above assumes one e applied to both arms. If model B produces output that is easier to grade correctly then e_B &amp;lt; e_A, the arms attenuate by different factors, and the observed gap mixes a real quality difference with a difference in how legibly each model fails. Those two are not separable from the judge's output alone.&lt;/p&gt;

&lt;p&gt;Estimating e per model on a small human-labelled slice was my first attempt, and at the sample sizes I could afford the two estimates were too noisy to propagate, which the standard error above explains: separating a 3-point difference in e needs a couple of thousand labels per arm, not the few hundred I assumed when I started. If you have a design that identifies model-dependent judge reliability without a full human pass over both arms, I would like to read it.&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>llm</category>
      <category>datascience</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your eval's confidence interval assumes independent examples. Yours are clustered.</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Tue, 28 Jul 2026 21:24:36 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/your-evals-confidence-interval-assumes-independent-examples-yours-are-clustered-292c</link>
      <guid>https://dev.to/maya_andersson_dev/your-evals-confidence-interval-assumes-independent-examples-yours-are-clustered-292c</guid>
      <description>&lt;p&gt;Every binomial confidence interval you have ever computed on an eval pass rate, Wald, Wilson, Clopper-Pearson, all of them, rests on one assumption: each example is an independent draw. Most eval sets violate it. You have 40 questions generated from the same 8 documents, or 200 turns from the same 30 conversations, or 150 examples that are really 50 cases with 3 paraphrases each. Those are not 200 independent observations. And when you feed a correlated set into a formula that assumes independence, the interval comes out too narrow, which means you declare differences significant that aren't.&lt;/p&gt;

&lt;p&gt;I want to walk through why, put a number on how much it matters, and show the fix, because this one is invisible: the code runs, the interval prints, and it is quietly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why clustering shrinks your real sample size
&lt;/h2&gt;

&lt;p&gt;Independent examples each carry their own information. Correlated examples carry overlapping information. If five questions come from the same document, and the model either understands that document or doesn't, those five outcomes move together. You did not learn five independent things about the model. You learned something closer to one and a half.&lt;/p&gt;

&lt;p&gt;The survey-statistics name for this is the design effect (Kish, "Survey Sampling," 1965). For clustered data it is approximately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deff = 1 + (m̄ - 1) · ICC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where m̄ is the average cluster size and ICC is the intra-cluster correlation, the fraction of total variance that lives between clusters rather than within them. Your effective sample size is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n_eff = n / Deff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the number of independent examples your clustered set is actually worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;Take a realistic eval set: n = 200 examples, drawn from 40 source documents, so average cluster size m̄ = 5. Suppose the ICC is 0.3, which is unremarkable for "questions from the same document" (I have measured higher).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deff  = 1 + (5 - 1) · 0.3 = 2.2
n_eff = 200 / 2.2 ≈ 91
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your 200-example eval is worth about 91 independent examples. The correct confidence interval is √2.2 ≈ 1.48 times wider than the naive one. So the interval you proudly reported as plus or minus 3.5 points is really plus or minus 5.2. The 4-point improvement you shipped last sprint, the one that "cleared the CI," may not clear the corrected interval at all.&lt;/p&gt;

&lt;p&gt;And note the direction of the error. Clustering never makes your interval too wide. It always makes it too narrow. So the bias is always toward false confidence, toward shipping a change that didn't actually beat baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three clusterings I check for
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Source clustering. Multiple examples generated from or grounded in the same document, table, or context. This is the big one for RAG evals, where synthetic questions are minted per-document. ICC here is often 0.2 to 0.4.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Conversation clustering. Multiple turns scored from the same multi-turn session. Turns within a session share the same user, same goal, same accumulated context, so they correlate hard. Scoring 10 turns from 20 conversations is not 200 independent points.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Template clustering. Paraphrases or perturbations of the same underlying case. If you augmented 50 seed cases into 150 by rewording, your n is closer to 50 for the purpose of the interval. The rewordings measure robustness to phrasing, not 150 independent capabilities.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The fix: resample clusters, not rows
&lt;/h2&gt;

&lt;p&gt;The cleanest correction that does not require you to estimate ICC by hand is a cluster bootstrap. Instead of resampling individual examples (which assumes independence, reintroducing the exact bug), you resample whole clusters with replacement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cluster_bootstrap_ci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cluster_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;clusters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cluster_ids&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;means&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;drawn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;replace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# resample CLUSTERS
&lt;/span&gt;        &lt;span class="n"&gt;pooled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;drawn&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
        &lt;span class="n"&gt;means&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pooled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;means&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resampling at the cluster level automatically bakes in the correlation structure. You do not have to estimate the ICC. The interval it returns is the honest one, and it will be wider than the binomial interval your CI currently prints. The wider interval is not an artifact of the bootstrap. The correlation in your data was always there, and this is just the first interval that stops ignoring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;What if my examples really are independent? Then the cluster bootstrap and the binomial interval will roughly agree, and you have lost nothing by checking. The check costs a few seconds of compute. Shipping a change that never actually beat baseline costs a lot more than that.&lt;/p&gt;

&lt;p&gt;Do I need to know the ICC? No. The cluster bootstrap sidesteps it. You only need the design-effect formula if you want a back-of-envelope sense of how bad the problem is before you code anything.&lt;/p&gt;

&lt;p&gt;Does this change my point estimate? No. The mean pass rate is unchanged. Only the interval around it widens. This is purely about how much you should trust the number, not the number itself.&lt;/p&gt;

&lt;p&gt;Is this the same as just using a bigger eval set? No, and this is the trap. Adding 50 more questions from the same 8 documents barely helps, because you are adding within-cluster examples that carry little new information. To tighten a clustered interval you need more clusters (more documents, more conversations), not more examples per cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;I do not have a good rule of thumb for the minimum number of clusters before a cluster bootstrap itself becomes unstable. With very few clusters (say under 15) the bootstrap distribution gets lumpy and the interval is itself uncertain. I have been using a soft floor of 20 clusters and falling back to reporting the design-effect-adjusted interval below that, but I have not seen a principled threshold for eval-sized data. If you know the literature here better than I do, point me at it.&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>llm</category>
      <category>datascience</category>
      <category>testing</category>
    </item>
    <item>
      <title>An LLM judge is a biased instrument, not a measurement</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Wed, 22 Jul 2026 19:27:15 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/an-llm-judge-is-a-biased-instrument-not-a-measurement-2b15</link>
      <guid>https://dev.to/maya_andersson_dev/an-llm-judge-is-a-biased-instrument-not-a-measurement-2b15</guid>
      <description>&lt;p&gt;Last month I shipped an eval that ranked two prompt variants. Variant A won by four points. A teammate reran the same eval the next morning and Variant B won. Same model, same judge, same test set. The only thing that changed was the order the two answers were pasted into the judge prompt.&lt;/p&gt;

&lt;p&gt;That is not a flake. It is position bias, and it is one of three biases that a careful paper documented three years ago. Lianmin Zheng and coauthors measured them in "Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena" (NeurIPS 2023, arXiv:2306.05685). Their headline number is genuinely encouraging: a strong judge like GPT-4 agreed with human preferences more than 80 percent of the time, about the same rate at which two humans agree with each other. Their second finding is the one people quote less often. The same judge shows position bias, verbosity bias, and self-enhancement bias.&lt;/p&gt;

&lt;p&gt;If you report an eval score without accounting for those, you are reporting a property of the judge as if it were a property of your model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bias is not noise, so you cannot average it away
&lt;/h2&gt;

&lt;p&gt;There is a habit, imported from good statistics, of assuming that if a measurement is noisy you take more readings and the noise shrinks. That works for random error. The standard error of a mean falls with the square root of the sample size, so a thousand judge calls do give you a tighter estimate than ten.&lt;/p&gt;

&lt;p&gt;The catch is that position, verbosity, and self-preference are not random error. They are systematic. Run the judge a thousand times with the wordier answer always in slot B, and the wordier answer wins a little more often than it should, every time. The bias sits in the expected value, not the variance. More samples estimate the biased number more precisely, so you end up more sure of a number that is off center.&lt;/p&gt;

&lt;p&gt;Three specific ways this shows up:&lt;/p&gt;

&lt;p&gt;Position. Many judges prefer whichever answer they read first, or second, depending on the model. If your harness always puts the baseline first and the candidate second, every comparison inherits the same tilt.&lt;/p&gt;

&lt;p&gt;Verbosity. Judges reward length and apparent thoroughness. A change that only made the output longer can win a bakeoff it did not deserve, and you will ship a prompt that pads.&lt;/p&gt;

&lt;p&gt;Self-preference. A judge tends to score outputs from its own model family higher. This quietly contaminates any comparison where the judge is also, directly or by family, one of the contestants.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the eval tools actually hand you
&lt;/h2&gt;

&lt;p&gt;I use several of these, and the landscape is more alike than the marketing suggests. Almost all of them ship an LLM-as-judge, and most stop there.&lt;/p&gt;

&lt;p&gt;DeepEval builds several of its metrics on a judge, G-Eval being a chain-of-thought scorer that asks a model to grade against a rubric. RAGAS scores faithfulness and answer relevancy with a judge as well. OpenAI Evals gives you model-graded templates alongside exact-match checks. Langfuse lets you attach judge evaluators to traced production data, which is useful precisely because it runs on real traffic rather than a frozen set.&lt;/p&gt;

&lt;p&gt;A smaller group gives you cheap deterministic scorers you can run first, and treat the judge as the expensive second opinion. promptfoo mixes deterministic assertions (equality, regex, JSON-schema) with model-graded ones in the same test. Future AGI's eval SDK takes the same shape, running local metrics with no network call behind one evaluate() function and adding a judge only when you pass augment=True. Braintrust's autoevals ships both heuristic scorers and LLM-judge scorers. Langfuse and Braintrust, like Future AGI, also attach evaluation to tracing rather than being eval-only, so you can score what actually ran.&lt;/p&gt;

&lt;p&gt;None of that is a knock on any of them, and it is not a ranking. Some of them go further than handing you the instrument. Langfuse's score analytics lines human annotations up against judge scores and reports Cohen's kappa, correlation, and percent agreement, with the interpretation bands printed next to the number. Braintrust puts human and automated scores side by side on the same traces so you can watch how often they agree and refine the scorer when they drift. That is real, and you should use it. It is also not the whole job. A tool can compute how often the judge agrees with your labels. It cannot produce the labels for your task, and it cannot decide how much agreement is enough for the call you are about to make. Those two stay with you no matter which tool you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to measure instead
&lt;/h2&gt;

&lt;p&gt;Treat the judge like any other instrument you did not build. Calibrate it, then use it.&lt;/p&gt;

&lt;p&gt;Calibrate against a human-labeled sample. Label a few hundred examples by hand, run the judge on the same set, and compute agreement. Cohen's kappa is the honest version because it discounts the agreement you would get by chance. The calculation is one function call, and as noted a couple of platforms will run it for you; what none of them can outsource is labeling your data and picking the threshold you will accept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.metrics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cohen_kappa_score&lt;/span&gt;

&lt;span class="c1"&gt;# human_labels and judge_labels are lists of the same labels, e.g. "pass"/"fail" or "A"/"B"
&lt;/span&gt;&lt;span class="n"&gt;kappa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cohen_kappa_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;human_labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge_labels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt; 0.41 is Fair-to-Slight on the Landis and Koch bands (the same ones Langfuse prints);
# there the judge is not yet a stand-in for your reviewers on this task
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap positions and check consistency. If the winner flips when you swap the order, the score is order-dependent, not a property of the answers. This is a few lines around whatever judge call you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer_b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. Wrap your own LLM-as-judge call here.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;swap_consistency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# candidate 'a' sits in slot A
&lt;/span&gt;    &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# candidate 'a' sits in slot B
&lt;/span&gt;    &lt;span class="n"&gt;a_wins_first&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;  &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;a_wins_second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;a_wins_first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;a_wins_second&lt;/span&gt;   &lt;span class="c1"&gt;# True if the verdict held after the swap
&lt;/span&gt;
&lt;span class="c1"&gt;# Average this over your eval set. On my sets I start to distrust a judge below
# about 0.9 here: at that point it is grading position as much as content,
# so score both orders and average them.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Triage with something cheap. A local metric or a deterministic check can resolve the clear cases for free, which shrinks how many times you lean on the biased judge at all. Reserve the judge for the genuinely uncertain tail. This is the one place the tool choice helps, because the tools that ship local scorers make the triage a config flag rather than a rewrite.&lt;/p&gt;

&lt;p&gt;Report agreement next to the score. "94 percent pass rate" means little on its own. "94 percent pass rate, judge-human kappa 0.71 on a 300-example calibration set, scored in both orders" is a number a reviewer can trust, and it is the same amount of work to print.&lt;/p&gt;

&lt;p&gt;None of this makes the judge unbiased. It makes the bias visible and gives you a number for how large it is, which is the most you can honestly claim for an instrument you did not build.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;GPT-4 agrees with humans more than 80 percent of the time. Is that not good enough?&lt;br&gt;
It depends entirely on what the disagreeing 20 percent correlates with. If those cases were random, you could average them away. They are not random. They line up with length and position, so the error has structure, and structure survives averaging. The aggregate agreement can look fine while every close call tilts the same direction.&lt;/p&gt;

&lt;p&gt;Pairwise or pointwise scoring?&lt;br&gt;
Pairwise (A versus B) is usually more stable than asking for an absolute 1-to-10 score, because relative judgments are easier for a model to make consistently. But pairwise is exactly where position bias lives, so if you go pairwise you have to score both orders and average. Pointwise avoids position bias and trades it for scale drift, where the judge's notion of "7 out of 10" wanders between runs.&lt;/p&gt;

&lt;p&gt;Do I need human labels forever?&lt;br&gt;
No, you need a calibration sample. Label a few hundred examples once, measure judge-human agreement, and recheck when something changes: a new judge model, a new prompt, a shift in the kind of inputs you see. The labels are a periodic audit, not a standing tax.&lt;/p&gt;

&lt;p&gt;Will a bigger judge model fix this?&lt;br&gt;
It raises raw agreement, which is why people reach for the strongest model they can afford. It can also make self-preference worse, because a stronger judge is often from the same family as the strong models you are testing. The cheap defense is to pick a judge from a different family than the models under test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;The thing I do not have a clean answer to is drift. You calibrate against human labels in July, the provider silently updates the hosted judge model in September, and your kappa is now describing a different instrument than the one you validated. Nobody I know version-pins their judge the way they pin a training dependency, and most hosted judges do not expose a stable version to pin to. So how often is often enough to recalibrate, and should a judge model be treated as a pinned dependency with its own changelog? I have not seen a convention settle, and I would take pointers from anyone who has one that works.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>evaluation</category>
      <category>statistics</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your eval dashboard has 30 metrics. When one "moves," that is usually arithmetic, not a regression.</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:39:53 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/your-eval-dashboard-has-30-metrics-when-one-moves-that-is-usually-arithmetic-not-a-regression-9c8</link>
      <guid>https://dev.to/maya_andersson_dev/your-eval-dashboard-has-30-metrics-when-one-moves-that-is-usually-arithmetic-not-a-regression-9c8</guid>
      <description>&lt;p&gt;Here is the ritual. You ship a prompt change, rerun the eval suite, and open the dashboard. Thirty numbers sit there: faithfulness, answer relevance, context precision, toxicity, latency-adjusted quality, and two dozen more. Twenty-nine are flat. One dropped from 0.86 to 0.81 and the cell is red. Someone says "we regressed on groundedness," and the next hour goes to explaining a number that never needed explaining.&lt;/p&gt;

&lt;p&gt;I want to make the boring case that most of these red cells are not findings. They are what you get when you run many comparisons and let each one fire on its own. This has a name, the multiple comparisons problem, and it has arithmetic you can do on a napkin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim 1: the false alarm rate is per-metric, and you have a lot of metrics
&lt;/h2&gt;

&lt;p&gt;Set a threshold for "this metric moved" that would fire 5% of the time by chance when nothing actually changed. That is what a two-sided test at alpha = 0.05 means: a 5% false positive rate per test, under the null.&lt;/p&gt;

&lt;p&gt;One metric, one test, 5% chance of a false alarm. Fine. But you are not looking at one metric. Assume for a moment the metrics are independent (they are not, and that matters below). The probability that at least one of n metrics throws a false alarm is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P(at least one false positive) = 1 - (1 - alpha)^n

n = 1:   1 - 0.95^1  = 0.05    (5%)
n = 20:  1 - 0.95^20 = 0.64    (64%)
n = 30:  1 - 0.95^30 = 0.79    (79%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 30 metrics, a run where nothing changed has a 79% chance of showing you at least one red cell. The expected count is just n times alpha: 30 x 0.05 = 1.5 false alarms per clean run. So the "one metric moved" you are staring at is, on average, exactly what a no-op change produces. You did not find a regression. You found the metric that lost this round of a lottery you run every deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim 2: your metrics are correlated, which does not save you
&lt;/h2&gt;

&lt;p&gt;The obvious objection: eval metrics are not independent, so the formula above is wrong. Correct, it is wrong. It is not wrong in your favor.&lt;/p&gt;

&lt;p&gt;Correlation changes the shape of the false-alarm distribution but not its center. The expected number of false positives is n times alpha regardless of dependence, because expectation is linear and does not care whether the tests move together. Correlation mostly changes the variance. When metrics are highly correlated, false alarms clump: a clean run tends to show either zero red cells or several at once, because the correlated metrics fail together. That is worse for interpretation, not better, because a cluster of three red cells looks like a "real pattern" and is the single most convincing way to fool yourself. That cluster is three correlated metrics failing on the same noise, not three independent findings.&lt;/p&gt;

&lt;p&gt;So you cannot correlation your way out of this. You have to correct for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim 3: the fix is a decision made before you look, not a harder stare after
&lt;/h2&gt;

&lt;p&gt;There are two standard corrections, and they answer two different questions.&lt;/p&gt;

&lt;p&gt;Bonferroni controls the family-wise error rate, the probability of even one false positive across the whole family. To hold that at 0.05 across 30 metrics, you test each at 0.05 / 30 = 0.00167. It is exact enough (it slightly over-corrects under dependence) and it is strict. Strict is the point when a single false regression would block a release.&lt;/p&gt;

&lt;p&gt;Bonferroni is often too strict when you genuinely track many metrics and can tolerate a few false alarms among your flagged ones. That is what the false discovery rate is for. Benjamini and Hochberg's 1995 procedure (Controlling the false discovery rate, Journal of the Royal Statistical Society Series B) controls the expected proportion of your flagged metrics that are false, rather than the chance of any false flag at all. The procedure is short: sort your n p-values ascending, find the largest k where p(k) &amp;lt;= (k / n) x q for your target rate q, and flag everything up to k. It gives you far more power than Bonferroni while still bounding how much of your red is noise. For a dashboard where you expect a handful of real movements among thirty metrics, FDR is usually the right tool. For a release gate where one false block is expensive, family-wise control is.&lt;/p&gt;

&lt;p&gt;Either way, the correction is a rule you commit to before opening the dashboard. The failure mode is picking the correction after you have already seen which cell is red, because by then you are choosing the test that lets you believe what you already decided.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim 4: separate the metric you are testing from the metrics you are watching
&lt;/h2&gt;

&lt;p&gt;The cleaner fix is upstream of any correction. Most eval suites conflate two things:&lt;/p&gt;

&lt;p&gt;Confirmatory metrics are the one or two you changed the prompt to improve. You have a directional hypothesis. You test those, at full alpha, and you are allowed to act on them.&lt;/p&gt;

&lt;p&gt;Exploratory metrics are the other twenty-eight you keep on the board for monitoring. These do not get to trigger a "we regressed" conversation on a single run. They get watched over multiple runs, and a real regression in one shows up as a trend across several deploys, not a one-time red cell that is gone next Tuesday. A drop that reverts on the next run without any code change was regression to the mean, which is the second-most-common way a dashboard lies to you.&lt;/p&gt;

&lt;p&gt;Decide which bucket each metric is in before the run. Two primary metrics tested at 0.05, twenty-eight secondary metrics under an FDR rule or simply held to a "two consecutive runs" bar. Now your false-alarm budget is one or two tests wide, not thirty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I actually use
&lt;/h2&gt;

&lt;p&gt;Before the run, write down the one or two metrics the change is supposed to move, and the direction. Everything else is monitoring. For the monitoring set, do not react to a single red cell: apply an FDR correction across the set, or require the movement to survive a second run. When you must gate a release on a broad panel, use family-wise control and accept that you will miss small real effects, because at the gate a false block costs more than a missed 0.01.&lt;/p&gt;

&lt;p&gt;None of this makes your eval more sensitive. It makes the eval account for how many comparisons you ran before a cell turned red.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Does this mean my dashboard is useless?&lt;br&gt;
No. A dashboard is a fine monitoring instrument. It is a bad hypothesis test. Use it to watch trends across runs, not to adjudicate a single deploy. The moment a single-run red cell triggers a decision, you have turned a monitor into a test without paying for the test.&lt;/p&gt;

&lt;p&gt;If I only ever look at one metric, do I need any of this?&lt;br&gt;
No. One pre-specified metric, one test, no correction needed. The problem is entirely created by the number of comparisons you let fire. Two or three metrics tied to a hypothesis tell you more than thirty scanned for whatever happened to move.&lt;/p&gt;

&lt;p&gt;Bonferroni or Benjamini-Hochberg?&lt;br&gt;
Bonferroni when a single false alarm is expensive, such as a release gate: it controls the chance of any false positive. Benjamini-Hochberg when you track many metrics and can tolerate a known fraction of false flags in exchange for catching more real ones. They optimize different things on purpose.&lt;/p&gt;

&lt;p&gt;My eval scores are not p-values, they are just averages. Does this still apply?&lt;br&gt;
Yes, and it is easier to abuse. A raw score delta with no notion of sampling variability is a comparison with an implicit, undocumented threshold. The multiplicity is still there; you have just hidden the alpha. Put a confidence interval on each delta and the correction problem becomes visible again.&lt;/p&gt;

&lt;p&gt;Will correcting make me miss real regressions?&lt;br&gt;
It will reduce power, yes. That is the trade. The answer is not to skip the correction, it is to keep the confirmatory set small so each test keeps full power, and let the large monitoring set be governed by trend, not by a single run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;Family-wise and FDR corrections both assume the family is well defined: you know how many tests you are running. On a live eval dashboard the family is open-ended. You add metrics over months, you rerun after every deploy, and you look at the board on days when nothing shipped. The true number of comparisons is not 30, it is 30 times every run times every glance. I do not have a clean way to define "the family" for a metric board that is queried continuously by a whole team. Sequential testing methods (alpha-spending, always-valid p-values) are the honest direction, but I have not seen them adapted well to eval suites where the metric set itself keeps growing. If you have a working formulation of the family for a continuously-watched dashboard, I would like to see it.&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>evaluation</category>
    </item>
    <item>
      <title>Your eval pass rate is 98 percent. Your confidence interval is probably wrong.</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Thu, 16 Jul 2026 15:23:29 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/your-eval-pass-rate-is-98-percent-your-confidence-interval-is-probably-wrong-455f</link>
      <guid>https://dev.to/maya_andersson_dev/your-eval-pass-rate-is-98-percent-your-confidence-interval-is-probably-wrong-455f</guid>
      <description>&lt;p&gt;TL;DR. Almost every eval harness reports a pass rate with an error bar, and almost every one of those error bars comes from the normal approximation: p̂ plus or minus 1.96 times the square root of p̂(1 - p̂)/n. That formula is taught first, implemented everywhere, and reasonable near a pass rate of 50 percent. It falls apart at the extremes, which is precisely where any model worth shipping lives. At 49 of 50 passing it produces an upper bound of 1.0188, a probability above one. At 50 of 50 it produces the interval [1.0, 1.0], a claim of perfect certainty from fifty observations. Worse than either artifact: when the true pass rate is 98 percent and n is 50, its actual coverage is 63.5 percent. The reframing is that this is a solved problem, and has been since 1927. Invert the score test instead of approximating around the estimate, and you get the Wilson interval, which is one argument change in the library you already have installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regression I shipped because I misread an interval
&lt;/h2&gt;

&lt;p&gt;Three years ago I owned the eval suite for a document extraction pipeline. Fifty held-out documents, hand-labeled, each either extracted correctly or not. We were shipping a prompt change and the numbers looked clean: 49 of 50 passing, and the harness printed a 95 percent confidence interval of [0.941, 1.000].&lt;/p&gt;

&lt;p&gt;I read the lower bound and did what most people do with a lower bound. I treated it as the pessimistic case. I told the stakeholder that the worst plausible outcome was about 94 percent, that we would be fine at 94 percent, and we shipped on a Thursday.&lt;/p&gt;

&lt;p&gt;Production accuracy landed near 91 percent. That was outside the interval I had quoted, and I spent a week looking for the distribution shift that explained it. There was a small one. It was not the story. The story was that my interval had no business excluding 91 percent in the first place. Running the same 49 of 50 through the Wilson interval returns [0.895, 0.996]. The lower bound is 89.5 percent, not 94.1 percent. Wilson's interval contained the truth. Mine did not, and the gap between the two was not a rounding difference. It was 4.6 percentage points of false confidence, manufactured by a formula that should not have been applied to that data.&lt;/p&gt;

&lt;p&gt;The distribution shift got the postmortem. The interval got nothing, because nobody in the room, including me, thought of the error bar as a thing that could itself be wrong. That is the failure mode I want to argue against here, criterion by criterion.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Wald interval is, and where it comes from
&lt;/h2&gt;

&lt;p&gt;The estimator is uncontroversial. With k passes out of n cases, p̂ = k/n.&lt;/p&gt;

&lt;p&gt;The interval most harnesses report is the Wald interval:&lt;/p&gt;

&lt;p&gt;p̂ ± z · sqrt( p̂(1 - p̂) / n )&lt;/p&gt;

&lt;p&gt;The derivation is a normal approximation to the binomial, with one extra move that does the damage: the standard error is evaluated at p̂, the number you happened to observe, rather than at p, the parameter you are trying to bracket. That substitution is harmless when p̂ is near 0.5 and n is large. Near the boundaries it is not harmless at all, because the standard error sqrt(p(1-p)/n) is itself a function of p that collapses to zero as p approaches 1. Plug in p̂ = 1.0 and the formula obediently reports that it has no uncertainty.&lt;/p&gt;

&lt;p&gt;So the question is not whether the Wald interval is defensible in general. It is whether it is defensible in the regime where eval results actually land. Below are five criteria any interval estimator should satisfy, and how Wald does against each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 1: the interval must stay inside the parameter space
&lt;/h2&gt;

&lt;p&gt;A pass rate is a proportion. It lives in [0, 1]. An interval that includes values above 1 is not reporting uncertainty about a proportion, it is reporting that the model has been arithmetically mangled.&lt;/p&gt;

&lt;p&gt;At 49 of 50, the Wald interval computes to [0.9412, 1.0188]. The upper bound is a probability of 1.0188.&lt;/p&gt;

&lt;p&gt;Here is the part that keeps this from being obvious, and the reason I did not catch it for years. Your library hides it. In statsmodels, the proportion_confint function ends with an explicit clip for exactly two methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agresti_coull&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;ci_low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ci_low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ci_upp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ci_upp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So proportion_confint(49, 50, method="normal") returns [0.9412, 1.0000], and the 1.0188 never reaches your logs. The docstring says so plainly, and nobody reads it, because who reads the docstring for a confidence interval.&lt;/p&gt;

&lt;p&gt;The clip is cosmetic, and I can show that precisely: clipping the upper bound from 1.0188 down to 1.0 only removes values in the range (1.0, 1.0188] from the interval, and no true proportion can live there. The set of true values the interval covers is identical before and after the clip. Coverage does not move by a single decimal place. What the clip accomplishes is that a number which would have announced the method's failure now looks like an ordinary, tidy upper bound of 1.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 2: the interval must not vanish when the data is unanimous
&lt;/h2&gt;

&lt;p&gt;Run a perfect eval. 50 of 50. The Wald interval is [1.0, 1.0], width zero.&lt;/p&gt;

&lt;p&gt;Read that as an epistemic claim and it says: having observed fifty successes, I am now certain, to the exclusion of all alternatives, that this system never fails. Not "very likely above 95 percent". Certain. A zero-width interval assigns probability zero to a true rate of 0.999.&lt;/p&gt;

&lt;p&gt;Fifty of fifty is a perfectly ordinary eval outcome. It is also the outcome where the Wald interval fails hardest, and the failure has a sign: it always errs toward overconfidence, never toward caution.&lt;/p&gt;

&lt;p&gt;Wilson at 50 of 50 returns [0.9287, 1.0]. Clopper-Pearson returns [0.9289, 1.0]. Both say the sensible thing: fifty consecutive passes is real evidence, it is consistent with a true rate around 93 percent, and you cannot rule out roughly a 1-in-14 failure rate on this evidence.&lt;/p&gt;

&lt;p&gt;This connects to a heuristic some readers will know, the "rule of three": with zero failures in n trials, the upper bound on the failure rate is about 3/n. At n = 50 that gives 6 percent, implying a lower bound near 94 percent, which is close to Wilson's 92.9 percent but not equal to it. The reason is that the rule of three is the &lt;em&gt;one-sided&lt;/em&gt; 95 percent bound. I checked: 1 - 0.05^(1/50) = 0.0582, and 3/50 = 0.06. The two-sided version needs 0.025 in each tail, which gives 1 - 0.025^(1/50) = 0.0711, closer to 3.7/n. Useful heuristic, commonly misquoted by half a tail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 3: nominal coverage must resemble actual coverage
&lt;/h2&gt;

&lt;p&gt;This is the criterion that matters, and the one nobody checks.&lt;/p&gt;

&lt;p&gt;A 95 percent confidence interval makes a frequentist promise: across repeated experiments, the interval contains the true parameter 95 percent of the time. That promise is testable. The binomial has finitely many outcomes, so you do not even need simulation. Enumerate every k from 0 to n, ask whether the interval built from that k contains the true p, and weight by the binomial probability of observing that k. The answer is exact.&lt;/p&gt;

&lt;p&gt;I ran that enumeration. At n = 50, nominal 95 percent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;true p&lt;/th&gt;
&lt;th&gt;Wald&lt;/th&gt;
&lt;th&gt;Wilson&lt;/th&gt;
&lt;th&gt;Clopper-Pearson&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;0.935&lt;/td&gt;
&lt;td&gt;0.935&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;0.938&lt;/td&gt;
&lt;td&gt;0.951&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;0.879&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;0.962&lt;/td&gt;
&lt;td&gt;0.988&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;0.635&lt;/td&gt;
&lt;td&gt;0.922&lt;/td&gt;
&lt;td&gt;0.982&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;0.395&lt;/td&gt;
&lt;td&gt;0.911&lt;/td&gt;
&lt;td&gt;0.986&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At a true pass rate of 98 percent, an interval labeled "95 percent confidence" contains the truth 63.5 percent of the time. At 99 percent, it manages 39.5 percent. Those are not slightly optimistic numbers. An interval that misses the parameter more than a third of the time is not delivering what its label promises, and at 99 percent it misses more often than it hits.&lt;/p&gt;

&lt;p&gt;Note the shape of the failure. Near p = 0.5 the Wald interval is fine (0.935, close enough to nominal). The degradation is monotone in how good your model is. The better the system under test, the more the interval lies, and it lies in the direction of telling you the system is more reliable than it is. Any team whose models improve over time is walking into this, and the error bar gets quieter about it every quarter.&lt;/p&gt;

&lt;p&gt;None of this is news to statisticians. Brown, Cai and DasGupta laid it out in 2001 in &lt;em&gt;Statistical Science&lt;/em&gt;, in a paper whose abstract describes the Wald interval's coverage as "erratic" and "chaotic," and states that "common textbook prescriptions regarding its safety are misleading and defective" (Brown, Cai and DasGupta, 2001, &lt;em&gt;Statistical Science&lt;/em&gt; 16(2), 101 to 133). They recommend Wilson or the equal-tailed Jeffreys interval for small n, and Agresti-Coull for larger n. The paper is 25 years old. The formula it warns about is still the default in most eval code I read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 4: more data must not make the interval worse
&lt;/h2&gt;

&lt;p&gt;The standard defense of Wald is that it is asymptotic, so just use enough data. There are rules of thumb attached: n ≥ 30, or np ≥ 5, or np ≥ 10.&lt;/p&gt;

&lt;p&gt;Test the rule. At p = 0.98, the binding constraint is the &lt;em&gt;failure&lt;/em&gt; count, n(1 - p), because that is the small one. The np ≥ 5 rule, applied to failures, demands n ≥ 250.&lt;/p&gt;

&lt;p&gt;At n = 250 and p = 0.98, Wald coverage is 0.873.&lt;/p&gt;

&lt;p&gt;The rule is satisfied, and the interval is still nowhere near 95 percent. So the rule of thumb does not work, which is what "misleading and defective" meant.&lt;/p&gt;

&lt;p&gt;It gets less comfortable. Coverage is not even monotone in n. Holding p = 0.98 fixed and computing exact coverage as n grows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Wald coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;0.916&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;0.941&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;td&gt;0.935&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;0.873&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adding eight test cases, from 142 to 150, drops coverage from 0.941 to 0.800. More data, worse interval. I verified each of these two ways, by exact enumeration over the binomial and by a one-million-replication Monte Carlo, and they agree to three decimals (the Monte Carlo returns 0.9408, 0.8003 and 0.8732 for n = 142, 150 and 250).&lt;/p&gt;

&lt;p&gt;The mechanism is not mysterious once you see it. k is discrete. As n changes, the lattice of achievable k values slides relative to the true p, and whether p sits inside the interval built from the most probable k flips on and off. That produces a sawtooth, which is why Brown, Cai and DasGupta call the behavior chaotic rather than merely biased. There is no threshold n above which you are safe, because the function you would be thresholding oscillates.&lt;/p&gt;

&lt;p&gt;Which brings me to the honest caveat, and I would rather state it than have it found. Wilson oscillates too. Every interval for a discrete parameter does. Sweeping n from 50 to 600 at p = 0.98, Wilson's coverage ranges from 0.918 to 0.980, while Wald's ranges from 0.635 to 0.951. The difference is that Wilson's oscillation is centered near the nominal level, so its errors go in both directions and stay small. Wald's ceiling across that entire sweep is 0.951. It essentially never over-delivers, and its floor is 0.635. Wilson is not exact. It is well-behaved, which is a different and more achievable property.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 5: the interval should be asymmetric when the estimate is near a boundary
&lt;/h2&gt;

&lt;p&gt;Wald intervals are symmetric by construction, because the formula is "estimate plus or minus a fixed quantity." Near a boundary, symmetry is the wrong shape.&lt;/p&gt;

&lt;p&gt;At 49 of 50, the truth cannot be far above 0.98, since only 1.0 is available up there. It can quite easily be below, because 0.95 and 0.93 and 0.91 all produce 49 of 50 with unremarkable probability. The interval should be lopsided: short on the top, long on the bottom.&lt;/p&gt;

&lt;p&gt;Wilson does this automatically. At 49 of 50 it returns [0.8950, 0.9965], which extends 0.085 below the point estimate and 0.0165 above it, roughly five times more room downward than upward. That asymmetry is not a patch bolted onto the formula. It falls out of the derivation, because Wilson inverts the score test: rather than approximating the standard error at p̂, it asks which values of p would fail to be rejected, evaluating the standard error at each candidate p. Solving that quadratic in p gives&lt;/p&gt;

&lt;p&gt;center = (p̂ + z²/2n) / (1 + z²/n)&lt;/p&gt;

&lt;p&gt;half-width = (z / (1 + z²/n)) · sqrt( p̂(1 - p̂)/n + z²/4n² )&lt;/p&gt;

&lt;p&gt;Notice the center is not p̂. It is p̂ pulled toward 0.5, which is why the interval never escapes [0, 1] and never collapses at p̂ = 1: the z²/4n² term inside the square root keeps the width positive even when p̂(1 - p̂) is exactly zero. Wilson published this in 1927 (Wilson, 1927, &lt;em&gt;Journal of the American Statistical Association&lt;/em&gt; 22(158), 209 to 212). It predates the eval harness by about ninety years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Pasteable, and the output below is what it actually prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pip install statsmodels scipy
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;norm&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;statsmodels.stats.proportion&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;proportion_confint&lt;/span&gt;

&lt;span class="n"&gt;Z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ppf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.975&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1.959963...
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wald_by_hand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The textbook normal-approximation interval, unclipped.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="n"&gt;se&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; passing  (point estimate = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wald_by_hand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  Wald, by hand      : [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]   width=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;proportion_confint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  Wald, statsmodels  : [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]   width=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;proportion_confint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wilson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  Wilson             : [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]   width=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;proportion_confint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;beta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  Clopper-Pearson    : [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]   width=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;exact_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;true_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Actual coverage, computed by enumerating every possible k.
    No simulation: the binomial has finitely many outcomes.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;wald_by_hand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wald&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;proportion_confint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;true_p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;binom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pmf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;true_p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;


&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Actual coverage of a nominal 95% interval, n=50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true p&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Wald&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Wilson&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Clopper&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.98&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;exact_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wald&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;8.3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;exact_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wilson&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;8.3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;exact_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;beta&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;9.3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;49/50 passing  (point estimate = 0.980)
  Wald, by hand      : [0.9412, 1.0188]   width=0.0776
  Wald, statsmodels  : [0.9412, 1.0000]   width=0.0588
  Wilson             : [0.8950, 0.9965]   width=0.1014
  Clopper-Pearson    : [0.8935, 0.9995]   width=0.1060

50/50 passing  (point estimate = 1.000)
  Wald, by hand      : [1.0000, 1.0000]   width=0.0000
  Wald, statsmodels  : [1.0000, 1.0000]   width=0.0000
  Wilson             : [0.9287, 1.0000]   width=0.0713
  Clopper-Pearson    : [0.9289, 1.0000]   width=0.0711


Actual coverage of a nominal 95% interval, n=50
  true p     Wald   Wilson   Clopper
    0.50    0.935    0.935     0.967
    0.80    0.938    0.951     0.967
    0.90    0.879    0.970     0.970
    0.95    0.920    0.962     0.988
    0.98    0.635    0.922     0.982
    0.99    0.395    0.911     0.986
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two library notes. In statsmodels, method="beta" is Clopper-Pearson (it is named for the Beta distribution used to compute it, which is a naming choice that has cost me time). In scipy, the same intervals are available as binomtest(k, n).proportion_ci(method="wilson") and method="exact" for Clopper-Pearson. I checked both libraries against each other and against the hand-rolled Wilson formula above; all three agree to machine precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Wilson is the default. One keyword argument, no new dependency, correct behavior at the boundary, coverage that stays near nominal across the range where eval results actually land.&lt;/p&gt;

&lt;p&gt;Clopper-Pearson when someone external is going to rely on the number, or when the cost of overstating reliability is asymmetric (safety filters, anything with a compliance story attached). It is called "exact" because it inverts the binomial test directly rather than approximating, but note from the table that its coverage runs to 0.982 and 0.986 where nominal is 0.95. It is conservative, and its intervals are wider than they strictly need to be. That is a deliberate trade, not a free upgrade. Agresti and Coull made this argument in 1998, in a paper titled "Approximate Is Better than 'Exact' for Interval Estimation of Binomial Proportions" (Agresti and Coull, 1998, &lt;em&gt;The American Statistician&lt;/em&gt; 52(2), 119 to 126), and their point stands: guaranteed-conservative is not the same as accurate, and if you want coverage near 95 percent rather than above it, the approximate methods do better.&lt;/p&gt;

&lt;p&gt;Report the interval, not the point estimate. "98 percent" and "98 percent, 95 percent CI [89.5, 99.7]" are the same measurement, and only one of them communicates that fifty test cases is fifty test cases. If the interval you get is too wide to support the decision you are making, the answer is more test cases, and the width tells you roughly how many. I worked that arithmetic in a separate piece on eval-set size, so I will not repeat it here.&lt;/p&gt;

&lt;p&gt;And stop reading the lower bound as the pessimistic case. It is a bound on a plausible range, not a floor. I have made that mistake in production and it cost me a week of looking for a distribution shift that explained three of the nine points I was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Does this matter if my pass rate is around 70 percent?&lt;/p&gt;

&lt;p&gt;Much less. At n = 50 and p = 0.80, exact Wald coverage is 0.938 against a nominal 0.95, which is a real but tolerable error. At p = 0.50 it is 0.935. The Wald interval was designed for this regime and behaves acceptably in it. The problem is that a 70 percent pass rate is usually a system you are still fixing, not one you are reporting on, and by the time you are writing the number in a document it has moved to 95 percent or higher. The method degrades precisely as your project succeeds, which is an unfortunate property for a measurement tool.&lt;/p&gt;

&lt;p&gt;Is Wilson always better than Wald?&lt;/p&gt;

&lt;p&gt;Not always, and I would rather not oversell it. At p = 0.50 and n = 50 they return identical coverage of 0.935, because near the center the two derivations nearly coincide. Wilson's advantage appears at the extremes and at small n, and it grows as you move toward the boundary. There is no regime I am aware of where Wilson is meaningfully worse, so "always use Wilson" is a defensible default even though "Wilson is always better" is too strong a claim. The honest version: Wilson is never worse in any way that matters, and is dramatically better exactly where you need it.&lt;/p&gt;

&lt;p&gt;Why not just clip the Wald interval to [0, 1] and move on?&lt;/p&gt;

&lt;p&gt;Because clipping changes nothing that matters. I showed this above: truncating an upper bound of 1.0188 to 1.0 only excludes values above 1.0 from the interval, and no true proportion lives there. The coverage is bit-for-bit identical before and after. What clipping does is remove the visible symptom, the absurd number that would have prompted you to check the method. statsmodels clips by default for normal and agresti_coull, which means the most common way to compute a Wald interval in Python is also the way that hides its most obvious failure.&lt;/p&gt;

&lt;p&gt;What about the 50 of 50 case specifically? Is any interval sensible there?&lt;/p&gt;

&lt;p&gt;Wilson and Clopper-Pearson both handle it, returning [0.9287, 1.0] and [0.9289, 1.0] respectively at n = 50. Both are saying the same reasonable thing: fifty consecutive passes is genuine evidence of a high rate, and it is also consistent with a true rate near 93 percent. Wald returns [1.0, 1.0], claiming certainty. If your eval dashboard has ever shown a perfect score with no error bar, or an error bar of zero width, this is why, and it is worth grepping your harness for.&lt;/p&gt;

&lt;p&gt;Is Clopper-Pearson the safe choice since it is "exact"?&lt;/p&gt;

&lt;p&gt;"Exact" describes the construction, not the coverage. Clopper-Pearson inverts the binomial test directly instead of approximating, which guarantees coverage of at least 95 percent. It overshoots: 0.982 at p = 0.98 and 0.986 at p = 0.99 in the table above, against a nominal 0.95. You are paying for that guarantee with width, and wide intervals have their own cost, since an interval too wide to distinguish two candidate models is not helping you decide anything. Use it when understating reliability is cheaper than overstating it. Otherwise Wilson.&lt;/p&gt;

&lt;p&gt;Does the same problem hit the standard error I report on a per-metric basis?&lt;/p&gt;

&lt;p&gt;Yes, for anything that is fundamentally a count of successes over trials. Pass rate, exact-match accuracy, tool-call validity, refusal rate, any binary judge verdict aggregated across a test set. If the reported number is k/n and the error bar is p̂ ± z·sqrt(p̂(1-p̂)/n), the analysis here applies unchanged. It does not apply to metrics that are means of continuous scores, like a 1-to-5 rating averaged across cases, which have a different and generally better-behaved sampling distribution.&lt;/p&gt;

&lt;p&gt;What should I actually change in my code tomorrow?&lt;/p&gt;

&lt;p&gt;Search for sqrt(p * (1 - p) / n) and for proportion_confint( without an explicit method argument, since statsmodels defaults to method="normal". Both are the Wald interval. Change the default to method="wilson". That is the whole migration. If you have historical eval numbers with published intervals, the point estimates are unaffected and only the intervals move, so a backfill is cheap and mostly widens old lower bounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;The thing I have not resolved is what to do when the eval set is not a random sample, which is most of the time.&lt;/p&gt;

&lt;p&gt;Everything above assumes n independent Bernoulli draws from a fixed distribution. Real eval sets are curated. We add cases because they broke something, we keep cases because they are hard, we group cases by document or by conversation so that the units are correlated rather than independent. Under curation the binomial model is the wrong likelihood, and an interval derived from it, Wilson included, is answering a question about a population that does not exist. Wilson gives you a correct interval for the wrong model. That is an improvement over Wald, which gives you an incorrect interval for the wrong model, but I notice it is a smaller improvement than this whole essay implies.&lt;/p&gt;

&lt;p&gt;I have seen three responses. Treat the eval set as the entire population and report no interval at all, which is honest but gives up on generalization. Cluster-bootstrap at the document or conversation level, which handles the correlation but not the curation. Maintain a separate uncurated random sample purely for estimation, which is correct and which I have never seen anyone actually staff.&lt;/p&gt;

&lt;p&gt;I do not have a defensible fourth answer. If you have shipped one, I would like to read it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>statistics</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Comparing Two Eval Runs by Their Average Pass Rate Is the Wrong Test</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Tue, 14 Jul 2026 16:54:09 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/comparing-two-eval-runs-by-their-average-pass-rate-is-the-wrong-test-1fm5</link>
      <guid>https://dev.to/maya_andersson_dev/comparing-two-eval-runs-by-their-average-pass-rate-is-the-wrong-test-1fm5</guid>
      <description>&lt;p&gt;TL;DR. You run version A and version B against the same 500-item eval set. A passes 71.4 percent, B passes 74.0 percent, and you conclude B is better. That reasoning throws away the one fact that matters most: both systems answered the same questions, so their per-item outcomes are correlated, not independent. Reading two separate averages (or eyeballing whether their confidence intervals overlap) is the wrong test for a same-items design. The fix is to pair the outcomes per item and test the difference directly. For pass/fail, that is McNemar's test on the items where the two runs disagree. For graded scores, it is a bootstrap over the per-item deltas. Report an effect size and a confidence interval on the delta, not two averages sitting next to each other.&lt;/p&gt;

&lt;p&gt;I have shipped a regression because two dashboard numbers looked close enough. The new prompt scored 68.9 percent, the old one 69.7 percent, and I called it noise and moved on. It was not noise. The new prompt was quietly worse on a specific slice, and pairing the runs would have shown me a tight interval that sat entirely below zero. This has practical stakes, not just cosmetic ones: the unpaired reading can hide a regression that the paired reading would surface.&lt;/p&gt;

&lt;p&gt;The rest of this post is one claim, argued criterion by criterion: when two eval runs share the same items, you owe them a paired analysis, and the paired analysis is not hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, and why it looks reasonable
&lt;/h2&gt;

&lt;p&gt;Here is the pattern I see in almost every eval report. There is a table with two rows. Row one is the baseline, row two is the candidate, and each row has a single number: mean pass rate, or mean rubric score, or mean judge score. Sometimes there is a 95 percent confidence interval on each row, drawn as a little error bar. The reader compares the two numbers, glances at whether the error bars overlap, and makes a call.&lt;/p&gt;

&lt;p&gt;The instinct is not stupid. A mean is a legitimate summary, and an error bar is more honest than a bare point. The problem is narrower and more specific: the two error bars in that picture are each computed as if that run stood alone, and the comparison you actually care about (is B better than A) is a comparison the picture does not draw. The uncertainty you need is the uncertainty of the difference, and the difference has its own variance that depends on how the two runs move together across items.&lt;/p&gt;

&lt;p&gt;That last phrase is the whole argument, so here it is in concrete terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 1: Same items means paired data, not two independent samples
&lt;/h2&gt;

&lt;p&gt;Some prompts in your eval set are simply harder than others. A gnarly multi-hop question, an ambiguous instruction, a long context that buries the answer. Both A and B face that same hard prompt. When A fails it, B often fails it too, because the difficulty is a property of the item, shared by both systems.&lt;/p&gt;

&lt;p&gt;Statistically, that shared difficulty induces a positive correlation between A's per-item outcome and B's per-item outcome. And the variance of a difference is not the sum of the variances when the two things are correlated:&lt;/p&gt;

&lt;p&gt;Var(A - B) = Var(A) + Var(B) - 2 Cov(A, B).&lt;/p&gt;

&lt;p&gt;When Cov(A, B) is positive (and with a shared test set it usually is), the variance of the delta is smaller than what you would get by treating the runs as independent. Two consequences follow, and they point in opposite directions depending on which mistake you make.&lt;/p&gt;

&lt;p&gt;If you run an unpaired two-proportion test (the kind that assumes independence), you plug in a standard error that ignores that covariance term. You typically overestimate the uncertainty of the difference, which makes you underpowered: you fail to detect improvements that are actually there. If instead you eyeball two marginal confidence intervals and check for overlap, you hit a separate and well-documented trap. Two intervals can overlap while the paired difference is comfortably significant, because the overlap of marginal intervals is not the same question as whether the interval on the difference excludes zero.&lt;/p&gt;

&lt;p&gt;This is not a niche observation. Dror, Baumer, Shlomov, and Reichart lay it out for language work in "The Hitchhiker's Guide to Testing Statistical Significance in NLP" (ACL 2018): the structure of your data determines which test is valid, and a shared test set produces dependent measurements that a naive test mishandles. Dietterich made the same point a generation earlier for classifiers in "Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms" (Neural Computation, 1998), where he recommends tests that respect the paired structure of predictions on a single held-out set and warns against procedures that quietly assume independence.&lt;/p&gt;

&lt;p&gt;So the first criterion is a modeling decision rather than a choice of statistic: same items, therefore paired. Everything below follows from taking that seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 2: For pass/fail, McNemar's test looks only at the disagreements
&lt;/h2&gt;

&lt;p&gt;When each item is a binary pass or fail, pairing the two runs sorts every item into one of four buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;both pass&lt;/li&gt;
&lt;li&gt;both fail&lt;/li&gt;
&lt;li&gt;A passes, B fails (call it a_only)&lt;/li&gt;
&lt;li&gt;B passes, A fails (call it b_only)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The items where both systems agree carry no information about which system is better. They cancel. All the signal lives in the discordant pairs, the items where exactly one of the two got it right. McNemar's test (McNemar, "Note on the Sampling Error of the Difference Between Correlated Proportions or Percentages," Psychometrika, 1947) asks a single question: among the discordant items, is the split between a_only and b_only further from 50/50 than chance would produce?&lt;/p&gt;

&lt;p&gt;The exact version is a binomial test on the discordant count. The large-sample version is a chi-square statistic with a continuity correction, (|b - c| - 1)^2 / (b + c), on one degree of freedom. For the sample sizes typical of an eval set, I reach for the exact binomial and stop thinking about it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.stats&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;binomtest&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;

&lt;span class="c1"&gt;# Some prompts are just harder for every system. That shared difficulty is
# exactly why the two runs are correlated, and exactly why you must pair.
&lt;/span&gt;&lt;span class="n"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;p_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.45&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.40&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# system A
&lt;/span&gt;&lt;span class="n"&gt;p_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.40&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# system B, a bit stronger
&lt;/span&gt;&lt;span class="n"&gt;pass_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_a&lt;/span&gt;
&lt;span class="n"&gt;pass_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_b&lt;/span&gt;

&lt;span class="n"&gt;a_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pass_a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;pass_b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# A right, B wrong
&lt;/span&gt;&lt;span class="n"&gt;b_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pass_b&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;pass_a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# B right, A wrong
&lt;/span&gt;&lt;span class="n"&gt;n_disc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a_only&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b_only&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pass_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  B=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pass_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  discordant: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a_only&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; vs &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b_only&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n_disc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no disagreements: McNemar is undefined, there is nothing to weigh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;binomtest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b_only&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_disc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alternative&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;two-sided&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;pvalue&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exact McNemar p = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the guard on n_disc == 0. It is not decoration. If your two runs are very similar (a small prompt tweak, a temperature change), you can land with almost no discordant items, and both the chi-square and the exact test degrade or become undefined. The honest reading in that case is not "p is large, no difference" but "I do not have enough disagreements to say anything," which is a different sentence and a cue to collect more items.&lt;/p&gt;

&lt;p&gt;To make the reframe concrete, take an illustrative outcome from a run like the one above. Suppose across 500 items you see 40 items where only A passed and 53 where only B passed, with 407 concordant. The marginal gap is (53 - 40) / 500 = 2.6 points, which matches a 71.4 versus 74.0 headline. But McNemar is weighing 53 against 40 out of 93 discordant items, and the exact binomial p for that split is about 0.21 (illustrative). That 2.6-point gap is inside the range you would see from shuffling which system got the coin flip on the contested items. The averages made it look decided. The paired test says wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criterion 3: For graded scores, bootstrap the per-item deltas
&lt;/h2&gt;

&lt;p&gt;Plenty of evals do not produce a clean pass or fail. You get a rubric score in [0, 1], a 1-to-5 judge rating, a similarity score, a latency. The same logic holds, and the mechanical move is the same: form the per-item difference first, then reason about the distribution of those differences.&lt;/p&gt;

&lt;p&gt;The paired bootstrap does this without assuming the deltas are normal. You compute diff_i = score_B(i) - score_A(i) for each item, then resample those paired differences with replacement many times, recomputing the mean each time, and read a confidence interval off the resampled means. The word "paired" is doing real work: you resample the differences, not the two score columns independently. Resampling the columns separately would break the pairing and reintroduce the very independence assumption you are trying to avoid.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;paired_bootstrap_delta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_boot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;diffs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores_b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores_a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# pair FIRST
&lt;/span&gt;    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no items to compare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_boot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;            &lt;span class="c1"&gt;# resample the PAIRS
&lt;/span&gt;    &lt;span class="n"&gt;boot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Illustrative graded scores in [0, 1] on the same 400 items.
&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;scores_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.70&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;scores_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores_a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# correlated with A
&lt;/span&gt;
&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paired_bootstrap_delta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores_b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean delta (B - A) = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, 95% CI = [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bootstrap resampling of a single test set is not something I am inventing here. Koehn introduced it to machine translation evaluation in "Statistical Significance Tests for Machine Translation Evaluation" (EMNLP 2004), precisely to attach a confidence interval to a metric computed on one fixed test set, and the general method traces to Efron and Tibshirani's "An Introduction to the Bootstrap" (1993). The one discipline to keep is the pairing. If your items come in natural groups (several questions drawn from the same source document, or several turns from the same conversation), even the paired item bootstrap can understate uncertainty, and you want to resample at the level of the group. More on that in the open question.&lt;/p&gt;

&lt;p&gt;Criterion 4: Report an effect size and a CI on the delta, not a bare p-value&lt;/p&gt;

&lt;p&gt;Suppose the paired test comes back at p = 0.03. You still do not know whether B is better by half a point or by twelve. A p-value answers "could this be zero," not "how big is it and does the size matter to anyone." On a 5,000-item eval, a 0.3-point improvement can clear significance while being operationally meaningless. On a 120-item eval, a genuinely useful 4-point gain can miss the 0.05 line entirely.&lt;/p&gt;

&lt;p&gt;So report the delta and its interval as the headline, and treat the p-value as secondary. For the binary case, the difference in paired proportions is (b - c) / n, and a standard error that respects the pairing is&lt;/p&gt;

&lt;p&gt;SE = (1 / n) * sqrt(b + c - (b - c)^2 / n),&lt;/p&gt;

&lt;p&gt;which for the illustrative 40-versus-53 example gives a 2.6-point delta with a roughly [-1.2, +6.4]-point 95 percent interval. That interval straddles zero, which is the same verdict McNemar gave, but now stated in units a product owner can act on. For the continuous case, the bootstrap interval on the mean delta is already in the right form.&lt;/p&gt;

&lt;p&gt;This emphasis is exactly what the American Statistical Association argued for in its 2016 statement on p-values (Wasserstein and Lazar, "The ASA Statement on p-Values: Context, Process, and Purpose," The American Statistician): a p-value does not measure the size of an effect or the importance of a result, and good practice reports estimates with their uncertainty. Dror and colleagues make the same recommendation for language experiments. An effect size with an interval tells you direction, magnitude, and precision in one line. A lone p-value tells you one bit and hides the rest.&lt;/p&gt;

&lt;p&gt;Criterion 5: If you compare many metrics at once, correct for it&lt;/p&gt;

&lt;p&gt;Modern eval runs are wide. You are not testing one number, you are testing pass rate and faithfulness and a toxicity check and format-adherence and latency and a half dozen rubric dimensions, all at once, all with their own paired test. Each test at the 0.05 level has a 1-in-20 chance of a false alarm under the null. Run twelve of them and the probability of at least one false positive, if nothing truly changed, is 1 - 0.95^12, which is about 0.46. Almost a coin flip that you will "discover" a difference that is not there and chase it for a day.&lt;/p&gt;

&lt;p&gt;There are two standard responses. Bonferroni divides your alpha by the number of tests (0.05 / 12 = 0.0042 each), which controls the chance of any false positive but is conservative and will hide real effects. Benjamini and Hochberg's procedure ("Controlling the False Discovery Rate," Journal of the Royal Statistical Society Series B, 1995) instead controls the expected fraction of your flagged results that are false, which keeps more power when several metrics really did move. For eval dashboards, where you would rather not miss a real regression, I default to Benjamini-Hochberg and reserve Bonferroni for the small number of metrics I would gate a release on. Either way, the point stands: the more comparisons you draw from one run, the more of them will look significant by luck, and a wide dashboard without any correction will regularly flag differences that are not real.&lt;/p&gt;

&lt;p&gt;When the simple average is fine&lt;/p&gt;

&lt;p&gt;I promised a measured claim, so here is the boundary. Comparing two averages is not always wrong, and there are cases where I do exactly that and sleep fine.&lt;/p&gt;

&lt;p&gt;If the two runs used different, independently sampled item sets, they are not paired, and an unpaired analysis is the correct one. If the gap is so large that no reasonable interval could touch zero (A at 40 percent, B at 88 percent, on a few hundred items), the paired test will agree with your eyes and you can skip the ceremony for a quick read, then do it properly before you write it down. If you are not making a decision, just watching a number drift over time on a monitoring chart, a smoothed average is a fine early-warning signal and nobody needs a p-value to notice a cliff. And if your eval set is tiny (say under 30 items), no test rescues you; the honest move is to report the raw counts, resist a verdict, and go collect more data.&lt;/p&gt;

&lt;p&gt;The through line is the same. The average is a fine description. It becomes the wrong test the moment you use two of them, side by side, on the same items, to decide which system won. That specific move, two averages side by side on the same items to decide which system won, is the one that needs a paired test.&lt;/p&gt;

&lt;p&gt;I will also admit what pairing does not fix. It does not repair a biased judge, a leaky eval set, or items that do not represent production. Get a real improvement wrong and no statistic saves you. Pairing only makes sure that, given honest measurements, you read the comparison the measurements actually support.&lt;/p&gt;

&lt;p&gt;FAQ&lt;/p&gt;

&lt;p&gt;Is a two-proportion z-test fine if my eval set is large enough?&lt;br&gt;
No, and size does not rescue it. The two-proportion z-test assumes the two samples are independent. With a shared test set they are not, because item difficulty is common to both runs. A larger n makes the wrong standard error more precisely wrong, not correct. What large n does buy you is stability for the paired test: McNemar and the paired bootstrap both behave well with more items, and their intervals tighten. So keep growing the eval set, but feed the counts into a paired procedure. The independence assumption is a property of the design, not of the sample size, and you cannot buy your way out of it with more rows.&lt;/p&gt;

&lt;p&gt;My two confidence intervals overlap. Doesn't that mean no significant difference?&lt;br&gt;
This is the most common trap in the whole topic. Overlapping marginal intervals do not imply the paired difference is non-significant. The interval you drew on A and the interval you drew on B each describe one run in isolation. The question you care about lives in a third interval, the one on the delta, which depends on how A and B covary across items. Because a shared test set makes them positively correlated, the interval on the difference is often much tighter than the overlap picture suggests. Draw the interval on the delta and check whether it excludes zero. Ignore the overlap of the two marginal bars.&lt;/p&gt;

&lt;p&gt;What if I only have the two average pass rates, not the per-item results?&lt;br&gt;
Then you cannot run the correct test, and you should go get the per-item results. This is the practical reason to log outcomes at the item level for every run: the paired analysis needs to know, item by item, whether each system passed. Two summary numbers have already discarded the pairing, and no post-hoc formula reconstructs it. If retrieving the raw outcomes is genuinely impossible, be honest that you can only make an unpaired, underpowered comparison, and treat any close call as undecided rather than shipping on it. Store the per-item pass/fail and scores by default so this never becomes the blocker.&lt;/p&gt;

&lt;p&gt;When should I use McNemar's chi-square versus the exact binomial version?&lt;br&gt;
Use the exact binomial test on the discordant count when that count is small, which for eval sets is most of the time. The chi-square form, with the continuity correction (|b - c| - 1)^2 / (b + c), is a large-sample approximation and is fine when the number of discordant pairs is comfortably into the dozens (a common rule of thumb is b + c of at least 25). Below that, the approximation drifts and the exact test is both safer and, in Python, no harder to call. When discordant pairs are near zero, neither version is meaningful; you simply do not have enough disagreements, and the answer is more data, not a smaller p.&lt;/p&gt;

&lt;p&gt;How many items do I need for the paired test to detect a real difference?&lt;br&gt;
It depends on the discordance rate, not the total item count, which is the counterintuitive part. Power comes from the items where the two systems disagree, so two nearly identical runs need a large set to accumulate enough discordant pairs, while two clearly different runs reach significance on far fewer items. As a rough planning move, estimate the fraction of items where you expect the runs to differ, multiply by your set size to get expected discordant pairs, and aim for that to be at least in the dozens. If you cannot get there, you are testing a difference too small to resolve at your current scale, which is itself a useful finding.&lt;/p&gt;

&lt;p&gt;I track 15 metrics per run. Do I really need a multiple-comparisons correction?&lt;br&gt;
Yes, if you are making decisions on whichever metric lights up. Fifteen independent tests at 0.05 give roughly a 1 - 0.95^15, about 0.54, chance of at least one false positive under the null. That is worse than a coin flip. You do not need to correct metrics you are only monitoring, but any metric that can trigger a decision (block a release, revert a prompt) belongs in a corrected family. I use Benjamini-Hochberg to control the false discovery rate across the dashboard, because it keeps more power than Bonferroni when several metrics genuinely moved, and I reserve the stricter Bonferroni cut for the two or three release-gating metrics where a single false alarm is expensive.&lt;/p&gt;

&lt;p&gt;Open question&lt;/p&gt;

&lt;p&gt;Pairing solves the correlation between two systems on the same item. It does not, by itself, solve the correlation between items that are not independent of each other.&lt;/p&gt;

&lt;p&gt;Real eval sets are full of hidden clusters. Ten questions generated from the same source document. Eight turns sampled from the same conversation. A batch of items authored by the same annotator with the same blind spots. When items cluster like this, the effective number of independent observations is smaller than your row count, and both McNemar and the item-level paired bootstrap will hand you an interval that is too tight, because they treat correlated items as if they were independent draws. You end up overconfident in the opposite direction from where we started.&lt;/p&gt;

&lt;p&gt;The candidate fixes are known in name: a cluster bootstrap that resamples whole groups instead of individual items, or a mixed-effects model with a random intercept per cluster. What I do not have is a clean, agreed-on recipe for the messy case where the clustering is partial and unlabeled, where some items share a document and others do not, and where nobody logged the grouping at eval-authoring time. How much does ignoring soft clustering actually inflate false positives on a typical agent eval, and is the cluster bootstrap worth the complexity for sets in the low thousands?&lt;/p&gt;

&lt;p&gt;I have opinions and no proof. If you have run this comparison on your own evals, with the grouping tracked and the intervals computed both ways, I would genuinely like to see the numbers. That is the next thing I want to measure, and I would rather learn it from your data than guess.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>statistics</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>The regression your eval set is too small to catch</title>
      <dc:creator>Maya Andersson</dc:creator>
      <pubDate>Fri, 10 Jul 2026 16:53:27 +0000</pubDate>
      <link>https://dev.to/maya_andersson_dev/the-regression-your-eval-set-is-too-small-to-catch-2nlg</link>
      <guid>https://dev.to/maya_andersson_dev/the-regression-your-eval-set-is-too-small-to-catch-2nlg</guid>
      <description>&lt;p&gt;TL;DR. To catch a drop from a 0.90 pass rate to 0.85 at 80% power (one-sided, alpha 0.05), you need about 253 examples. A 50-example set has roughly 35% power, so it misses that regression about two times in three. The move that matters is not "collect more data" as a slogan. Size the set to the effect you actually care about, report a confidence interval on each run instead of a bare point delta, and prefer per-criterion binary labels over vague graded scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-point win that wasn't
&lt;/h2&gt;

&lt;p&gt;A few months ago I sat in a review where a team was pleased with itself. A new prompt had moved their eval pass rate from 87.5 percent to 90 percent, and someone had already written "prompt v3: +2pp" in the changelog. The eval set had forty examples.&lt;/p&gt;

&lt;p&gt;Here is what those points were made of. On forty examples every result is a multiple of 2.5 points, so the pass rate can only land on 35 out of 40, or 36, or 37, with nothing in between. The move from 87.5 to 90 was 35 correct becoming 36 correct. One example. A single test case that used to fail now passed, and it could flip back next week when the decoding lands differently.&lt;/p&gt;

&lt;p&gt;I asked the obvious question. If we reran the old prompt three more times, would it always score 35 out of 40? Nobody knew, because nobody had rerun it. The honest summary of that meeting is that we had watched one example change its mind, and we had written it into the changelog as progress.&lt;/p&gt;

&lt;p&gt;This is not a story about one careless team. It is the default failure mode of eval-driven development. We compare two numbers, see a gap, and our brains supply a cause. The possibility we skip is that the gap sits entirely inside the noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Power is the number nobody computes
&lt;/h2&gt;

&lt;p&gt;The quantity that decides whether your eval can see a regression is statistical power: the probability that your test declares a difference when a real difference of a given size exists. Power depends on three things. The effect size you want to catch, the baseline rate, and the number of examples. Most teams pick the effect size implicitly ("a five-point drop would matter"), never write down the baseline variance, and let the sample size be whatever happened to be sitting in the folder.&lt;/p&gt;

&lt;p&gt;Jacob Cohen spent a career arguing that this is backwards, most durably in "Statistical Power Analysis for the Behavioral Sciences" (2nd ed., 1988). The discipline he asked for is boring and effective: decide the smallest effect that would change a decision, then size the study so you can actually see it. An eval set is a study. The same arithmetic applies, whether the outcome is a clinical result or a pass or fail from a grader.&lt;/p&gt;

&lt;p&gt;For a pass rate the math is the two-proportion normal approximation. You have a baseline rate p0, a rate you would not want to miss p1, and you solve for the n that gives you, say, 80 percent power. For the case that comes up constantly, catching a slide from 0.90 to 0.85, the answer is about 253 examples. Not forty. Not fifty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the numbers yourself
&lt;/h2&gt;

&lt;p&gt;Here is the whole calculation in standard-library Python. No dependencies, so you can paste it into a scratch file and swap in your own baseline and the drop you care about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_phi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# standard normal CDF
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;erf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# inverse normal CDF (Acklam)
&lt;/span&gt;    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;3.969683028665376e+01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.209460984245205e+02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.759285104469687e+02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="mf"&gt;1.383577518672690e+02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;3.066479806614716e+01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.506628277459239e+00&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;5.447609879822406e+01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.615858368580409e+02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.556989798598866e+02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="mf"&gt;6.680131188771972e+01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.328068155288572e+01&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;7.784894002430293e-03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;3.223964580411365e-01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.400758277161838e+00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.549732539343734e+00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.374664141464968e+00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.938163982698783e+00&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;7.784695709041462e-03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.224671290700398e-01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.445134137142996e+00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="mf"&gt;3.754408661907416e+00&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;plow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phigh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.02425&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.02425&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;plow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(((((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;((((&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;phigh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(((((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(((((&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(((((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;((((&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;power_one_proportion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# one-sided
&lt;/span&gt;    &lt;span class="n"&gt;z_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_phi&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;z_a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;n_for_power&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;z_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;_z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;z_a&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;z_b&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;  &lt;span class="c1"&gt;# detect a 5-point drop in pass rate
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  power to catch &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;power_one_proportion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n needed for 80% power:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;n_for_power&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n=  50  power to catch 0.9-&amp;gt;0.85 = 0.35
n= 100  power to catch 0.9-&amp;gt;0.85 = 0.51
n= 250  power to catch 0.9-&amp;gt;0.85 = 0.80
n needed for 80% power: 253
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it slowly. A fifty-example set has about 35 percent power against a five-point drop, which means it misses that regression roughly two times in three. At a hundred examples you reach 51 percent, a coin flip. You do not clear 80 percent until about 250, and the closed-form requirement is 253.&lt;/p&gt;

&lt;p&gt;Two caveats carry weight here. This is a normal approximation and a one-sided test, appropriate when you only care about catching a drop rather than an improvement. And it describes a single run measured against a fixed baseline. If you evaluate the same items before and after (a paired design), the right test is McNemar on the items that flipped, and it needs fewer examples because it cancels the item-to-item difficulty that otherwise inflates your variance. If instead you compare two independent runs, that is a two-sample problem, and you need roughly twice as many per run. The forty-example set was never in the conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When more labels are not an option
&lt;/h2&gt;

&lt;p&gt;"Collect more data" is easy to say and expensive to do, because the labels are the cost, not the inputs. Three things help when you are genuinely stuck at a small n.&lt;/p&gt;

&lt;p&gt;Report an interval, not a point. Every pass rate is an estimate with a standard error, and near the boundaries where eval scores live (roughly 0.85 to 0.97) the ordinary Wald interval behaves badly. Use the Wilson score interval instead, from Wilson, E. B. (1927), "Probable inference, the law of succession, and statistical inference," JASA. For 36 out of 40, the Wilson 95 percent interval runs from about 0.77 to 0.96. Print that next to the number. An interval that wide makes the two-point win argue against itself, and nobody has to say a thing.&lt;/p&gt;

&lt;p&gt;Prefer per-criterion binary labels over graded scores. A vague one-to-five rubric hides two problems: raters disagree about whether an answer is a three or a four, and that disagreement is pure measurement noise that widens every interval you compute. Splitting the judgment into specific binary criteria (did it cite a source, did it follow the format, did it answer the question) is easier to label reliably, and each example then yields several outcomes instead of one. The honest caveat is that criteria inside one example are correlated, so k criteria are not k independent examples. You still come out ahead, because the reliability gain is real and the correlated-labels problem is smaller than the rater-noise problem it replaces.&lt;/p&gt;

&lt;p&gt;Fix the effect size before the run, not after. Decide in advance the smallest regression you care about, size the set to it, and write the decision rule down. Five points at 80 percent power means 253 examples. If you can only get 80, then say plainly that you are powered to catch about a ten-point drop and nothing smaller, and stop reading two-point moves as signal. A known blind spot is a manageable risk. An unknown one ships regressions to users.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning is that eval sets fail quietly. A set that is too small does not error out. It returns a number with two decimal places and a confident sign, and that number is noise that reads exactly like a real measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Does a bigger or better model change the sample size?&lt;br&gt;
Only through its pass rate. The arithmetic depends on the effect size and the baseline rate, not on which model produced the answers. A stronger model with a higher baseline actually needs fewer examples to catch the same absolute drop, because the variance p(1-p) shrinks as the rate approaches 1. Catching a five-point slide from 0.97 costs far fewer labels than the same slide from 0.90.&lt;/p&gt;

&lt;p&gt;What if my eval is paired, the same items scored under both versions?&lt;br&gt;
Then use McNemar's test on the discordant pairs, the items that passed under one version and failed under the other. It ignores the items that agree, and it needs fewer examples than the independent-samples formula because it removes item difficulty from the variance. The catch is that if almost nothing flips, you have almost no information no matter how many items you scored. Paired designs concentrate all the signal in the disagreements.&lt;/p&gt;

&lt;p&gt;One-sided or two-sided?&lt;br&gt;
One-sided is right when you will only act on a drop, which is the regression-detection case. If you will act on a move in either direction, use two-sided and expect to need somewhat more, since the critical value rises from 1.645 to 1.96. Do not reach for one-sided just to shrink the sample size on paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open question
&lt;/h2&gt;

&lt;p&gt;The clean part of this is the single-metric, binary-outcome case. The messy part is the multi-criterion eval, where each example carries five or ten correlated binary judgments and I want one honest power calculation for the whole thing. Treating the criteria as independent overcounts the evidence, and treating each example as one outcome throws information away. The right answer sits somewhere in between, governed by the intra-example correlation, and the design-effect corrections from cluster sampling are the closest tool I know of. The trouble is that they need a correlation estimate you usually do not have until you have already run the eval several times. I do not have a tidy recipe for sizing a correlated multi-criterion eval up front, and non-binary rubric scores make it harder still. If you have solved this in a way that survives contact with real data, I would like to read it.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>statistics</category>
      <category>evaluation</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
