Skip to content

Commit 3f5519a

Browse files
committed
fix(evaluation): Support non-English responses in ROUGE-1 matching
The default rouge_score tokenizer drops every character outside [a-z0-9], so responses in non-Latin scripts (Thai, Chinese, Arabic, etc.) tokenize to nothing and response_match_score is always 0, even for identical texts. Use a Unicode-aware tokenizer that keeps non-ASCII word characters (including combining marks such as Thai vowel signs) and delegates ASCII tokens to the default tokenizer, so stemming and scores for English text are unchanged. Fixes #3111 Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>
1 parent e6df097 commit 3f5519a

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

src/google/adk/dependencies/rouge_scorer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
from __future__ import annotations
1616

1717
from rouge_score import rouge_scorer as rouge_scorer
18+
from rouge_score import tokenizers as tokenizers

src/google/adk/evaluation/final_response_match_v1.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
from __future__ import annotations
1616

1717
from typing import Optional
18+
import unicodedata
1819

1920
from google.genai import types as genai_types
2021
from typing_extensions import override
2122

2223
from ..dependencies.rouge_scorer import rouge_scorer
24+
from ..dependencies.rouge_scorer import tokenizers
2325
from .eval_case import ConversationScenario
2426
from .eval_case import Invocation
2527
from .eval_metrics import EvalMetric
@@ -92,6 +94,39 @@ def _get_eval_status(score: float, threshold: float):
9294
return EvalStatus.PASSED if score >= threshold else EvalStatus.FAILED
9395

9496

97+
def _is_word_char(char: str) -> bool:
98+
# Combining marks (e.g. Thai vowel signs, Devanagari matras) are not
99+
# alphanumeric on their own but must stay attached to their base character.
100+
return char.isalnum() or unicodedata.category(char).startswith("M")
101+
102+
103+
class _UnicodeAwareTokenizer(tokenizers.Tokenizer):
104+
"""Tokenizer that keeps non-ASCII word characters.
105+
106+
The default rouge_score tokenizer discards any character outside [a-z0-9],
107+
so text in non-Latin scripts (e.g. Thai, Chinese, Arabic) tokenizes to
108+
nothing and always scores 0. This tokenizer keeps Unicode word characters
109+
and delegates ASCII tokens to the default tokenizer, preserving its
110+
stemming and scoring behavior for English text.
111+
"""
112+
113+
def __init__(self, use_stemmer: bool = False):
114+
self._default_tokenizer = tokenizers.DefaultTokenizer(use_stemmer)
115+
116+
def tokenize(self, text: str) -> list[str]:
117+
text = text.lower()
118+
words = "".join(
119+
char if _is_word_char(char) else " " for char in text
120+
).split()
121+
tokens = []
122+
for word in words:
123+
if word.isascii():
124+
tokens.extend(self._default_tokenizer.tokenize(word))
125+
else:
126+
tokens.append(word)
127+
return tokens
128+
129+
95130
def _calculate_rouge_1_scores(candidate: str, reference: str):
96131
"""Calculates the ROUGE-1 score between a candidate and reference text.
97132
@@ -110,7 +145,9 @@ def _calculate_rouge_1_scores(candidate: str, reference: str):
110145
Returns:
111146
A dictionary containing the ROUGE-1 precision, recall, and f-measure.
112147
"""
113-
scorer = rouge_scorer.RougeScorer(["rouge1"], use_stemmer=True)
148+
scorer = rouge_scorer.RougeScorer(
149+
["rouge1"], tokenizer=_UnicodeAwareTokenizer(use_stemmer=True)
150+
)
114151

115152
# The score method returns a dictionary where keys are the ROUGE types
116153
# and values are Score objects (tuples) with precision, recall, and fmeasure.

tests/unittests/evaluation/test_final_response_match_v1.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
2020
from google.adk.evaluation.evaluator import EvalStatus
2121
from google.adk.evaluation.final_response_match_v1 import _calculate_rouge_1_scores
22+
from google.adk.evaluation.final_response_match_v1 import _UnicodeAwareTokenizer
2223
from google.adk.evaluation.final_response_match_v1 import RougeEvaluator
2324
from google.genai import types as genai_types
2425
import pytest
26+
from rouge_score import tokenizers
2527

2628

2729
def _create_test_rouge_evaluator(threshold: float) -> RougeEvaluator:
@@ -87,6 +89,58 @@ def test_calculate_rouge_1_scores():
8789
assert rouge_1_score.fmeasure == pytest.approx(8 / 11)
8890

8991

92+
@pytest.mark.parametrize(
93+
"text",
94+
[
95+
"สวัสดี", # Thai
96+
"你好世界", # Chinese
97+
"مرحبا بالعالم", # Arabic
98+
"こんにちは", # Japanese
99+
"Здравствуйте", # Russian
100+
],
101+
)
102+
def test_calculate_rouge_1_scores_identical_non_english_text(text: str):
103+
rouge_1_score = _calculate_rouge_1_scores(text, text)
104+
assert rouge_1_score.precision == pytest.approx(1)
105+
assert rouge_1_score.recall == pytest.approx(1)
106+
assert rouge_1_score.fmeasure == pytest.approx(1)
107+
108+
109+
def test_calculate_rouge_1_scores_different_non_english_text():
110+
candidate = "мир привет"
111+
reference = "привет только"
112+
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
113+
assert rouge_1_score.precision == pytest.approx(1 / 2)
114+
assert rouge_1_score.recall == pytest.approx(1 / 2)
115+
assert rouge_1_score.fmeasure == pytest.approx(1 / 2)
116+
117+
118+
def test_calculate_rouge_1_scores_mixed_language_text():
119+
candidate = "hello สวัสดี"
120+
reference = "hello world"
121+
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
122+
assert rouge_1_score.precision == pytest.approx(1 / 2)
123+
assert rouge_1_score.recall == pytest.approx(1 / 2)
124+
assert rouge_1_score.fmeasure == pytest.approx(1 / 2)
125+
126+
127+
@pytest.mark.parametrize(
128+
"text",
129+
[
130+
"The quick brown fox jumps over the lazy dog.",
131+
"Testing stemmed words like running and jumped, don't split!",
132+
"Numbers 123 and mixed a1b2 tokens under_scored.",
133+
"",
134+
],
135+
)
136+
def test_unicode_aware_tokenizer_matches_default_tokenizer_for_ascii(
137+
text: str,
138+
):
139+
default_tokens = tokenizers.DefaultTokenizer(use_stemmer=True).tokenize(text)
140+
unicode_tokens = _UnicodeAwareTokenizer(use_stemmer=True).tokenize(text)
141+
assert unicode_tokens == default_tokens
142+
143+
90144
@pytest.mark.parametrize(
91145
"candidates, references, expected_score, expected_status",
92146
[
@@ -114,6 +168,12 @@ def test_calculate_rouge_1_scores():
114168
1.0,
115169
EvalStatus.PASSED,
116170
),
171+
(
172+
["สวัสดี", "你好"],
173+
["สวัสดี", "你好"],
174+
1.0,
175+
EvalStatus.PASSED,
176+
),
117177
],
118178
)
119179
def test_rouge_evaluator_multiple_invocations(

0 commit comments

Comments
 (0)