Skip to content

P3-07: the calibration stack — calib-only temperature scaling + Saerens/Elkan prior-shift - #100

Merged
bioedca merged 3 commits into
mainfrom
p3-07-calibration-stack
Aug 3, 2026
Merged

bioedca merged 3 commits into
mainfrom
p3-07-calibration-stack

Conversation

@bioedca

@bioedca bioedca commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Implements imp.md step P3-07 — the ADR-0005 D11 recalibration stack for the Stage-2 binary head. LOCAL only: no GPU-h, no SLURM, no DVC artifact.

What lands

src/tbox_finder/calib/recalibrate.pytemperature_scale, prior_shift, calibrated_posterior, plus assign_rung / rung_labels / prior_from_odds_ratio / deployment_prior_in_prd_band. numpy-only (no scipy, no sklearn, no torch), so the whole unit tier runs in CI's stack as-is.

D11 pins the stack order and which posterior is graded: GATE-2's in-distribution ECE is measured on the named posterior — temperature-scaled, before the prior shift. calibrated_posterior therefore returns both, with gated_posterior_key naming the gated one inside the payload so a downstream report cannot quietly grade the wrong object.

One temperature fit, not two

Stage 2's head is a single logit. Stacking it as [0, z] makes P2-13's multi-class calib/temperature.py::fit_temperature identically the binary fit, since softmax([0, βz])[1] = σ(βz) — the same reduction stage2/losses.py already uses to route the binary term through the audited focal-CE kernel. The adapter changes inputs, never arithmetic, so P2-13's two degenerate-limit refusals (perfect separation, worse-than-uniform) carry through unchanged.

Delegating makes "A agrees with B" a tautology, so the primary test is neither: for z=[+a,+a,−a], y=[1,0,0] the stationarity condition collapses to 3σ(βa)=2, giving β* = ln2/a exactly. Backed by a second root at a second scale (4σ=3), a ternary search in the test that minimises the value rather than rooting the derivative, and a manufactured-miscalibration draw recovering T ≈ 2.5 from 20k rows. The delegation check is present and labelled secondary.

"T fit only on calib, never test" — structural, not asserted

temperature_scale takes the whole table plus a per-row rung label and selects the calib rows itself, so no argument admits a graded row to the fit. Around that:

  • empty selection raises with the full census — an all-False calib column filters clean and fits on nothing, which reads exactly like a working filter;
  • unknown rung token raises rather than being skipped — a silently vanished row shrinks the calibration set while every count still looks fine;
  • single-class calib rung raises;
  • assign_rung re-derives the P3-02 invariant (a calib row lives in the train rung) and tests missingness with masking.is_missing, because bool(float("nan")) is True and pandas 3 delivers nulls as NaN — the naive guard would promote a row with no calibration flag into the fit.

Fixtures are asymmetric (7 calib / 4 train / 3 val / 5 test) with both the calib and test arms independently fittable, so the inversion test runs both ways: swapping the senses is asserted to yield exactly the test arm's own T. A balanced, count-only fixture would refuse the right number of the wrong rows and pass.

Two numbers this step declines to produce

  1. No deployment prior is pinned. ~10³–10⁴:1 is prose only (PRD.md:188, ADR-0005:80); nothing in the repo encodes a value (priors.py is the unrelated union novelty prior). Pinning a scalar would be a new blinded-frozen default needing ADR-0005 re-sign-off, so both priors are required kwargs with no defaults, a half-specified shift raises, and DEPLOYMENT_PRIOR_RANGE is reporting-only — with the predicate shown to discriminate, since ADR-0005 D7's own 100:1 benchmark prevalence must fall outside it.
  2. No T is fitted. Nothing in the repo reads a Stage-2 checkpoint back (stage2_heads.pt / lora_adapter/ have write sites and zero readers; train.py::evaluate discards per-row logits), so real calib-split logits do not exist yet. This ships and certifies the machinery; the score producer, and the first real T, belong to P3-08/P3-10. A T invented to fill the gap would look exactly like a measured one.

Path drift (recorded in imp.md)

imp.md named src/tbox_finder/calibration/recalibrate.py; it lands at src/tbox_finder/calib/recalibrate.py — the calib/ package already exists, and two near-homonymous calibration namespaces is a permanent import confusion. That block's sibling calibration/ paths already drifted on their own (P3-09's calibration/ece.py::binned_ece shipped as metrics.py::binned_ece at P0-31). Same shape as the recorded P3-02 correction.

