mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
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:
committed by
Copybara-Service
parent
fa025d7559
commit
9597a446fd
@@ -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
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user