Skip to content

Commit 8200fae

Browse files
agharsallahi-yliu
authored andcommitted
fix(evaluation): support non-English responses in ROUGE-1 matching
Merge #6292 Closes #3111 Problem: The `response_match_score` metric (`RougeEvaluator`) always returns 0 for responses in non-Latin scripts, even when the actual and expected responses are identical. Root Cause: The default `rouge_score` tokenizer (`DefaultTokenizer`) lowercases text and replaces every character outside `[a-z0-9]` with a space. Consequently, non-Latin scripts (Thai, Chinese, Arabic, Japanese, Cyrillic, etc.) tokenize to an empty token list (`[]`) and every comparison scores 0.0. Reproduction on `main`: ```python from rouge_score import rouge_scorer scorer = rouge_scorer.RougeScorer(["rouge1"], use_stemmer=True) scorer.score("สวัสดี", "สวัสดี")["rouge1"].fmeasure # 0.0 — identical strings scorer.score("hello", "hello")["rouge1"].fmeasure # 1.0 ``` Solution: Pass a custom `_UnicodeAwareTokenizer` to `RougeScorer` in `final_response_match_v1.py` to handle non-English scripts: 1. CJK (Chinese, Japanese Hiragana/Katakana, Hangul): Characters identified via `_is_cjk()` (Unicode ranges `0x4E00..0x9FFF`, `0x3040..0x309F`, `0x30A0..0x30FF`, `0xAC00..0xD7AF`) are separated into individual character tokens. This enables character-level ROUGE-1 unigram matching for CJK text without external heavy NLP segmentation dependencies. 2. Non-spaced scripts (Thai, Lao, Khmer, Myanmar): Detected via `_is_non_spaced_script()` (`0x0E00..0x0E7F` etc.). Characters are bundled into grapheme clusters where base consonants trigger new word boundaries while combining marks/vowels/tone marks (Unicode category `M`, e.g. `Mn` like Thai vowel signs) stay attached to their base consonant. 3. Spaced Non-ASCII scripts (Arabic, Cyrillic, Hindi, etc.): Non-ASCII word characters (`str.isalnum()` or category `M`) are preserved as distinct tokens instead of being stripped. 4. ASCII compatibility: Pure-ASCII tokens are delegated to `rouge_score`s default `DefaultTokenizer`, keeping lowercasing and Porter stemming identical for English text. Testing Plan: Unit Tests: - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. New tests in `tests/unittests/evaluation/test_final_response_match_v1.py`: - Identical non-English text scores 1.0 (Thai, Chinese, Arabic, Japanese, Russian) - Partially overlapping non-English text scores expected ROUGE-1 fractions (CJK & Russian) - Mixed English + non-English text handling - `_UnicodeAwareTokenizer` produces identical tokens to `DefaultTokenizer` for ASCII inputs (regression guard for English scoring) - Evaluator-level test cases for identical, partially matching (Chinese & Thai), and completely non-overlapping non-English responses ``` $ pytest tests/unittests/evaluation/test_final_response_match_v1.py -q 65 passed in 14.17s ``` Manual End-to-End (E2E) Tests: Ran the evaluator directly on the scenario from #3111 (agent instructed to reply with the word `สวัสดี`, expected response `สวัสดี`): ```python ev = RougeEvaluator(EvalMetric(metric_name="response_match_score", threshold=0.8)) result = ev.evaluate_invocations([inv("สวัสดี")], [inv("สวัสดี")]) # before: score=0.0, status=EvalStatus.FAILED # after: score=1.0, status=EvalStatus.PASSED ``` Original PR by @agharsallah Co-authored-by: Yi Liu <yiliuly@google.com> COPYBARA_INTEGRATE_REVIEW=#6292 from agharsallah:fix/eval-rouge-non-english-3111 4810719975 PiperOrigin-RevId: 954809249
1 parent d31b5e7 commit 8200fae

3 files changed

