Evaluation¶
Openbtk ships harnesses, not scores. Every number this project publishes comes from a harness in the repository (see Benchmarks); the harnesses below let you produce the same kind of number for your model and your data. Reports hold counts and identifiers, never text, so they are safe to file.
| You want to measure | Use |
|---|---|
| how well de-identification finds PHI | openbtk.eval.deid.evaluate_deid (guide) |
| whether retrieval surfaces the right chunks | openbtk.eval.retrieval.evaluate_retrieval |
| a model's accuracy on multiple-choice clinical questions | openbtk.eval.qa.evaluate_qa |
| how faithful generated text is to its sources | openbtk.eval.groundedness.score_groundedness |
Retrieval: recall@k, MRR, nDCG@k¶
from openbtk.eval.retrieval import RetrievalQuery, evaluate_retrieval
queries = [
RetrievalQuery(
query_id="q1", query="statin therapy", relevance={"c1": 1.0, "c2": 1.0}
),
RetrievalQuery(query_id="q2", query="insulin dosing", relevance={"c3": 1.0}),
]
# A retriever is any function from a query to a ranked list of ids.
ranked = {"statin therapy": ["c1", "c9", "c2"], "insulin dosing": ["c7", "c3"]}
report = evaluate_retrieval(lambda q: ranked[q], queries, ks=(1, 3))
assert report.n_queries == 2
assert report.recall_at_k[1] == 0.25 # (1/2 relevant found + 0/1) / 2 queries
assert report.recall_at_k[3] == 1.0
assert report.mrr == 0.75 # ranks 1 and 2 -> (1 + 1/2) / 2
retriever_from(embedding, vector_store) builds such a function from a real
embedding provider and vector store.
Clinical QA (MedQA / MedMCQA format)¶
evaluate_qa scores any function from a prompt to a reply, so a real model is
one adapter away. This example uses a stand-in "model" so it runs anywhere:
import json
import pathlib
import tempfile
from openbtk.eval.qa import evaluate_qa, read_medmcqa_jsonl
rows = [ # invented questions, in MedMCQA's file format
{
"id": "q1",
"question": "Which vitamin deficiency causes scurvy?",
"opa": "Vitamin A",
"opb": "Vitamin C",
"opc": "Vitamin D",
"opd": "Vitamin K",
"cop": 1,
"subject_name": "Biochemistry",
},
{
"id": "q2",
"question": "Which organ produces insulin?",
"opa": "Liver",
"opb": "Kidney",
"opc": "Pancreas",
"opd": "Spleen",
"cop": 2,
"subject_name": "Physiology",
},
]
folder = tempfile.TemporaryDirectory()
path = pathlib.Path(folder.name, "validation.jsonl")
path.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
replies = iter(["B", "The answer is A."]) # right, then wrong
report = evaluate_qa(lambda prompt: next(replies), read_medmcqa_jsonl(path))
assert (report.n, report.correct, report.unanswered) == (2, 1, 0)
assert report.accuracy == 0.5
low, high = report.accuracy_ci95 # 2 questions: a very wide interval
assert low < 0.5 < high
folder.cleanup()
To score a real model, wrap a provider: evaluate_qa(LLMAnswerer(provider), items).
Points to keep straight:
- No dataset and no score is bundled.
read_medqa_jsonlandread_medmcqa_jsonlread files you have downloaded from the public sources. MedMCQA's public test split hides its answers, so that file is refused; score the validation split. - A reply that names no option counts as wrong and is also reported as
unanswered, so a model that rambles is visible rather than merely low. - Read the interval, not just the accuracy.
accuracy_ci95is a Wilson 95% interval; on a few hundred questions it is several points wide. qa_manifest(report, component=provider.provenance(), source=path)records which model, which file (by SHA-256) and when, as anEvalManifest.
Groundedness¶
from openbtk.eval.groundedness import (
GroundedExample,
LabelledExample,
evaluate_detector,
score_groundedness,
)
context = ["Assessment: type 2 diabetes mellitus, stable. Metformin continued."]
answers = [
GroundedExample(
example_id="a", answer="The patient has type 2 diabetes.", context=context
),
GroundedExample(
example_id="b", answer="The patient has a fractured femur.", context=context
),
]
report = score_groundedness(answers)
assert report.faithfulness == 0.5 # 1 of 2 claims supported
# How good is the checker itself? Score it against answers a human has judged.
judged = [
LabelledExample(
example_id="a", answer=answers[0].answer, context=context, grounded=True
),
LabelledExample(
example_id="b", answer=answers[1].answer, context=context, grounded=False
),
]
assert evaluate_detector(judged).f1 == 1.0
The default checker is a word-overlap heuristic
It measures lexical overlap with the context. It cannot see a negation, a
swapped dose, or a paraphrase, so a high faithfulness score does not mean a
clinically faithful answer. Build a GroundednessGuardrail with your own
is_supported (an entailment model or an LLM judge) for a stronger check, and
run evaluate_detector on your own labelled examples before trusting any
faithfulness number.
Model cards¶
A model card is the short document that travels with a model: what it is, what it is for, how well it does, where it fails. Openbtk already records the facts a card needs, so it assembles them: the exact model and pinned revision a component used, and the scores an evaluation produced.
from datetime import UTC, datetime
from openbtk.eval.manifest import EvalManifest
from openbtk.eval.model_card import ModelCard
from openbtk.retrieval.cross_encoder import CrossEncoderReranker
card = ModelCard.for_component(
CrossEncoderReranker(),
intended_use="Reordering retrieved passages for clinical question answering.",
limitations="Not evaluated on non-English text or on notes over 512 tokens.",
)
# A score reaches a card only from an evaluation run's manifest.
run = EvalManifest(
eval_id="retrieval-2026-09",
kind="retrieval",
started_at=datetime(2026, 9, 1, tzinfo=UTC),
ended_at=datetime(2026, 9, 1, tzinfo=UTC),
report={"recall_at_5": 0.5},
)
card = card.with_evaluation(run, "recall_at_5", name="Recall@5")
text = card.to_markdown()
assert "ncbi/MedCPT-Cross-Encoder" in text
assert "| Recall@5 | 0.5 | `retrieval-2026-09` (retrieval) |" in text
assert "## Training data\n\nNot provided." in text # nothing invented
What is written for you, and what is not:
- The identity (model, pinned revision, source, the Openbtk component and its
settings) comes from the component's provenance, and the evaluation table from
EvalManifests you attach, each with its id and input digests. - Everything that needs judgement (intended use, out-of-scope use, training data, limitations, ethical considerations) is text you provide. A section you leave out reads "Not provided." A card never describes a model's training data, invents limitations or praises it.
- There is no way to type a score into a card.
with_evaluationreads a number from a manifest's report and refuses one that is not there, so no figure appears without the run that produced it. A card with no evaluation says so. - For a whole run,
cards_from_run(manifest)writes one card per distinct model the run used. For a model you fine-tuned, build aModelCardfrom itsModelIdentityand say what you trained it on.
Summaries: ROUGE and BERTScore¶
For a discharge summary or similar, evaluate_summaries compares each candidate with a
reference on ROUGE (word, word-pair and longest-common-subsequence overlap) and, if you
give it a scorer, BERTScore (similarity in a language model's embedding space, which credits
a paraphrase that ROUGE misses).
from openbtk.eval.summarisation import SummaryPair, evaluate_summaries
pairs = [
SummaryPair(
example_id="d1",
reference="the patient is stable today",
candidate="the patient is stable",
),
]
report = evaluate_summaries(pairs, use_stemmer=False)
# The candidate has 4 of the reference's 5 words and nothing extra:
assert report.rouge["rouge1"].precision == 1.0
assert report.rouge["rouge1"].recall == 0.8
assert report.as_dict()["n"] == 1
Read these numbers for what they are:
- They measure similarity to a reference, not correctness. A fluent summary that leaves out the one abnormal result can score well, and a faithful paraphrase can score badly. Compare systems on your own data with them, next to the groundedness check above; do not use them as a safety measure.
- ROUGE wraps Google's
rouge-score(theevalextra). Its tokenizer keeps onlya-zand0-9, so accented letters and symbols such as%or>are dropped before scoring, a real limit for clinical text. - BERTScore needs choices Openbtk will not make for you. You give the model, its pinned commit and the layer, and it downloads nothing until first use:
from openbtk.eval.summarisation import BertScorer
scorer = BertScorer(
model="distilbert-base-uncased",
revision="12040accade4e8a0f71eabdb258fecc2e7e948be", # pragma: allowlist secret
layer=5,
)
assert scorer.provenance().model_identity.revision.startswith("12040acc")
# evaluate_summaries(pairs, bertscorer=scorer) # downloads the model, needs torch
Openbtk implements BERTScore itself rather than wrapping the bert-score package,
because that package fetches its model by name with no way to pin a revision. The
implementation reproduces the reference package's raw scores (five sentence pairs agree
to within 1e-7; the test that checks this carries the recorded values), but supports
neither idf weighting nor baseline rescaling, so its numbers are comparable only with
other raw scores from the same model and layer.
- The report holds scores and counts, never text. summarisation_manifest(report,
bertscorer=scorer) records when it ran, the scores, and the BERTScore model, so a score
can go on a model card through its manifest.