Skip to content

fix(evals): the 95th percentile is one rank too high when the sample size is a multiple of 20 - #698

Closed
devloper961-maker wants to merge 1 commit into
NandhaKishorM:mainfrom
devloper961-maker:fix/p95-nearest-rank
Closed

devloper961-maker wants to merge 1 commit into
NandhaKishorM:mainfrom
devloper961-maker:fix/p95-nearest-rank

Conversation

@devloper961-maker

Copy link
Copy Markdown
Contributor

Fixes #680

The bug

_percentiles() in laya/evals.py indexed the p95 as:

ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]

int(n * 0.95) is the nearest-rank ceil(0.95 * n) - 1 only when 0.95 * n is not whole. At every multiple of 20 the product is exact, so the rank came out one too high. Reproduced on main (0.3.21) before the fix:

from laya.evals import _percentiles
_percentiles([float(i) for i in range(1, 21)])[1]   # 20.0, nearest-rank says 19.0
_percentiles([float(i) for i in range(1, 41)])[1]   # 39.0, nearest-rank says 38.0

Concretely: for 20 request waits of 1..20 ms, the report claimed latency_p95_ms = 20 where the 19th smallest — the nearest-rank p95 the docstring promises — is 19.

The min(len - 1, ...) clamp only stopped the index running past the last element at n = 20; it did nothing about the off-by-one inside the range (and at any n where the product exceeds n - 1, the clamp additionally returned the maximum, not the p95).

The fix

ordered[math.ceil(len(ordered) * 0.95) - 1]

ceil(0.95 * n) - 1 is always in [0, n - 1] (n ≥ 1), so the clamp disappears along with the bug — math joins the existing stdlib imports. Applies to both call sites, latency_p95_ms and cost_per_decision_p95_ms.

Regression test

test_p95_is_nearest_rank_when_the_sample_size_is_a_multiple_of_20 in tests/test_evals.py, next to the other timing tests: n = 20 → 19.0 and n = 40 → 38.0 (the exact-boundary cases), n = 21 → 20.0 (rank unchanged away from the boundary), plus n = 1 and n = 2 ends.

Verification

repro on main before fix        n=20 -> 20.0, n=40 -> 39.0 (wrong)
repro after fix                 n=20 -> 19.0, n=40 -> 38.0 (nearest-rank)
python -m pytest tests/test_evals.py -q   59 passed (the CI evals lane)
python tests/test_evals_api.py            12 passed, 0 failed
python tests/test_evals_onnx.py           20 passed, 0 failed
ruff check (CI's selection)               All checks passed!
python -m compileall laya/ tests/         OK

🤖 Generated with Codebuff
Co-Authored-By: Codebuff [email protected]

…size is a multiple of 20

_percentiles() indexed the p95 with int(n * 0.95), which equals
ceil(0.95 * n) - 1 only when 0.95 * n is not whole. At every multiple
of 20 the product is exact, so the rank came out one too high: for the
waits 1..20 ms the report said p95 = 20 ms where nearest-rank -- what
the docstring promises -- is the 19th smallest, 19 ms. The clamp on top
of it only masked the overflow past the last element, not the off-by-one
inside the range.

The index is now math.ceil(len * 0.95) - 1, which is always inside
[0, n - 1], so the min() clamp goes away with the bug.

Fixes NandhaKishorM#680.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
@devloper961-maker

Copy link
Copy Markdown
Contributor Author

Triage for the red dependency CVEs check: it is the pre-existing #645 failure, not this change. The branch is based on main @ 9d95567, before the audit split in #694, so this PR runs the old single-scope job — same "Found 7 known vulnerabilities in 3 packages" (json-repair / chromadb / nltk, all optional-extra transitives) that fails on main itself and on every open PR, including #693. #694 fixes the job; once that merges, rebasing this branch will turn the check green. The evals suites here are the ones that exercise this change, and they are green.

@Bruce-Yii Bruce-Yii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging an overlap: this and #682 are the same fix, and the two of you will conflict if both are merged.

Both compute exact nearest-rank and both are correct. I checked yours independently against a reference written from the definition (exact rational ceil(95n/100), so the expected value never depends on float behaviour), swept to N=2,000,000: ordered[math.ceil(len(ordered) * 0.95) - 1] gives 0 disagreements, and so does #682's (95 * n + 99) // 100. Yours drops the now-unnecessary min(len-1, …) clamp, which is fine — ceil(0.95n) <= n for every n >= 1, so the index is always valid and there's no new IndexError path. The two differ in style, not behaviour; the float form is a little more readable, the integer form doesn't depend on how 0.95 rounds.

The failure mode matters more than the style question. git merge-tree reports a content conflict in laya/evals.py but auto-merges tests/test_evals.py — so a naive resolution lands both test_p95_is_nearest_rank_when_the_sample_size_is_a_multiple_of_20 and #682's test_percentiles_use_nearest_rank, testing the same thing twice. I resolved to your side and ran the artifact: 0 mismatches to N=3000, 60 passed. Correct, just carrying a redundant test.

The thing that actually differs is the close keyword, and it's in your favour. Yours says Fixes #680; #682 has no close keyword, so #680 stays open if #682 lands. #680 is a real report of this bug, not a self-filed duplicate, and it is distinct from the already-merged #592 (which only introduced the _percentiles helper — the off-by-one was there in the helper from the start). So landing this one closes it; landing #682 does not.

Coverage-wise it's close to a tie rather than one being strictly better. You uniquely pin n=21→20, which specifically guards against over-correcting to always n-1, plus n=2 and n=1. #682 uniquely pins n=100→95, n=30→29 and n=50→48, and a median assertion. If you'd rather take #682's implementation, the clean move is to take its test cases into this PR so the broader sizes are covered, and keep the close keyword here.

One nit on the description: it's truncated mid-sentence at "When \ is a multiple of 20, " — worth reposting in full.

@NandhaKishorM

Copy link
Copy Markdown
Owner

Merged #682 first for the nearest-rank p95 fix. Closing this as duplicate. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(evals): 95th percentile is off by one when N is a multiple of 20

3 participants