Add conformal prediction: calibrated coverage/risk guarantees for zero-shot NER#374
Open
ALI-AL-MARJANI wants to merge 14 commits into
Open
Add conformal prediction: calibrated coverage/risk guarantees for zero-shot NER#374ALI-AL-MARJANI wants to merge 14 commits into
ALI-AL-MARJANI wants to merge 14 commits into
Conversation
added 14 commits
July 13, 2026 11:29
CLAUDE.md is per-branch persistent agent memory, not part of the eventual PR. docs/archive/ holds the verbatim mission brief for reference. Both excluded from version control.
ROADMAP.md, pruning_adr.md, and results/ belong to the unrelated feature/vocab-pruning-engine and feat/focal-dice-loss-openvino work. They're untracked (physically present since untracked files survive checkout) but irrelevant to conformal-prediction; ignore to keep git status clean on this branch.
Four parallel agent reports on this checkout (vanilla upstream GLiNER, feat/conformal-prediction branched from upstream/main): - repo_map.md: verified span-score tensor shapes for all 6 forward-pass variants, confirmed raw pre-sigmoid scores are reachable via the public run_batch() with zero core-model changes, no existing conformal/calibration code anywhere in the codebase. - theory.md: full-text-verified split-conformal, CRC, and Mondrian theorems with proofs adapted to GLiNER's independent per-(span,type) sigmoid architecture; proves GLiNER's decode rule satisfies CRC's monotonicity condition by construction; establishes that rigorous zero-shot coverage for never-calibrated entity types is not supportable under standard exchangeability (drives design.md). - prior_art.md: license survey of MAPIE/crepes/TorchCP/Fortuna/PUNCC/ nonconformist; confirms no released conformal-NER implementation exists anywhere, closed-set or otherwise. - eval_plan.md: concrete dataset access (CoNLL-2003/WNUT-17/CrossNER via DFKI-SLT/cross_ner to route around datasets' script-loading rejection), split strategy, metrics, and required plots.
Synthesizes the four Phase 0 reports into concrete design decisions: - Ship span_filter (marginal per-entity), risk_control (CRC on missed- entity rate, the flagship compliance/PII mode), and mondrian (class-conditional) guarantee modes. - Guarantees are scoped explicitly to calibration-represented entity types (theory.md's exchangeability finding leaves no rigorous alternative); out-of-calibration types get a loud warning and an uncalibrated fallback, never a silent or blended guarantee. - Full-sequence (2601.16999) and PASC pipeline-joint (2605.18812) modes explicitly descoped for v1, with reasons. - CRC's monotonicity/nesting requirement is proven to hold for GLiNER's decode rule by construction, with one implementation hazard identified: greedy overlap resolution must not run before the nonconformity threshold is applied, or nesting breaks. Design fixes this by defining Cλ on the pre-overlap-resolution candidate set. - Nonconformity score: s = 1 - sigmoid(span_logit), no extra forward pass, no architecture change. - API surface, package layout (gliner/conformal/), calibration data format, and serialization schema specified. - Six open questions posed for HARD STOP urchade#1 sign-off before any implementation code is written.
Implements the design from docs/research/design.md:
- gliner/conformal/scores.py: raw pre-sigmoid span-score extraction,
reusing GLiNER's own prepare_base_input/collate_batch/run_batch (no
core model changes). Scoped to span-mode models only -- verified by
reading every forward() in gliner/modeling/base.py that token-mode/
decoder/relex variants apply `threshold` *inside* the forward pass to
prune candidates, so run_batch()'s output isn't the full candidate
universe for those architectures; raises NotImplementedError rather
than silently mis-calibrating.
- gliner/conformal/calibrators.py: pure-Python split-conformal quantile
(the ceil((n+1)(1-alpha))/n order statistic), CRC lambda search (with
a runtime monotonicity check tied directly to the theory.md proof),
and Mondrian per-type calibration with an explicit floor. Raises
rather than silently degrading below the floor. Synthetically
verified: 20000-trial coverage check lands at 0.9016 +/- 0.0021
against a 0.9 target.
- gliner/conformal/wrapper.py: ConformalGLiNER.{calibrate,
predict_entities, coverage_report, save_calibration, load_calibration}.
Never mutates the wrapped model. Out-of-calibration types get a loud
warning and GLiNER's original uncalibrated p>0.5 behavior, flagged
"calibrated": false on every affected entity -- never silently
blended into a guaranteed-looking number (design.md §5).
Verified end-to-end against gliner-community/gliner_small-v2.5 across
all three modes (span_filter/risk_control/mondrian), including
save/load round-trip and the out-of-calibration warning path.
tests/test_conformal_calibrators.py -- network-free, mirrors test_decoder.py's fixture pattern. Includes a 20000-trial empirical coverage check for split_conformal_quantile (0.9016 +/- 0.0021 against a 0.9 target) and a 200-trial risk-control check for crc_lambda_search. One test caught a real bug in its own first draft (an unrepresentable- entity scenario where the target risk was mathematically unreachable even at lambda=infinity) -- fixed the test, not the code, since returning infinity there is the mathematically correct answer per theory.md's CRC derivation, confirmed by hand before committing. tests/test_conformal_gliner.py -- integration tests against gliner-community/gliner_small-v2.5 (mirrors test_models.py's only network-touching test). Covers all three modes, calibration-floor enforcement and its warning path, the out-of-calibration-type warn+ degrade fallback, empty predictions, save/load round-trip including the model-mismatch warning, coverage_report's shape and its calibration- set "canary" (coverage on the calibration set itself should sit at/above target, since the threshold was tuned to fit exactly that data -- documents why it's not a valid held-out estimate), and rejection of non-span-mode models. 334 pre-existing tests still pass unmodified -- confirms the change is fully additive with zero core-model regressions.
docs/conformal.md: motivation ("stop using threshold=0.5"), quickstart,
the three guarantee modes explained at a practitioner level (full math
in docs/research/theory.md), coverage_report/save/load usage.
Limitations section states plainly, before any usage example gets a
chance to look more authoritative than it is: the guarantee only
covers calibration-represented types (with the exact per-type floor),
this is explicitly NOT a zero-shot guarantee for novel types and why
(pointer to theory.md §vi's exchangeability argument), domain shift
still degrades calibrated-type coverage, mondrian's linear
calibration-data cost, the overlap-resolution-after-filtering design
choice CRC's proof depends on, and the span-mode-only scope with the
concrete reason (threshold pruning inside forward() for other
architectures).
Added to docs/index.md's toctree.
scripts/conformal_validation.py implements docs/research/eval_plan.md's protocol against real data: CoNLL-2003 and WNUT-17 via DFKI-SLT/cross_ner (sidesteps datasets' script-loading rejection), gliner-community/ gliner_small-v2.5. One forward pass per pooled sentence set; all T-trial calibration/test resampling runs on cached scores afterward, not via repeated model calls, so hundreds of trials stay CPU-tractable. Correctly separates calibrated-type coverage (the guaranteed number) from uncalibrated-type coverage (raw p>0.5, descriptive only, no guarantee) per design.md §5 -- an earlier draft blended these for the zero-shot pair, which would have silently produced exactly the misleadingly-reassuring number design.md §0 warns against; caught and fixed before the real run, not after. Disclosed scope: in-domain CoNLL-2003, in-domain WNUT-17, and zero-shot Pair A (CoNLL-2003 -> WNUT-17) from eval_plan.md -- not the full 5-domain CrossNER sweep or Pairs B/C, noted explicitly rather than silently dropped, given CPU-only compute budget. results/ is gitignored (upstream convention); this script is the reproducible source, output committed separately as a text summary.
…isk_control, not the per-sentence rate it actually calibrates risk_control calibrates and guarantees a per-SENTENCE average missed- entity rate (theory.md Eq. 4, the CRC loss). coverage_report was reporting per-entity coverage pooled flat across all sentences instead -- a different quantity whenever gold-entity count varies per sentence (theory.md part ii's "informative m" point), which can show spurious undercoverage unrelated to whether the actual CRC guarantee holds. Found empirically, not by inspection: the first full validation run showed WNUT-17's risk_control coverage undershooting target by up to 4.7 standard deviations while span_filter tracked target closely on the same data. The existing synthetic unit test (test_empirical_risk_control_matches_theory) couldn't have caught this -- its synthetic data happened to have exactly one gold entity per example, which makes per-entity and per-sentence averages coincide. Fix: coverage_report now groups gold entities by source example and reports 1 - mean_per_sentence_miss_rate for risk_control mode, matching exactly what crc_lambda_search calibrated against; span_filter/mondrian keep the pooled per-entity definition, which is correct for what those modes actually guarantee (theory.md iii-a/iii-c). Added a deterministic regression test (test_risk_control_reports_per_sentence_not_per_entity_pooled) using a 1-entity vs 3-entity sentence pair specifically constructed so the two quantities are provably different, so this can't silently regress again. 30 conformal tests pass (was 29); re-ran the empirical validation after this fix -- WNUT-17 risk_control now tracks target (alpha=0.1: 0.8998 vs 0.9, was 0.8316 before the fix).
… the run itself Two real bugs, both found by noticing the empirical numbers didn't match theory and refusing to hand-wave it away (per the mission's own standard): 1. In-domain runs calibrated on CoNLL-2003's official validation split and tested on its official test split as static, separately-sourced pools. Measured coverage undershot target by ~4-5pp at every alpha, both modes, on both datasets -- consistent and far outside sampling noise (~5 std devs at alpha=0.1). Root cause: CoNLL-2003's val/test splits are not fully exchangeable for this model (mean nonconformity 0.22 on validation vs 0.27 on test -- a real property of that benchmark's split construction, not a code bug). eval_plan.md always specified the correct protocol (pool validation+test, draw a fresh random partition every trial); the first implementation had deviated from it. Added trial_metrics(pool_and_resplit=...), applied to in-domain CoNLL/WNUT runs and the calibration-size sensitivity sweep; left disabled for zero-shot Pair A, where keeping the pools separate is the entire point of that experiment. 2. Same bug as gliner/conformal/wrapper.py's coverage_report fix (see that commit): risk_control's reported coverage pooled gold entities flat across sentences instead of averaging the per-sentence miss rate CRC actually calibrates. Confirmed by a targeted diagnostic (calibrate on real CoNLL data, compare pooled-flat coverage against per-sentence-average on the same calibration) before committing to another full run. Also splits calibrated-type coverage from uncalibrated-type coverage (descriptive-only, no guarantee) in every reported row, per design.md Sec5 -- an even earlier draft blended these for the zero-shot pair, which would have silently produced exactly the misleadingly-reassuring number design.md Sec0 warns against. Final numbers after both fixes track target closely across the board; see docs/research/validation_results.md.
docs/research/validation_results.md: full post-fix numbers. Every in-domain row (CoNLL-2003, WNUT-17, both modes) tracks its target within ~0.3-2 standard deviations. Pair A's calibrated types (location/person, shared vocabulary with CoNLL) reach 0.87-0.98 coverage; its uncalibrated types (corporation/creative-work/group/ product, never seen in calibration) sit flat at 0.551 regardless of alpha or mode -- the concrete number behind the zero-shot descope in design.md Sec0. CoNLL's organisation type measures 0.717 coverage against a 0.90 pooled target, a real measured instance of the under-covered-rare-type failure mode that motivates mondrian mode. Calibration-size sensitivity: mean within 0.006 of target from n_calib=50 to 1000, std shrinking monotonically 0.0374 -> 0.0092. docs/PR_DESCRIPTION.md: filled in with final numbers, corrected the earlier false claim that datasets/matplotlib were already optional deps of this repo (verified: neither appears in pyproject.toml or requirements.txt), and added both bug-fix stories to the PR body itself so "the empirical section validates the theory" is auditable by a reviewer, not asserted on faith.
…lity Diffed pyproject.toml against origin/feature/vocab-pruning-engine (the sibling branch with the same datasets/matplotlib-shaped need for its own eval scripts) -- file is byte-identical across upstream/main, that branch, and this one. That branch never declared those deps either despite scripts/baseline_eval.py and scripts/visualize_results.py needing the same tooling, confirming this is the established repo-wide convention (scripts/ dependencies installed ad hoc) rather than a gap specific to this PR. Also verified directly against .github/workflows/tests.yml: pytest only sees requirements.txt + pytest/sentencepiece/onnxruntime and never collects scripts/ (testpaths = ["tests"]), and ruff's CI invocation (`ruff check gliner`) doesn't cover tests/ or scripts/ at all -- both pass clean on this branch. No CI risk from the undeclared deps. Updated docs/PR_DESCRIPTION.md to state this as a confirmed, evidence-based decision rather than an open question for the maintainer.
…ner docs Per user decision: strip the docs/research/ working notes (repo cartography, theory, prior art, eval plan, design, validation results -- ~2,500 lines) and the local-only docs/PR_DESCRIPTION.md draft from the tracked diff, matching typical OSS PR scope (code + tests + practitioner-facing docs, not the internal research trail). Files are kept on disk, gitignored, for local reference -- same treatment as CLAUDE.md. Every docstring/comment in the shipped package and tests that cited docs/research/*.md by path is rewritten to be self-contained (the substantive math/reasoning stays inline, only the now-nonexistent file citations are removed) -- a reviewer opening this PR fresh won't hit a single dangling reference to a file that isn't there. Also reset .gitignore to upstream/main's original content and re-added only what this PR actually needs: /CLAUDE.md (memory), /docs/archive/ + /docs/research/ + /docs/PR_DESCRIPTION.md (local reference material, consistent gitignore treatment), and /results/ (this PR's own scripts/conformal_validation.py writes there by default). Dropped the unrelated fork-branch entries (ROADMAP.md, pruning_adr.md) that don't belong in a PR to upstream. Net effect: diff vs upstream/main goes from 17 files/4456 insertions to 10 files/1895 insertions -- every remaining file is the shipped package, its tests, the validation script, or the one practitioner doc. 364 tests still pass; `ruff check gliner` (CI's exact invocation) clean.
The previous commit used `git add -A`, which swept up ROADMAP.md and pruning_adr.md -- untracked leftover files from the unrelated feature/vocab-pruning-engine branch that were physically sitting in the working directory (untracked files survive `git checkout` across branches). These have nothing to do with conformal prediction and don't belong in this PR. Untracked them again and re-added the gitignore entries that were dropped when .gitignore was reset to upstream/main's original content a few commits ago. Files are still physically on disk (git rm --cached, not git rm) in case they're needed on the branch they actually belong to.
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.
Motivation
GLiNER scores every candidate
(span, type)pair with an independent sigmoid and filters withthreshold=0.5by default — a convenient default, not a statistical guarantee. It doesn't saywhat fraction of true entities a deployment should expect to miss, and it isn't calibrated to
any particular dataset or entity-type mix. This is a symptom several open issues already point
at: #69 (feature request just to expose confidence values), #192 (label ordering changing
confidence scores — a miscalibration symptom), #324 (transformers v5 causing uniformly
low/meaningless scores).
This PR adds
gliner.conformal, a small additive module that replaces the arbitrarythreshold=0.5cutoff with a threshold calibrated on a held-out labeled set, backed byfinite-sample, distribution-free guarantees (Vovk et al.; Angelopoulos & Bates,
arXiv:2107.07511; Conformal Risk Control, arXiv:2208.02814). Two very recent papers
(arXiv:2601.16999, arXiv:2605.18812) establish conformal-prediction theory for NER
specifically, but neither ships code and neither addresses open-vocabulary/zero-shot label
sets — as far as I can find, no released implementation of conformal prediction for NER exists
anywhere. This is, to my knowledge, the first one, and the first that works with GLiNER's
inference-time arbitrary label sets.
What this is not claiming
Not a rigorous zero-shot coverage guarantee for entity types never seen in calibration.
Split-conformal validity requires calibration/test exchangeability; a type with zero
calibration occurrences has no well-defined quantile — undefined, not merely wide — and no
theorem in the literature I surveyed licenses a coverage claim for it.
ConformalGLiNERhandles this honestly: types below the calibration floor get a loud warning and GLiNER's
original uncalibrated behavior, flagged
"calibrated": Falseon every affected entity — neversilently blended into a guaranteed-looking number. Full discussion in
docs/conformal.md'sLimitations section.
API