Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ pip install bert-score

`ChrF` computes the character n-gram F-score (`chrF` / `chrF++`). Adjust `beta`, `char_order`, and `word_order` to switch between the two variants.

When several references are passed, the score is computed against each reference separately and the best (highest) score is returned.

```python
from opik.evaluation.metrics import ChrF

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ pip install bert-score

`ChrF` computes the character n-gram F-score (`chrF` / `chrF++`). Adjust `beta`, `char_order`, and `word_order` to switch between the two variants.

When several references are passed, the score is computed against each reference separately and the best (highest) score is returned.

```python
from opik.evaluation.metrics import ChrF

Expand Down
57 changes: 42 additions & 15 deletions sdks/python/src/opik/evaluation/metrics/heuristics/chrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
except ImportError: # pragma: no cover - optional dependency
nltk_chrf_score = None

ChrFFn = Callable[[Sequence[str], Sequence[str]], float]
# The candidate is a single string; only the references are a sequence.
ChrFFn = Callable[[str, Sequence[str]], float]


class ChrF(BaseMetric):
Expand All @@ -25,6 +26,12 @@ class ChrF(BaseMetric):
is not supported by the NLTK backend; provide a custom ``chrf_fn`` to compute it.
Scores range from `0.0` (no overlap) to `1.0` (perfect match).

When several references are supplied, the default backend scores the candidate
against each reference separately and returns the **best** (highest) score, the
standard multi-reference behaviour for chrF. NLTK's ``sentence_chrf`` accepts a
single reference only, so a list handed to it directly would be joined into one
string and score lower than the best individual match.

References:
- Popović, "chrF: character n-gram F-score for automatic MT evaluation" (WMT 2015)
https://aclanthology.org/W15-3049/
Expand All @@ -44,7 +51,11 @@ class ChrF(BaseMetric):
word_order: Maximum word n-gram order for chrF++. Not supported by the
default NLTK backend; provide ``chrf_fn`` to use it.
lowercase: Whether to lowercase candidate and references prior to scoring.
chrf_fn: Optional custom scoring callable for testing or offline usage.
chrf_fn: Optional custom scoring callable ``(candidate, references) -> float``
receiving the candidate string and the sequence of reference strings,
for testing or offline usage. Note this differs from NLTK's
``sentence_chrf``, which takes a single reference — supplying it
directly means multi-reference scoring is left to your callable.

Example:
>>> from opik.evaluation.metrics import ChrF
Expand Down Expand Up @@ -85,20 +96,36 @@ 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: str, reference: str) -> float:
# `sentence_chrf` has accepted these keywords since NLTK 3.4
# (2018-11-17), the release that added `ignore_whitespace`:
# https://github.com/nltk/nltk/blob/3.4/nltk/translate/chrf_score.py#L18
# It is unchanged in the latest release:
# https://github.com/nltk/nltk/blob/3.9.1/nltk/translate/chrf_score.py#L16
# Every NLTK release installable on the Python versions this SDK
# supports is newer than 3.4, so a TypeError here would signal a
# genuine error rather than an old NLTK. Catching it would mask
# that 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_compute now calls ChrF.score once per reference and takes the maximum, so multi-reference results, persisted reports, and pass/fail thresholds can change after an upgrade — should we document this best-reference contract and upgrade impact in the metric docs/changelog and add a regression test? score() accepts unbounded Sequence[str] inputs and text sizes, so one call can keep a worker busy indefinitely while futures.wait blocks — should we bound the inputs and add a per-score timeout or cancellation budget?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/metrics/heuristics/chrf.py` around lines 94-112: 1)
Document that `ChrF` scores each reference independently and returns the maximum score
rather than passing the full reference sequence to NLTK, explain the impact on
multi-reference results, persisted reports, and thresholds, update the public metric
documentation and release changelog, and add/update a regression test covering the
best-reference behavior. 2) The `_compute`/`_score_single` logic invokes NLTK once per
reference without bounding reference count or total text size, letting a single score
consume a worker indefinitely. Add configurable maximum reference-count and
total-reference-text-size limits, validate them before entering the loop, fail with a
clear validation error when exceeded, and propagate a per-score timeout/cancellation
budget in the evaluation execution path so oversized or pathological NLTK work cannot
block a scoring worker.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 9029aa3. The best-reference behaviour is now stated in the ChrF class docstring and in the user-facing metric docs (fern/docs/evaluation/metrics/heuristic_metrics.mdx and the docs-v2 copy): when several references are passed, the candidate is scored against each one separately and the highest score is returned. That is the standard multi-reference behaviour for chrF, and it is a fix rather than a behaviour change in the usual sense — the previous result came from NLTK joining the reference list into a single string, which scored an exact match against one reference at ~0.42.

