Code
import warnings
warnings.filterwarnings("ignore")
import evaluate
accuracy = evaluate.load("accuracy")
print(accuracy.compute(predictions=[0, 1, 1, 0], references=[0, 1, 0, 0])){'accuracy': 0.75}
evaluate Libraryevaluate makes that swap one line — which is the danger, not the feature.
Ravi Kalia
March 12, 2025

evaluate LibraryTake one predicted sentence, "The cat sat.", and one reference, "A cat was sitting.". Score them with ROUGE-2 and you get 0.0 — a total failure. Score the same pair with BERTScore and you get 0.96 — near-perfect. Neither metric is broken. They are answering different questions, and only one of them is the question you meant.
The evaluate library makes swapping between them a single argument. That uniformity is genuinely useful and it is also the trap: evaluate.load("rouge") and evaluate.load("bertscore") look equally authoritative at the call site, cost nothing to interchange, and encode completely different theories of what “correct” means. This post is about reading that difference off the numbers.
The library’s whole interface is load, then compute, with predictions and references. Classification is where this feels least surprising, because the notion of correct is unambiguous:
{'accuracy': 0.75}
Three of four match, so 0.75, and there is nothing to argue about — the prediction either equals the reference or it does not. Metrics for generated text have no such luxury, because a prediction can be right without being identical to the reference. Each one resolves that differently.
The metric is also an object rather than a function, which is what lets evaluate.combine run several over a single pass:
{'accuracy': 0.75, 'f1': 0.6666666666666666, 'precision': 0.5, 'recall': 1.0}
Accuracy 0.75 but F1 lower on the same four predictions — already a preview of the point. Accuracy counts every position equally; F1 only cares about the positive class.
Here is the disagreement in full. One prediction, one reference, several metrics:
prediction = "The cat sat."
reference = "A cat was sitting."
rouge = evaluate.load("rouge")
bleu = evaluate.load("bleu")
rouge_scores = rouge.compute(predictions=[prediction], references=[reference])
bleu_scores = bleu.compute(predictions=[prediction], references=[[reference]])
print("ROUGE:", {k: round(float(v), 4) for k, v in rouge_scores.items()})
print("BLEU :", round(bleu_scores["bleu"], 4))
print("BLEU precisions by n-gram:", [round(p, 3) for p in bleu_scores["precisions"]])ROUGE: {'rouge1': 0.2857, 'rouge2': 0.0, 'rougeL': 0.2857, 'rougeLsum': 0.2857}
BLEU : 0.0
BLEU precisions by n-gram: [0.5, 0.0, 0.0, 0.0]
ROUGE-1 finds some unigram overlap — cat and little else. ROUGE-2 is exactly 0.0, because the two sentences share no adjacent word pair. BLEU is 0.0 too, and its per-n-gram precisions show why: it matches half the unigrams and then nothing at all from bigrams upward, and BLEU’s geometric mean over n-gram precisions is zero the moment any one of them is.
All of these are counting string overlap. sat and sitting are different strings, so as far as these metrics are concerned the model did not say the same thing. Now change the theory of correctness:
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
WARNING:huggingface_hub.utils._http:Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
[transformers] RobertaModel LOAD REPORT from: roberta-large
Key | Status |
--------------------------+------------+-
lm_head.layer_norm.bias | UNEXPECTED |
lm_head.layer_norm.weight | UNEXPECTED |
lm_head.bias | UNEXPECTED |
lm_head.dense.weight | UNEXPECTED |
lm_head.dense.bias | UNEXPECTED |
pooler.dense.weight | MISSING |
pooler.dense.bias | MISSING |
Notes:
- UNEXPECTED: can be ignored when loading from different task/architecture; not ok if you expect identical arch.
- MISSING: those params were newly initialized because missing from the checkpoint. Consider training on your downstream task.
BERTScore: {'precision': 0.9666, 'recall': 0.958, 'f1': 0.9623}
backbone : roberta-large_L17_no-idf_version=0.3.12(hug_trans=5.14.1)
0.96. BERTScore embeds both sentences with a pretrained transformer and matches tokens by cosine similarity, so sat and sitting land close together and the pair reads as near-equivalent.
Two metrics, one pair, verdicts of 0.0 and 0.96. The gap is not noise — it is the difference between asking “did the model produce these exact words” and “did the model mean this”. Summarization and translation papers report ROUGE and BLEU because they are cheap, deterministic and comparable across papers, not because anyone believes n-gram overlap is meaning.
Every number above is computed on one example, which is fine for showing that the metrics disagree and useless for judging a model. BLEU in particular is designed as a corpus-level statistic: its brevity penalty and n-gram precisions are meant to be aggregated over a whole test set, and per-sentence BLEU is notoriously unstable — the same system can score 0.0 on a sentence it translated perfectly well with different word order.
The honest use of these calls is to accumulate over a corpus, then report once.
bertscore is not a formula. The hashcode printed above names the backbone it silently downloaded — roughly 1.4 GB of roberta-large — and the score depends on that choice, on the layer used, and on the language flag. Two BERTScores from different backbones are not comparable, which is why the hashcode exists and why it belongs in anything you report.
It also means evaluate.load("bertscore") costs a model download and a forward pass per example, where evaluate.load("rouge") costs neither. The uniform interface hides a several-order-of-magnitude difference in what the call actually does.
evaluate.load fetches a small implementation module from the Hub, and that module may import a package you do not have. The failure then arrives at compute time rather than load time, which makes it look like a data problem rather than an install problem — an earlier version of this post shipped a commented-out BERTScore cell for exactly this reason, with a note that it was broken. It was not broken; bert_score simply was not installed.
ROUGE needs rouge_score, nltk and absl-py; BLEU variants may want sacrebleu; BERTScore needs bert_score. All are pinned in the requirements.txt beside this post, which is why the cell above runs.
To evaluate a model rather than a hand-written string, generate something first. Note that pipeline("summarization") — which earlier versions of this post used — was removed in transformers 5, along with translation and text2text-generation. Sequence-to-sequence models are now driven directly:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained("google-t5/t5-small")
model = AutoModelForSeq2SeqLM.from_pretrained("google-t5/t5-small")
article = (
"The Hugging Face Transformers library provides thousands of pretrained models "
"for natural language processing. It supports text classification, information "
"extraction, question answering, summarization and translation, and it works "
"with both PyTorch and TensorFlow."
)
enc = tok(f"summarize: {article}", return_tensors="pt").to(model.device)
summary = tok.decode(model.generate(**enc, max_new_tokens=40)[0], skip_special_tokens=True)
print("summary:", summary)
human = "Transformers offers many pretrained models for NLP tasks in PyTorch and TensorFlow."
print("\nROUGE:", {k: round(float(v), 4)
for k, v in rouge.compute(predictions=[summary], references=[human]).items()})summary: the Hugging Face Transformers library provides thousands of pretrained models for natural language processing. it supports text classification, information extraction, question answering, summarization and translation.
ROUGE: {'rouge1': 0.2703, 'rouge2': 0.1143, 'rougeL': 0.2703, 'rougeLsum': 0.2703}
The summary is largely extractive — t5-small copies source phrasing rather than rewriting — and ROUGE rewards exactly that. A model that paraphrased more aggressively would score worse against this reference while arguably summarizing better. That bias belongs to the metric, not the model, and it is a large part of why summarization leaderboards reward extraction.
When no built-in metric encodes your notion of correct, a plain function will do, and the thing worth getting right is that it should express the asymmetry of your actual decision:
def expected_cost(predictions, references, false_negative_cost=5.0):
"""Mean cost per example when a missed positive hurts more than a false alarm."""
incurred = sum(
(false_negative_cost if ref == 1 else 1.0)
for pred, ref in zip(predictions, references)
if pred != ref
)
return {"expected_cost": round(incurred / len(references), 4)}
refs = [0, 1, 1, 0]
missed_positive = [0, 0, 1, 0] # one false negative
false_alarm = [1, 1, 1, 0] # one false positive
for name, preds in [("missed positive", missed_positive), ("false alarm", false_alarm)]:
acc = accuracy.compute(predictions=preds, references=refs)["accuracy"]
print(f"{name:16s} accuracy={acc:.2f} {expected_cost(preds, refs)}")missed positive accuracy=0.75 {'expected_cost': 1.25}
false alarm accuracy=0.75 {'expected_cost': 0.25}
This is the point of writing your own. Both prediction sets make exactly one mistake, so accuracy scores them identically at 0.75 and reports that the two models are equally good. They are not: one missed a positive and one raised a false alarm, and the cost metric separates them by the factor of five it was told to care about.
Which number you publish should follow from what a mistake costs in the world. A cancer screen and a spam filter have opposite asymmetries, and unweighted accuracy is blind to both.
To use a custom function inside Trainer, hand it back in the same {"name": value} shape that compute returns, which is what the built-ins produce and what compute_metrics expects.
The claim at the top was that swapping metrics is one line, and that this is the danger. The evidence is 0.0 and 0.96 on an identical pair of sentences, produced by calls that differ only in a string.
What evaluate genuinely buys is that the plumbing stops being a variable: the same compute(predictions=…, references=…) shape works for every metric, combine runs a suite in one pass, and the implementation is versioned on the Hub rather than copy-pasted from a paper’s appendix. Reproducibility of the computation is real and worth having.
What it cannot do is choose for you, and the uniformity actively obscures how different the choices are — in what they measure, in what they cost, and in whether they quietly carry a 1.4 GB model and a hashcode you are then obliged to report. Pick the metric from the decision it informs, and only then let the library make it easy.
---
title: "Evaluating NLP Models with the Hugging Face `evaluate` Library"
description: "The same prediction scores 0.0 and 0.96 depending on which metric you load. `evaluate` makes that swap one line — which is the danger, not the feature."
author: "Ravi Kalia"
date: "2025-03-12"
categories: [NLP, Hugging Face, Evaluation]
image: "./cover.png"
tags: [evaluate, metrics, nlp, huggingface]
jupyter: huggingface-blog
format:
html:
toc: true
toc-depth: 2
code-fold: true
code-tools: true
code-copy: true
---