Lines changed: 291 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: 82 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
@@ -96,6 +98,83 @@ def _get_eval_status(score: float, threshold: float) -> EvalStatus:
9698
return EvalStatus.PASSED if score >= threshold else EvalStatus.FAILED
9799

98100

101+
def _is_cjk(char: str) -> bool:
102+
"""Checks if a character belongs to CJK (Chinese, Japanese, Korean) scripts."""
103+
code = ord(char)
104+
return (
105+
0x4E00 <= code <= 0x9FFF # CJK Unified Ideographs
106+
or 0x3040 <= code <= 0x309F # Hiragana
107+
or 0x30A0 <= code <= 0x30FF # Katakana
108+
or 0xAC00 <= code <= 0xD7AF # Hangul Syllables
109+
)
110+
111+
112+
def _is_non_spaced_script(char: str) -> bool:
113+
"""Checks if a character belongs to non-spaced scripts like Thai, Lao, Khmer, Myanmar."""
114+
code = ord(char)
115+
return (
116+
0x0E00 <= code <= 0x0E7F # Thai
117+
or 0x0E80 <= code <= 0x0EFF # Lao
118+
or 0x1780 <= code <= 0x17FF # Khmer
119+
or 0x1000 <= code <= 0x109F # Myanmar
120+
)
121+
122+
123+
def _is_word_char(char: str) -> bool:
124+
# Combining marks (e.g. Thai vowel signs, Devanagari matras) are not
125+
# alphanumeric on their own but must stay attached to their base character.
126+
return char.isalnum() or unicodedata.category(char).startswith("M")
127+
128+
129+
class _UnicodeAwareTokenizer:
130+
"""Tokenizer that keeps non-ASCII word characters and splits CJK/non-spaced characters.
131+
132+
The default rouge_score tokenizer discards any character outside [a-z0-9],
133+
so text in non-Latin scripts (e.g. Thai, Chinese, Arabic) tokenizes to
134+
nothing and always scores 0. This tokenizer keeps Unicode word characters,
135+
normalizes Unicode variants (NFKC), splits non-spaced CJK characters at the
136+
character level, bundles non-spaced scripts by grapheme clusters (base
137+
consonant + attached combining marks), and delegates ASCII tokens to the
138+
default tokenizer.
139+
140+
Note:
141+
Languages written without spaces (e.g. Thai, Chinese, Japanese) are
142+
tokenized at the character or grapheme cluster level rather than at true
143+
word granularity, since dictionary-based word segmentation requires heavy
144+
external NLP dependencies. As a result, ROUGE-1 unigram overlap for these
145+
languages operates on character/grapheme cluster units rather than full words.
146+
"""
147+
148+
def __init__(self, use_stemmer: bool = False):
149+
self._default_tokenizer = tokenizers.DefaultTokenizer(use_stemmer)
150+
151+
def tokenize(self, text: str) -> list[str]:
152+
text = unicodedata.normalize("NFKC", text).lower()
153+
processed_chars = []
154+
for char in text:
155+
if _is_cjk(char):
156+
processed_chars.extend([" ", char, " "])
157+
elif _is_non_spaced_script(char):
158+
if unicodedata.category(char).startswith("M"):
159+
# Combining mark (vowel/tone mark): attach directly to previous base consonant!
160+
processed_chars.append(char)
161+
else:
162+
# Base consonant: start a new grapheme cluster boundary by prepending a space!
163+
processed_chars.extend([" ", char])
164+
elif _is_word_char(char):
165+
processed_chars.append(char)
166+
else:
167+
processed_chars.append(" ")
168+
words = "".join(processed_chars).split()
169+
tokens = []
170+
for word in words:
171+
if word.isascii():
172+
tokens.extend(self._default_tokenizer.tokenize(word))
173+
else:
174+
tokens.append(word)
175+
return tokens
176+
177+
99178
def _calculate_rouge_1_scores(candidate: str, reference: str):
100179
"""Calculates the ROUGE-1 score between a candidate and reference text.
101180
@@ -114,7 +193,9 @@ def _calculate_rouge_1_scores(candidate: str, reference: str):
114193
Returns:
115194
A dictionary containing the ROUGE-1 precision, recall, and f-measure.
116195
"""
117-
scorer = rouge_scorer.RougeScorer(["rouge1"], use_stemmer=True)
196+
scorer = rouge_scorer.RougeScorer(
197+
["rouge1"], tokenizer=_UnicodeAwareTokenizer(use_stemmer=True)
198+
)
118199

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

