Add conformal prediction: calibrated coverage/risk guarantees for zero-shot NER - #374
Add conformal prediction: calibrated coverage/risk guarantees for zero-shot NER#374ALI-AL-MARJANI wants to merge 17 commits into
Conversation
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.
|
Hi @ALI-AL-MARJANI, thank you for implementing the good idea. I think it would be better to update the main class API to support per-label thresholds and an additional calibration method. Also, please, fix ruff errors. Best regards, |
Same root cause as urchade#366: CI's unpinned `pip install ruff` picked up 0.16.0, which added PLR0917 and flagged 4 pre-existing large-API-surface functions (GLiNERConfig.__init__, three from_pretrained-style methods in model.py) untouched by this branch's own diff. PLR0913 is already deliberately ignored here for the same reason (max-args=20 in [tool.ruff.lint.pylint]); PLR0917 is the same category for the positional-only subset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses part of the review feedback on urchade#374: adds `calibrated_types` (public, copy-safe) and `thresholds()` -- the nonconformity threshold actually applied per label at prediction time, previously only reachable via the private `_state` attribute. Uniform across all three modes: under "mondrian" thresholds genuinely differ per label (that's the point of Mondrian calibration); under "span_filter"/"risk_control" every calibrated label currently shares one pooled value, returned per-label anyway so callers don't need to branch on mode to read a threshold. Both raise RuntimeError before calibrate(), same as predict_entities/coverage_report. Still open from that review comment: "an additional calibration method" is ambiguous between a new algorithm (e.g. jackknife+) and exposing calibrate() directly on GLiNER -- needs a clarifying reply on the PR before implementing either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses the rest of the review feedback on urchade#374 ("an additional calibration method"): BaseEncoderGLiNER.calibrate(calib_data, alpha, mode, labels) builds a ConformalGLiNER around self, calibrates it, and stores it as self._conformal_model, exposed via the new `conformal` property. Calibration and inference now live on the same object instead of requiring callers to construct ConformalGLiNER separately: model.calibrate(calib_data, alpha=0.1, mode="risk_control") model.conformal.predict_entities(text, labels) Thin wrapper, not a duplicated API surface -- coverage_report, save_calibration/load_calibration, thresholds() etc. stay on `model.conformal` / ConformalGLiNER directly, not re-exposed on GLiNER itself. conformal is imported lazily inside calibrate() so gliner.conformal stays opt-in, matching the PR's existing "not imported by gliner/__init__.py by default" principle. Scope unchanged: NotImplementedError on non-span-mode architectures, raised by ConformalGLiNER itself, not duplicated here. Note: this does touch gliner/model.py, unlike the PR description's original "zero changes to any existing model file" framing -- that claim needs updating in the PR description, since this is precisely what was requested ("update the main class API"). 5 new tests (calibrate() chaining, conformal property lifecycle, identical predictions via model.conformal vs a directly-constructed ConformalGLiNER, state cleanup for the shared module-scoped model fixture, unsupported architecture still raises). 380 passed, 1 skipped, ruff check gliner clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Ruff fixed.
Also merged latest main in along the way (streaming feature), no conflicts now |
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