Take one predicted sentence, `"The cat sat."`, and one reference, `"A cat was sitting."`. Score them with ROUGE-2 and you get **0.0** — a total failure. Score the same pair with BERTScore and you get **0.96** — near-perfect. Neither metric is broken. They are answering different questions, and only one of them is the question you meant.
The `evaluate` library makes swapping between them a single argument. That uniformity is genuinely useful and it is also the trap: `evaluate.load("rouge")` and `evaluate.load("bertscore")` look equally authoritative at the call site, cost nothing to interchange, and encode completely different theories of what "correct" means. This post is about reading that difference off the numbers.
## Every metric is the same three calls
The library's whole interface is `load`, then `compute`, with predictions and references. Classification is where this feels least surprising, because the notion of correct is unambiguous:
```{python}
import warnings
warnings.filterwarnings("ignore")
import evaluate
accuracy = evaluate.load("accuracy")
print(accuracy.compute(predictions=[0, 1, 1, 0], references=[0, 1, 0, 0]))
```
Three of four match, so 0.75, and there is nothing to argue about — the prediction either equals the reference or it does not. Metrics for generated *text* have no such luxury, because a prediction can be right without being identical to the reference. Each one resolves that differently.
The metric is also an object rather than a function, which is what lets `evaluate.combine` run several over a single pass:
```{python}
clf_metrics = evaluate.combine(["accuracy", "f1", "precision", "recall"])
print(clf_metrics.compute(predictions=[0, 1, 1, 0], references=[0, 1, 0, 0]))
```
Accuracy 0.75 but F1 lower on the same four predictions — already a preview of the point. Accuracy counts every position equally; F1 only cares about the positive class.
## The same pair, four verdicts
Here is the disagreement in full. One prediction, one reference, several metrics:
```{python}
prediction = "The cat sat."
reference = "A cat was sitting."
rouge = evaluate.load("rouge")
bleu = evaluate.load("bleu")
rouge_scores = rouge.compute(predictions=[prediction], references=[reference])
bleu_scores = bleu.compute(predictions=[prediction], references=[[reference]])
print("ROUGE:", {k: round(float(v), 4) for k, v in rouge_scores.items()})
print("BLEU :", round(bleu_scores["bleu"], 4))
print("BLEU precisions by n-gram:", [round(p, 3) for p in bleu_scores["precisions"]])
```
ROUGE-1 finds some unigram overlap — `cat` and little else. ROUGE-2 is exactly 0.0, because the two sentences share no adjacent word pair. BLEU is 0.0 too, and its per-n-gram precisions show why: it matches half the unigrams and then nothing at all from bigrams upward, and BLEU's geometric mean over n-gram precisions is zero the moment any one of them is.
All of these are counting string overlap. `sat` and `sitting` are different strings, so as far as these metrics are concerned the model did not say the same thing. Now change the theory of correctness:
```{python}
bertscore = evaluate.load("bertscore")
bs = bertscore.compute(predictions=[prediction], references=[reference], lang="en")
print("BERTScore:", {k: round(v[0], 4) for k, v in bs.items() if isinstance(v, list)})
print("backbone :", bs["hashcode"])
```
0.96. BERTScore embeds both sentences with a pretrained transformer and matches tokens by cosine similarity, so `sat` and `sitting` land close together and the pair reads as near-equivalent.
Two metrics, one pair, verdicts of 0.0 and 0.96. The gap is not noise — it is the difference between asking "did the model produce these exact words" and "did the model mean this". Summarization and translation papers report ROUGE and BLEU because they are cheap, deterministic and comparable across papers, not because anyone believes n-gram overlap is meaning.
### Caveat: single-sentence scores are close to meaningless anyway
Every number above is computed on one example, which is fine for showing that the metrics disagree and useless for judging a model. BLEU in particular is designed as a corpus-level statistic: its brevity penalty and n-gram precisions are meant to be aggregated over a whole test set, and per-sentence BLEU is notoriously unstable — the same system can score 0.0 on a sentence it translated perfectly well with different word order.
The honest use of these calls is to accumulate over a corpus, then report once.
### Caveat: BERTScore has a model inside it
`bertscore` is not a formula. The `hashcode` printed above names the backbone it silently downloaded — roughly 1.4 GB of `roberta-large` — and the score depends on that choice, on the layer used, and on the language flag. Two BERTScores from different backbones are not comparable, which is why the hashcode exists and why it belongs in anything you report.
It also means `evaluate.load("bertscore")` costs a model download and a forward pass per example, where `evaluate.load("rouge")` costs neither. The uniform interface hides a several-order-of-magnitude difference in what the call actually does.
## Metrics carry dependencies the interface hides
`evaluate.load` fetches a small implementation module from the Hub, and that module may import a package you do not have. The failure then arrives at `compute` time rather than `load` time, which makes it look like a data problem rather than an install problem — an earlier version of this post shipped a commented-out BERTScore cell for exactly this reason, with a note that it was broken. It was not broken; `bert_score` simply was not installed.
ROUGE needs `rouge_score`, `nltk` and `absl-py`; BLEU variants may want `sacrebleu`; BERTScore needs `bert_score`. All are pinned in the `requirements.txt` beside this post, which is why the cell above runs.
## Scoring real generated text
To evaluate a model rather than a hand-written string, generate something first. Note that `pipeline("summarization")` — which earlier versions of this post used — was **removed in `transformers` 5**, along with `translation` and `text2text-generation`. Sequence-to-sequence models are now driven directly:
```{python}
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained("google-t5/t5-small")
model = AutoModelForSeq2SeqLM.from_pretrained("google-t5/t5-small")
article = (
"The Hugging Face Transformers library provides thousands of pretrained models "
"for natural language processing. It supports text classification, information "
"extraction, question answering, summarization and translation, and it works "
"with both PyTorch and TensorFlow."
)
enc = tok(f"summarize: {article}", return_tensors="pt").to(model.device)
summary = tok.decode(model.generate(**enc, max_new_tokens=40)[0], skip_special_tokens=True)
print("summary:", summary)
human = "Transformers offers many pretrained models for NLP tasks in PyTorch and TensorFlow."
print("\nROUGE:", {k: round(float(v), 4)
for k, v in rouge.compute(predictions=[summary], references=[human]).items()})
```
The summary is largely extractive — `t5-small` copies source phrasing rather than rewriting — and ROUGE rewards exactly that. A model that paraphrased more aggressively would score *worse* against this reference while arguably summarizing better. That bias belongs to the metric, not the model, and it is a large part of why summarization leaderboards reward extraction.
## Writing your own
When no built-in metric encodes your notion of correct, a plain function will do, and the thing worth getting right is that it should express the asymmetry of your actual decision:
```{python}
def expected_cost(predictions, references, false_negative_cost=5.0):
"""Mean cost per example when a missed positive hurts more than a false alarm."""
incurred = sum(
(false_negative_cost if ref == 1 else 1.0)
for pred, ref in zip(predictions, references)
if pred != ref
)
return {"expected_cost": round(incurred / len(references), 4)}
refs = [0, 1, 1, 0]
missed_positive = [0, 0, 1, 0] # one false negative
false_alarm = [1, 1, 1, 0] # one false positive
for name, preds in [("missed positive", missed_positive), ("false alarm", false_alarm)]:
acc = accuracy.compute(predictions=preds, references=refs)["accuracy"]
print(f"{name:16s} accuracy={acc:.2f} {expected_cost(preds, refs)}")
```
This is the point of writing your own. Both prediction sets make exactly one mistake, so accuracy scores them **identically at 0.75** and reports that the two models are equally good. They are not: one missed a positive and one raised a false alarm, and the cost metric separates them by the factor of five it was told to care about.
Which number you publish should follow from what a mistake costs in the world. A cancer screen and a spam filter have opposite asymmetries, and unweighted accuracy is blind to both.
To use a custom function inside `Trainer`, hand it back in the same `{"name": value}` shape that `compute` returns, which is what the built-ins produce and what `compute_metrics` expects.
## What the uniform interface is worth
The claim at the top was that swapping metrics is one line, and that this is the danger. The evidence is 0.0 and 0.96 on an identical pair of sentences, produced by calls that differ only in a string.
What `evaluate` genuinely buys is that the plumbing stops being a variable: the same `compute(predictions=…, references=…)` shape works for every metric, `combine` runs a suite in one pass, and the implementation is versioned on the Hub rather than copy-pasted from a paper's appendix. Reproducibility of the *computation* is real and worth having.
What it cannot do is choose for you, and the uniformity actively obscures how different the choices are — in what they measure, in what they cost, and in whether they quietly carry a 1.4 GB model and a hashcode you are then obliged to report. Pick the metric from the decision it informs, and only then let the library make it easy.