fix: asymmetric quantile score cancels the calibration-size guard - #978
Conversation
`QuantileRegressionScore.get_signed_conformity_scores` returns one row per side, of shape (2, n_samples). `BaseRegressionScore.get_effective_calibration_ samples` counts non-NaN entries and halves the result for asymmetric scores, so the two-row layout returned 2n // 2 == n. The halving that accounts for each side being calibrated at `alpha / 2` was exactly cancelled. `_check_alpha_and_n_samples` therefore validated `alpha` against n instead of n // 2, and accepted calibration sets in which the requested order statistic does not exist. `_compute_regression_quantile` then clipped the corrected level to 1.0 and both bounds fell back to the extreme order statistic, with no warning. `allow_infinite_bounds=True` did not help either: the `unbounded` escape tests `alpha_ref >= 1`, not the corrected level. At n = 15 and alpha = 0.1 the lower side asks for order statistic ceil(0.95 * 16) = 16 out of 15 available; isolating the conformal step on exchangeable draws measured 0.8725 two-sided coverage against a requested 0.90. `SplitConformalRegressor` with `AbsoluteConformityScore(sym=False)` already refuses the same n, alpha and alpha/2 split. Reading the count from a single row restores the halving, so an asymmetric quantile score now reports the same effective sample count as every other asymmetric score. The default `AbsoluteQuantileRegressionScore` is symmetric and reduces both rows to a single one-dimensional score, so it is unaffected. The deeper fix — raising or returning +inf instead of clipping in `_compute_regression_quantile` — is tracked in scikit-learn-contrib#974 and out of scope here.
The score is `max(y_pred_lower - y, y - y_pred_upper)`, a single distribution
of absolute distances that calibrates both bounds, so it is symmetric by
construction. `sym` was settable anyway, and `_check_score` accepts any
`QuantileRegressionScore` subclass, so `AbsoluteQuantileRegressionScore(sym=
False)` reached the asymmetric branch of `get_bounds`. That branch expects one
signed distribution per side; given a single non-negative one it takes the
`beta` quantile for the lower bound and adds it, instead of subtracting a
`1 - alpha` quantile.
Measured on 200 calibration samples at a 0.9 confidence level, with symmetric
Gaussian noise:
sym=True coverage=0.9375 width=3.8130
sym=False coverage=0.9834 width=5.4773
Conservative rather than under-covering, but 44% wider for no gain, and
lopsided — the lower bound widens by 1.3533 against 0.3110 above, an artefact
of the branch rather than of the data.
Both scores are new in scikit-learn-contrib#958 and unreleased as of v1.4.1, so no released
configuration can break. `QuantileRegressionScore` keeps `sym` for callers who
want the asymmetric variant.
| np.zeros(n_calib) | ||
| ) | ||
|
|
||
| assert asymmetric == n_calib // 2 |
| ) | ||
|
|
||
| assert asymmetric == n_calib // 2 | ||
| assert symmetric == n_calib |
scikit-learn-contrib#958 documented none of its own changes, so `CrossConformalizedQuantileRegressor` and the two new quantile conformity scores had no entry. Adds one for them and one for each fix in this branch. The 27 duplicated entries scikit-learn-contrib#958 also introduced are removed separately in scikit-learn-contrib#979, to keep this branch to the quantile scores.
85d76f8 to
0482140
Compare
|
Hi @shivamlalakiya — following on from my comment on #973, same disclaimer: outside contributor, so this is a comment and not a review. @allglc / @GBrelurut own the call. I checked commit 1 rather than read it, and it holds up. Two things to add: a closed form that I think can replace the simulation, and a docstring defect sitting right next to the code being fixed. Commit 1 verifiedThe cancellation is exactly as described. return np.vstack((y - y_pred[0], y - y_pred[1]))so the array is I reproduced your coverage numbers by reimplementing the asymmetric branch of
Same picture, within Monte Carlo error. The clip-window coverage has a closed formWith fixed quantile predictions — your setup — the clipped case collapses analytically. When
Both distribution-free, no simulation required. At It also gives your threshold directly: which is the "correctness needs n >=" column of your table, derived rather than tabulated. I checked the smallest Worth swapping in, I think. A 200k-trial simulation invites "is the harness right?", whereas The caveat is that this is exact only for fixed predictions. With fitted quantile regressors the bounds move and you would be back to simulation — but that is equally true of the numbers already in the description. Docstring defect in the code being fixed
All three are wrong. The code indexes This seems in scope: the bug you are fixing is a misreading of that exact row layout, and commit 1 adds a docstring that correctly says On commit 2No opinion on removing Thanks for the detailed write-up — the review-thread permalinks and the split into independent commits made this easy to check piece by piece. Analysis assisted by Claude (Claude Code); I verified the numbers and reasoning. |
`get_signed_conformity_scores` documented `y` as `(2, n_samples)`, `y_pred` as `(n_samples,)` and the return as `(n_samples, 2)`. All three are inverted: the method indexes `y_pred[0]` and `y_pred[1]` and vstacks the two rows, so `y_pred` is the two-row array, `y` is one-dimensional and the return is `(2, n_samples)`. Passing the documented shapes `y (2, 6)`, `y_pred (6,)` returns `(4, 6)`; the real shapes return `(2, 6)`. The row layout is exactly what the effective-calibration-sample fix in 14e0e07 turns on, and that commit added a docstring on `get_effective_calibration_samples` correctly describing the scores as `(2, n_samples)` — directly below one asserting the opposite.
…e-effective-calibration-samples # Conflicts: # AUTHORS.md # HISTORY.md
|
Thanks @LEDazzio01 — both points taken, and both are now in the branch. The closed form replaces the simulation. You're right that Two things I checked while adopting it, one of which widens the claim: Both sides really do clip together, which the degeneracy argument needs. It isn't obvious from the code — The degeneracy region is wider than the clip window. The interval is
So the bug window is the intersection of the last two columns — the table in the description is unchanged — but for The 1,000,000-trial check lands on the closed form: The docstring is fixed in 5b9e467, as a separate commit. Confirmed your reproduction — documented shapes Also merged current Analysis assisted by Claude (Claude Code); every number above was produced by executing the code and I verified them. |
Matches the entry added on the branch for scikit-learn-contrib#973, so the two do not conflict when both land.
|
@GBrelurut this change is about your cross-CQR PR, I will let you check it it makes sense. Also maybe check the comments on the cross-CQR PR discussion. |
Description
Follow-up to #958, which merged with a review still open. This picks up the blocking finding from that review and the smaller ones that came with it. All of them are still live on
master, and all concern code that is new in #958 and unreleased as of v1.4.1, so nothing here can break an existing configuration.Review threads: guard, suggested fix,
sym, HISTORY.md, test range.Four independent commits, so any one of them can be dropped without touching the others. The HISTORY.md de-duplication that came out of the same review is split off into #979, since it shares nothing with this code.
1.
QuantileRegressionScorecancels the calibration-size guard (14e0e072)get_signed_conformity_scoresreturns one row per side, of shape(2, n_samples).BaseRegressionScore.get_effective_calibration_samplescounts non-NaN entries and halves the result for asymmetric scores:so the two-row layout returns
2n // 2 == n. The halving that accounts for each side being calibrated atalpha / 2is exactly cancelled, and_check_alpha_and_n_samplesvalidatesalphaagainstnrather thann // 2.What actually happens below the threshold
Both sides of the asymmetric branch resolve the same reference level:
alpha_low = beta = alpha / 2withreverse=Truegivesalpha_ref = 1 - alpha / 2, andalpha_up = 1 - alpha + beta = 1 - alpha / 2. So both clip together, as soon asWhen they do, and for fixed quantile predictions
q_lo,q_hi, the interval collapses analytically:signed = +1, level 1, so the quantile ismax(y_cal - q_hi)andbound_up = q_hi + max(y_cal - q_hi) = max(y_cal)signed = -1, level 1, so the quantile ismin(y_cal - q_lo)andbound_low = q_lo + min(y_cal - q_lo) = min(y_cal)q_loandq_hicancel. The interval degenerates exactly to[min(y_cal), max(y_cal)]— the range of the calibration sample. For exchangeable continuous draws that gives, distribution-free and without simulation,which is below the requested
1 - alphaexactly whenThat is the correctness column below, derived rather than tabulated. Note the degeneracy region is wider than the bug: for
19 <= n <= 38atalpha = 0.1the interval is still the calibration range, but(n-1)/(n+1) >= 0.9there, so it is merely conservative. The bug is the sub-region where the guard passes and the closed form falls short:== [min, max]for n <=Verified two ways. Structurally: running the real
_compute_regression_quantilethrough the asymmetric branch with fixed predictions,bound_low == min(y_cal)andbound_up == max(y_cal)holds for everynthe level condition predicts and fails at the firstnpast it, on all fouralpha. Numerically, 1,000,000 trials of standard normaly:matching the end-to-end figures in the original write-up of this PR (0.8750, 0.8939, 0.9005, 0.9043) and the independent reproduction by @LEDazzio01, who suggested the closed form. It is exact only for fixed quantile predictions; with fitted quantile regressors the bounds move and the statement reverts to a simulated one — but that was equally true of the simulation it replaces.
allow_infinite_bounds=Truedoes not help either: theunboundedescape testsalpha_ref >= 1, not the corrected level. Atn = 15,alpha = 0.1the lower side asks for order statisticceil(0.95 * 16) = 16out of 15 available.SplitConformalRegressorwithAbsoluteConformityScore(sym=False)already refuses the samen, the samealpha, and the samealpha / 2split.The fix reads the count from a single row, so an asymmetric quantile score now reports the same effective sample count as every other asymmetric score in MAPIE. That makes the guard reject
n <= 19atalpha = 0.1— one sample more conservative than the exact requirementn >= 19, which is the same slackAbsoluteConformityScore(sym=False)already carries, rather than a new rule.The dimension check is load-bearing rather than defensive:
AbsoluteQuantileRegressionScoreinherits the method and reduces both rows to a single one-dimensional score, so the default path must keep reading the array whole. That path is symmetric and unchanged.The deeper fix — raising or returning
+infinstead of clipping in_compute_regression_quantile— is #974 and out of scope here.2.
symis not a knob onAbsoluteQuantileRegressionScore(44c4915a)The score is
max(y_pred_lower - y, y - y_pred_upper), a single distribution of absolute distances calibrating both bounds, so it is symmetric by construction.symwas settable anyway, and_check_scoreaccepts anyQuantileRegressionScoresubclass, soAbsoluteQuantileRegressionScore(sym=False)reached the asymmetric branch ofget_bounds. That branch expects one signed distribution per side; given a single non-negative one it takes thebetaquantile for the lower bound and adds it, instead of subtracting a1 - alphaquantile.Measured on 200 calibration samples at a 0.9 confidence level, with symmetric Gaussian noise:
Conservative rather than under-covering, but 44% wider for no gain, and lopsided — the lower bound widens by 1.3533 against 0.3110 above, an artefact of the branch rather than of the data.
QuantileRegressionScorekeepssymfor callers who want the asymmetric variant.Happy to swap this for an explicit
ValueErroronsym=Falseif you would rather keep the argument in the signature.3. HISTORY.md (
0482140b)#958 documented none of its own changes, so this commit originally added an entry for
CrossConformalizedQuantileRegressorand the two new quantile conformity scores, plus one for each fix above.Since then the v1.5.0 pre-release (60031ac) restructured
HISTORY.md, opening an empty1.x.xsection and writing its ownCrossConformalizedQuantileRegressorentry under1.5.0. On merging that in I dropped my now-redundant feature entry and kept only the two entries for the fixes in this PR, both under1.x.x. The same restructure appears to supersede #979, which I will close unless a maintainer sees something left in it.4. Inverted shapes in the
get_signed_conformity_scoresdocstring (5b9e4675)Flagged by @LEDazzio01 in the same comment.
QuantileRegressionScore.get_signed_conformity_scoresdocumentedyas(2, n_samples),y_predas(n_samples,)and the return as(n_samples, 2). All three are inverted — the method indexesy_pred[0]andy_pred[1]and vstacks the rows:In scope because that row layout is precisely what commit 1 turns on, and commit 1 adds a docstring on
get_effective_calibration_samplescorrectly describing the scores as(2, n_samples)— immediately below one asserting the opposite.Type of change
Removing
symfromAbsoluteQuantileRegressionScore.__init__is technically a signature change, but the class is new in #958 and absent from v1.4.1, so no released configuration can start raising on upgrade and no deprecation cycle is needed.How Has This Been Tested?
Two new tests in
mapie/tests/test_quantile_regression.py, both of which fail onmasterand pass with commit 1:test_effective_calibration_samples_are_counted_per_side— pins the arithmetic: a(2, n)asymmetric layout must report the same effective count as a one-dimensional asymmetric score for the samen. Before:15; after:7, matchingAbsoluteConformityScore(sym=False).test_asymmetric_score_rejects_a_calibration_set_too_small_for_alpha_over_two— pins the user-visible behaviour end-to-end atn = 15,confidence_level = 0.9. Before: a finite interval built on a clipped level; after:ValueError.The range is the point here. Every calibration set in #958's new tests sits well outside the silent-clip windows above —
_asymmetric_scoresuses 500,test_asymmetric_bounds_split_the_miscoverage_between_both_sidesuses 5000, the end-to-end tests useX[:100]— which is why this fix moves no existing test result across the +2118 lines that PR added to the file.pytest mapie/tests/test_quantile_regression.py mapie/tests/test_conformity_scores_bounds.py→304 passed.make coverageexits clean, 100% coverage, withmapie/conformity_scores/bounds/quantile.pyat 100% and all 4 branches covered._compute_regression_quantilerather than from algebra alone.master(60031ac, the v1.5.0 pre-release) into the branch, resolving theHISTORY.mdandAUTHORS.mdconflicts against the restructured files. Merged rather than rebased so the commit hashes referenced above stay valid — happy to rebase and force-push instead if you prefer a linear history.Checklist
Guidelines
Quality Checks
make lintmake type-check(green in the Quality checks job; on my machine mypy fails inside the numpy stubs for Python 3.13 before reachingmapie— verified clean withmypy mapie --python-version 3.13, which reports the same 5 pre-existing errors asmasterand none in the changed files)make testsmake coveragemake doc— n/a, no changes underdoc/; the only prose changes are class docstrings and HISTORY.mdmake doctest— n/a, no doctest added or changed (--doctest-modulesruns as part ofmake coverageand is green)Contribution Documentation
LLM Usage
I used a Large Language Model (LLM) for this contribution.
I carefully reviewed and verified all LLM-generated content.
LLM used: Claude (Claude Code)
Purpose: Very limited, mechanical use of Claude Code to assist with tracing the conformity-score and quantile call paths, scaffolding the two tests, and drafting the boilerplate for this description. The core analysis and logic were human-driven. Every number here was produced by executing the code on this branch, and every claim was rigorously checked against the failing-before/passing-after tests rather than taken on the model's word.