feat: Add rouge_score library to ADK eval dependencies, and implement RougeEvaluator that is computes ROUGE-1 for "response_match_score" metric

PiperOrigin-RevId: 774949712
This commit is contained in:
Joseph Pagadora
2025-06-23 15:31:03 -07:00
committed by Copybara-Service
parent fa025d7559
commit 9597a446fd
5 changed files with 301 additions and 2 deletions
+1
View File
@@ -87,6 +87,7 @@ eval = [
"google-cloud-aiplatform[evaluation]>=1.87.0", "google-cloud-aiplatform[evaluation]>=1.87.0",
"pandas>=2.2.3", "pandas>=2.2.3",
"tabulate>=0.9.0", "tabulate>=0.9.0",
"rouge-score>=0.1.2",
# go/keep-sorted end # go/keep-sorted end
] ]
@@ -0,0 +1,110 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Optional
from google.genai import types as genai_types
from rouge_score import rouge_scorer
from typing_extensions import override
from .eval_case import Invocation
from .eval_metrics import EvalMetric
from .evaluator import EvalStatus
from .evaluator import EvaluationResult
from .evaluator import Evaluator
from .evaluator import PerInvocationResult
class RougeEvaluator(Evaluator):
"""Calculates the ROUGE-1 metric to compare responses."""
def __init__(self, eval_metric: EvalMetric):
self._eval_metric = eval_metric
@override
def evaluate_invocations(
self,
actual_invocations: list[Invocation],
expected_invocations: list[Invocation],
) -> EvaluationResult:
total_score = 0.0
num_invocations = 0
per_invocation_results = []
for actual, expected in zip(actual_invocations, expected_invocations):
reference = _get_text_from_content(expected.final_response)
response = _get_text_from_content(actual.final_response)
rouge_1_scores = _calculate_rouge_1_scores(response, reference)
score = rouge_1_scores.fmeasure
per_invocation_results.append(
PerInvocationResult(
actual_invocation=actual,
expected_invocation=expected,
score=score,
eval_status=_get_eval_status(score, self._eval_metric.threshold),
)
)
total_score += score
num_invocations += 1
if per_invocation_results:
overall_score = total_score / num_invocations
return EvaluationResult(
overall_score=overall_score,
overall_eval_status=_get_eval_status(
overall_score, self._eval_metric.threshold
),
per_invocation_results=per_invocation_results,
)
return EvaluationResult()
def _get_text_from_content(content: Optional[genai_types.Content]) -> str:
if content and content.parts:
return "\n".join([part.text for part in content.parts if part.text])
return ""
def _get_eval_status(score: float, threshold: float):
return EvalStatus.PASSED if score >= threshold else EvalStatus.FAILED
def _calculate_rouge_1_scores(candidate: str, reference: str):
"""Calculates the ROUGE-1 score between a candidate and reference text.
ROUGE-1 measures the overlap of unigrams (single words) between the
candidate and reference texts. The score is broken down into:
- Precision: The proportion of unigrams in the candidate that are also in the
reference.
- Recall: The proportion of unigrams in the reference that are also in the
candidate.
- F-measure: The harmonic mean of precision and recall.
Args:
candidate: The generated text to be evaluated.
reference: The ground-truth text to compare against.
Returns:
A dictionary containing the ROUGE-1 precision, recall, and f-measure.
"""
scorer = rouge_scorer.RougeScorer(["rouge1"], use_stemmer=True)
# The score method returns a dictionary where keys are the ROUGE types
# and values are Score objects (tuples) with precision, recall, and fmeasure.
scores = scorer.score(reference, candidate)
return scores["rouge1"]
@@ -27,10 +27,12 @@ from vertexai.preview.evaluation import MetricPromptTemplateExamples
from .eval_case import IntermediateData from .eval_case import IntermediateData
from .eval_case import Invocation from .eval_case import Invocation
from .eval_metrics import EvalMetric
from .evaluator import EvalStatus from .evaluator import EvalStatus
from .evaluator import EvaluationResult from .evaluator import EvaluationResult
from .evaluator import Evaluator from .evaluator import Evaluator
from .evaluator import PerInvocationResult from .evaluator import PerInvocationResult
from .final_response_match_v1 import RougeEvaluator
class ResponseEvaluator(Evaluator): class ResponseEvaluator(Evaluator):
@@ -40,7 +42,7 @@ class ResponseEvaluator(Evaluator):
if "response_evaluation_score" == metric_name: if "response_evaluation_score" == metric_name:
self._metric_name = MetricPromptTemplateExamples.Pointwise.COHERENCE self._metric_name = MetricPromptTemplateExamples.Pointwise.COHERENCE
elif "response_match_score" == metric_name: elif "response_match_score" == metric_name:
self._metric_name = "rouge_1" self._metric_name = "response_match_score"
else: else:
raise ValueError(f"`{metric_name}` is not supported.") raise ValueError(f"`{metric_name}` is not supported.")
@@ -52,6 +54,15 @@ class ResponseEvaluator(Evaluator):
actual_invocations: list[Invocation], actual_invocations: list[Invocation],
expected_invocations: list[Invocation], expected_invocations: list[Invocation],
) -> EvaluationResult: ) -> EvaluationResult:
# If the metric is response_match_score, just use the RougeEvaluator.
if self._metric_name == "response_match_score":
rouge_evaluator = RougeEvaluator(
EvalMetric(metric_name=self._metric_name, threshold=self._threshold)
)
return rouge_evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
total_score = 0.0 total_score = 0.0
num_invocations = 0 num_invocations = 0
per_invocation_results = [] per_invocation_results = []
@@ -0,0 +1,140 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.final_response_match_v1 import _calculate_rouge_1_scores
from google.adk.evaluation.final_response_match_v1 import RougeEvaluator
from google.genai import types as genai_types
import pytest
def _create_test_rouge_evaluator(threshold: float) -> RougeEvaluator:
return RougeEvaluator(
EvalMetric(metric_name="response_match_score", threshold=threshold)
)
def _create_test_invocations(
candidate: str, reference: str
) -> tuple[Invocation, Invocation]:
"""Returns tuple of (actual_invocation, expected_invocation)."""
return Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=candidate)]
),
), Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=reference)]
),
)
def test_calculate_rouge_1_scores_empty_candidate_and_reference():
candidate = ""
reference = ""
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == 0
assert rouge_1_score.recall == 0
assert rouge_1_score.fmeasure == 0
def test_calculate_rouge_1_scores_empty_candidate():
candidate = ""
reference = "This is a test reference."
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == 0
assert rouge_1_score.recall == 0
assert rouge_1_score.fmeasure == 0
def test_calculate_rouge_1_scores_empty_reference():
candidate = "This is a test candidate response."
reference = ""
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == 0
assert rouge_1_score.recall == 0
assert rouge_1_score.fmeasure == 0
def test_calculate_rouge_1_scores():
candidate = "This is a test candidate response."
reference = "This is a test reference."
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == pytest.approx(2 / 3)
assert rouge_1_score.recall == pytest.approx(4 / 5)
assert rouge_1_score.fmeasure == pytest.approx(8 / 11)
@pytest.mark.parametrize(
"candidates, references, expected_score, expected_status",
[
(
["The quick brown fox jumps.", "hello world"],
["The quick brown fox jumps over the lazy dog.", "hello"],
0.69048, # (5/7 + 2/3) / 2
EvalStatus.FAILED,
),
(
["This is a test.", "Another test case."],
["This is a test.", "This is a different test."],
0.625, # (1 + 1/4) / 2
EvalStatus.FAILED,
),
(
["No matching words here.", "Second candidate."],
["Completely different text.", "Another reference."],
0.0, # (0 + 1/2) / 2
EvalStatus.FAILED,
),
(
["Same words", "Same words"],
["Same words", "Same words"],
1.0,
EvalStatus.PASSED,
),
],
)
def test_rouge_evaluator_multiple_invocations(
candidates: list[str],
references: list[str],
expected_score: float,
expected_status: EvalStatus,
):
rouge_evaluator = _create_test_rouge_evaluator(threshold=0.8)
actual_invocations = []
expected_invocations = []
for candidate, reference in zip(candidates, references):
actual_invocation, expected_invocation = _create_test_invocations(
candidate, reference
)
actual_invocations.append(actual_invocation)
expected_invocations.append(expected_invocation)
evaluation_result = rouge_evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == pytest.approx(
expected_score, rel=1e-3
)
assert evaluation_result.overall_eval_status == expected_status
@@ -16,7 +16,10 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.response_evaluator import ResponseEvaluator from google.adk.evaluation.response_evaluator import ResponseEvaluator
from google.genai import types as genai_types
import pandas as pd import pandas as pd
import pytest import pytest
from vertexai.preview.evaluation import MetricPromptTemplateExamples from vertexai.preview.evaluation import MetricPromptTemplateExamples
@@ -63,7 +66,7 @@ SAMPLE_TURN_4_MINIMAL = {
"google.adk.evaluation.response_evaluator.ResponseEvaluator._perform_eval" "google.adk.evaluation.response_evaluator.ResponseEvaluator._perform_eval"
) )
class TestResponseEvaluator: class TestResponseEvaluator:
"""A class to help organize "patch" that are applicabple to all tests.""" """A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_none_dataset_raises_value_error(self, mock_perform_eval): def test_evaluate_none_dataset_raises_value_error(self, mock_perform_eval):
"""Test evaluate function raises ValueError for an empty list.""" """Test evaluate function raises ValueError for an empty list."""
@@ -77,6 +80,40 @@ class TestResponseEvaluator:
ResponseEvaluator.evaluate([], ["response_evaluation_score"]) ResponseEvaluator.evaluate([], ["response_evaluation_score"])
mock_perform_eval.assert_not_called() # Ensure _perform_eval was not called mock_perform_eval.assert_not_called() # Ensure _perform_eval was not called
def test_evaluate_invocations_rouge_metric(self, mock_perform_eval):
"""Test evaluate_invocations function for Rouge metric."""
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = ResponseEvaluator(
threshold=0.8, metric_name="response_match_score"
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == pytest.approx(8 / 11)
# ROUGE-1 F1 is approx. 0.73 < 0.8 threshold, so eval status is FAILED.
assert evaluation_result.overall_eval_status == EvalStatus.FAILED
def test_evaluate_determines_metrics_correctly_for_perform_eval( def test_evaluate_determines_metrics_correctly_for_perform_eval(
self, mock_perform_eval self, mock_perform_eval
): ):