Skip to content

Commit 5b1b83c

Browse files
jsondaicopybara-github
authored andcommitted
chore: Add evals metric validation, load/dump, and versioning utils
PiperOrigin-RevId: 769872843
1 parent 3d747e7 commit 5b1b83c

5 files changed

Lines changed: 460 additions & 11 deletions

File tree

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
"scikit-learn; python_version>'3.10'",
172172
"jsonschema",
173173
"ruamel.yaml",
174+
"pyyaml",
174175
]
175176

176177
langchain_extra_require = [

tests/unit/vertexai/genai/test_evals.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def test_eval_batch_eval(self, mock_evaluate, mock_get_api_client):
8484
test_client = vertexai.Client(project=_TEST_PROJECT, location=_TEST_LOCATION)
8585
test_client.evals.batch_eval(
8686
dataset=vertexai_genai_types.EvaluationDataset(),
87-
metrics=[vertexai_genai_types.Metric()],
87+
metrics=[vertexai_genai_types.Metric(name="test")],
8888
output_config=vertexai_genai_types.OutputConfig(),
8989
autorater_config=vertexai_genai_types.AutoraterConfig(),
9090
config=vertexai_genai_types.EvaluateDatasetConfig(),
@@ -1354,6 +1354,73 @@ def test_convert_with_additional_columns(self):
13541354
assert eval_case.custom_column == "custom_value"
13551355

13561356

1357+
class TestMetric:
1358+
"""Unit tests for the Metric class."""
1359+
1360+
def test_metric_creation_success(self):
1361+
metric = vertexai_genai_types.Metric(name="TestMetric")
1362+
assert metric.name == "testmetric"
1363+
assert metric.custom_function is None
1364+
1365+
def test_metric_creation_with_custom_function(self):
1366+
def my_custom_function(data: dict):
1367+
return 1.0
1368+
1369+
metric = vertexai_genai_types.Metric(
1370+
name="custom_metric", custom_function=my_custom_function
1371+
)
1372+
assert metric.name == "custom_metric"
1373+
assert metric.custom_function == my_custom_function
1374+
1375+
def test_metric_name_validation_empty_raises_error(self):
1376+
with pytest.raises(ValueError, match="Metric name cannot be empty."):
1377+
vertexai_genai_types.Metric(name="")
1378+
with pytest.raises(ValueError, match="Metric name cannot be empty."):
1379+
vertexai_genai_types.Metric(name=None)
1380+
1381+
def test_llm_metric_prompt_template_validation_empty_raises_error(self):
1382+
with pytest.raises(ValueError, match="Prompt template cannot be empty."):
1383+
vertexai_genai_types.LLMMetric(name="test_metric", prompt_template=None)
1384+
with pytest.raises(
1385+
ValueError, match="Prompt template cannot be an empty string."
1386+
):
1387+
vertexai_genai_types.LLMMetric(name="test_metric", prompt_template="")
1388+
with pytest.raises(
1389+
ValueError, match="Prompt template cannot be an empty string."
1390+
):
1391+
vertexai_genai_types.LLMMetric(name="test_metric", prompt_template=" ")
1392+
1393+
def test_llm_metric_sampling_count_validation_raise_errors(self):
1394+
with pytest.raises(
1395+
ValueError, match="judge_model_sampling_count must be between 1 and 32."
1396+
):
1397+
vertexai_genai_types.LLMMetric(
1398+
name="test_metric",
1399+
prompt_template="test_prompt_template",
1400+
judge_model_sampling_count=0,
1401+
)
1402+
with pytest.raises(
1403+
ValueError, match="judge_model_sampling_count must be between 1 and 32."
1404+
):
1405+
vertexai_genai_types.LLMMetric(
1406+
name="test_metric",
1407+
prompt_template="test_prompt_template",
1408+
judge_model_sampling_count=-1,
1409+
)
1410+
with pytest.raises(
1411+
ValueError, match="judge_model_sampling_count must be between 1 and 32."
1412+
):
1413+
vertexai_genai_types.LLMMetric(
1414+
name="test_metric",
1415+
prompt_template="test_prompt_template",
1416+
judge_model_sampling_count=100,
1417+
)
1418+
1419+
def test_metric_name_validation_lowercase(self):
1420+
metric = vertexai_genai_types.Metric(name="UPPERCASEMetric")
1421+
assert metric.name == "uppercasemetric"
1422+
1423+
13571424
class TestMergeResponseDatasets:
13581425
"""Unit tests for the merge_response_datasets_into_canonical_format function."""
13591426

vertexai/_genai/_evals_utils.py

Lines changed: 251 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@
1717
import json
1818
import logging
1919
import os
20+
import re
2021
import time
21-
from typing import Any, Union
22+
from typing import Any, Union, Optional
23+
2224
from google.cloud import bigquery
2325
from google.cloud import storage
2426
from google.genai._api_client import BaseApiClient
2527
import pandas as pd
28+
import yaml
29+
30+
from . import types
2631

2732
logger = logging.getLogger(__name__)
2833

@@ -275,3 +280,248 @@ def load(self, source: Union[str, "pd.DataFrame"]) -> list[dict[str, Any]]:
275280
" a valid GCS path with `jsonl` or `csv` suffix, a local"
276281
" file path, or a valid BigQuery table URI."
277282
)
283+
284+
285+
class LazyLoadedPrebuiltMetric:
286+
"""A proxy object representing a prebuilt metric that will be loaded on demand."""
287+
288+
_cache: dict[str, types.LLMMetric] = {}
289+
_base_gcs_path = (
290+
"gs://vertex-ai-generative-ai-eval-sdk-resources/metrics/{metric_name}/"
291+
)
292+
293+
def __init__(self, name: str, version: Optional[str] = None):
294+
self.name = name.lower()
295+
self.version = version or "latest"
296+
self._resolved_metric: Optional[types.LLMMetric] = None
297+
298+
def _get_latest_version_uri(self, api_client: Any, metric_gcs_dir: str) -> str:
299+
"""Lists files in GCS directory and determines the latest version URI."""
300+
gcs_utils = GcsUtils(api_client)
301+
bucket_name, prefix = gcs_utils.parse_gcs_path(metric_gcs_dir)
302+
303+
blobs = gcs_utils.storage_client.list_blobs(bucket_name, prefix=prefix)
304+
305+
version_files: list[
306+
dict[str, Union[list[int], str]]
307+
] = [] # {'version_parts': [1,0,0], 'filename': 'v1.0.0.yaml'}
308+
309+
# Regex to capture versions like v1, v1.0, v1.0.0 (supports .yaml and .json)
310+
# It prioritizes more specific versions (e.g., v1.2.3 over v1.2)
311+
version_pattern = re.compile(
312+
r"v(\d+)(?:\.(\d+))?(?:\.(\d+))?\.(yaml|yml|json)$", re.IGNORECASE
313+
)
314+
315+
for blob in blobs:
316+
match = version_pattern.match(os.path.basename(blob.name))
317+
if match:
318+
major = int(match.group(1))
319+
minor = int(match.group(2)) if match.group(2) else 0
320+
patch = int(match.group(3)) if match.group(3) else 0
321+
version_files.append(
322+
{
323+
"version_parts": [major, minor, patch],
324+
"filename": os.path.basename(blob.name),
325+
}
326+
)
327+
328+
if not version_files:
329+
raise IOError(f"No versioned metric files found in {metric_gcs_dir}")
330+
331+
version_files.sort(key=lambda x: x["version_parts"], reverse=True)
332+
333+
latest_filename = version_files[0]["filename"]
334+
return os.path.join(metric_gcs_dir, latest_filename)
335+
336+
def _fetch_and_parse(self, api_client: Any) -> types.LLMMetric:
337+
"""Fetches and parses the metric definition from the URI."""
338+
metric_gcs_dir = self._base_gcs_path.format(metric_name=self.name)
339+
uri: str
340+
if self.version == "latest":
341+
uri = self._get_latest_version_uri(api_client, metric_gcs_dir)
342+
resolved_version_match = re.match(
343+
r"(v\d+(?:\.\d+)*)\.(?:yaml|yml|json)",
344+
os.path.basename(uri),
345+
re.IGNORECASE,
346+
)
347+
if resolved_version_match:
348+
self.version = resolved_version_match.group(1)
349+
else:
350+
self.version = os.path.splitext(
351+
os.path.splitext(os.path.basename(uri))[0]
352+
)[0]
353+
else:
354+
yaml_uri = os.path.join(metric_gcs_dir, f"{self.version}.yaml")
355+
json_uri = os.path.join(metric_gcs_dir, f"{self.version}.json")
356+
357+
gcs_utils = GcsUtils(api_client)
358+
try:
359+
bucket_name, blob_path = gcs_utils.parse_gcs_path(yaml_uri)
360+
if (
361+
gcs_utils.storage_client.bucket(bucket_name)
362+
.blob(blob_path)
363+
.exists()
364+
):
365+
uri = yaml_uri
366+
else:
367+
bucket_name_json, blob_path_json = gcs_utils.parse_gcs_path(
368+
json_uri
369+
)
370+
if (
371+
gcs_utils.storage_client.bucket(bucket_name_json)
372+
.blob(blob_path_json)
373+
.exists()
374+
):
375+
uri = json_uri
376+
else:
377+
raise IOError(
378+
f"Metric file for version '{self.version}' not found as .yaml"
379+
f" or .json in {metric_gcs_dir}"
380+
)
381+
except Exception as e:
382+
raise IOError(
383+
f"Error checking for metric file version '{self.version}' in"
384+
f" {metric_gcs_dir}: {e}"
385+
) from e
386+
387+
logger.info(
388+
"Fetching predefined metric '%s@%s' from %s...",
389+
self.name,
390+
self.version,
391+
uri,
392+
)
393+
394+
gcs_utils = GcsUtils(api_client)
395+
content_str = gcs_utils.read_file_contents(uri)
396+
397+
file_extension = os.path.splitext(uri)[1].lower()
398+
data: dict[str, Any]
399+
if file_extension == ".yaml" or file_extension == ".yml":
400+
if yaml is None:
401+
raise ImportError(
402+
"YAML parsing requires the pyyaml library. Please install it"
403+
" using 'pip install google-cloud-aiplatform[evaluation]'."
404+
)
405+
data = yaml.safe_load(content_str)
406+
elif file_extension == ".json":
407+
data = json.loads(content_str)
408+
else:
409+
raise ValueError(
410+
f"Unsupported file extension for metric config: {file_extension}."
411+
" Must be .yaml, .yml, or .json"
412+
)
413+
414+
if not isinstance(data, dict):
415+
raise ValueError("Metric config content did not parse into a dictionary.")
416+
417+
if "prompt_template" in data and isinstance(data["prompt_template"], dict):
418+
data["prompt_template"] = types.MetricPromptBuilder.model_validate(
419+
data["prompt_template"]
420+
)
421+
422+
metric_obj = types.LLMMetric.model_validate(data)
423+
424+
metric_obj._is_predefined = True
425+
metric_obj._config_source = uri
426+
metric_obj._version = self.version
427+
return metric_obj
428+
429+
def resolve(self, api_client: Any) -> types.LLMMetric:
430+
"""Resolves the metric by loading it from the cache or fetching it if necessary."""
431+
if self._resolved_metric is None:
432+
temp_version_for_key = self.version
433+
434+
cache_key = f"{self.name}@{temp_version_for_key}"
435+
436+
if (
437+
cache_key in LazyLoadedPrebuiltMetric._cache
438+
and temp_version_for_key != "latest"
439+
):
440+
self._resolved_metric = LazyLoadedPrebuiltMetric._cache[cache_key]
441+
logger.debug(f"Metric '{cache_key}' found in cache.")
442+
else:
443+
logger.debug(
444+
f"Metric '{self.name}@{self.version}' not in cache or version is"
445+
" 'latest'. Fetching..."
446+
)
447+
try:
448+
fetched_metric = self._fetch_and_parse(api_client)
449+
final_cache_key = f"{self.name}@{self.version}"
450+
LazyLoadedPrebuiltMetric._cache[final_cache_key] = fetched_metric
451+
self._resolved_metric = fetched_metric
452+
except Exception as e:
453+
logger.error(
454+
f"Error loading predefined metric {self.name} (requested version:"
455+
f" {self.version}): {e}"
456+
)
457+
raise
458+
return self._resolved_metric
459+
460+
def __call__(self, version: Optional[str] = None) -> "LazyLoadedPrebuiltMetric":
461+
"""Allows setting a specific version."""
462+
if version is not None:
463+
return LazyLoadedPrebuiltMetric(name=self.name, version=version)
464+
return self
465+
466+
467+
class PrebuiltMetricLoader:
468+
"""Provides access to predefined evaluation metrics via attributes.
469+
470+
This class provides a set of predefined LLM-based metrics (Autorater recipes)
471+
for evaluation. These metrics are lazily loaded from a GCS repository
472+
when they are first accessed.
473+
474+
Example:
475+
metric = PrebuiltMetric.TEXT_QUALITY
476+
metric = PrebuiltMetric.TEXT_QUALITY(version="v1")
477+
"""
478+
479+
def __getattr__(self, name: str) -> LazyLoadedPrebuiltMetric:
480+
return LazyLoadedPrebuiltMetric(name=name)
481+
482+
@property
483+
def TEXT_QUALITY(self) -> LazyLoadedPrebuiltMetric:
484+
return self.__getattr__("TEXT_QUALITY")
485+
486+
@property
487+
def INSTRUCTION_FOLLOWING(self) -> LazyLoadedPrebuiltMetric:
488+
return self.__getattr__("INSTRUCTION_FOLLOWING")
489+
490+
@property
491+
def COHERENCE(self) -> LazyLoadedPrebuiltMetric:
492+
return self.__getattr__("COHERENCE")
493+
494+
@property
495+
def FLUENCY(self) -> LazyLoadedPrebuiltMetric:
496+
return self.__getattr__("FLUENCY")
497+
498+
@property
499+
def GROUNDEDNESS(self) -> LazyLoadedPrebuiltMetric:
500+
return self.__getattr__("GROUNDEDNESS")
501+
502+
@property
503+
def SAFETY(self) -> LazyLoadedPrebuiltMetric:
504+
return self.__getattr__("SAFETY")
505+
506+
@property
507+
def VERBOSITY(self) -> LazyLoadedPrebuiltMetric:
508+
return self.__getattr__("VERBOSITY")
509+
510+
@property
511+
def SUMMARIZATION_QUALITY(self) -> LazyLoadedPrebuiltMetric:
512+
return self.__getattr__("SUMMARIZATION_QUALITY")
513+
514+
@property
515+
def QUESTION_ANSWERING_QUALITY(self) -> LazyLoadedPrebuiltMetric:
516+
return self.__getattr__("QUESTION_ANSWERING_QUALITY")
517+
518+
@property
519+
def MULTI_TURN_CHAT_QUALITY(self) -> LazyLoadedPrebuiltMetric:
520+
return self.__getattr__("MULTI_TURN_CHAT_QUALITY")
521+
522+
@property
523+
def MULTI_TURN_SAFETY(self) -> LazyLoadedPrebuiltMetric:
524+
return self.__getattr__("MULTI_TURN_SAFETY")
525+
526+
527+
PrebuiltMetric = PrebuiltMetricLoader()

vertexai/_genai/evals.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ def _Metric_to_vertex(
715715
parent_object: Optional[dict[str, Any]] = None,
716716
) -> dict[str, Any]:
717717
to_object: dict[str, Any] = {}
718+
718719
if getv(from_object, ["prompt_template"]) is not None:
719720
setv(
720721
to_object,

0 commit comments

Comments
 (0)