fix(ceiling): NaN for non-positive split-half reliability, fail loudly on an unsplittable split - #246
Merged
Merged
Conversation
…y on an unsplittable split Spearman-Brown 2r/(1+r) is a reliability correction only for r > 0. Below zero it is a pole rather than a correction, and it was applied unconditionally: r = -0.5 produced a ceiling of -2.0, r = -0.9 produced -18.0, and r = -1 divided by zero, which polars renders as a silent -inf rather than raising. Three metrics in SB_METRICS are sign-unbounded and can land there on a small or degenerate context, which is exactly when a ceiling gets reached for: pearson_delta, de_spearman_sig and de_spearman_lfc_sig. A non-positive split-half reliability means the two halves do not agree at all, i.e. there is no defensible ceiling — so it is now reported as NaN, the same disposition already used for metrics excluded from the correction. A negative number would instead claim a ceiling worse than any achievable score. Null means fall through the same branch. The threshold is 0 for every metric, including pr_auc/roc_auc whose chance baseline is 0.5 rather than 0; a 0.5 floor would be a stricter rule than the one the ceiling was empirically validated under, so below-chance AUCs are still corrected. Noted in the docstring. _disjoint_halves also failed obscurely at its two edges. With no splittable perturbation it reached np.concatenate([]) and surfaced a bare numpy error, and if the control specifically had < 2 cells it was silently dropped and the failure appeared later in the pipeline as a missing control. Both now raise a ValueError that names the cause. Perturbations dropped for having < 2 cells are also counted and logged, since the ceiling is then averaged over a different perturbation set than the main evaluation's aggregate. Docs: - say floor(n/2), and note that an odd n discards the leftover cell (both halves must be the same depth for the doubling to hold) - record the cost: ~2x peak memory for the two half copies on top of the loaded pair, and ~2x wall time since DE is computed for both halves - note in the --ceiling help text and the docstring that the halves need their own DE, so a precomputed --de-real/--de-pred does not carry over to the ceiling Tests: - pin the guard: r = -1, -0.9, -0.5 and the r = 0 boundary all yield NaN (never a negative ceiling, an inf, or a raise), r > 0 still corrected, null -> NaN - pin both new errors: nothing splittable, and a control with < 2 cells - make the end-to-end ceiling assertion two-sided over every SB metric; the previous one-sided `<= 1.0` admitted a blown-up pole value - drop a stale "best_value == ONE" docstring reference; SB_METRICS is an explicit inclusion list
Contributor
There was a problem hiding this comment.
Code Review
This pull request refines the data ceiling computation by using floor(n/2) cells for disjoint splits, handling edge cases where perturbations or controls have fewer than two cells, and guarding the Spearman-Brown correction to only apply when the measured reliability is greater than zero (returning NaN otherwise to avoid poles). It also adds corresponding unit tests. The reviewer suggested a performance optimization in _disjoint_halves to call .unique() on the perturbation column before casting to strings, which avoids an inefficient O(N) operation on large datasets.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the data ceiling merged in #243. The Spearman-Brown correction was applied unconditionally, so a non-positive split-half reliability produced a negative or infinite "ceiling" instead of no answer, and
_disjoint_halvesfailed obscurely at two edges.No version bump:
0.8.2is unreleased, so these fixes ship as part of it.1. Spearman-Brown below zero
r' = 2r/(1+r)is a reliability correction only forr > 0. Below zero it is a pole, and it was applied to any value:r-0.5-2.0NaN-0.9-18.0NaN-1.0-infNaN0.00.0NaN0.50.6670.667Note the
r = -1case is a silent-inf, not a crash: polars renders float division by zero as infinity rather than raising, so a wrong number reachedagg_ceiling_results.csvwith no error.Three metrics in
SB_METRICSare sign-unbounded and can land there on a small or degenerate context — precisely when someone reaches for a ceiling:pearson_delta,de_spearman_sig,de_spearman_lfc_sig.A non-positive split-half reliability means the halves carry no shared signal, i.e. there is no defensible ceiling — so it is reported as
NaN, the same disposition already used for metrics excluded from the correction. A negative value would instead claim a ceiling worse than any achievable score. Null means fall through the same branch.The threshold is
0for every metric, includingpr_auc/roc_aucwhose chance baseline is 0.5 rather than 0 — a below-chance AUC is still corrected. That is deliberate: a 0.5 floor would be a stricter rule than the one the ceiling was empirically validated under. Recorded in the docstring so it stays a conscious choice.2.
_disjoint_halvesfailed obscurely at both edgesnp.concatenate([])and surfaced a bare numpy error.Both now raise a
ValueErrornaming the cause. Perturbations dropped for having < 2 cells are counted andlogger.warning-ed, since the ceiling is then averaged over a different perturbation set than the main evaluation's aggregate — worth surfacing rather than leaving to be discovered.3. Documentation
floor(n/2)rather thann/2(three places), and an explicit note that an oddndiscards the leftover cell — both halves must be the same depth for the doubling to hold.2xthe real matrix on top of the already-loaded pair, and DE is computed for both halves, so a ceiling run roughly doubles wall time.--ceilinghelp text and docstring now state that the halves need their own DE, so a precomputed--de-real/--de-preddoes not carry over to the ceiling.Tests
r = -1/-0.9/-0.5and ther = 0boundary all yieldNaN— never a negative ceiling, aninf, or a raise;r > 0still corrected; a null mean →NaN.assert np.all(cv <= 1.0 + 1e-9)was one-sided, so a-18.0ceiling passed. Verified the new bound is not vacuous — all four SB metrics in theanndataprofile return positive values, so it is actually exercised.best_value == ONEdocstring reference;SB_METRICSis an explicit inclusion list.Verification
All four CI gates locally:
ruff format --checkclean,cell-eval --helpOK,pytest48 passed.ty checkreports 3 diagnostics, all pre-existing onmain(_baseline.py,_cli/_prep.py, a tutorial notebook) — none in the files touched here, and the count is identical with this branch's changes stashed.