Skip to content

Commit 24ce78b

Browse files
vaibhav-patelcopybara-github
authored andcommitted
feat: add option to save eval results to CSV
Merge google#6182 Fixes google#2652 PiperOrigin-RevId: 959813686
1 parent 5a5d55f commit 24ce78b

2 files changed

Lines changed: 282 additions & 1 deletion

File tree

src/google/adk/evaluation/agent_evaluator.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ async def evaluate_eval_set(
129129
agent_name: Optional[str] = None,
130130
print_detailed_results: bool = True,
131131
artifact_service: Optional[BaseArtifactService] = None,
132+
output_file: Optional[str] = None,
132133
) -> None:
133134
"""Evaluates an agent using the given EvalSet.
134135
@@ -150,6 +151,10 @@ async def evaluate_eval_set(
150151
Pre-load artifacts here and pin each eval case to a session id (via
151152
`SessionInput.session_id`) to make them reachable. Defaults to an
152153
in-memory service.
154+
output_file: If provided, per-invocation evaluation results (for both
155+
passing and failing metrics) are written to this path as a CSV file.
156+
Disabled by default. The parent directory is created if it does not
157+
already exist.
153158
"""
154159
if criteria:
155160
logger.warning(
@@ -193,7 +198,11 @@ async def evaluate_eval_set(
193198
# test failures. We track them and then report them towards the end.
194199
failures: list[str] = []
195200

196-
for _, eval_results_per_eval_id in eval_results_by_eval_id.items():
201+
# Optionally, we collect per-invocation results across all eval cases and
202+
# metrics so that they can be written out to a CSV file at the end.
203+
csv_rows: list[dict[str, Any]] = []
204+
205+
for eval_id, eval_results_per_eval_id in eval_results_by_eval_id.items():
197206
eval_metric_results = (
198207
AgentEvaluator._get_eval_metric_results_with_invocation(
199208
eval_results_per_eval_id
@@ -207,6 +216,20 @@ async def evaluate_eval_set(
207216

208217
failures.extend(failures_per_eval_case)
209218

219+
if output_file:
220+
csv_rows.extend(
221+
AgentEvaluator._get_results_as_rows(
222+
eval_set_id=eval_set.eval_set_id,
223+
eval_id=eval_id,
224+
eval_metric_results=eval_metric_results,
225+
)
226+
)
227+
228+
if output_file:
229+
AgentEvaluator._write_results_to_csv(
230+
rows=csv_rows, output_file=output_file
231+
)
232+
210233
failure_message = "Following are all the test failures."
211234
if not print_detailed_results:
212235
failure_message += (
@@ -225,6 +248,7 @@ async def evaluate(
225248
initial_session_file: Optional[str] = None,
226249
print_detailed_results: bool = True,
227250
artifact_service: Optional[BaseArtifactService] = None,
251+
output_file: Optional[str] = None,
228252
) -> None:
229253
"""Evaluates an Agent given eval data.
230254
@@ -247,6 +271,10 @@ async def evaluate(
247271
Pre-load artifacts here and pin each eval case to a session id (via
248272
`SessionInput.session_id`) to make them reachable. Defaults to an
249273
in-memory service.
274+
output_file: If provided, per-invocation evaluation results are written to
275+
this path as a CSV file. Disabled by default. When the eval data spans
276+
multiple test files, results from all of them are appended to the same
277+
file.
250278
"""
251279
test_files = []
252280
if isinstance(eval_dataset_file_path_or_dir, str) and os.path.isdir(
@@ -275,6 +303,7 @@ async def evaluate(
275303
agent_name=agent_name,
276304
print_detailed_results=print_detailed_results,
277305
artifact_service=artifact_service,
306+
output_file=output_file,
278307
)
279308

280309
@staticmethod
@@ -775,3 +804,76 @@ def _process_metrics_and_get_failures(
775804
)
776805

777806
return failures
807+
808+
@staticmethod
809+
def _get_results_as_rows(
810+
eval_set_id: str,
811+
eval_id: str,
812+
eval_metric_results: dict[str, list[_EvalMetricResultWithInvocation]],
813+
) -> list[dict[str, Any]]:
814+
"""Flattens eval results into one row per metric per invocation.
815+
816+
The columns mirror the ones used in `_print_details`, with additional
817+
identifier columns so that rows from different eval cases and metrics can be
818+
distinguished within a single CSV file.
819+
"""
820+
rows: list[dict[str, Any]] = []
821+
for metric_name, results_with_invocations in eval_metric_results.items():
822+
for result_with_invocation in results_with_invocations:
823+
eval_metric_result = result_with_invocation.eval_metric_result
824+
expected_invocation = result_with_invocation.expected_invocation
825+
actual_invocation = result_with_invocation.actual_invocation
826+
rows.append({
827+
"eval_set_id": eval_set_id,
828+
"eval_id": eval_id,
829+
"metric_name": metric_name,
830+
"threshold": eval_metric_result.threshold,
831+
"score": eval_metric_result.score,
832+
"eval_status": eval_metric_result.eval_status.name,
833+
"prompt": AgentEvaluator._convert_content_to_text(
834+
expected_invocation.user_content
835+
if expected_invocation
836+
else actual_invocation.user_content
837+
),
838+
"expected_response": AgentEvaluator._convert_content_to_text(
839+
expected_invocation.final_response
840+
if expected_invocation
841+
else None
842+
),
843+
"actual_response": AgentEvaluator._convert_content_to_text(
844+
actual_invocation.final_response
845+
),
846+
"expected_tool_calls": AgentEvaluator._convert_tool_calls_to_text(
847+
expected_invocation.intermediate_data
848+
if expected_invocation
849+
else None
850+
),
851+
"actual_tool_calls": AgentEvaluator._convert_tool_calls_to_text(
852+
actual_invocation.intermediate_data
853+
),
854+
})
855+
return rows
856+
857+
@staticmethod
858+
def _write_results_to_csv(
859+
rows: list[dict[str, Any]], output_file: str
860+
) -> None:
861+
"""Writes eval results to a CSV file.
862+
863+
Appends rows to the file if it already exists, writing the header only once.
864+
Creates parent directories if necessary.
865+
"""
866+
try:
867+
import pandas as pd
868+
except ModuleNotFoundError as e:
869+
raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e
870+
871+
output_dir = os.path.dirname(output_file)
872+
if output_dir:
873+
os.makedirs(output_dir, exist_ok=True)
874+
875+
file_exists = os.path.isfile(output_file)
876+
pd.DataFrame(rows).to_csv(
877+
output_file, mode="a", header=not file_exists, index=False
878+
)
879+
logger.info("Saved eval results to %s", output_file)

tests/unittests/evaluation/test_agent_evaluator.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,23 @@
1616

1717
from __future__ import annotations
1818

19+
import os
1920
from types import SimpleNamespace
2021

2122
from google.adk.agents.base_agent import BaseAgent
2223
from google.adk.apps.app import App
2324
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
25+
from google.adk.evaluation.agent_evaluator import _EvalMetricResultWithInvocation
2426
from google.adk.evaluation.agent_evaluator import AgentEvaluator
2527
from google.adk.evaluation.eval_case import EvalCase
28+
from google.adk.evaluation.eval_case import Invocation
2629
from google.adk.evaluation.eval_config import EvalConfig
30+
from google.adk.evaluation.eval_metrics import EvalMetricResult
2731
from google.adk.evaluation.eval_set import EvalSet
32+
from google.adk.evaluation.evaluator import EvalStatus
2833
from google.adk.evaluation.simulation.user_simulator_provider import UserSimulatorProvider
34+
from google.genai import types as genai_types
35+
import pandas as pd
2936
import pytest
3037

3138

@@ -258,3 +265,175 @@ async def test_none_app_is_forwarded_by_default(self, mocker):
258265
)
259266

260267
assert mock_service_cls.call_args.kwargs["app"] is None
268+
269+
270+
def _content(text: str) -> genai_types.Content:
271+
return genai_types.Content(parts=[genai_types.Part(text=text)])
272+
273+
274+
def _make_result_with_invocation(
275+
metric_name: str,
276+
score: float,
277+
threshold: float,
278+
eval_status: EvalStatus,
279+
prompt: str,
280+
expected_response: str,
281+
actual_response: str,
282+
) -> _EvalMetricResultWithInvocation:
283+
return _EvalMetricResultWithInvocation(
284+
actual_invocation=Invocation(
285+
user_content=_content(prompt),
286+
final_response=_content(actual_response),
287+
),
288+
expected_invocation=Invocation(
289+
user_content=_content(prompt),
290+
final_response=_content(expected_response),
291+
),
292+
eval_metric_result=EvalMetricResult(
293+
metric_name=metric_name,
294+
threshold=threshold,
295+
score=score,
296+
eval_status=eval_status,
297+
),
298+
)
299+
300+
301+
def test_get_results_as_rows_flattens_metrics_and_invocations():
302+
eval_metric_results = {
303+
"response_match_score": [
304+
_make_result_with_invocation(
305+
metric_name="response_match_score",
306+
score=1.0,
307+
threshold=0.8,
308+
eval_status=EvalStatus.PASSED,
309+
prompt="What is 2 + 2?",
310+
expected_response="4",
311+
actual_response="4",
312+
),
313+
_make_result_with_invocation(
314+
metric_name="response_match_score",
315+
score=0.0,
316+
threshold=0.8,
317+
eval_status=EvalStatus.FAILED,
318+
prompt="Capital of France?",
319+
expected_response="Paris",
320+
actual_response="London",
321+
),
322+
],
323+
}
324+
325+
rows = AgentEvaluator._get_results_as_rows(
326+
eval_set_id="my_eval_set",
327+
eval_id="my_eval_case",
328+
eval_metric_results=eval_metric_results,
329+
)
330+
331+
assert len(rows) == 2
332+
first = rows[0]
333+
assert first["eval_set_id"] == "my_eval_set"
334+
assert first["eval_id"] == "my_eval_case"
335+
assert first["metric_name"] == "response_match_score"
336+
assert first["threshold"] == 0.8
337+
assert first["score"] == 1.0
338+
assert first["eval_status"] == "PASSED"
339+
assert first["prompt"] == "What is 2 + 2?"
340+
assert first["expected_response"] == "4"
341+
assert first["actual_response"] == "4"
342+
343+
# Failing invocation should still be captured.
344+
assert rows[1]["eval_status"] == "FAILED"
345+
assert rows[1]["actual_response"] == "London"
346+
347+
348+
def test_get_results_as_rows_handles_missing_expected_invocation():
349+
result = _EvalMetricResultWithInvocation(
350+
actual_invocation=Invocation(
351+
user_content=_content("hi"),
352+
final_response=_content("hello"),
353+
),
354+
expected_invocation=None,
355+
eval_metric_result=EvalMetricResult(
356+
metric_name="safety_v1",
357+
threshold=0.5,
358+
score=1.0,
359+
eval_status=EvalStatus.PASSED,
360+
),
361+
)
362+
363+
rows = AgentEvaluator._get_results_as_rows(
364+
eval_set_id="s",
365+
eval_id="c",
366+
eval_metric_results={"safety_v1": [result]},
367+
)
368+
369+
assert len(rows) == 1
370+
assert rows[0]["prompt"] == "hi"
371+
assert rows[0]["expected_response"] == ""
372+
assert rows[0]["actual_response"] == "hello"
373+
374+
375+
def test_write_results_to_csv_writes_expected_file(tmp_path):
376+
rows = [
377+
{
378+
"eval_set_id": "s",
379+
"eval_id": "c",
380+
"metric_name": "response_match_score",
381+
"threshold": 0.8,
382+
"score": 1.0,
383+
"eval_status": "PASSED",
384+
"prompt": "What is 2 + 2?",
385+
"expected_response": "4",
386+
"actual_response": "4",
387+
"expected_tool_calls": "",
388+
"actual_tool_calls": "",
389+
},
390+
]
391+
output_file = os.path.join(str(tmp_path), "nested", "eval_results.csv")
392+
393+
AgentEvaluator._write_results_to_csv(rows=rows, output_file=output_file)
394+
395+
# The nested directory should have been created.
396+
assert os.path.isfile(output_file)
397+
398+
df = pd.read_csv(output_file)
399+
assert list(df.columns) == list(rows[0].keys())
400+
assert len(df) == 1
401+
assert df.iloc[0]["metric_name"] == "response_match_score"
402+
assert df.iloc[0]["eval_status"] == "PASSED"
403+
assert df.iloc[0]["score"] == 1.0
404+
405+
406+
def test_write_results_to_csv_appends_without_duplicate_header(tmp_path):
407+
output_file = os.path.join(str(tmp_path), "eval_results.csv")
408+
409+
def _row(eval_id: str, score: float, status: str) -> dict:
410+
return {
411+
"eval_set_id": "s",
412+
"eval_id": eval_id,
413+
"metric_name": "response_match_score",
414+
"threshold": 0.8,
415+
"score": score,
416+
"eval_status": status,
417+
"prompt": "p",
418+
"expected_response": "e",
419+
"actual_response": "a",
420+
"expected_tool_calls": "",
421+
"actual_tool_calls": "",
422+
}
423+
424+
AgentEvaluator._write_results_to_csv(
425+
rows=[_row("case_1", 1.0, "PASSED")], output_file=output_file
426+
)
427+
AgentEvaluator._write_results_to_csv(
428+
rows=[_row("case_2", 0.0, "FAILED")], output_file=output_file
429+
)
430+
431+
df = pd.read_csv(output_file)
432+
# Two appends should accumulate two rows, with the header written only once.
433+
assert len(df) == 2
434+
assert sorted(df["eval_id"].tolist()) == ["case_1", "case_2"]
435+
assert "eval_id" not in df["eval_id"].tolist()
436+
437+
438+
if __name__ == "__main__":
439+
raise SystemExit(pytest.main([__file__, "-v"]))

0 commit comments

Comments
 (0)