Validation

  • tests/unit/test_recalibrate.py 108 pass; test_temperature 81 pass, unchanged.
  • ruff 0.15.15 + black 25.11.0 clean.
  • 15 source sabotages, each RED against its NAMED test, each restored byte-identically — fold sense inverted; emptiness guard removed; unknown-token refusal removed; is_missing weakened to is None; P3-02 invariant dropped; shift direction inverted; + flipped to ; gated key repointed at the shifted posterior; a deployment prior given a default; half-specified guard removed; PRD band widened to swallow D7's 100:1; branch-wise sigmoid replaced by the overflowing one; single-class guard removed; priors of exactly 0/1 admitted; prior_from_odds_ratio inverted.

No ADR amendment and no §7 sign-off taken: D11 pins the stack order and the named posterior, both implemented verbatim, and the one value that would have needed a pin is the one the module refuses to supply.

Summary by CodeRabbit

  • New Features

    • Added a complete recalibration workflow for binary predictions, including temperature scaling and optional deployment-prior adjustment.
    • Provides both gated and prior-shifted posterior probability outputs.
    • Adds calibration-rung labeling and reporting for clearer calibration coverage.
    • Supports stable odds, probability, and prior conversions.
  • Bug Fixes

    • Added validation for invalid, missing, empty, or single-class calibration data and unsupported prior values.

…ng + Saerens/Elkan prior-shift

Inputs:  calib/temperature.py::{fit_temperature, apply_temperature, STACK_ORDER} (P2-13);
         the P3-02 `calib` column contract; stage2/model.py "tbox_logit" (B,) raw 1-logit head.
Outputs: src/tbox_finder/calib/recalibrate.py::{temperature_scale, prior_shift,
         calibrated_posterior, assign_rung, rung_labels, prior_from_odds_ratio,
         deployment_prior_in_prd_band}; tests/unit/test_recalibrate.py; dev-log stanza.

ADR-0005 D11 pins the stack order (train -> temperature-scale -> prior-shift) and, more
consequentially, which posterior is graded: GATE-2's in-distribution ECE is measured on the
NAMED posterior, temperature-scaled and PRE prior-shift. calibrated_posterior therefore
returns both, with gated_posterior_key naming the former inside the payload.