Regression coverage already exists in this PR:

  • test_chrf_metric__default_backend__scores_each_reference_separately (stubbed NLTK, asserts one call per reference and that the best score wins)
  • test_chrf_metric__multiple_references__scores_against_best_reference (real backend, exact match against one of two references scores 1.0)

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 (SentenceBLEU, ROUGE, GLEU included), chrF is linear in the input per reference, and a per-score budget belongs at the evaluation-framework level, not inside one metric — adding it to chrF alone would be inconsistent without fixing anything.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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: 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-finite ChrF feedback reaches backend

max() can select a non-finite scorer result, while float(value) only changes its type, so it reaches ScoreResult.value, passes feedback validation, and is serialized by log_test_result_feedback_scores — should we apply math.isfinite before constructing ScoreResult and add a regression test?

Severity web_search

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/metrics/heuristics/chrf.py` around lines 126-128,
update the `ChrF.__init__` default `_compute` multi-reference scoring so the score
selected by `max()` is validated with `math.isfinite` before it can become a
`ScoreResult`. Reject non-finite values such as NaN and positive or negative infinity
with the metric’s appropriate error behavior, and add a regression test covering a
scorer that returns a non-finite result.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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. sentence_chrf returns an epsilon rather than dividing by zero, so degenerate inputs bottom out at 1e-16, not NaN:

sentence_chrf("a", "b", max_len=6, ignore_whitespace=True)  -> 1.0000000000000001e-16
sentence_chrf("x", "y" * 20, max_len=1, ignore_whitespace=False) -> 1e-16

ChrF.score also rejects empty/whitespace candidates and references before anything reaches the scorer, so there is no input that makes max() select a non-finite value here.

That leaves a custom chrf_fn returning NaN, which is caller-supplied and identical in shape for every heuristic metric in this package (SentenceBLEU, ROUGE, GLEU, METEOR all do float(...) on an injected scorer's result). Guarding only chrF would be inconsistent without fixing anything, and the SDK already treats this as a framework-level concern — _is_valid_score_value in opik/evaluation/score_statistics.py filters non-finite values with math.isfinite during aggregation.

Happy to be overruled if the maintainers would rather have ScoreResult validate finiteness centrally; that seems like the right layer for it, but out of scope for an NLTK usage fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Expand Down
92 changes: 87 additions & 5 deletions sdks/python/src/opik/evaluation/metrics/heuristics/meteor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, Callable, Optional, Sequence, Union
import re
from typing import Any, Callable, Optional, Sequence, Tuple, Union

try:
import nltk # type: ignore
Expand All @@ -10,6 +11,52 @@
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.
#
# Upstream change: https://github.com/nltk/nltk/pull/2822 ("Accept pre-tokenized
# references & hypothesis for METEOR calculation"), first shipped in 3.6.5 —
# see https://github.com/nltk/nltk/blob/3.6.5/ChangeLog ("Version 3.6.5
# 2021-10-11 ... METEOR evaluation now requires pre-tokenized input"). Compare
# https://github.com/nltk/nltk/blob/3.6.4/nltk/translate/meteor_score.py#L343
# with https://github.com/nltk/nltk/blob/3.6.5/nltk/translate/meteor_score.py#L347
# for the signature change.
MINIMUM_NLTK_VERSION = "3.6.5"
Comment on lines +14 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unverifiable NLTK API boundary

The 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?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/metrics/heuristics/meteor.py` around lines 14-17,
update the NLTK compatibility comment for the pre-tokenized `meteor_score` API boundary.
Add full, authoritative URLs to the upstream change/bug and the NLTK release
documentation for version 3.6.5, while preserving the explanation for rejecting older
releases.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9029aa3. The comment above MINIMUM_NLTK_VERSION now links the upstream change and the release that shipped it:

MINIMUM_NLTK_VERSION_INFO = (3, 6, 5)

# NLTK reports two-component versions for several real releases ("3.5", "3.4",
# "3.3") and used prerelease suffixes in the past ("3.0a3"), so the version
# string cannot be fed to a strict SemVer parser — it would raise ValueError on
# exactly the old installs this guard exists to reject. Read the leading numeric
# components instead.
_VERSION_RE = re.compile(r"^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?P<suffix>\S*)")
_PRERELEASE_RE = re.compile(r"^[-._]?(a|b|c|rc|alpha|beta|dev|pre)", re.IGNORECASE)


