Compare a code-generation model with paired evidence, uncertainty for lift, and pass@k under a fixed sampling budget.
Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Sampling rates in Distributions and Sampling moved from sample to sample. Hypothesis tests build on that wobble: a one-task win isn't yet a model replacement. It matters when a platform engineering team evaluates a coding assistant that writes small functions for timestamp parsing, retry limits, permission checks, and cache invalidation. Hidden tests mark each generated function as pass or fail.
Model B passes one more task than Model A on a six-task evaluation. Is that enough evidence to replace the old model? Learn how to answer without confusing a promising result with a proven improvement.
The numbers below are deliberately small so you can calculate them by hand. They teach the method, not a production launch threshold.
Each prompt asks for one Python helper used in an internal developer workflow. Both models receive the same prompt and are checked by the same hidden tests.
| Task | Hidden-test requirement | Model A | Model B |
|---|---|---|---|
| 1 | timestamp range is inclusive | pass | pass |
| 2 | permission fallback preserves denial | fail | pass |
| 3 | retry budget caps at configured limit | pass | pass |
| 4 | event deduplication keeps latest version | fail | fail |
| 5 | quota rounding is deterministic | pass | pass |
| 6 | cache invalidation skips expired keys | fail | fail |
Convert pass to 1 and fail to 0. Then Model A has a pass rate of 3 / 6 = 0.500, Model B has 4 / 6 = 0.667, and the observed lift is 1 / 6 = 0.167, or 16.7 percentage points.
Run the calculation rather than trusting a headline.
1model_a = [1, 0, 1, 0, 1, 0]
2model_b = [1, 1, 1, 0, 1, 0]
3
4rate_a = sum(model_a) / len(model_a)
5rate_b = sum(model_b) / len(model_b)
6lift = rate_b - rate_a
7
8print(f"Model A pass@1: {rate_a:.3f}")
9print(f"Model B pass@1: {rate_b:.3f}")
10print(f"observed lift: {lift:+.3f} ({lift * 100:+.1f} percentage points)")1Model A pass@1: 0.500
2Model B pass@1: 0.667
3observed lift: +0.167 (+16.7 percentage points)A paired evaluation preserves more information than two totals. Subtract A from B for each task:
| Result on one task | Difference B - A | Count |
|---|---|---|
| both pass | 0 | 3 |
| both fail | 0 | 2 |
| B passes, A fails | +1 | 1 |
| A passes, B fails | -1 | 0 |
Only disagreement tasks tell you which model won. Five ties make the benchmark look larger without giving any directional evidence.
1model_a = [1, 0, 1, 0, 1, 0]
2model_b = [1, 1, 1, 0, 1, 0]
3
4differences = [b - a for a, b in zip(model_a, model_b)]
5b_wins = differences.count(1)
6a_wins = differences.count(-1)
7ties = differences.count(0)
8
9print("paired differences:", differences)
10print(f"B wins={b_wins}, A wins={a_wins}, ties={ties}")
11print("directional evidence comes from disagreements:", b_wins + a_wins)1paired differences: [0, 1, 0, 0, 0, 0]
2B wins=1, A wins=0, ties=5
3directional evidence comes from disagreements: 1The null hypothesis says that, on disagreement tasks, Model A and Model B are equally likely to win. The directional alternative says Model B wins more often.
Under that null hypothesis, each disagreement is like a fair coin: B wins or A wins. Our six-task benchmark has one disagreement, and it went to B. A one-sided p-value for the planned claim "B is better" asks:
If both models were equally likely to win a disagreement, how often would B win at least this many of the disagreements?
With one disagreement, B wins it with probability 0.5. That result isn't rare. The sample moved upward, but the evidence is weak.
This exact coin calculation is easy to implement with the binomial coefficients you already know.
1from math import comb
2
3def b_wins_one_sided_p_value(b_wins: int, a_wins: int) -> float:
4 if b_wins < 0 or a_wins < 0:
5 raise ValueError("win counts must be nonnegative")
6 disagreements = b_wins + a_wins
7 if disagreements == 0:
8 return 1.0
9 tail_count = sum(comb(disagreements, wins) for wins in range(b_wins, disagreements + 1))
10 return tail_count / (2 ** disagreements)
11
12print(f"one B win, zero A wins: p={b_wins_one_sided_p_value(1, 0):.3f}")
13print(f"thirteen B wins, three A wins: p={b_wins_one_sided_p_value(13, 3):.4f}")1one B win, zero A wins: p=0.500
2thirteen B wins, three A wins: p=0.0106The second line shows why more disagreement evidence matters. A result with 13 B wins and 3 A wins under a predeclared directional question is much harder to explain with an equal-win coin.
If you had planned to detect a difference in either direction, you would use a two-sided version instead. Pick the question before seeing the winning direction.
A confidence interval should target the decision. If your question is "How much better is B than A on the same tasks?", calculate an interval for the paired lift B - A. Comparing two separate pass-rate intervals can hide the pairing and isn't the right decision rule.
A paired bootstrap interval repeatedly resamples complete task rows with replacement. Each resample keeps Model A's outcome beside Model B's outcome, then recomputes the mean difference. The bootstrap is a general resampling method for estimating sampling uncertainty from observed data.[1]
Six tasks are useful for intuition but painfully sparse. Use a slightly larger illustrative evaluation with 40 paired tasks:
| Paired outcome | Tasks |
|---|---|
| both pass | 17 |
| both fail | 10 |
| B passes, A fails | 8 |
| A passes, B fails | 5 |
The observed lift is (8 - 5) / 40 = 0.075, or +7.5 percentage points. Bootstrap the paired differences to see how unstable that lift remains.
1import numpy as np
2
3differences = np.array([0] * 27 + [1] * 8 + [-1] * 5)
4rng = np.random.default_rng(7)
5
6resamples = rng.choice(differences, size=(20_000, differences.size), replace=True)
7bootstrap_lifts = resamples.mean(axis=1)
8low, high = np.quantile(bootstrap_lifts, [0.025, 0.975])
9
10print(f"observed paired lift: {differences.mean() * 100:+.1f} percentage points")
11print(f"approximate 95% bootstrap interval: {low * 100:+.1f} to {high * 100:+.1f} points")
12print("interval includes zero:", low <= 0 <= high)1observed paired lift: +7.5 percentage points
2approximate 95% bootstrap interval: -10.0 to +25.0 points
3interval includes zero: True
Bootstrap intervals are approximate, especially with tiny or highly discrete samples. They help you see uncertainty; they don't turn weak evidence into certainty. Here the correct statement is: "B gained 7.5 points in this paired sample, and the interval still includes zero."
Write that conclusion as a rule your evaluation report can enforce.
1def comparison_claim(observed_lift: float, interval: tuple[float, float]) -> str:
2 low, high = interval
3 if low > high:
4 raise ValueError("interval low must not exceed high")
5 if low > 0:
6 return f"evidence of improvement: estimated lift {observed_lift:+.3f}"
7 if high < 0:
8 return f"evidence of regression: estimated lift {observed_lift:+.3f}"
9 return f"inconclusive: estimated lift {observed_lift:+.3f}, interval crosses zero"
10
11print(comparison_claim(0.075, (-0.100, 0.250)))
12print(comparison_claim(0.075, (0.010, 0.140)))1inconclusive: estimated lift +0.075, interval crosses zero
2evidence of improvement: estimated lift +0.075So far, each task used one candidate completion from each model. A coding assistant can also generate several candidate functions and let an evaluator check whether at least one passes hidden tests. That's the question measured by pass@k in functional code-generation evaluations such as HumanEval.[2]
Consider this illustrative ordered sample of three platform-helper tasks with three completions apiece. It shows one realized batch, not the HumanEval estimator:
| Task | Attempt 1 | Attempt 2 | Attempt 3 | first_attempt_hit | any_of_3_hit |
|---|---|---|---|---|---|
| parse timestamp window | pass | fail | fail | 1 | 1 |
| retry-state fallback | fail | fail | pass | 0 | 1 |
| quota split rounding | fail | fail | fail | 0 | 0 |
Here first_attempt_hit records whether Attempt 1 passed, and any_of_3_hit asks whether any of three completions passed. These columns illustrate how a larger attempt budget can solve more tasks. They are not what a HumanEval-style harness reports.
1attempts = [
2 [1, 0, 0],
3 [0, 0, 1],
4 [0, 0, 0],
5]
6
7first_attempt_hit = sum(row[0] for row in attempts) / len(attempts)
8any_of_3_hit = sum(any(row[:3]) for row in attempts) / len(attempts)
9
10print(f"first_attempt_hit: {first_attempt_hit:.3f}")
11print(f"any_of_3_hit: {any_of_3_hit:.3f}")
12print("extra solved tasks from extra attempts:", int((any_of_3_hit - first_attempt_hit) * len(attempts)))1first_attempt_hit: 0.333
2any_of_3_hit: 0.667
3extra solved tasks from extra attempts: 1For reporting HumanEval-style numbers, treat the n candidates as an unordered bag and use the combinatorial estimator on the count of correct outcomes. For the same three tasks with n=3 per task, correct counts are c = [1, 1, 0], so unbiased pass@1 is the average of c/n:
That is not "column 0 of an ordered table." When k=1, the combinatorial formula always reduces to c/n, regardless of which sample happened to appear first. The model isn't being awarded the same budget at k=1 and k=3.
For pass@k, a report must publish k, the number of generated samples, the decoding policy, and the tests used to judge correctness. A higher score under a larger attempt budget isn't evidence of stronger single-candidate behavior.
Suppose the assistant generates n = 10 candidate implementations for one function and hidden tests accept c = 2 of them. You want the expected pass@5 result if you select five candidates from that pool.
Counting success cases is tedious. Count the failure case instead:
10 - 2 = 8 failing candidates.C(10, 5) = 252 ways to choose five candidates.C(8, 5) = 56 all-failing choices.1 - 56 / 252 = 0.778.In notation:
The quantity we care about is still generative: the probability that at least one of k i.i.d. model samples passes, often written for unknown per-sample success rate . The combinatorial formula is an unbiased U-statistic for that generative quantity; combinatorially it equals the fraction of -subsets of the draws that contain a success. The paper therefore samples and reports the U-statistic, not a with-replacement plug-in. Chen and colleagues use this unbiased estimator for HumanEval evaluation after generating more samples per task than the reported k.[2]
1from math import comb
2
3n = 10
4c = 2
5k = 5
6all_groups = comb(n, k)
7all_failing_groups = comb(n - c, k)
8score = 1 - all_failing_groups / all_groups
9
10print("all groups:", all_groups)
11print("all-failing groups:", all_failing_groups)
12print(f"pass@5: {score:.3f}")1all groups: 252
2all-failing groups: 56
3pass@5: 0.778A tempting plug-in estimate for pass@k is:
where is the observed single-sample success rate. The plug-in is a biased estimator of the generative pass@k probability . The combinatorial HumanEval form is unbiased for that same generative quantity; combinatorially it also equals the chance that at least one of distinct selections from the frozen pool of candidates succeeds.[2]
After drawing a failing candidate without replacement, correct candidates make up a larger share of the remaining pool. For and , the plug-in calculation is strictly smaller than the unbiased combinatorial estimate on the same .
For , , and , compare both calculations:
1p_hat = c / n
2naive_score = 1.0 - (1.0 - p_hat) ** k
3unbiased_score = score # From previous cell: 1 - comb(8, 5) / comb(10, 5)
4
5shortfall = unbiased_score - naive_score
6print(f"Plug-in pass@5 1-(1-c/n)^k: {naive_score:.4f}")
7print(f"Unbiased combinatorial pass@5: {unbiased_score:.4f}")
8print(f"Plug-in shortfall vs unbiased estimate: {shortfall:.4f}")
9
10assert abs(naive_score - 0.67232) < 1e-51Plug-in pass@5 1-(1-c/n)^k: 0.6723
2Unbiased combinatorial pass@5: 0.7778
3Plug-in shortfall vs unbiased estimate: 0.1055Two boundary checks should feel right:
c = 0, no selected group can pass.k failures exist, every k-sized group contains at least one passing candidate.For larger values of n, avoid assembling enormous combinations. The HumanEval paper gives an equivalent product implementation that stays numerically well behaved.[2]
1import numpy as np
2
3def pass_at_k(n: int, c: int, k: int) -> float:
4 if n <= 0 or not 0 <= c <= n or not 1 <= k <= n:
5 raise ValueError("require n > 0, 0 <= c <= n, and 1 <= k <= n")
6 if n - c < k:
7 return 1.0
8 failure_probability = np.prod(1.0 - k / np.arange(n - c + 1, n + 1))
9 return float(1.0 - failure_probability)
10
11print(f"n=10, c=2, k=1: {pass_at_k(10, 2, 1):.3f}")
12print(f"n=10, c=2, k=5: {pass_at_k(10, 2, 5):.3f}")
13print(f"no passing candidates: {pass_at_k(10, 0, 5):.3f}")
14print(f"not enough failures: {pass_at_k(10, 8, 5):.3f}")
15
16try:
17 pass_at_k(10, 11, 5)
18except ValueError as error:
19 print(error)1n=10, c=2, k=1: 0.200
2n=10, c=2, k=5: 0.778
3no passing candidates: 0.000
4not enough failures: 1.000
5require n > 0, 0 <= c <= n, and 1 <= k <= nA benchmark score averages task-level results. One difficult permission-check function counts as one task; it shouldn't disappear beneath hundreds of samples from an easier string formatter.
Assume four tasks each produce n = 10 candidates, with the following correct-count vector:
1[0, 1, 2, 4]Compute each task's estimate first, then average those four estimates.
1import numpy as np
2
3def pass_at_k(n: int, c: int, k: int) -> float:
4 if n <= 0 or not 0 <= c <= n or not 1 <= k <= n:
5 raise ValueError("require n > 0, 0 <= c <= n, and 1 <= k <= n")
6 if n - c < k:
7 return 1.0
8 return float(1.0 - np.prod(1.0 - k / np.arange(n - c + 1, n + 1)))
9
10correct_counts = [0, 1, 2, 4]
11for k in (1, 3, 5):
12 task_scores = [pass_at_k(10, correct, k) for correct in correct_counts]
13 print(f"pass@{k}: {np.mean(task_scores):.3f} per-task={[round(score, 3) for score in task_scores]}")1pass@1: 0.175 per-task=[0.0, 0.1, 0.2, 0.4]
2pass@3: 0.417 per-task=[0.0, 0.3, 0.533, 0.833]
3pass@5: 0.563 per-task=[0.0, 0.5, 0.778, 0.976]Notice that pass@5 can rise dramatically while pass@1 remains modest. That's useful information if your product can test several generated candidates, but it isn't a substitute for measuring single-candidate behavior under the same protocol.
pass@k only supports a comparison when the protocol matches. Keep the same task set, hidden tests, number of generated samples n, retained attempt budget k, and decoding rule.
A deterministic decoder illustrates the trap. If every generated candidate is identical for a task, extra attempt slots can't discover a different correct solution. In a controlled deterministic fixture, pass@5 collapses to pass@1.
1import numpy as np
2
3def pass_at_k(n: int, c: int, k: int) -> float:
4 if n <= 0 or not 0 <= c <= n or not 1 <= k <= n:
5 raise ValueError("require n > 0, 0 <= c <= n, and 1 <= k <= n")
6 if n - c < k:
7 return 1.0
8 return float(1.0 - np.prod(1.0 - k / np.arange(n - c + 1, n + 1)))
9
10identical_failed_candidates = [0] * 10
11identical_passing_candidates = [1] * 10
12
13for name, outcomes in [
14 ("same failed candidate", identical_failed_candidates),
15 ("same passing candidate", identical_passing_candidates),
16]:
17 correct = sum(outcomes)
18 print(name, f"pass@1={pass_at_k(10, correct, 1):.1f}", f"pass@5={pass_at_k(10, correct, 5):.1f}")1same failed candidate pass@1=0.0 pass@5=0.0
2same passing candidate pass@1=1.0 pass@5=1.0Real serving stacks can introduce nondeterminism from outside the sampling policy, so record the actual decoder and run settings. Operationally, multiple attempts only buy search when they produce meaningfully different candidates.
A second failure is subtler: generated code that passes weak hidden tests can still be wrong on missing cases or unsafe to execute. Unit-test passing measures functional correctness under that test suite. It doesn't authorize running untrusted code in a production environment. HumanEval's authors evaluated generated code in a sandbox for that reason.[2]
Put the pieces together into one report for a model-review meeting. The first comparison measures a paired pass@1 lift. The second metric measures multi-attempt capability under an explicitly recorded stochastic sampling setup.
1import numpy as np
2
3def pass_at_k(n: int, c: int, k: int) -> float:
4 if n <= 0 or not 0 <= c <= n or not 1 <= k <= n:
5 raise ValueError("require n > 0, 0 <= c <= n, and 1 <= k <= n")
6 if n - c < k:
7 return 1.0
8 return float(1.0 - np.prod(1.0 - k / np.arange(n - c + 1, n + 1)))
9
10model_a = np.array([1] * 17 + [0] * 10 + [0] * 8 + [1] * 5)
11model_b = np.array([1] * 17 + [0] * 10 + [1] * 8 + [0] * 5)
12paired_lift = float((model_b - model_a).mean())
13
14correct_counts_b = [0, 1, 2, 4]
15task_scores = [pass_at_k(10, correct, 5) for correct in correct_counts_b]
16pass5 = float(np.mean(task_scores))
17# Task-level bootstrap of macro pass@k: every with-replacement resample of the
18# four task scores (4^4 = 256), then percentile interval of the resample means.
19# With only four toy tasks the interval is huge; the point is the reporting contract.
20from itertools import product
21
22boot_means = [
23 float(np.mean([task_scores[i] for i in idxs]))
24 for idxs in product(range(len(task_scores)), repeat=len(task_scores))
25]
26pass5_lo, pass5_hi = np.quantile(boot_means, [0.025, 0.975])
27protocol = {
28 "paired_tasks": 40,
29 "metric": "hidden-test functional correctness",
30 "samples_per_task": 10,
31 "reported_k": 5,
32 "decoding": "stochastic sampling, fixed settings for both models",
33}
34
35print(f"paired pass@1 lift: {paired_lift * 100:+.1f} percentage points")
36print(f"Model B pass@5 on candidate pool: {pass5:.3f}")
37print(f"pass@5 95% task-bootstrap interval: [{pass5_lo:.3f}, {pass5_hi:.3f}]")
38for key, value in protocol.items():
39 print(f"{key}: {value}")1paired pass@1 lift: +7.5 percentage points
2Model B pass@5 on candidate pool: 0.563
3pass@5 95% task-bootstrap interval: [0.194, 0.877]
4paired_tasks: 40
5metric: hidden-test functional correctness
6samples_per_task: 10
7reported_k: 5
8decoding: stochastic sampling, fixed settings for both modelspass@k is a nonlinear U-statistic averaged over tasks. A bare point estimate reintroduces the overconfidence habit this chapter fights. Resample tasks, recompute macro pass@k on each draw, and report the percentile interval beside the point. With four toy tasks the interval is enormous; that is honest, not a bug.
Before anyone calls a winner, your report still needs:
Statistical significance and product value answer different questions. Even convincing statistical evidence can't tell you whether a gain justifies additional candidate generation, test execution, latency, or risk.
A teammate writes: "Model B is better because its pass@5 is 64%, while Model A's pass@1 is 58%."
Write a review comment with three corrections:
k, n, task set, tests, and decoding policy for both models.A good rewritten claim would sound like this:
Under the same 200 paired platform-helper tasks and fixed
pass@1protocol, Model B improved hidden-test pass rate by 2.5 percentage points; the paired interval and cost guardrails are reported below.pass@5is listed separately because it measures a larger candidate-search budget.
Answer every question, then check your score. Score 75% or higher to mark this lesson complete.
9 questions remaining.
Questions and insights from fellow learners.