One temperature fit, not two. Stage 2's head is a single logit, and stacking it as [0, z]
makes P2-13's multi-class fit identically the binary one (softmax([0, bz])[1] = sigmoid(bz)) —
the same reduction stage2/losses.py already uses for the binary term. The adapter changes
inputs, never arithmetic, so P2-13's degenerate-limit refusals carry through. Delegation makes
"A agrees with B" a tautology, so the primary test is a hand-derived closed form:
z=[+a,+a,-a], y=[1,0,0] => 3*sigmoid(b*a)=2 => b* = ln2/a exactly, plus a second root at a
second scale, a ternary search in the test (minimises the value, not the derivative's root),
and a manufactured-miscalibration recovery of a known T.

"T fit only on calib, never test" is structural: temperature_scale takes the whole table plus
a per-row rung and selects the calib rows itself. Around it — an emptiness raise (an
all-False calib column filters clean and fits on nothing, which reads exactly like a working
filter), an unknown-rung-token raise (a vanished row shrinks the set while the counts look
fine), a single-class raise, and assign_rung re-deriving the P3-02 invariant with
masking.is_missing (bool(nan) is True, and pandas 3 delivers nulls as NaN). Fixtures are
asymmetric 7/4/3/5 and assert identity both ways round.

No deployment prior is pinned and none is invented: ~10^3-10^4:1 is PRD prose only, so both
priors are required kwargs with no defaults, a half-specified shift raises, and the PRD band
is reporting-only (shown to discriminate — D7's own 100:1 falls outside it).

No T is fitted here: nothing in the repo reads a Stage-2 checkpoint back, so real calib-split
logits do not exist yet. The score producer belongs to P3-08/P3-10.

Path drift recorded in imp.md: calib/recalibrate.py, not calibration/recalibrate.py — the
calib package already exists and the block's sibling calibration/ paths drifted at P0-31 too.

Validation: 108/108 unit (test_recalibrate), test_temperature 81 unchanged; ruff 0.15.15 +
black 25.11.0 clean; 15 source sabotages, each RED against its NAMED test, each restored
byte-identically. No ADR amendment, no §7 sign-off: D11 is implemented verbatim and the one
value that would need a pin is the one the module refuses to supply.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8538d56d-f71e-4946-8884-50c150a84deb

📥 Commits

Reviewing files that changed from the base of the PR and between 88879fd and 23f9342.

⛔ Files ignored due to path filters (1)
  • analyses/phase3_log.qmd is excluded by !**/*.qmd
📒 Files selected for processing (5)
  • src/tbox_finder/calib/recalibrate.py
  • src/tbox_finder/masking.py
  • src/tbox_finder/stage2/heads.py
  • src/tbox_finder/stage2/train.py
  • tests/unit/test_recalibrate.py
📝 Walkthrough

Walkthrough

The PR adds the Stage-2 recalibration stack. It derives calibration rungs, fits temperature on calib rows, applies optional prior shifting, returns two posterior outputs, and exposes validation and metadata APIs with comprehensive unit tests.

Changes

Stage-2 recalibration

Layer / File(s) Summary
Recalibration contracts and numeric primitives
src/tbox_finder/calib/__init__.py, src/tbox_finder/calib/recalibrate.py
The package exports recalibrate. The module defines stack metadata, result metadata, prior conversions, stable posterior conversion, and log-odds correction.
Calibration rung selection and temperature fitting
src/tbox_finder/calib/recalibrate.py, tests/unit/test_recalibrate.py
The stack derives rung labels, selects only calib rows, validates fitting data, delegates temperature fitting, and records rung counts. Tests cover fitting, determinism, validation, and numerical stability.
Prior-shifted posterior production
src/tbox_finder/calib/recalibrate.py, tests/unit/test_recalibrate.py
The producer returns the gated pre-shift posterior and optional prior-shifted posterior. Tests verify formulas, metadata, prior requirements, boundaries, and stack ordering.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant recalibrate
  participant temperature_scale
  participant prior_shift
  participant PosteriorOutput
  Caller->>recalibrate: provide logits, labels, rungs, and optional priors
  recalibrate->>temperature_scale: fit temperature on calib rows
  temperature_scale-->>recalibrate: return fitted temperature and metadata
  recalibrate->>prior_shift: apply prior correction when both priors exist
  prior_shift-->>recalibrate: return shifted posterior
  recalibrate->>PosteriorOutput: return gated and shifted posterior outputs
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the calibration stack, calibration-only temperature scaling, and Saerens/Elkan prior shifting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch p3-07-calibration-stack

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/tbox_finder/calib/recalibrate.py (1)

104-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

STEP, GENERATED_BY, and ADR are defined but never read.

No function in this module reads these three constants, and they are absent from __all__ and from the calibrated_posterior payload. The payload already carries stack_order and gated_posterior_key for provenance, so add these fields to it, or drop the constants. The test module docstring names this exact pattern ([[pinned-constant-that-nothing-reads]]).

♻️ Option: carry the provenance into the payload
     payload: dict[str, Any] = {
+        "step": STEP,
+        "generated_by": GENERATED_BY,
+        "adr": ADR,
         "stack_order": list(STACK_ORDER),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tbox_finder/calib/recalibrate.py` around lines 104 - 106, Update the
calibrated_posterior payload construction to include the existing STEP,
GENERATED_BY, and ADR constants as provenance fields, alongside stack_order and
gated_posterior_key; ensure the constants are actually read by that payload
rather than removing them.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tbox_finder/calib/recalibrate.py`:
- Around line 371-376: Validate label integrality on the original y values
before the astype(np.int64, copy=False) conversion in the recalibration flow.
Reject fractional and non-finite labels with the existing validation error path,
then perform the int64 cast and retain the binary 0/1 range check for integral
values.

---

Nitpick comments:
In `@src/tbox_finder/calib/recalibrate.py`:
- Around line 104-106: Update the calibrated_posterior payload construction to
include the existing STEP, GENERATED_BY, and ADR constants as provenance fields,
alongside stack_order and gated_posterior_key; ensure the constants are actually
read by that payload rather than removing them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 761adeef-b560-4ab1-b3ab-32eca4da6fad

📥 Commits

Reviewing files that changed from the base of the PR and between e05b9ae and 88879fd.

⛔ Files ignored due to path filters (1)
  • analyses/phase3_log.qmd is excluded by !**/*.qmd
📒 Files selected for processing (3)
  • src/tbox_finder/calib/__init__.py
  • src/tbox_finder/calib/recalibrate.py
  • tests/unit/test_recalibrate.py

Comment thread src/tbox_finder/calib/recalibrate.py
…oolean parse instead of forking it

CodeRabbit r1 on 88879fd (1 actionable + 1 nitpick) plus a 44-agent adversarial pass
(20 findings, 10 surviving two independent refuters), deduplicated to six fixes. Both
sources independently found the same defect at the int64 cast.

1. The "labels must be binary 0/1" guard was VACUOUS: it ran after astype(np.int64),
   which truncates toward zero. Demonstrated — temperature_scale(z=[1,1,-1],
   y=[1.0,0.4,0.6]) SUCCEEDS and returns T bit-identical to the fit for [1,0,0]; a
   target written as 0.6 was silently scored a negative. A NaN label cast to the int64
   sentinel and surfaced as "range [-9223372036854775808, 1]". The domain check now
   happens in the original dtype: bool/int pass, floats must be finite and integral,
   any other dtype is refused by name.

2. bool(calib) closed the NaN hole and left the string hole open. The guard exists
   BECAUSE pandas 3 delivers a null as NaN and bool(nan) is True; one step out,
   bool("False") and bool("0") are also True, so a string-typed calib column would
   promote its explicit negatives into the set T is fitted on with every count
   agreeing. Reproduced: rung_labels(calib=["True","False",...]) returned ["calib"]*6.

   The fix was already in the repo. stage2/train.py::_bool_or_none is exactly this
   parse and is documented as also reading `calib`, but cannot be imported here (that
   module is torch-adjacent; this one is deliberately numpy-only). So it is PROMOTED
   DOWN to masking.py, beside the is_missing/row_text it completes, with NULL_TOKENS
   moving with it. stage2.heads.NULL_TOKENS is now a re-export and
   stage2.train._bool_or_none a delegation: one vocabulary, one parse, three call
   sites. A fourth copy would have been the cheaper diff and the wrong one.

3. The single-class guard is a disjunct (n_pos == 0 or n_pos == n_fitted) and only the
   all-positive half was tested; with the other half deleted, an all-negative calib
   carve returns a cleanly converged, fully certified T. Both halves now parametrized.
4. test_shape_and_domain_violations_raise used a bare pytest.raises(ValueError) — both
   of this module's guards could be deleted and it stayed green, because the delegated
   fitter raises its own error further in. Now matches this module's own text.
5. The empty-calib refusal asserted only its first clause, leaving the census and the
   GRADED_RUNGS count (the only place that constant is read) unchecked.
6. The payload's source_prior/target_prior were write-only: swapping the two
   assignments left the suite green while recording the deployment prior as the calib
   prevalence. Also carries step/generated_by/adr now (CodeRabbit nitpick: three
   constants nothing read).

Citation: "Elkan 2001, Theorem 1" -> Elkan 2001 with no theorem number. A reviewer put
the base-rate result at Theorem 2; the numbering could not be verified from here, and a
pointer to the wrong theorem is worse than one to the paper.

Validation: test_recalibrate 138 pass (was 108); test_temperature 81; masking + heads +
stage2_train_smoke 411 pass together (the promotion is behaviour-preserving on both
sides). Full unit+golden+ml: the worktree's 14 failures are a strict SUBSET of the 31
untouched `main` produces in this local runner (py3.11/numpy 2.4.2) — zero regressions.
ruff 0.15.15 + black 25.11.0 clean. 24 source sabotages (up from 15), each RED against
its NAMED test, each restored byte-identically.
…still refuse the neighbours

The GitHub CodeRabbit check read `pass` on a "Review rate limited" body and no review
had run on a721d06 (CLAUDE.md §5.1: a rate-limit notice is an absence, not a pass), so
this round came from rung 1 of the ladder — the CodeRabbit CLI, scoped with
--committed --base-commit 88879fd. One minor finding, and it is correct.

masking.bool_or_none stringifies before matching, so a float flag becomes "1.0"/"0.0",
matches no spelling, and raises. That reads like fail-closed behaviour on a schema
fault until the shape is checked: pd.Series([True, None, False]).astype(float) and a
`boolean`-dtype cast to float64 BOTH produce 1.0 / nan / 0.0. The NaN was already the
missing case; the 1.0 and 0.0 beside it are a well-formed nullable-boolean column, and
refusing them aborts a legitimate run.

The parse now accepts a finite Real equal to exactly 0 or 1 before the text path. 0.5,
1.5, -0.5, 2 and -1 still raise — those are schema faults and the widening does not
touch them.

This is inherited behaviour, not new: stage2/train.py::_bool_or_none has had it since
P3-06, and it only became visible because r1 moved the parse to a shared home where a
reviewer read it. Fixing it once fixes it for the fold readers too — the argument for
promoting rather than forking, paying off one round later.

Validation: test_recalibrate 146 pass; masking + heads + stage2_dataset +
stage2_train_smoke + temperature + no_leakage 502 pass beside it. Full unit+golden+ml:
14 failures, still a strict subset of main's 31 in this local runner (comm -13 over the
two ^FAILED sets is empty). ruff + black clean. Two more sabotages, both bitten and in
both directions: removing the acceptance reddens the float test, and widening it to any
finite real (0.5 -> True) reddens the refusal test.
@bioedca
bioedca merged commit a4c075b into main Aug 3, 2026
9 checks passed
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.

2 participants