def _is_below_minimum_nltk_version(version: str) -> bool:
"""Whether `version` is known to predate `MINIMUM_NLTK_VERSION`.

Version strings that cannot be read at all — such as the ``"unknown (...)"``
fallback a broken install can report — return `False`. Refusing to build the
metric because a version string was unparseable would be worse than letting
NLTK speak for itself, and `_scorer` turns the resulting `TypeError` into an
actionable error anyway.
"""
match = _VERSION_RE.match(version)
if match is None:
return False

parsed: Tuple[int, ...] = tuple(
int(part) if part else 0 for part in match.group(1, 2, 3)
)
if parsed == MINIMUM_NLTK_VERSION_INFO:
# A prerelease of the minimum version ("3.6.5rc1") predates its API;
# a post-release or local version ("3.6.5.post1", "3.6.5+local") does not.
return _PRERELEASE_RE.match(match.group("suffix")) is not None
return parsed < MINIMUM_NLTK_VERSION_INFO


try:
from nltk.translate import meteor_score as nltk_meteor_score
except ImportError: # pragma: no cover - optional dependency
Expand All @@ -33,9 +80,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.
Expand Down Expand Up @@ -65,6 +116,15 @@ def __init__(
" `pip install nltk` or provide `meteor_fn`."
)

installed_version = getattr(nltk, "__version__", "")
if nltk is not None and _is_below_minimum_nltk_version(installed_version):
raise ImportError(
f"METEOR metric requires nltk >= {MINIMUM_NLTK_VERSION}, but "
f"{installed_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]
Expand All @@ -82,12 +142,34 @@ 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented callback shape causes METEOR crashes

The meteor_fn docstring advertises NLTK’s tokenized meteor_score interface even though the adapter passes raw (Sequence[str], str) inputs, so callers using NLTK’s function directly get TypeError for every non-empty score — should we document meteor_fn as (Sequence[str], str) -> float and distinguish it from the tokenized adapter, or normalize custom callbacks consistently?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/metrics/heuristics/meteor.py` around lines 85-89,
update the `METEOR.__init__` documentation and `_scorer` explanation to explicitly
define `meteor_fn` as `(Sequence[str], str) -> float` receiving raw string references
and hypothesis text. Clarify that the built-in adapter tokenizes these strings before
calling NLTK’s `meteor_score`, and that NLTK’s tokenized callback should not be
passed directly unless wrapped. Preserve the existing string-based `score()` and
injected callback contract.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 03e8bd6. The meteor_fn docstring now documents the contract as (references, hypothesis) -> float over untokenized text (a sequence of reference strings, one hypothesis string), and explicitly warns that this differs from nltk.translate.meteor_score.meteor_score, so passing NLTK's function in directly will not work. The tokenizing adapter is internal to the default backend and is what normalizes strings into NLTK's pre-tokenized form.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Older NLTK installs make METEOR fail

The adapter always passes list[list[str]] references and a list[str] hypothesis, but NLTK 3.6.4 and earlier expect list[str] references and a raw str hypothesis, so METEOR.score() raises a type error when those unconstrained versions are installed — should we require NLTK >=3.6.5 or branch for the legacy API?

Severity web_search

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/metrics/heuristics/meteor.py` around lines 90-96,
update the nested `_scorer` adapter in `METEOR.__init__` because unconditional
tokenization only works with NLTK 3.6.5 and newer, while older installable versions
expect string references and a raw-string hypothesis. Prefer adding and enforcing an
NLTK minimum version wherever optional dependency requirements are declared, or, if
legacy support is required, branch the call based on the installed NLTK version and pass
the legacy argument shapes accordingly. Add or update tests to verify the
supported-version behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 TypeError at score time: MINIMUM_NLTK_VERSION = "3.6.5", and the constructor raises an ImportError naming the required version and pointing at pip install -U nltk or supplying meteor_fn. Covered by test_meteor_metric__legacy_nltk__raises_actionable_import_error.

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 TypeError as error:
# Only reachable when the version guard could not read
# `nltk.__version__`: NLTK < 3.6.5 rejects the tokenized
# input built above. Keep NLTK's own message so a genuine
# type error is not misreported as a version problem.
raise MetricComputationError(
f"NLTK rejected the pre-tokenized METEOR input: {error}. "
f"This usually means nltk < {MINIMUM_NLTK_VERSION} is "
"installed, which expects untokenized strings. Upgrade via "
"`pip install -U nltk` or supply `meteor_fn`."
) from error
except LookupError as error:
raise MetricComputationError(
"NLTK resource requirement for METEOR not satisfied. "
Expand Down
Loading