-
Notifications
You must be signed in to change notification settings - Fork 1.8k
[NA] [SDK] fix: correct NLTK usage in METEOR and chrF metrics #7925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
f094d55
03e8bd6
9029aa3
f4915fb
b0f5dd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,20 +85,31 @@ def __init__( | |
| " `pip install nltk` or provide `chrf_fn`." | ||
| ) | ||
|
|
||
| def _compute(candidate: Sequence[str], references: Sequence[str]) -> float: | ||
| try: | ||
| return float( | ||
| nltk_chrf_score.sentence_chrf( | ||
| references, | ||
| candidate, | ||
| max_len=self._char_order, | ||
| beta=self._beta, | ||
| ignore_whitespace=self._ignore_whitespace, | ||
| ) | ||
| def _score_single(candidate: Sequence[str], reference: str) -> float: | ||
| # `sentence_chrf` has exposed this exact signature since NLTK 3.5, | ||
| # so every version installable on the Python versions this SDK | ||
| # supports accepts these keywords. Catching TypeError here would | ||
| # only mask genuine errors (and silently drop char_order/beta/ | ||
| # ignore_whitespace), so let it propagate. | ||
| return float( | ||
| nltk_chrf_score.sentence_chrf( | ||
| reference, | ||
| candidate, | ||
| max_len=self._char_order, | ||
| beta=self._beta, | ||
|
Comment on lines
+110
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documented in 9029aa3. The best-reference behaviour is now stated in the Regression coverage already exists in this PR:
On the timeout/bounded-input half: I'd rather not add that here. Every heuristic metric in this package takes caller-supplied text of arbitrary size (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detailed context. I can only save feedback to memory for specific code review findings, not general feedback or PR-level context. |
||
| ignore_whitespace=self._ignore_whitespace, | ||
| ) | ||
| except TypeError: | ||
| # Older NLTK versions expose the helper with fewer keyword arguments. | ||
| return float(nltk_chrf_score.sentence_chrf(references, candidate)) | ||
| ) | ||
|
|
||
| def _compute(candidate: Sequence[str], references: Sequence[str]) -> float: | ||
| # NLTK's sentence_chrf scores against a *single* reference; handing | ||
| # it the whole list makes NLTK join the references into one string, | ||
| # which drags the score down instead of rewarding the best match. | ||
| # Score each reference on its own and keep the highest, the standard | ||
| # multi-reference behaviour for chrF. | ||
| return max( | ||
| _score_single(candidate, reference) for reference in references | ||
| ) | ||
|
Comment on lines
+126
to
+128
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-finite ChrF feedback reaches backend
Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked into whether this is reachable, and I don't think it is for the default backend — NLTK guards the division itself.
That leaves a custom Happy to be overruled if the maintainers would rather have
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detailed validation—you're right that this is not reachable for the default backend, and custom scorer finiteness should be handled centrally if needed. I'll save this context to memory once the PR is merged. |
||
|
|
||
| self._chrf_fn = _compute | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,9 +7,15 @@ | |
| nltk = None | ||
| wordnet = None | ||
|
|
||
| from opik import semantic_version | ||
| from opik.exceptions import MetricComputationError | ||
| from opik.evaluation.metrics import base_metric, score_result | ||
|
|
||
| # NLTK 3.6.5 switched `meteor_score` to pre-tokenized input; 3.6.4 and earlier | ||
| # expect untokenized strings. Supporting both would mean branching on a release | ||
| # from 2021, so the default backend requires the modern API and says so clearly. | ||
| MINIMUM_NLTK_VERSION = "3.6.5" | ||
|
Comment on lines
+14
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unverifiable NLTK API boundaryThe version-specific NLTK compatibility comment omits authoritative full URLs for the upstream change and the release that introduced the pre-tokenized API, so the rationale for rejecting older releases is difficult to verify — should we add both links? Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9029aa3. The comment above
|
||
|
|
||
| try: | ||
| from nltk.translate import meteor_score as nltk_meteor_score | ||
| except ImportError: # pragma: no cover - optional dependency | ||
|
|
@@ -33,9 +39,13 @@ class METEOR(base_metric.BaseMetric): | |
| https://huggingface.co/spaces/evaluate-metric/meteor | ||
|
|
||
| Args: | ||
| meteor_fn: Optional callable with the same interface as | ||
| ``nltk.translate.meteor_score.meteor_score``. When omitted the | ||
| function from NLTK is used. | ||
| meteor_fn: Optional callable ``(references, hypothesis) -> float`` that | ||
| receives **untokenized** text: a sequence of reference strings and a | ||
| single hypothesis string. Note this deliberately differs from | ||
| ``nltk.translate.meteor_score.meteor_score``, which requires | ||
| pre-tokenized input — passing that function in directly will not | ||
| work. When omitted, NLTK is used through an adapter that tokenizes | ||
| on your behalf. | ||
| alpha: Precision weight. | ||
| beta: Penalty exponent. | ||
| gamma: Fragmentation penalty weight. | ||
|
|
@@ -65,6 +75,18 @@ def __init__( | |
| " `pip install nltk` or provide `meteor_fn`." | ||
| ) | ||
|
|
||
| if ( | ||
| nltk is not None | ||
| and semantic_version.SemanticVersion.parse(nltk.__version__) | ||
| < MINIMUM_NLTK_VERSION | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. METEOR leaks version-parser failuresThe constructor feeds Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — this was a real bug, fixed in f4915fb.
The check now reads the leading numeric components and compares them as a tuple:
On mapping parse failures to Covered by
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detailed correction—this handling and test coverage make sense. I’ll save this to memory once the PR is merged. |
||
| ): | ||
| raise ImportError( | ||
| f"METEOR metric requires nltk >= {MINIMUM_NLTK_VERSION}, but " | ||
| f"{nltk.__version__} is installed. Earlier versions expect " | ||
| "untokenized input. Upgrade via `pip install -U nltk` or supply " | ||
| "`meteor_fn`." | ||
| ) | ||
|
|
||
| if nltk is not None and wordnet is not None: | ||
| try: | ||
| wordnet.ensure_loaded() # type: ignore[attr-defined] | ||
|
|
@@ -82,10 +104,21 @@ def __init__( | |
| ) from download_error | ||
|
|
||
| def _scorer(references: Sequence[str], hypothesis: str) -> float: | ||
| # NLTK's meteor_score expects pre-tokenized input: an iterable of | ||
| # token lists for the references and a token list for the | ||
| # hypothesis. Handing it raw strings raises TypeError, so tokenize | ||
| # here (whitespace split, matching BLEU/GLEU) while keeping the | ||
| # public `meteor_fn` contract string-based. | ||
|
Comment on lines
+145
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documented callback shape causes METEOR crashesThe Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 03e8bd6. The |
||
| tokenized_references = [reference.split() for reference in references] | ||
| tokenized_hypothesis = hypothesis.split() | ||
| try: | ||
| return float( | ||
| nltk_meteor_score.meteor_score( | ||
| references, hypothesis, alpha=alpha, beta=beta, gamma=gamma | ||
| tokenized_references, | ||
| tokenized_hypothesis, | ||
|
Comment on lines
+150
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Older NLTK installs make METEOR failThe adapter always passes Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 03e8bd6. The default backend now refuses old NLTK at construction time rather than failing with a confusing 9029aa3 adds the upstream citations behind that boundary: nltk/nltk#2822 and the 3.6.5 ChangeLog entry "METEOR evaluation now requires pre-tokenized input". |
||
| alpha=alpha, | ||
| beta=beta, | ||
| gamma=gamma, | ||
| ) | ||
| ) | ||
| except LookupError as error: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import re | ||
| import types | ||
|
|
||
| import pytest | ||
|
|
||
|
|
@@ -411,6 +412,17 @@ def test_kl_divergence_avg_direction(): | |
| assert result.value >= 0.0 | ||
|
|
||
|
|
||
| def _skip_without_wordnet() -> None: | ||
| """Skip when the optional `nltk` dependency or its WordNet corpus is missing.""" | ||
| pytest.importorskip("nltk") | ||
| from nltk.corpus import wordnet | ||
|
|
||
| try: | ||
| wordnet.ensure_loaded() | ||
| except LookupError: | ||
| pytest.skip("NLTK WordNet corpus is not available") | ||
|
Comment on lines
+415
to
+423
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. METEOR regression tests routinely become skipsBoth new default-backend METEOR tests call Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 03e8bd6 by adding 9029aa3 also makes that test assert the returned |
||
|
|
||
|
|
||
| def test_meteor_metric_with_custom_fn(): | ||
| captured = [] | ||
|
|
||
|
|
@@ -433,6 +445,84 @@ def test_meteor_rejects_empty_inputs(): | |
| metric.score(output="hyp", reference=" ") | ||
|
|
||
|
|
||
| def test_meteor_metric__default_backend__hands_nltk_pretokenized_input(monkeypatch): | ||
| # Locks the tokenization contract with a stubbed NLTK, so this regression stays | ||
| # covered on a bare CI runner with neither `nltk` nor the WordNet corpus | ||
| # installed. Reverting the fix makes the recorded call raw strings and fails here. | ||
| from opik.evaluation.metrics.heuristics import meteor as meteor_module | ||
|
|
||
| recorded = {} | ||
|
|
||
| class _StubMeteor: | ||
| @staticmethod | ||
| def meteor_score(references, hypothesis, alpha, beta, gamma): | ||
| recorded["references"] = references | ||
| recorded["hypothesis"] = hypothesis | ||
| return 0.5 | ||
|
|
||
| monkeypatch.setattr(meteor_module, "nltk_meteor_score", _StubMeteor) | ||
| monkeypatch.setattr(meteor_module, "nltk", None) | ||
| monkeypatch.setattr(meteor_module, "wordnet", None) | ||
|
|
||
| METEOR(track=False).score(output="the cat sat", reference="the cat ran") | ||
|
|
||
| assert recorded["hypothesis"] == ["the", "cat", "sat"] | ||
| assert recorded["references"] == [["the", "cat", "ran"]] | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incorrect METEOR scores go undetectedThe test discards Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9029aa3 — the test now keeps the result and asserts both the value and the reason string: result = METEOR(track=False).score(output="the cat sat", reference="the cat ran")
assert recorded["hypothesis"] == ["the", "cat", "sat"]
assert recorded["references"] == [["the", "cat", "ran"]]
assert result.value == pytest.approx(0.5)
assert result.reason == "METEOR score: 0.5000" |
||
|
|
||
| def test_meteor_metric__legacy_nltk__raises_actionable_import_error(monkeypatch): | ||
| # NLTK <= 3.6.4 expects untokenized input, so the tokenizing adapter cannot | ||
| # work there. Fail at construction with a clear message instead of a confusing | ||
| # TypeError at score time. | ||
| from opik.evaluation.metrics.heuristics import meteor as meteor_module | ||
|
|
||
| monkeypatch.setattr(meteor_module, "nltk_meteor_score", object()) | ||
| monkeypatch.setattr( | ||
| meteor_module, "nltk", types.SimpleNamespace(__version__="3.6.4") | ||
| ) | ||
| monkeypatch.setattr(meteor_module, "wordnet", None) | ||
|
|
||
| with pytest.raises(ImportError, match="requires nltk >= 3.6.5"): | ||
| METEOR(track=False) | ||
|
|
||
|
|
||
| def test_meteor_metric__default_nltk_backend__scores_without_error(): | ||
| # NLTK's meteor_score requires pre-tokenized input; before the fix the default | ||
| # backend passed raw strings and every call raised | ||
| # `TypeError: "hypothesis" expects pre-tokenized hypothesis`, so the metric was | ||
| # unusable outside of dependency-injected tests. Needs the WordNet corpus. | ||
|
Comment on lines
+568
to
+572
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Third-party dependency contaminates unit suiteThe unit tests invoke real NLTK backends—including METEOR cases gated by Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This follows the existing convention in that file rather than introducing it: In practice this does not slow CI or make it environment-dependent, because Happy to move the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, that context makes the existing split appropriate. I’ll save this to memory once the PR is merged. |
||
| _skip_without_wordnet() | ||
|
|
||
| metric = METEOR(track=False) | ||
|
|
||
| identical = metric.score( | ||
| output="the cat sat on the mat", reference="the cat sat on the mat" | ||
| ).value | ||
| different = metric.score( | ||
| output="the cat sat on the mat", reference="a completely unrelated sentence" | ||
| ).value | ||
|
|
||
| # METEOR always applies a fragmentation penalty, so an exact match tops out | ||
| # just below 1.0 rather than at it. | ||
| assert identical > 0.99 | ||
| assert identical > different | ||
|
|
||
|
|
||
| def test_meteor_metric__multiple_references__picks_best_match(): | ||
| _skip_without_wordnet() | ||
|
|
||
| metric = METEOR(track=False) | ||
| hypothesis = "the cat sat on the mat" | ||
|
|
||
| best_reference_only = metric.score(output=hypothesis, reference=hypothesis).value | ||
| with_distractor = metric.score( | ||
| output=hypothesis, | ||
| reference=["a completely unrelated sentence", hypothesis], | ||
| ).value | ||
|
|
||
| assert with_distractor == pytest.approx(best_reference_only) | ||
|
|
||
|
|
||
| def test_gleu_metric_with_custom_fn(): | ||
| def gleu_fn(references, hypothesis): | ||
| return 0.5 | ||
|
|
@@ -525,6 +615,52 @@ def test_chrf_metric__char_order_and_ignore_whitespace_vary__change_score(): | |
| assert order_1 != order_6 | ||
|
|
||
|
|
||
| def test_chrf_metric__default_backend__scores_each_reference_separately(monkeypatch): | ||
| # Locks the per-reference contract with a stubbed NLTK so it runs without the | ||
| # optional dependency. Before the fix NLTK received the reference list in one | ||
| # call and joined it; now it must be called once per reference, best score kept. | ||
| from opik.evaluation.metrics.heuristics import chrf as chrf_module | ||
|
|
||
| seen_references = [] | ||
|
|
||
| class _StubChrf: | ||
| @staticmethod | ||
| def sentence_chrf( | ||
| reference, | ||
| hypothesis, | ||
| min_len=1, | ||
| max_len=6, | ||
| beta=3.0, | ||
| ignore_whitespace=True, | ||
| ): | ||
| seen_references.append(reference) | ||
| return 0.25 if reference == "first ref" else 0.75 | ||
|
|
||
| monkeypatch.setattr(chrf_module, "nltk_chrf_score", _StubChrf) | ||
|
|
||
| result = ChrF(track=False).score( | ||
| output="hypothesis", reference=["first ref", "second ref"] | ||
| ) | ||
|
|
||
| assert seen_references == ["first ref", "second ref"] | ||
| assert result.value == pytest.approx(0.75) | ||
|
|
||
|
|
||
| def test_chrf_metric__multiple_references__scores_against_best_reference(): | ||
| # NLTK's sentence_chrf takes a single reference. Before the fix the whole list | ||
| # was handed to it, so NLTK joined the references into one string and an exact | ||
| # match against one of them scored ~0.42 instead of 1.0. | ||
| pytest.importorskip("nltk") | ||
|
|
||
| metric = ChrF(track=False) | ||
| result = metric.score( | ||
| output="the cat sat on the mat", | ||
| reference=["totally unrelated words here", "the cat sat on the mat"], | ||
| ) | ||
|
|
||
| assert result.value == pytest.approx(1.0) | ||
|
|
||
|
|
||
| def test_spearman_ranking_metric(): | ||
| metric = SpearmanRanking(track=False) | ||
| result = metric.score(output=["b", "a", "c"], reference=["a", "b", "c"]) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unverifiable NLTK compatibility claim
The compatibility comment claims
sentence_chrfhas supported this signature since NLTK 3.5 but gives no authoritative link, so maintainers can't verify why the unguarded keyword call is safe or where the compatibility boundary applies — should we add full URLs to the relevant NLTK fix and release?Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair — fixed in 9029aa3, and the claim was slightly off as well. The keyword signature has been stable since NLTK 3.4 (2018-11-17), the release that added
ignore_whitespace, not 3.5. The comment now cites both ends of the range:Both show
sentence_chrf(reference, hypothesis, min_len=1, max_len=6, beta=3.0, ignore_whitespace=True), which is why an unguarded keyword call is safe here and why catchingTypeErrorwould only mask genuine errors.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for correcting the version detail and updating it with supporting references. I’ll save this to memory once the PR is merged.