tests/unittests/evaluation/test_final_response_match_v1.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,21 @@
1414

1515
from __future__ import annotations
1616

17+
import unicodedata
18+
1719
from google.adk.evaluation.eval_case import Invocation
1820
from google.adk.evaluation.eval_metrics import EvalMetric
1921
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
2022
from google.adk.evaluation.evaluator import EvalStatus
2123
from google.adk.evaluation.final_response_match_v1 import _calculate_rouge_1_scores
24+
from google.adk.evaluation.final_response_match_v1 import _is_cjk
25+
from google.adk.evaluation.final_response_match_v1 import _is_non_spaced_script
26+
from google.adk.evaluation.final_response_match_v1 import _is_word_char
27+
from google.adk.evaluation.final_response_match_v1 import _UnicodeAwareTokenizer
2228
from google.adk.evaluation.final_response_match_v1 import RougeEvaluator
2329
from google.genai import types as genai_types
2430
import pytest
31+
from rouge_score import tokenizers
2532

2633

2734
def _create_test_rouge_evaluator(threshold: float) -> RougeEvaluator:
@@ -87,6 +94,183 @@ def test_calculate_rouge_1_scores():
8794
assert rouge_1_score.fmeasure == pytest.approx(8 / 11)
8895

8996

97+
@pytest.mark.parametrize(
98+
"text",
99+
[
100+
"สวัสดี", # Thai
101+
"你好世界", # Chinese
102+
"مرحبا بالعالم", # Arabic
103+
"こんにちは", # Japanese
104+
"Здравствуйте", # Russian
105+
],
106+
)
107+
def test_calculate_rouge_1_scores_identical_non_english_text(text: str):
108+
rouge_1_score = _calculate_rouge_1_scores(text, text)
109+
assert rouge_1_score.precision == pytest.approx(1)
110+
assert rouge_1_score.recall == pytest.approx(1)
111+
assert rouge_1_score.fmeasure == pytest.approx(1)
112+
113+
114+
def test_calculate_rouge_1_scores_different_non_english_text():
115+
candidate = "мир привет"
116+
reference = "привет только"
117+
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
118+
assert rouge_1_score.precision == pytest.approx(1 / 2)
119+
assert rouge_1_score.recall == pytest.approx(1 / 2)
120+
assert rouge_1_score.fmeasure == pytest.approx(1 / 2)
121+
122+
123+
def test_calculate_rouge_1_scores_cjk_partial_overlap_and_inversion():
124+
candidate = "天气很好今天"
125+
reference = "今天天气很好"
126+
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
127+
# Character-level matching: 6/6 characters overlap in unigram space.
128+
assert rouge_1_score.precision == pytest.approx(1.0)
129+
assert rouge_1_score.recall == pytest.approx(1.0)
130+
assert rouge_1_score.fmeasure == pytest.approx(1.0)
131+
132+
133+
def test_calculate_rouge_1_scores_mixed_language_text():
134+
candidate = "hello สวัสดี"
135+
reference = "hello world"
136+
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
137+
# Candidate tokens: ['hello', 'สั', 'ส', 'ด', 'ดี'] (5 tokens).
138+
# Reference tokens: ['hello', 'world'] (2 tokens).
139+
assert rouge_1_score.precision == pytest.approx(1 / 5)
140+
assert rouge_1_score.recall == pytest.approx(1 / 2)
141+
assert rouge_1_score.fmeasure == pytest.approx(2 / 7)
142+
143+
144+
def test_unicode_aware_tokenizer_combining_marks_category_m():
145+
"""Tests that combining marks (category 'M', e.g. Thai vowel signs) stay attached to base characters."""
146+
tokenizer = _UnicodeAwareTokenizer()
147+
148+
# Thai word "ดี" (Consonant 'ด' + Combining Mark Vowel ' ี' [category Mn]).
149+
# Verifies that category 'M' combining marks hit the startswith("M") branch and attach to 'ด'.
150+
# Extracting mark from "ดี"[1] ensures clean visual rendering without font overlap.
151+
thai_vowel_mark = "ดี"[1]
152+
assert unicodedata.category(thai_vowel_mark).startswith("M")
153+
154+
tokens = tokenizer.tokenize("ดี")
155+
assert len(tokens) == 1
156+
assert tokens[0] == "ดี"
157+
158+
# Hindi / Devanagari word "नमस्ते" (contains combining mark matras).
159+
tokens_hindi = tokenizer.tokenize("नमस्ते")
160+
assert len(tokens_hindi) == 1
161+
assert tokens_hindi[0] == "नमस्ते"
162+
163+
164+
@pytest.mark.parametrize(
165+
"input_text, use_stemmer, expected_tokens",
166+
[
167+
# Mixed English + Thai (with stemmer)
168+
("hello สวัสดี", True, ["hello", "ส", "วั", "ส", "ดี"]),
169+
# Branch 1a: CJK Hanzi
170+
("中文测试", False, ["中", "文", "测", "试"]),
171+
("今天天气很好", False, ["今", "天", "天", "气", "很", "好"]),
172+
# Branch 1b: CJK Hiragana
173+
("ひらがな", False, ["ひ", "ら", "が", "な"]),
174+
("こんにちは", False, ["こ", "ん", "に", "ち", "は"]),
175+
# Branch 1c: CJK Katakana
176+
("カタカナ", False, ["カ", "タ", "カ", "ナ"]),
177+
# Branch 1d: CJK Hangul
178+
("한글", False, ["한", "글"]),
179+
# Branch 2a: Non-spaced script (Thai consonant + combining mark M)
180+
("ดี", False, ["ดี"]),
181+
("ฉันรักคุณมาก", False, ["ฉั", "น", "รั", "ก", "คุ", "ณ", "ม", "า", "ก"]),
182+
# Branch 2b: Non-spaced script (Lao)
183+
("ດີ", False, ["ດີ"]),
184+
# Branch 2c: Non-spaced script (Khmer)
185+
("ល្អ", False, ["ល្", "អ"]),
186+
# Branch 2d: Non-spaced script (Myanmar)
187+
("မင်္ဂလာ", False, ["မ", "င်္", "ဂ", "လာ"]),
188+
# Branch 3a: Alphanumeric ASCII (with and without stemmer)
189+
("Running jumped 123", True, ["run", "jump", "123"]),
190+
("Running jumped 123", False, ["running", "jumped", "123"]),
191+
# Branch 3b & 3c: Non-ASCII spaced script with combining mark M (Arabic Harakat & Hindi Matra)
192+
("مَرْحَبًا", False, ["مَرْحَبًا"]),
193+
("नमस्ते", False, ["नमस्ते"]),
194+
("Hello World! Привет мир", True, ["hello", "world", "привет", "мир"]),
195+
# Branch 4: Punctuation and non-word symbols (triggers else: append(" "))
196+
("hello, world! @123 #test", True, ["hello", "world", "123", "test"]),
197+
],
198+
)
199+
def test_unicode_aware_tokenizer_all_branches_coverage(
200+
input_text: str, use_stemmer: bool, expected_tokens: list[str]
201+
):
202+
"""Verifies 100% branch coverage for all script types, combining marks, stemmer flag, and punctuation handling."""
203+
tokenizer = _UnicodeAwareTokenizer(use_stemmer=use_stemmer)
204+
assert tokenizer.tokenize(input_text) == expected_tokens
205+
206+
207+
@pytest.mark.parametrize(
208+
"char, expected",
209+
[
210+
("中", True), # Hanzi
211+
("ぁ", True), # Hiragana
212+
("ァ", True), # Katakana
213+
("한", True), # Hangul
214+
("a", False),
215+
("1", False),
216+
("ส", False),
217+
],
218+
)
219+
def test_is_cjk(char: str, expected: bool):
220+
"""Tests _is_cjk helper for Chinese, Hiragana, Katakana, and Hangul boundaries."""
221+
assert _is_cjk(char) == expected
222+
223+
224+
@pytest.mark.parametrize(
225+
"char, expected",
226+
[
227+
("ส", True), # Thai
228+
("ກ", True), # Lao
229+
("ក", True), # Khmer
230+
("က", True), # Myanmar
231+
("中", False),
232+
("a", False),
233+
],
234+
)
235+
def test_is_non_spaced_script(char: str, expected: bool):
236+
"""Tests _is_non_spaced_script helper for Thai, Lao, Khmer, and Myanmar boundaries."""
237+
assert _is_non_spaced_script(char) == expected
238+
239+
240+
@pytest.mark.parametrize(
241+
"char, expected",
242+
[
243+
("a", True),
244+
("9", True),
245+
("中", True),
246+
("ส", True),
247+
("ดี"[1], True), # Combining Mark Category Mn (Thai Vowel)
248+
(" ", False),
249+
("!", False),
250+
],
251+
)
252+
def test_is_word_char(char: str, expected: bool):
253+
"""Tests _is_word_char helper for alphanumerics and combining marks."""
254+
assert _is_word_char(char) == expected
255+
256+
257+
@pytest.mark.parametrize(
258+
"text",
259+
[
260+
"The quick brown fox jumps over the lazy dog.",
261+
"Testing stemmed words like running and jumped, don't split!",
262+
"Numbers 123 and mixed a1b2 tokens under_scored.",
263+
"",
264+
],
265+
)
266+
def test_unicode_aware_tokenizer_matches_default_tokenizer_for_ascii(
267+
text: str,
268+
):
269+
default_tokens = tokenizers.DefaultTokenizer(use_stemmer=True).tokenize(text)
270+
unicode_tokens = _UnicodeAwareTokenizer(use_stemmer=True).tokenize(text)
271+
assert unicode_tokens == default_tokens
272+
273+
90274
@pytest.mark.parametrize(
91275
"candidates, references, expected_score, expected_status",
92276
[
@@ -114,6 +298,30 @@ def test_calculate_rouge_1_scores():
114298
1.0,
115299
EvalStatus.PASSED,
116300
),
301+
(
302+
["สวัสดี", "你好"],
303+
["สวัสดี", "你好"],
304+
1.0,
305+
EvalStatus.PASSED,
306+
),
307+
(
308+
["今天天气不错", "我想吃炒饭"],
309+
["今天天气很好", "我想吃面条"],
310+
0.63333, # (2/3 + 3/5) / 2
311+
EvalStatus.FAILED,
312+
),
313+
(
314+
["สวัสดีครับ", "ฉันชอบกินข้าวผัด"],
315+
["สวัสดีค่ะ", "ฉันชอบกินก๋วยเตี๋ยว"],
316+
0.61538, # (8/13 + 8/13) / 2
317+
EvalStatus.FAILED,
318+
),
319+
(
320+
["你好世界", "人工智能"],
321+
["再见", "机器学习"],
322+
0.0,
323+
EvalStatus.FAILED,
324+
),
117325
],
118326
)
119327
def test_rouge_evaluator_multiple_invocations(

0 commit comments

Comments
 (0)