Assessing Reference Support for IPBES Background Messages via NLI
Technical Design Document
Assessing Reference Support for IPBES Background Messages via NLI
Overview
This document describes a pipeline for systematically classifying whether scientific references support, contradict, or are not relevant to a given IPBES background message (BM). The approach uses Natural Language Inference (NLI) — a text classification task where a model determines the logical relationship between a premise (here: an abstract) and a hypothesis (here: a background message).
The pipeline deliberately avoids a retrieval/filtering stage. Instead, NLI is run across the complete reference set, using the model’s own confidence scores as the filter. This eliminates retrieval recall risk and produces a fully auditable, reproducible result with no embedding hyperparameters to justify.
Why NLI Without Prior Retrieval?
A natural first instinct is to pre-filter references using semantic similarity (e.g. SPECTER2 embeddings), then run NLI only on the top-k candidates. This is computationally attractive but has a critical weakness: false negatives at retrieval stage are silent.
A paper may be highly relevant to a BM without being semantically close in embedding space. IPBES background messages are synthetic, policy-oriented claims — they do not resemble any individual abstract. A study on soil carbon flux in Amazonia may directly support a BM about terrestrial carbon sinks, but the vocabulary overlap is low and the paper would likely not rank in the top-k.
Running NLI on the full set avoids this entirely. With GPU access the compute is manageable (see numbers below), and the pipeline becomes a single auditable step.
The NLI Task
NLI models take a premise–hypothesis pair and return a probability distribution over three classes:
| Class | Meaning in this context |
|---|---|
SUPPORTS |
The abstract provides evidence consistent with the BM |
REFUTES |
The abstract provides evidence against the BM |
NOT_ENOUGH_INFO |
The abstract does not address the BM |
The claim–abstract framing maps directly onto the SciFact benchmark (Wadden et al., 2020), which is precisely: given a scientific claim and an abstract, classify as SUPPORTS / REFUTES / NOT_ENOUGH_INFO. Models fine-tuned on SciFact are the natural starting point.
Recommended Model
MoritzLaurer/deberta-v3-large-zeroshot-v2.0
DeBERTa-v3-large is consistently among the strongest NLI models across benchmarks. Laurer’s version is fine-tuned on a large cross-domain NLI corpus and performs well in zero-shot settings on new domains — relevant here because biodiversity/IPBES language differs from biomedical or computer science text that many SciFact models are trained on.
Alternatives worth benchmarking:
| Model | Notes |
|---|---|
MoritzLaurer/deberta-v3-large-zeroshot-v2.0 |
Recommended. Strong cross-domain NLI |
allenai/scibert_scivocab_uncased fine-tuned on SciFact |
Science-specific vocabulary, shorter context |
facebook/bart-large-mnli |
General NLI, zero-shot, weaker on scientific text |
pritamdeka/S-PubMedBert-MS-MARCO-SciFact |
Biomedical focus, likely suboptimal for ecology |
For IPBES specifically, validate model choice by manually labelling 20–30 BM–abstract pairs and checking model agreement before committing to a full run.
Context Length Considerations
DeBERTa-v3-large has a maximum context of 512 tokens (claim + abstract combined). Most abstracts are 150–250 words (~200–330 tokens). A typical IPBES BM is 30–80 words (~40–110 tokens). This leaves comfortable headroom in most cases.
Action: Check your abstract length distribution before running:
library(dplyr)
library(tokenizers)
refs |>
mutate(n_tokens = map_int(abstract, ~ length(tokenize_words(.x)[[1]]))) |>
summarise(
median_tokens = median(n_tokens),
p95_tokens = quantile(n_tokens, 0.95),
n_over_400 = sum(n_tokens > 400)
)For abstracts exceeding ~400 tokens, truncate from the end (the claim is typically stated early). Do not truncate mid-sentence.
Claim Granularity: naive_bm vs. complete_bm (see also atomic_bm below)
The implemented pipeline (R/build_nli_ready_evidence_parquet.R) doesn’t score a whole BM against a paper in one pair — it first cuts each BM’s bm_description/bm_label into smaller claims. input/config.yaml’s per-NLI-config granularity field picks how:
naive_bm(default, the active approach) —segment_bm_by_evidence()splits at brace evidence-references ({5.4.1, 5.4.2}) that end a sentence, producing 2-17 claims per BM. Each claim is short (measured: 62-86 tokens average, max 229-328), comfortably inside the 512-token headroom discussed above.complete_bm—segment_bm_whole()takes each field whole, unsegmented: 2 claims per BM (bm_description+bm_label), or 1 if the two are identical text after whitespace normalization (checked against all 88 declared BMs across both assessments: currently 0 are identical, so this collapses nothing today, but the rule exists for correctness).
Measured claim/pair reduction, from the real on-disk naive_bm data:
| Assessment | BMs | naive_bm claims (mean/BM) |
naive_bm (claim,work) pairs |
complete_bm claims |
Projected pairs | Reduction |
|---|---|---|---|---|---|---|
| GA1 | 30 | 171 (5.7) | 1,887,289 | 60 | ~662,382 | ~65% |
| IAS | 49 | 429 (8.76) | 1,695,040 | 98 | ~387,190 | ~77% |
| Combined | 79 | 600 | 3,582,329 | 158 | ~1,049,572 | ~71% |
Phase 2 (LLM verification) volume would shrink by roughly the same factor if the REFUTES/SUPPORTS-certain rate per pair holds under the much longer hypothesis text complete_bm produces — genuinely uncertain, not a rounding caveat (see below).
Why complete_bm isn’t just a free win — the token-limit problem is worse than the “headroom” framing above suggests. A whole bm_description/bm_label averages ~245-271 tokens (median 120-155, up to 733-885 at the extreme) — 3-10x longer than a naive_bm claim. max_length: 512 in input/config.yaml looks like a conservative, raisable setting; it is not. Fetched MoritzLaurer/deberta-v3-large-zeroshot-v2.0’s real config.json directly from Hugging Face and confirmed max_position_embeddings: 512 — a hard architectural limit of the model itself (its position-embedding table has exactly 512 slots), already matching input/config.yaml’s setting exactly. external/runpod/docker/nli-runpod/server.py’s tokenizer call truncates the combined premise+hypothesis pair (truncation="longest_first"), so for any BM whose complete_bm claim already exceeds ~512 tokens on its own, the hypothesis itself gets cut — not just the premise’s tail, which is the only thing that ever truncates under naive_bm. Treat complete_bm results for the largest BMs with real skepticism until truncation is actually measured against a run, not as a minor footnote.
The mitigation is a different model, not a bigger max_length. input/config.yaml ships a second config, bge_m3_zeroshot (MoritzLaurer/bge-m3-zeroshot-v2.0-c, max_position_embeddings: 8194 — also confirmed against its real config.json), meant to pair with granularity: complete_bm. It needed to be multilingual, not just long-context: the hypothesis (BM claim) is always English, but the premise (citing-paper title+abstract, from OpenAlex) can be in whatever language the source paper was published in, so an English-only long-context alternative (MoritzLaurer/ModernBERT-large-zeroshot-v2.0, also 8192 tokens, considered first) would still fail on non-English premises. bge_m3_zeroshot and the currently-deployed deberta_zeroshot use the same 2-class entailment/not_entailment head (both real config.jsons checked) — MoritzLaurer’s “v2.0” series reformulates zero-shot as one entailment-vs-not_entailment pass per candidate label, not classic 3-way MNLI — so server.py’s already-dynamic _entailment_id() (derived from _model.config.label2id, not hardcoded) needs no changes to serve either model; only a different NLI_MODEL build arg (external/runpod/docker/nli-runpod/Dockerfile) and a new image tag, ghcr.io/rkrug/nli-runpod-bge-m3 (see input/nli_pods_bge_m3.conf).
Not yet built, provisioned, or run — bge_m3_zeroshot’s uncertain_threshold: 0.60 is carried over unverified from deberta_zeroshot as a starting point, not a calibrated value for this different model. See TODO.md.
A third granularity: atomic_bm
Real-money validation of complete_bm found a structural problem, not just a token-limit one. A stratified sample of 60 real complete_bm REFUTES/SUPPORTS-certain pairs was independently reviewed by two LLMs (gpt-4o-mini and gpt-oss-20b, 96.6% agreement between them) — 0/20 REFUTES-certain and 1/40 SUPPORTS-certain pairs were confirmed; both models defaulted to NOT_ENOUGH_INFO for the same reason: a whole BM is a compound, multi-clause statement, and no single citing paper addresses every clause of it. This isn’t fixable by raising max_length or picking a longer-context model — it’s a mismatch between the claim’s granularity and what one paper can actually speak to.
The same problem exists inside naive_bm, one level down. Real GA1/A2 text: “Nature provides a broad diversity of nutritious foods, medicines and clean water {ref1}; can help to regulate disease and the immune system {ref2}; can reduce levels of certain air pollutants {ref3}…” — four semicolon-separated clauses, each with its own distinct evidence reference, but segment_bm_by_evidence() treats mid-sentence braces as non-splitting inline citations, so all four get bundled into one claim. Same compound-claim problem, smaller scale. A regex hunt across all 88 real BMs (both assessments) for a brace immediately followed by a continuation word (“particularly”, “and”, “including”, …) found this pattern in 13/88 BMs (~15%) — a real, non-rare construct.
atomic_bm fixes this by splitting at every evidence brace, not just sentence-terminal ones (segment_bm_atomic(), R/build_nli_ready_evidence_parquet.R) — each evidence reference becomes its own candidate claim. This routinely produces grammatically elliptical fragments (“can help to regulate disease and the immune system” has no subject of its own), so a second pass, complete_bm_fragments() (R/build_claim_completion.R), asks an LLM to either confirm a fragment is already complete or rewrite it into one self-contained sentence, reusing only words already present in the fragments that precede it in the same BM field — never introducing new facts. A faithfulness guard (claim_completion_is_faithful(), same spirit as quote_is_verbatim() in Phase 2) checks every substantive word in a completion against the source fragments and falls back to the uncompleted original on failure.
Confidence qualifiers are extracted into their own column, for both naive_bm and atomic_bm. IPBES’s 4 standard qualifiers — “(well established)”, “(established but incomplete)”, “(unresolved)”, “(inconclusive)” — appear inline in the source text (598 occurrences across both assessments, checked directly). Leaving them in the claim text risked two problems: NLI/LLM scoring treating them as part of the assertion to entail/refute, and — found directly in an early smoke test — the completion LLM silently dropping one while rewording a fragment (complete_bm_fragments()’s own faithfulness guard only catches fabricated content, not omitted content, so a dropped qualifier wouldn’t be caught after the fact). Extracting them out before either step reaches the text removes the failure mode structurally rather than detecting it after the fact.
complete_bm’s segmentation (segment_bm_whole()) later got the same treatment, for consistency across all three granularities — a deliberate, knowingly-accepted tradeoff, not an oversight: it already had 949,334 real scored pairs on disk at the time, scored against claim text that still had qualifiers inline. score_one_claim()’s resumability check is purely directory-existence, so those existing scores were not automatically redone — they now reflect the old (pre-strip) claim text until a real rescore is run across all three granularities (planned, not yet executed as of this writing). A field can carry several qualifiers across its original clauses (unlike naive_bm/atomic_bm’s per-fragment extraction); all of them are joined into one confidence value for the single whole-field claim.
QA report, not a scoring result: QA_BM_Split_Report.qmd (bm_split_report_table/bm_split_report_html targets) renders one table per assessment, per whichever granularity is currently active, showing every original BM field alongside every resulting claim (and, where applicable, its extracted confidence qualifier) — generated directly from nli_ready_evidence_parquet’s own real output, so it can never drift from what was actually produced. Meant to be reviewed by eye before committing to a real scoring run under a new granularity, the same role QA_BM_Split_Report-style validation played during this design’s own development.
Not yet run for real: atomic_bm has never been scored under any NLI config — this section documents the capability and the real validation work that motivated it, not a decision to switch the active granularity. naive_bm (the renamed sub_bm) has likewise never been scored under any NLI config to date, which is what made extending the confidence-qualifier fix to it, and not just atomic_bm, a zero-risk change.
Compute Estimates
Throughput by hardware
| Setup | Throughput | Basis |
|---|---|---|
| RunPod L4 GPU (24 GB) | 50–100 pairs/sec | DeBERTa-large, batch size 32 |
| RunPod A100 (80 GB) | 150–250 pairs/sec | Larger batch sizes |
| HuggingFace Inference API | 5–10 pairs/sec | Free tier, shared inference |
| CPU only (local) | 1–2 pairs/sec | Not recommended at scale |
Time estimates for common scales
Assuming 50 background messages and varying reference set sizes, at 75 pairs/sec (L4 GPU, conservative midpoint):
| References | Total pairs | L4 GPU | HF API |
|---|---|---|---|
| 500 | 25,000 | ~6 min | ~45 min |
| 2,000 | 100,000 | ~22 min | ~3 hrs |
| 5,000 | 250,000 | ~56 min | ~7 hrs |
| 10,000 | 500,000 | ~1.9 hrs | ~14 hrs |
For the typical IPBES assessment reference set (500–3,000 papers), a single L4 GPU run of 20–60 minutes covers the full pipeline. This makes retrieval pre-filtering unnecessary.
Pipeline Design
Inputs
background_messages: a data frame with columnsbm_id,bm_textreferences: a data frame with columnsref_id,title,abstract
Steps
1. Preprocess
├── Concatenate title + abstract for each reference (title adds context)
├── Truncate to 400 tokens if needed
└── Cross-join BMs × references → pairs data frame
2. NLI inference (batched)
├── Send batches of pairs to model
├── Receive probability scores for [SUPPORTS, REFUTES, NOT_ENOUGH_INFO]
└── Store raw scores alongside predicted label
3. Post-filter
├── Drop pairs where p(NOT_ENOUGH_INFO) > 0.90 (configurable threshold)
└── Flag pairs where max(p) < 0.60 as "uncertain" for human review
4. Output
├── Full results table (all pairs, raw scores)
├── Filtered table (SUPPORTS / REFUTES only, above threshold)
└── Per-BM summary: n_supporting, n_contradicting, n_uncertain
Output schema
# One row per BM–reference pair
tibble(
bm_id = character(), # Background message identifier
ref_id = character(), # Reference identifier (DOI or internal ID)
label = character(), # "SUPPORTS" | "REFUTES" | "NOT_ENOUGH_INFO"
p_supports = double(), # Model probability for SUPPORTS
p_refutes = double(), # Model probability for REFUTES
p_nei = double(), # Model probability for NOT_ENOUGH_INFO
confidence = double(), # max(p_supports, p_refutes, p_nei)
uncertain = logical() # TRUE if confidence < threshold
)R Implementation Outline
Option A — HuggingFace Inference API (prototyping)
library(httr2)
library(purrr)
library(dplyr)
classify_nli <- function(premise, hypothesis,
model = "MoritzLaurer/deberta-v3-large-zeroshot-v2.0",
hf_token = Sys.getenv("HF_TOKEN")) {
resp <- request("https://api-inference.huggingface.co/models") |>
req_url_path_append(model) |>
req_auth_bearer_token(hf_token) |>
req_body_json(list(
inputs = list(premise = premise, hypothesis = hypothesis)
)) |>
req_retry(max_tries = 3, backoff = ~ 5) |>
req_perform() |>
resp_body_json()
# Response is a list of lists: [[label, score], ...]
scores <- resp[[1]] |>
map_dfr(~ tibble(label = .x$label, score = .x$score))
scores
}
# Apply across all pairs (rate-limit aware)
results <- pairs |>
mutate(
nli = map2(abstract_text, bm_text, classify_nli, .progress = TRUE)
) |>
unnest(nli)Option B — Local/RunPod inference via reticulate (production)
library(reticulate)
# Python environment with transformers + torch
transformers <- import("transformers")
torch <- import("torch")
pipe <- transformers$pipeline(
"zero-shot-classification",
model = "MoritzLaurer/deberta-v3-large-zeroshot-v2.0",
device = 0L # GPU device index; -1 for CPU
)
classify_batch <- function(premises, hypothesis,
candidate_labels = c("supports", "refutes", "not relevant"),
batch_size = 32L) {
pipe(
premises,
candidate_labels = candidate_labels,
hypothesis_template = paste("This paper", "{}", "the following claim:", hypothesis),
batch_size = batch_size
)
}Note: the hypothesis_template is important for zero-shot NLI — it frames the classification correctly relative to the candidate labels.
Thresholds and Human Review
The model returns a probability distribution, not a binary decision. Choose thresholds based on your downstream use:
| Threshold | Recommendation |
|---|---|
p(NOT_ENOUGH_INFO) > 0.90 |
Discard as not relevant |
confidence < 0.60 |
Flag as uncertain, queue for human review |
p(SUPPORTS) > 0.75 |
High-confidence support |
p(REFUTES) > 0.75 |
High-confidence contradiction — always human-reviewed |
Contradictions deserve special attention: a high-confidence REFUTES classification is scientifically significant and should never be accepted without expert review.
Limitations
- BMs are synthetic claims. They aggregate evidence from multiple papers. No single paper may directly assert a BM; the model may undercount support as a result.
- Partial support is not modelled. A paper may address one aspect of a multi-part BM. Consider splitting complex BMs into atomic sub-claims before classification.
- Domain shift. Models trained on biomedical SciFact may underperform on ecology and biodiversity language. Benchmark on a hand-labelled sample first.
- Abstract-only coverage. Full-text classification would require chunking and aggregation across sections — feasible but adds complexity.
- 512-token limit. Long abstracts must be truncated, with possible loss of relevant detail.
Recommended Workflow
- Sample and label 30–50 BM–abstract pairs manually (covering all three classes)
- Benchmark two or three candidate models on this sample; pick the best
- Run full pipeline on RunPod L4 with chosen model, batches of 32
- Apply thresholds to produce filtered results table
- Human expert review of all
REFUTEScalls and alluncertaincases - Summarise per BM: n_supporting, n_contradicting, n_uncertain, n_not_relevant
See also
- https://towardsdatascience.com/natural-language-inference-an-overview-57c0eecf6517/
- https://medium.com/@mllabucu/natural-language-inference-for-fact-checking-on-wikipedia-d3f0825b062f
References
- Wadden, D. et al. (2020). Fact or Fiction: Verifying Scientific Claims. EMNLP 2020. SciFact dataset and baseline models. https://github.com/allenai/scifact
- Laurer, M. et al. (2022). Less Annotating, More Classifying. DeBERTa zero-shot NLI models. https://huggingface.co/MoritzLaurer
- He, P. et al. (2021). DeBERTa: Decoding-enhanced BERT with Disentangled Attention. ICLR 2021.