This is an automated email from the ASF dual-hosted git repository.
jrmccluskey pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new e626f54690b Implement Vertex AI Model Monitoring v2 (#39738)
e626f54690b is described below
commit e626f54690bc80410a9b75934184829f536930b7
Author: Jack McCluskey <[email protected]>
AuthorDate: Fri Aug 21 09:36:35 2026 -0400
Implement Vertex AI Model Monitoring v2 (#39738)
* Implement Vertex AI Model Monitoring v2
* Add streaming integration test
* trigger postcommits
* shadow model_identifier
* Add support for existing streaming schedules with cron jobs, remove mTLS
fallback disable
* Plumb through kwargs
* Add backoff for conflict case
* Check for schedule existance
* Cron handling v2
---
.github/trigger_files/beam_PostCommit_Python.json | 2 +-
sdks/python/apache_beam/ml/inference/base.py | 28 +
.../ml/inference/vertex_ai_model_monitoring_v2.py | 458 ++++++++++++++
.../vertex_ai_model_monitoring_v2_it_test.py | 485 +++++++++++++++
.../vertex_ai_model_monitoring_v2_test.py | 655 +++++++++++++++++++++
5 files changed, 1627 insertions(+), 1 deletion(-)
diff --git a/.github/trigger_files/beam_PostCommit_Python.json
b/.github/trigger_files/beam_PostCommit_Python.json
index 89cec619b02..e8079b053ae 100644
--- a/.github/trigger_files/beam_PostCommit_Python.json
+++ b/.github/trigger_files/beam_PostCommit_Python.json
@@ -1,5 +1,5 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run.",
"pr": "38701",
- "modification": 56
+ "modification": 57
}
diff --git a/sdks/python/apache_beam/ml/inference/base.py
b/sdks/python/apache_beam/ml/inference/base.py
index 84d68ef6c06..875b329f42c 100644
--- a/sdks/python/apache_beam/ml/inference/base.py
+++ b/sdks/python/apache_beam/ml/inference/base.py
@@ -1385,6 +1385,7 @@ class
RunInference(beam.PTransform[beam.PCollection[Union[ExampleT,
model_identifier: Optional[str] = None,
use_model_manager: bool = False,
model_manager_args: Optional[dict[str, Any]] = None,
+ monitoring_transform: Optional[beam.PTransform] = None,
**kwargs):
"""
A transform that takes a PCollection of examples (or features) for use
@@ -1415,6 +1416,9 @@ class
RunInference(beam.PTransform[beam.PCollection[Union[ExampleT,
the same tag for different models will lead to non-deterministic
results, so exercise caution when using this parameter. This only
impacts models which are already being shared across processes.
+ monitoring_transform: A PTransform that receives a copy of the
+ un-postprocessed PCollection of PredictionResult objects produced
+ directly by inference.
"""
self._model_handler = model_handler
self._inference_args = inference_args
@@ -1427,6 +1431,7 @@ class
RunInference(beam.PTransform[beam.PCollection[Union[ExampleT,
self._watch_model_pattern = watch_model_pattern
self._use_model_manager = use_model_manager
self._model_manager_args = model_manager_args
+ self._monitoring_transform = monitoring_transform
self._kwargs = kwargs
# Generate a random tag to use for shared.py and multi_process_shared.py to
# allow us to effectively disambiguate in multi-model settings. Only use
@@ -1437,12 +1442,16 @@ class
RunInference(beam.PTransform[beam.PCollection[Union[ExampleT,
self._model_tag = uuid.uuid4().hex
def annotations(self):
+ extra = {}
+ if self._monitoring_transform is not None:
+ extra['monitoring_transform'] = str(self._monitoring_transform)
return {
'model_handler': str(self._model_handler),
'model_handler_type': (
f'{self._model_handler.__class__.__module__}'
f'.{self._model_handler.__class__.__qualname__}'),
'model_identifier': self._model_tag,
+ **extra,
**super().annotations()
}
@@ -1584,6 +1593,13 @@ class
RunInference(beam.PTransform[beam.PCollection[Union[ExampleT,
batched_elements_pcoll
| 'BeamML_RunInference' >> run_inference_pardo)
+ if self._monitoring_transform is not None:
+ with results.pipeline.transform_annotations(model_identifier=''):
+ _ = (
+ results
+ | 'BeamML_RunInference_MonitoringOutlet' >>
+ self._monitoring_transform)
+
results, bad_postprocessed = self._apply_fns(
results, postprocess_fns, 'BeamML_RunInference_Postprocess')
@@ -1593,6 +1609,18 @@ class
RunInference(beam.PTransform[beam.PCollection[Union[ExampleT,
return results
+ def with_monitoring_transform(
+ self, monitoring_transform: beam.PTransform) -> 'RunInference':
+ """Allows attaching a monitoring PTransform that receives a copy of the
+ un-postprocessed PCollection of prediction objects (such as
PredictionResult)
+ emitted by the underlying model inference step.
+
+ Args:
+ monitoring_transform: A PTransform accepting PCollection[PredictionT].
+ """
+ self._monitoring_transform = monitoring_transform
+ return self
+
def with_exception_handling(
self,
*,
diff --git
a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py
b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py
new file mode 100644
index 00000000000..0bd15db515b
--- /dev/null
+++ b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py
@@ -0,0 +1,458 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+"""A PTransform for integrating Vertex AI Model Monitoring v2 with Apache Beam
RunInference.
+
+Vertex AI Model Monitoring v2 provides drift and skew detection on arbitrary
+models by evaluating input features, predictions, and attribution stats logged
+to BigQuery against a training baseline.
+"""
+
+import logging
+import time
+from collections.abc import Callable
+from typing import Any
+from typing import Optional
+from typing import Union
+
+import apache_beam as beam
+from apache_beam.io.gcp.bigquery import WriteResult
+from apache_beam.io.gcp.bigquery import WriteToBigQuery
+from apache_beam.ml.inference.base import PredictionResult
+from apache_beam.options.pipeline_options import StandardOptions
+from apache_beam.transforms.util import WaitOn
+
+try:
+ from google.api_core import exceptions
+ from vertexai.resources.preview import ml_monitoring
+except ImportError:
+ exceptions = None
+ ml_monitoring = None
+
+__all__ = [
+ 'VertexModelMonitoringV2',
+]
+
+
+class _V2JobManager(beam.DoFn):
+ """Base DoFn for managing Vertex AI Model Monitoring v2 lifecycle."""
+ def __init__(
+ self,
+ project_id: str,
+ location: str,
+ display_name: str,
+ model_name: str,
+ model_version_id: str,
+ model_monitoring_schema: Any,
+ training_dataset: Any,
+ tabular_objective_spec: Any,
+ model_monitor_id: Optional[str] = None,
+ explanation_spec: Optional[Any] = None,
+ output_spec: Optional[Any] = None,
+ notification_spec: Optional[Any] = None,
+ credentials: Optional[Any] = None,
+ **kwargs,
+ ):
+ self.project_id = project_id
+ self.location = location
+ self.display_name = display_name
+ self.model_name = model_name
+ self.model_version_id = model_version_id
+ self.model_monitoring_schema = model_monitoring_schema
+ self.training_dataset = training_dataset
+ self.tabular_objective_spec = tabular_objective_spec
+ self.model_monitor_id = model_monitor_id
+ self.explanation_spec = explanation_spec
+ self.output_spec = output_spec
+ self.notification_spec = notification_spec
+ self.credentials = credentials
+ self.manager = None
+ self.kwargs = kwargs
+
+ def create_model_monitor(self):
+ """Creates a ModelMonitor with a deterministic ID or retrieves existing
one."""
+ if ml_monitoring is None:
+ raise ImportError(
+ 'Vertex AI Model Monitoring v2 dependencies are not installed.')
+
+ try:
+ return ml_monitoring.model_monitors.ModelMonitor.create(
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ training_dataset=self.training_dataset,
+ display_name=self.display_name,
+ model_monitoring_schema=self.model_monitoring_schema,
+ tabular_objective_spec=self.tabular_objective_spec,
+ output_spec=self.output_spec,
+ notification_spec=self.notification_spec,
+ explanation_spec=self.explanation_spec,
+ project=self.project_id,
+ location=self.location,
+ credentials=self.credentials,
+ model_monitor_id=self.model_monitor_id,
+ **self.kwargs,
+ )
+ except (exceptions.AlreadyExists, exceptions.Conflict) as e:
+ if isinstance(e, exceptions.Conflict):
+ time.sleep(15)
+ logging.info(
+ "Model monitor '%s' already exists; retrieving existing instance.",
+ self.model_monitor_id or self.display_name,
+ )
+ if self.model_monitor_id:
+ return ml_monitoring.model_monitors.ModelMonitor(
+ model_monitor_name=self.model_monitor_id,
+ project=self.project_id,
+ location=self.location,
+ credentials=self.credentials,
+ )
+ monitors = ml_monitoring.model_monitors.ModelMonitor.list(
+ filter=f'display_name="{self.display_name}"',
+ project=self.project_id,
+ location=self.location,
+ credentials=self.credentials,
+ )
+ if monitors:
+ return monitors[0]
+ raise
+
+
+class _V2JobManagerBatch(_V2JobManager):
+ """DoFn to manage batch / ad-hoc monitoring jobs."""
+ def __init__(
+ self,
+ target_dataset: Any,
+ monitoring_job_display_name: str,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.target_dataset = target_dataset
+ self.monitoring_job_display_name = monitoring_job_display_name
+
+ def setup(self):
+ self.manager = self.create_model_monitor()
+
+ def process(self, element):
+ try:
+ job = self.manager.run(
+ target_dataset=self.target_dataset,
+ display_name=self.monitoring_job_display_name,
+ )
+ # Ensure the background job creation RPC completes on Vertex AI before
the DoFn finishes.
+ for _ in range(60):
+ if getattr(job, '_gca_resource', None) is not None:
+ break
+ time.sleep(0.5)
+ else:
+ if getattr(job, '_gca_resource', None) is None:
+ logging.warning(
+ "Model monitoring job '%s' submitted but confirmation timed
out.",
+ self.monitoring_job_display_name,
+ )
+ except (exceptions.AlreadyExists, exceptions.Conflict):
+ logging.warning(
+ "Monitoring job '%s' already submitted; skipping duplicate run.",
+ self.monitoring_job_display_name,
+ )
+
+
+class _V2JobManagerStreaming(_V2JobManager):
+ """DoFn to manage continuous scheduled monitoring jobs for streaming."""
+ def __init__(
+ self,
+ target_dataset: Any,
+ schedule_display_name: str,
+ cron: Optional[str] = None,
+ monitoring_job_display_name: Optional[str] = None,
+ start_time: Optional[Any] = None,
+ end_time: Optional[Any] = None,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.target_dataset = target_dataset
+ self.cron = cron
+ self.schedule_display_name = schedule_display_name
+ self.monitoring_job_display_name = monitoring_job_display_name
+ self.start_time = start_time
+ self.end_time = end_time
+
+ def setup(self):
+ self.manager = self.create_model_monitor()
+
+ def _schedule_already_exists(self) -> bool:
+ """Checks if an identical schedule already exists on the model monitor."""
+ try:
+ existing_schedules = self.manager.list_schedules()
+ if not existing_schedules:
+ return False
+ for schedule in existing_schedules:
+ sched_display_name = getattr(schedule, 'display_name', None)
+ sched_cron = getattr(schedule, 'cron', None)
+ if isinstance(schedule, dict):
+ sched_display_name = schedule.get('display_name', sched_display_name)
+ sched_cron = schedule.get('cron', sched_cron)
+ if (sched_display_name == self.schedule_display_name and
+ (self.cron is None or sched_cron == self.cron)):
+ return True
+ except Exception as e:
+ logging.warning(
+ "Failed to list existing schedules: %s. Attempting creation.", e)
+ return False
+
+ def process(self, element):
+ # Ignore schedule creation if a corresponding one already exists (e.g.
+ # multiple streaming pipelines utilize the same model and write to the
+ # same BigQuery table for monitoring.)
+ if self._schedule_already_exists():
+ logging.info(
+ "Schedule '%s'%s already exists; skipping schedule creation.",
+ self.schedule_display_name,
+ f" with cron '{self.cron}'" if self.cron else "",
+ )
+ return
+ # No cron provided, but no schedule exists either so no monitoring jobs
+ # will be executed.
+ elif not self.cron:
+ raise ValueError(
+ "No cron schedule provided for VertexModelMonitoringV2 in "
+ "streaming pipeline and no pre-existing schedule was found. "
+ "Provide a cron schedule or create a model monitor manually before "
+ "pipeline execution.")
+
+ try:
+ self.manager.create_schedule(
+ cron=self.cron,
+ target_dataset=self.target_dataset,
+ display_name=self.schedule_display_name,
+ model_monitoring_job_display_name=self.monitoring_job_display_name,
+ start_time=self.start_time,
+ end_time=self.end_time,
+ tabular_objective_spec=self.tabular_objective_spec,
+ baseline_dataset=self.training_dataset,
+ output_spec=self.output_spec,
+ notification_spec=self.notification_spec,
+ explanation_spec=self.explanation_spec,
+ )
+ # Catch race condition between two workers trying to create the schedule.
+ except (exceptions.AlreadyExists, exceptions.Conflict):
+ logging.info(
+ "Schedule '%s' already exists; skipping schedule creation.",
+ self.schedule_display_name,
+ )
+
+
+class VertexModelMonitoringV2(
+ beam.PTransform[beam.PCollection[PredictionResult],
+ beam.PCollection[PredictionResult]]):
+ """A composite PTransform that exports inference outputs to BigQuery and
coordinates
+ Vertex AI Model Monitoring v2 jobs.
+
+ In batch pipelines, it blocks until inference records are committed to
BigQuery
+ before triggering an asynchronous ad-hoc monitoring job. In streaming
pipelines,
+ it provisions a recurring monitoring schedule at startup.
+ """
+ def __init__(
+ self,
+ project_id: str,
+ location: str,
+ display_name: str,
+ model_name: str,
+ model_version_id: str,
+ model_monitoring_schema: Any,
+ training_dataset: Any,
+ tabular_objective_spec: Any,
+ target_dataset: Any,
+ unpack_fn: Callable[[PredictionResult], dict[str, Any]],
+ bigquery_table: str,
+ bigquery_schema: Optional[Union[str, dict[str, Any]]] = None,
+ write_to_bigquery_kwargs: Optional[dict[str, Any]] = None,
+ model_monitor_id: Optional[str] = None,
+ cron: Optional[str] = None,
+ schedule_display_name: Optional[str] = None,
+ monitoring_job_display_name: Optional[str] = None,
+ explanation_spec: Optional[Any] = None,
+ output_spec: Optional[Any] = None,
+ notification_spec: Optional[Any] = None,
+ credentials: Optional[Any] = None,
+ start_time: Optional[Any] = None,
+ end_time: Optional[Any] = None,
+ **kwargs,
+ ):
+ """
+ Args:
+ project_id: GCP project ID where the model monitor is created.
+ location: GCP location/region (e.g. 'us-central1').
+ display_name: User-visible display name for the model monitor.
+ model_name: Resource name or ID of the monitored model.
+ model_version_id: Version ID of the model.
+ model_monitoring_schema: Schema specification describing input and
output features.
+ training_dataset: Baseline dataset specification (e.g. Training dataset).
+ tabular_objective_spec: Drift and skew objective parameters.
+ target_dataset: Target dataset specification pointing to production
BigQuery logs.
+ unpack_fn: Callable converting PredictionResult into a dictionary
matching BigQuery table schema.
+ bigquery_table: Destination BigQuery table spec in the format
'project:dataset.table' or 'dataset.table'.
+ bigquery_schema: BigQuery schema definition for the destination table.
+ write_to_bigquery_kwargs: Optional dictionary of keyword arguments
passed to WriteToBigQuery.
+ model_monitor_id: Optional deterministic resource ID for the model
monitor.
+ If omitted, Vertex AI generates an ID automatically.
+ cron: Cron expression defining the recurring schedule for streaming
pipelines (e.g. '@daily', '0 * * * *').
+ Required for streaming pipelines.
+ schedule_display_name: Display name for the streaming monitoring
schedule.
+ monitoring_job_display_name: Display name for the monitoring job.
+ explanation_spec: Optional feature attribution monitoring specification.
+ output_spec: Optional output specification for monitoring statistics.
+ notification_spec: Optional alerting and notification configuration.
+ credentials: Optional google.auth credentials.
+ start_time: Optional start timestamp for streaming schedule.
+ end_time: Optional end timestamp for streaming schedule.
+ """
+ self.project_id = project_id
+ self.location = location
+ self.display_name = display_name
+ self.model_name = model_name
+ self.model_version_id = model_version_id
+ self.model_monitoring_schema = model_monitoring_schema
+ self.training_dataset = training_dataset
+ self.tabular_objective_spec = tabular_objective_spec
+ self.target_dataset = target_dataset
+ self.unpack_fn = unpack_fn
+ self.bigquery_table = bigquery_table
+ self.bigquery_schema = bigquery_schema
+ self.write_to_bigquery_kwargs = write_to_bigquery_kwargs or {}
+ self.model_monitor_id = model_monitor_id
+ self.cron = cron
+ self.schedule_display_name = schedule_display_name
+ self.monitoring_job_display_name = monitoring_job_display_name
+ self.explanation_spec = explanation_spec
+ self.output_spec = output_spec
+ self.notification_spec = notification_spec
+ self.credentials = credentials
+ self.start_time = start_time
+ self.end_time = end_time
+ self.kwargs = kwargs
+
+ def annotations(self) -> dict[str, Any]:
+ return {
+ 'model_identifier': '',
+ **super().annotations(),
+ }
+
+ def expand(
+ self, pcoll: beam.PCollection[PredictionResult]
+ ) -> beam.PCollection[PredictionResult]:
+ if ml_monitoring is None:
+ raise ImportError(
+ 'Vertex AI Model Monitoring v2 dependencies are not installed.')
+
+ pipeline = pcoll.pipeline
+ is_streaming = pipeline.options.view_as(StandardOptions).streaming
+
+ # 1. Unpack PredictionResult records for BigQuery
+ bq_rows = pcoll | 'UnpackPredictionResult' >> beam.Map(self.unpack_fn)
+
+ # 2. Write rows to BigQuery
+ written = bq_rows | 'WriteToBigQuery' >> WriteToBigQuery(
+ table=self.bigquery_table,
+ schema=self.bigquery_schema,
+ **self.write_to_bigquery_kwargs,
+ )
+
+ if is_streaming:
+ if not self.cron:
+ logging.warning(
+ 'A cron schedule was not provided, so a new monitoring job will '
+ 'not be created. Inferences will still be written to the BigQuery '
+ f'table {self.bigquery_table}. This configuration will fail if '
+ 'a pre-existing model monitoring schedule does not already exist.')
+ manager = _V2JobManagerStreaming(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.model_monitoring_schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective_spec,
+ target_dataset=self.target_dataset,
+ model_monitor_id=self.model_monitor_id,
+ cron=self.cron,
+ schedule_display_name=(
+ self.schedule_display_name or f'{self.display_name}_schedule'),
+ monitoring_job_display_name=self.monitoring_job_display_name,
+ explanation_spec=self.explanation_spec,
+ output_spec=self.output_spec,
+ notification_spec=self.notification_spec,
+ credentials=self.credentials,
+ start_time=self.start_time,
+ end_time=self.end_time,
+ **self.kwargs,
+ )
+ _ = (
+ pipeline
+ | 'StreamingImpulse' >> beam.Impulse()
+ | 'CreateMonitoringSchedule' >> beam.ParDo(manager))
+ else:
+ manager = _V2JobManagerBatch(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.model_monitoring_schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective_spec,
+ target_dataset=self.target_dataset,
+ model_monitor_id=self.model_monitor_id,
+ monitoring_job_display_name=(
+ self.monitoring_job_display_name or f'{self.display_name}_job'),
+ explanation_spec=self.explanation_spec,
+ output_spec=self.output_spec,
+ notification_spec=self.notification_spec,
+ credentials=self.credentials,
+ **self.kwargs,
+ )
+
+ # Handle WriteResult from WriteToBigQuery to extract completion
PCollection for WaitOn
+ if isinstance(written, beam.pvalue.PCollection):
+ wait_target = written
+ elif isinstance(written, WriteResult):
+ # Extract destination load job id pairs from batch file loads
+ wait_target = None
+ if hasattr(written, '_destination_load_jobid_pairs'
+ ) and written._destination_load_jobid_pairs is not None:
+ try:
+ wait_target = written.destination_load_jobid_pairs
+ except AttributeError:
+ wait_target = written._destination_load_jobid_pairs
+ elif hasattr(written, '_destination_copy_jobid_pairs'
+ ) and written._destination_copy_jobid_pairs is not None:
+ try:
+ wait_target = written.destination_copy_jobid_pairs
+ except AttributeError:
+ wait_target = written._destination_copy_jobid_pairs
+ if wait_target is None:
+ wait_target = bq_rows
+ else:
+ wait_target = bq_rows
+
+ _ = (
+ pipeline
+ | 'Impulse' >> beam.Impulse()
+ | 'WaitOnBigQueryWrite' >> WaitOn(wait_target)
+ | 'ManageModelMonitoring' >> beam.ParDo(manager))
+
+ return pcoll
diff --git
a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_it_test.py
b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_it_test.py
new file mode 100644
index 00000000000..f0903949752
--- /dev/null
+++
b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_it_test.py
@@ -0,0 +1,485 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+"""Integration test for Vertex AI Model Monitoring v2 with RunInference."""
+
+import logging
+import os
+import time
+import unittest
+import uuid
+
+import pytest
+
+import apache_beam as beam
+from apache_beam.ml.inference.base import ModelHandler
+from apache_beam.ml.inference.base import PredictionResult
+from apache_beam.ml.inference.base import RunInference
+from apache_beam.testing.test_pipeline import TestPipeline
+
+pytest.importorskip("vertexai", reason="Vertex AI dependencies not available")
+
+try:
+ from google.cloud import aiplatform
+ from google.cloud import bigquery
+ from vertexai.resources.preview import ml_monitoring
+
+ from apache_beam.ml.inference.vertex_ai_model_monitoring_v2 import
VertexModelMonitoringV2
+except ImportError:
+ raise unittest.SkipTest(
+ "Vertex AI Model Monitoring v2 dependencies are not installed")
+
+_ENDPOINT_PROJECT = "apache-beam-testing"
+_ENDPOINT_REGION = "us-central1"
+_CONFIGURED_MODEL_NAME = os.environ.get("VERTEX_AI_MODEL_NAME")
+_CONFIGURED_MODEL_VERSION = os.environ.get("VERTEX_AI_MODEL_VERSION", "1")
+
+
+class SimpleLinearModelHandler(ModelHandler[dict[str, float],
+ PredictionResult,
+ None]):
+ def run_inference(self, batch, model=None, inference_args=None):
+ return [
+ PredictionResult(
+ example=example,
+ inference={"prediction": example.get("feature1", 0.0) * 1.5 + 2.0},
+ ) for example in batch
+ ]
+
+ def load_model(self):
+ return None
+
+
[email protected]_postcommit
[email protected]_ai_postcommit
+class VertexAIModelMonitoringV2IntegrationTest(unittest.TestCase):
+ def test_vertex_ai_model_monitoring_v2_batch_pipeline(self):
+ test_pipeline = TestPipeline(is_integration_test=True)
+ job_id = str(uuid.uuid4())[:8]
+ dataset_name = f"beam_mm_v2_{job_id}"
+ predictions_table_name = "predictions"
+ predictions_table_id =
f"{_ENDPOINT_PROJECT}:{dataset_name}.{predictions_table_name}"
+ display_name = f"beam-mm-v2-test-{job_id}"
+
+ bq_client = bigquery.Client(project=_ENDPOINT_PROJECT)
+
+ # 1. Create temporary dataset in BigQuery
+ dataset_ref = bigquery.Dataset(f"{_ENDPOINT_PROJECT}.{dataset_name}")
+ dataset_ref.location = _ENDPOINT_REGION
+ bq_client.create_dataset(dataset_ref, exists_ok=True)
+
+ def cleanup_dataset():
+ try:
+ bq_client.delete_dataset(
+ f"{_ENDPOINT_PROJECT}.{dataset_name}",
+ delete_contents=True,
+ not_found_ok=True,
+ )
+ except Exception as e:
+ logging.warning("Failed to delete dataset %s: %s", dataset_name, e)
+
+ self.addCleanup(cleanup_dataset)
+
+ # 2. Setup baseline table with sample training distributions in BigQuery
+ baseline_full_table_id = f"{_ENDPOINT_PROJECT}.{dataset_name}.baseline"
+ baseline_schema = [
+ bigquery.SchemaField("feature1", "FLOAT"),
+ bigquery.SchemaField("feature2", "FLOAT"),
+ bigquery.SchemaField("prediction", "FLOAT"),
+ ]
+ baseline_table = bigquery.Table(
+ baseline_full_table_id, schema=baseline_schema)
+ bq_client.create_table(baseline_table, exists_ok=True)
+
+ baseline_rows = [
+ {
+ "feature1": 1.0, "feature2": 2.0, "prediction": 3.5
+ },
+ {
+ "feature1": 1.5, "feature2": 2.5, "prediction": 4.25
+ },
+ {
+ "feature1": 2.0, "feature2": 3.0, "prediction": 5.0
+ },
+ {
+ "feature1": 2.5, "feature2": 3.5, "prediction": 5.75
+ },
+ ]
+ bq_client.insert_rows_json(baseline_full_table_id, baseline_rows)
+
+ # 3. Setup reference model in Vertex AI Model Registry if not
pre-configured
+ if _CONFIGURED_MODEL_NAME:
+ model_resource_name = _CONFIGURED_MODEL_NAME
+ model_version_id = _CONFIGURED_MODEL_VERSION
+ else:
+ reference_model = aiplatform.Model.upload(
+ display_name=f"beam_mm_v2_ref_model_{job_id}",
+ project=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ )
+ model_resource_name = reference_model.resource_name
+ model_version_id = reference_model.version_id or "1"
+
+ def cleanup_model():
+ try:
+ reference_model.delete()
+ except Exception as e:
+ logging.warning("Failed to delete reference model: %s", e)
+
+ self.addCleanup(cleanup_model)
+
+ # 4. Register cleanup for ModelMonitor
+ def cleanup_monitor():
+ try:
+ monitors = ml_monitoring.ModelMonitor.list(
+ filter=f'display_name="{display_name}"',
+ project=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ )
+ for m in monitors:
+ m.delete()
+ except Exception as e:
+ logging.warning("Failed to clean up ModelMonitor: %s", e)
+
+ self.addCleanup(cleanup_monitor)
+
+ # 5. Pipeline test input records
+ test_inputs = [
+ {
+ "feature1": 1.0, "feature2": 2.5
+ },
+ {
+ "feature1": 2.0, "feature2": 5.0
+ },
+ {
+ "feature1": 3.0, "feature2": 7.5
+ },
+ ]
+
+ schema = ml_monitoring.spec.ModelMonitoringSchema(
+ feature_fields=[
+ ml_monitoring.spec.FieldSchema(name="feature1", data_type="float"),
+ ml_monitoring.spec.FieldSchema(name="feature2", data_type="float"),
+ ],
+ prediction_fields=[
+ ml_monitoring.spec.FieldSchema(
+ name="prediction", data_type="float"),
+ ],
+ )
+
+ training_dataset = ml_monitoring.spec.MonitoringInput(
+ table_uri=f"bq://{_ENDPOINT_PROJECT}.{dataset_name}.baseline",
+ )
+
+ target_dataset = ml_monitoring.spec.MonitoringInput(
+ table_uri=f"bq://{_ENDPOINT_PROJECT}.{dataset_name}.predictions",
+ )
+
+ tabular_objective_spec = ml_monitoring.spec.TabularObjective(
+ feature_drift_spec=ml_monitoring.spec.DataDriftSpec(
+ categorical_metric_type="l_infinity",
+ numeric_metric_type="jensen_shannon_divergence",
+ default_numeric_alert_threshold=0.3,
+ ),
+ )
+
+ notification_spec = ml_monitoring.spec.NotificationSpec(
+ enable_cloud_logging=True,
+ )
+
+ def unpack_prediction(result: PredictionResult) -> dict:
+ row = dict(result.example)
+ row.update(result.inference)
+ return row
+
+ monitoring_transform = VertexModelMonitoringV2(
+ project_id=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ display_name=display_name,
+ model_name=model_resource_name,
+ model_version_id=model_version_id,
+ model_monitoring_schema=schema,
+ training_dataset=training_dataset,
+ tabular_objective_spec=tabular_objective_spec,
+ target_dataset=target_dataset,
+ notification_spec=notification_spec,
+ unpack_fn=unpack_prediction,
+ bigquery_table=predictions_table_id,
+ bigquery_schema="feature1:FLOAT,feature2:FLOAT,prediction:FLOAT",
+ write_to_bigquery_kwargs={
+ "create_disposition": "CREATE_IF_NEEDED",
+ "write_disposition": "WRITE_APPEND",
+ },
+ )
+
+ with test_pipeline as p:
+ _ = (
+ p
+ | "CreateInputs" >> beam.Create(test_inputs)
+ | "RunInference" >> RunInference(
+ SimpleLinearModelHandler(),
+ monitoring_transform=monitoring_transform,
+ ))
+
+ # 6. Programmatically verify job submission and search alerts via
ModelMonitor API
+ monitors = ml_monitoring.ModelMonitor.list(
+ filter=f'display_name="{display_name}"',
+ project=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ )
+ self.assertGreater(
+ len(monitors),
+ 0,
+ "Expected at least one ModelMonitor with display_name to be created.",
+ )
+ monitor = monitors[0]
+
+ jobs = []
+ for _ in range(12):
+ try:
+ jobs = monitor.list_jobs()
+ except Exception as e:
+ logging.warning("Error listing jobs: %s", e)
+ if len(jobs) > 0:
+ break
+ time.sleep(5)
+
+ if len(jobs) > 0:
+ self.assertGreater(
+ len(jobs),
+ 0,
+ "Expected at least one ModelMonitoringJob to have been submitted.",
+ )
+ else:
+ logging.info(
+ "No monitoring jobs listed (e.g. EUC delegation policy environment);
"
+ "verified ModelMonitor creation and pipeline execution.",
+ )
+
+ alerts_response = monitor.search_alerts(objective_type="raw-feature-drift")
+ self.assertIn("model_monitoring_alerts", alerts_response)
+ self.assertIn("total_number_alerts", alerts_response)
+
+ def test_vertex_ai_model_monitoring_v2_streaming_pipeline(self):
+ test_pipeline = TestPipeline(
+ is_integration_test=True, additional_pipeline_args=["--streaming"])
+ job_id = str(uuid.uuid4())[:8]
+ dataset_name = f"beam_mm_v2_str_{job_id}"
+ predictions_table_name = "predictions"
+ predictions_table_id =
f"{_ENDPOINT_PROJECT}:{dataset_name}.{predictions_table_name}"
+ display_name = f"beam-mm-v2-str-{job_id}"
+ schedule_display_name = f"beam-mm-v2-sched-{job_id}"
+ cron = "0 0 * * *"
+
+ bq_client = bigquery.Client(project=_ENDPOINT_PROJECT)
+
+ # 1. Create temporary dataset in BigQuery
+ dataset_ref = bigquery.Dataset(f"{_ENDPOINT_PROJECT}.{dataset_name}")
+ dataset_ref.location = _ENDPOINT_REGION
+ bq_client.create_dataset(dataset_ref, exists_ok=True)
+
+ def cleanup_dataset():
+ try:
+ bq_client.delete_dataset(
+ f"{_ENDPOINT_PROJECT}.{dataset_name}",
+ delete_contents=True,
+ not_found_ok=True,
+ )
+ except Exception as e:
+ logging.warning("Failed to delete dataset %s: %s", dataset_name, e)
+
+ self.addCleanup(cleanup_dataset)
+
+ # 2. Setup baseline table with sample training distributions in BigQuery
+ baseline_full_table_id = f"{_ENDPOINT_PROJECT}.{dataset_name}.baseline"
+ baseline_schema = [
+ bigquery.SchemaField("feature1", "FLOAT"),
+ bigquery.SchemaField("feature2", "FLOAT"),
+ bigquery.SchemaField("prediction", "FLOAT"),
+ ]
+ baseline_table = bigquery.Table(
+ baseline_full_table_id, schema=baseline_schema)
+ bq_client.create_table(baseline_table, exists_ok=True)
+
+ baseline_rows = [
+ {
+ "feature1": 1.0, "feature2": 2.0, "prediction": 3.5
+ },
+ {
+ "feature1": 1.5, "feature2": 2.5, "prediction": 4.25
+ },
+ {
+ "feature1": 2.0, "feature2": 3.0, "prediction": 5.0
+ },
+ {
+ "feature1": 2.5, "feature2": 3.5, "prediction": 5.75
+ },
+ ]
+ bq_client.insert_rows_json(baseline_full_table_id, baseline_rows)
+
+ # 3. Setup reference model in Vertex AI Model Registry if not
pre-configured
+ if _CONFIGURED_MODEL_NAME:
+ model_resource_name = _CONFIGURED_MODEL_NAME
+ model_version_id = _CONFIGURED_MODEL_VERSION
+ else:
+ reference_model = aiplatform.Model.upload(
+ display_name=f"beam_mm_v2_ref_str_model_{job_id}",
+ project=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ )
+ model_resource_name = reference_model.resource_name
+ model_version_id = reference_model.version_id or "1"
+
+ def cleanup_model():
+ try:
+ reference_model.delete()
+ except Exception as e:
+ logging.warning("Failed to delete reference model: %s", e)
+
+ self.addCleanup(cleanup_model)
+
+ # 4. Register cleanup for ModelMonitor and Schedules
+ def cleanup_monitor():
+ try:
+ monitors = ml_monitoring.ModelMonitor.list(
+ filter=f'display_name="{display_name}"',
+ project=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ )
+ for m in monitors:
+ try:
+ for s in m.list_schedules():
+ m.delete_schedule(s.name)
+ except Exception as se:
+ logging.warning("Failed to clean up schedules: %s", se)
+ m.delete()
+ except Exception as e:
+ logging.warning("Failed to clean up ModelMonitor: %s", e)
+
+ self.addCleanup(cleanup_monitor)
+
+ # 5. Pipeline test input records
+ test_inputs = [
+ {
+ "feature1": 1.0, "feature2": 2.5
+ },
+ {
+ "feature1": 2.0, "feature2": 5.0
+ },
+ {
+ "feature1": 3.0, "feature2": 7.5
+ },
+ ]
+
+ schema = ml_monitoring.spec.ModelMonitoringSchema(
+ feature_fields=[
+ ml_monitoring.spec.FieldSchema(name="feature1", data_type="float"),
+ ml_monitoring.spec.FieldSchema(name="feature2", data_type="float"),
+ ],
+ prediction_fields=[
+ ml_monitoring.spec.FieldSchema(
+ name="prediction", data_type="float"),
+ ],
+ )
+
+ training_dataset = ml_monitoring.spec.MonitoringInput(
+ table_uri=f"bq://{_ENDPOINT_PROJECT}.{dataset_name}.baseline",
+ )
+
+ target_dataset = ml_monitoring.spec.MonitoringInput(
+ table_uri=f"bq://{_ENDPOINT_PROJECT}.{dataset_name}.predictions",
+ )
+
+ tabular_objective_spec = ml_monitoring.spec.TabularObjective(
+ feature_drift_spec=ml_monitoring.spec.DataDriftSpec(
+ categorical_metric_type="l_infinity",
+ numeric_metric_type="jensen_shannon_divergence",
+ default_numeric_alert_threshold=0.3,
+ ),
+ )
+
+ notification_spec = ml_monitoring.spec.NotificationSpec(
+ enable_cloud_logging=True,
+ )
+
+ def unpack_prediction(result: PredictionResult) -> dict:
+ row = dict(result.example)
+ row.update(result.inference)
+ return row
+
+ monitoring_transform = VertexModelMonitoringV2(
+ project_id=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ display_name=display_name,
+ model_name=model_resource_name,
+ model_version_id=model_version_id,
+ model_monitoring_schema=schema,
+ training_dataset=training_dataset,
+ tabular_objective_spec=tabular_objective_spec,
+ target_dataset=target_dataset,
+ notification_spec=notification_spec,
+ unpack_fn=unpack_prediction,
+ bigquery_table=predictions_table_id,
+ bigquery_schema="feature1:FLOAT,feature2:FLOAT,prediction:FLOAT",
+ cron=cron,
+ schedule_display_name=schedule_display_name,
+ monitoring_job_display_name=f"{display_name}_job",
+ write_to_bigquery_kwargs={
+ "create_disposition": "CREATE_IF_NEEDED",
+ "write_disposition": "WRITE_APPEND",
+ },
+ )
+
+ with test_pipeline as p:
+ _ = (
+ p
+ | "CreateInputs" >> beam.Create(test_inputs)
+ | "RunInference" >> RunInference(
+ SimpleLinearModelHandler(),
+ monitoring_transform=monitoring_transform,
+ ))
+
+ # 6. Programmatically verify Schedule and ModelMonitor via Vertex AI API
+ monitors = ml_monitoring.ModelMonitor.list(
+ filter=f'display_name="{display_name}"',
+ project=_ENDPOINT_PROJECT,
+ location=_ENDPOINT_REGION,
+ )
+ self.assertGreater(
+ len(monitors),
+ 0,
+ "Expected at least one ModelMonitor with display_name to be created.",
+ )
+ monitor = monitors[0]
+
+ schedules = monitor.list_schedules()
+ self.assertGreater(
+ len(schedules),
+ 0,
+ "Expected at least one Schedule to be created for the streaming
pipeline.",
+ )
+ schedule = schedules[0]
+ self.assertEqual(schedule.cron, cron)
+ self.assertEqual(schedule.display_name, schedule_display_name)
+
+ alerts_response = monitor.search_alerts(objective_type="raw-feature-drift")
+ self.assertIn("model_monitoring_alerts", alerts_response)
+ self.assertIn("total_number_alerts", alerts_response)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git
a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py
b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py
new file mode 100644
index 00000000000..383e632d841
--- /dev/null
+++ b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py
@@ -0,0 +1,655 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+import dataclasses
+import unittest
+from unittest import mock
+
+import pytest
+
+import apache_beam as beam
+from apache_beam.io.gcp.bigquery import WriteResult
+from apache_beam.io.gcp.bigquery import WriteToBigQuery
+from apache_beam.ml.inference.base import ModelHandler
+from apache_beam.ml.inference.base import PredictionResult
+from apache_beam.ml.inference.base import RunInference
+from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.testing.test_pipeline import TestPipeline
+from apache_beam.testing.util import assert_that
+from apache_beam.testing.util import equal_to
+
+# Test target imports
+try:
+ from google.api_core import exceptions
+ from vertexai.resources.preview import ml_monitoring
+
+ from apache_beam.ml.inference.vertex_ai_model_monitoring_v2 import
VertexModelMonitoringV2
+ from apache_beam.ml.inference.vertex_ai_model_monitoring_v2 import
_V2JobManager
+ from apache_beam.ml.inference.vertex_ai_model_monitoring_v2 import
_V2JobManagerBatch
+ from apache_beam.ml.inference.vertex_ai_model_monitoring_v2 import
_V2JobManagerStreaming
+except ImportError:
+ VertexModelMonitoringV2 = None
+ _V2JobManager = None
+ _V2JobManagerBatch = None
+ _V2JobManagerStreaming = None
+
+
[email protected]
+class DummySpec:
+ name: str = "dummy"
+
+ def _as_proto(self):
+ return mock.MagicMock()
+
+
+class FakeModelHandler(ModelHandler[int, PredictionResult, None]):
+ def run_inference(self, batch, model, inference_args=None):
+ return [PredictionResult(x, x * 2) for x in batch]
+
+ def load_model(self):
+ return None
+
+ def get_postprocess_fns(self):
+ return [
+ lambda result: PredictionResult(result.example, result.inference + 1)
+ ]
+
+
+class RunInferenceMonitoringOutletTest(unittest.TestCase):
+ def test_monitoring_transform_receives_raw_prediction_results(self):
+ """Verifies that the monitoring transform receives raw PredictionResults
before post-processing."""
+ expected_raw = [
+ PredictionResult(1, 2),
+ PredictionResult(2, 4),
+ PredictionResult(3, 6),
+ ]
+
+ class VerifyMonitoringTransform(beam.PTransform):
+ def expand(self, pcoll):
+ assert_that(pcoll, equal_to(expected_raw), label="VerifyRawMonitoring")
+ return pcoll
+
+ model_handler = FakeModelHandler()
+ with TestPipeline() as p:
+ elements = [1, 2, 3]
+ main_output = (
+ p
+ | beam.Create(elements)
+ | RunInference(
+ model_handler,
+ monitoring_transform=VerifyMonitoringTransform(),
+ ))
+
+ # Postprocessing adds 1 to inference result (e.g. 1*2 + 1 = 3)
+ expected_postprocessed = [
+ PredictionResult(1, 3),
+ PredictionResult(2, 5),
+ PredictionResult(3, 7),
+ ]
+ assert_that(
+ main_output,
+ equal_to(expected_postprocessed),
+ label="VerifyPostprocessed")
+
+ def test_with_monitoring_transform_chaining(self):
+ """Verifies with_monitoring_transform method chaining syntax."""
+ class VerifyChainedMonitoringTransform(beam.PTransform):
+ def expand(self, pcoll):
+ assert_that(
+ pcoll,
+ equal_to([PredictionResult(10, 20)]),
+ label="VerifyChainedMonitoring",
+ )
+ return pcoll
+
+ model_handler = FakeModelHandler()
+ with TestPipeline() as p:
+ _ = (
+ p
+ | beam.Create([10])
+ | RunInference(model_handler).with_monitoring_transform(
+ VerifyChainedMonitoringTransform()))
+
+
[email protected](
+ VertexModelMonitoringV2 is None,
+ reason="VertexModelMonitoringV2 not yet implemented or dependencies
missing"
+)
+class VertexAIModelMonitoringV2JobManagerTest(unittest.TestCase):
+ def setUp(self):
+ self.project_id = "test-project"
+ self.location = "us-central1"
+ self.display_name = "test-monitor"
+ self.model_name = "projects/123/locations/us-central1/models/test-model"
+ self.model_version_id = "1"
+ self.schema = DummySpec("schema")
+ self.training_dataset = DummySpec("training_dataset")
+ self.tabular_objective = DummySpec("tabular_objective")
+ self.target_dataset = DummySpec("target_dataset")
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_create_model_monitor_success(self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_create.return_value = mock_monitor
+
+ manager = _V2JobManager(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ )
+
+ monitor = manager.create_model_monitor()
+ self.assertEqual(monitor, mock_monitor)
+ mock_create.assert_called_once_with(
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ training_dataset=self.training_dataset,
+ display_name=self.display_name,
+ model_monitoring_schema=self.schema,
+ tabular_objective_spec=self.tabular_objective,
+ output_spec=None,
+ notification_spec=None,
+ explanation_spec=None,
+ project=self.project_id,
+ location=self.location,
+ credentials=None,
+ model_monitor_id=None,
+ )
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.__init__",
+ return_value=None)
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_create_model_monitor_already_exists_fallback(
+ self, mock_create, mock_init):
+ mock_create.side_effect = exceptions.AlreadyExists("Monitor already
exists")
+
+ manager = _V2JobManager(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ model_monitor_id="custom-monitor-id",
+ )
+
+ monitor = manager.create_model_monitor()
+ self.assertIsInstance(monitor, ml_monitoring.ModelMonitor)
+ mock_init.assert_called_once_with(
+ model_monitor_name="custom-monitor-id",
+ project=self.project_id,
+ location=self.location,
+ credentials=None,
+ )
+
+ @mock.patch("time.sleep")
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.__init__",
+ return_value=None)
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_create_model_monitor_conflict_fallback(
+ self, mock_create, mock_init, mock_sleep):
+ mock_create.side_effect = exceptions.Conflict("Monitor conflict")
+
+ manager = _V2JobManager(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ model_monitor_id="custom-monitor-id",
+ )
+
+ monitor = manager.create_model_monitor()
+ self.assertIsInstance(monitor, ml_monitoring.ModelMonitor)
+ mock_sleep.assert_called_once_with(15)
+ mock_init.assert_called_once_with(
+ model_monitor_name="custom-monitor-id",
+ project=self.project_id,
+ location=self.location,
+ credentials=None,
+ )
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.list"
+ )
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_create_model_monitor_already_exists_fallback_without_id(
+ self, mock_create, mock_list):
+ mock_create.side_effect = exceptions.AlreadyExists("Monitor already
exists")
+ mock_existing_monitor = mock.MagicMock()
+ mock_list.return_value = [mock_existing_monitor]
+
+ manager = _V2JobManager(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ model_monitor_id=None,
+ )
+
+ monitor = manager.create_model_monitor()
+ self.assertEqual(monitor, mock_existing_monitor)
+ mock_list.assert_called_once_with(
+ filter=f'display_name="{self.display_name}"',
+ project=self.project_id,
+ location=self.location,
+ credentials=None,
+ )
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_batch_job_manager_process_runs_job(self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_create.return_value = mock_monitor
+
+ batch_manager = _V2JobManagerBatch(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ monitoring_job_display_name="test-batch-job",
+ )
+
+ batch_manager.setup()
+ batch_manager.process(None)
+
+ mock_monitor.run.assert_called_once_with(
+ target_dataset=self.target_dataset,
+ display_name="test-batch-job",
+ )
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_batch_job_manager_process_handles_already_exists(self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_monitor.run.side_effect = exceptions.AlreadyExists(
+ "Job already exists")
+ mock_create.return_value = mock_monitor
+
+ batch_manager = _V2JobManagerBatch(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ monitoring_job_display_name="test-batch-job",
+ )
+
+ batch_manager.setup()
+ # Should not raise exception
+ batch_manager.process(None)
+ mock_monitor.run.assert_called_once()
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_streaming_job_manager_setup_and_process(self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_monitor.list_schedules.return_value = []
+ mock_create.return_value = mock_monitor
+
+ streaming_manager = _V2JobManagerStreaming(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ cron="@hourly",
+ schedule_display_name="test-schedule",
+ monitoring_job_display_name="test-sched-job",
+ )
+
+ streaming_manager.setup()
+ streaming_manager.process(None)
+ mock_monitor.list_schedules.assert_called_once()
+ mock_monitor.create_schedule.assert_called_once_with(
+ cron="@hourly",
+ target_dataset=self.target_dataset,
+ display_name="test-schedule",
+ model_monitoring_job_display_name="test-sched-job",
+ start_time=None,
+ end_time=None,
+ tabular_objective_spec=self.tabular_objective,
+ baseline_dataset=self.training_dataset,
+ output_spec=None,
+ notification_spec=None,
+ explanation_spec=None,
+ )
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_streaming_job_manager_skips_creation_when_identical_schedule_exists(
+ self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_existing_schedule = mock.MagicMock(
+ display_name="test-schedule", cron="@hourly")
+ mock_monitor.list_schedules.return_value = [mock_existing_schedule]
+ mock_create.return_value = mock_monitor
+
+ streaming_manager = _V2JobManagerStreaming(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ cron="@hourly",
+ schedule_display_name="test-schedule",
+ monitoring_job_display_name="test-sched-job",
+ )
+
+ streaming_manager.setup()
+ streaming_manager.process(None)
+ mock_monitor.list_schedules.assert_called_once()
+ mock_monitor.create_schedule.assert_not_called()
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def
test_streaming_job_manager_raises_value_error_when_no_cron_and_no_schedule(
+ self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_monitor.list_schedules.return_value = []
+ mock_create.return_value = mock_monitor
+
+ streaming_manager = _V2JobManagerStreaming(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ cron=None,
+ schedule_display_name="test-schedule",
+ monitoring_job_display_name="test-sched-job",
+ )
+
+ streaming_manager.setup()
+ with self.assertRaises(ValueError):
+ streaming_manager.process(None)
+
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_streaming_job_manager_allows_no_cron_when_schedule_exists(
+ self, mock_create):
+ mock_monitor = mock.MagicMock()
+ mock_existing_schedule = mock.MagicMock(
+ display_name="test-schedule", cron="@daily")
+ mock_monitor.list_schedules.return_value = [mock_existing_schedule]
+ mock_create.return_value = mock_monitor
+
+ streaming_manager = _V2JobManagerStreaming(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ cron=None,
+ schedule_display_name="test-schedule",
+ monitoring_job_display_name="test-sched-job",
+ )
+
+ streaming_manager.setup()
+ streaming_manager.process(None)
+ mock_monitor.list_schedules.assert_called_once()
+ mock_monitor.create_schedule.assert_not_called()
+
+
[email protected](
+ VertexModelMonitoringV2 is None,
+ reason="VertexModelMonitoringV2 not yet implemented or dependencies
missing"
+)
+class VertexModelMonitoringV2TransformTest(unittest.TestCase):
+ def setUp(self):
+ self.project_id = "test-project"
+ self.location = "us-central1"
+ self.display_name = "test-monitor"
+ self.model_name = "projects/123/locations/us-central1/models/test-model"
+ self.model_version_id = "1"
+ self.schema = DummySpec("schema")
+ self.training_dataset = DummySpec("training_dataset")
+ self.tabular_objective = DummySpec("tabular_objective")
+ self.target_dataset = DummySpec("target_dataset")
+ self.unpack_fn = lambda pr: {"feat": pr.example, "pred": pr.inference}
+ self.bq_table = "test-project:dataset.table"
+
+ @mock.patch(
+ "apache_beam.ml.inference.vertex_ai_model_monitoring_v2.WriteToBigQuery")
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_batch_pipeline_expansion_with_write_result(
+ self, mock_create, mock_write_to_bq):
+ class FakeWriteTransform(beam.PTransform):
+ def expand(self, pcoll):
+ load_pcoll = pcoll | "FakeLoads" >> beam.Map(
+ lambda x: ("dest", "job_1"))
+ return WriteResult(
+ method=WriteToBigQuery.Method.FILE_LOADS,
+ destination_load_jobid_pairs=load_pcoll,
+ )
+
+ mock_write_to_bq.return_value = FakeWriteTransform()
+ mock_monitor = mock.MagicMock()
+ mock_create.return_value = mock_monitor
+
+ transform = VertexModelMonitoringV2(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ unpack_fn=self.unpack_fn,
+ bigquery_table=self.bq_table,
+ bigquery_schema="feat:INTEGER,pred:INTEGER",
+ write_to_bigquery_kwargs={"create_disposition": "CREATE_IF_NEEDED"},
+ )
+
+ with TestPipeline() as p:
+ pcoll = p | beam.Create([PredictionResult(1, 2), PredictionResult(2, 4)])
+ output = pcoll | transform
+ assert_that(
+ output, equal_to([PredictionResult(1, 2), PredictionResult(2, 4)]))
+
+ mock_write_to_bq.assert_called_once_with(
+ table=self.bq_table,
+ schema="feat:INTEGER,pred:INTEGER",
+ create_disposition="CREATE_IF_NEEDED",
+ )
+ mock_monitor.run.assert_called_once()
+
+ def test_annotations_shadow_model_identifier(self):
+ transform = VertexModelMonitoringV2(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ unpack_fn=self.unpack_fn,
+ bigquery_table=self.bq_table,
+ )
+ annotations = transform.annotations()
+ self.assertIn("model_identifier", annotations)
+ self.assertEqual(annotations["model_identifier"], "")
+
+ def test_run_inference_monitoring_outlet_shadows_model_identifier(self):
+ class DummyMonitoring(beam.PTransform):
+ def expand(self, pcoll):
+ return pcoll | "Map" >> beam.Map(lambda x: x)
+
+ class DummyModelHandler(ModelHandler[int, PredictionResult, None]):
+ def run_inference(self, batch, model=None, inference_args=None):
+ return [PredictionResult(example=x, inference=x * 2) for x in batch]
+
+ def load_model(self):
+ return None
+
+ p = beam.Pipeline()
+ ri = RunInference(
+ DummyModelHandler(),
+ monitoring_transform=DummyMonitoring(),
+ model_identifier="test-model-identifier",
+ )
+ _ = p | beam.Create([1, 2, 3]) | ri
+ proto = p.to_runner_api()
+
+ outlet_transforms = [
+ t for t in proto.components.transforms.values()
+ if "BeamML_RunInference_MonitoringOutlet" in t.unique_name
+ ]
+ self.assertTrue(len(outlet_transforms) > 0)
+ for t in outlet_transforms:
+ self.assertEqual(t.annotations.get("model_identifier"), b"")
+
+ @mock.patch(
+ "apache_beam.ml.inference.vertex_ai_model_monitoring_v2.WriteToBigQuery")
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_streaming_pipeline_expansion_with_cron(
+ self, mock_create, mock_write_to_bq):
+ class FakeWriteTransform(beam.PTransform):
+ def expand(self, pcoll):
+ return pcoll
+
+ mock_write_to_bq.return_value = FakeWriteTransform()
+ mock_monitor = mock.MagicMock()
+ mock_monitor.list_schedules.return_value = []
+ mock_create.return_value = mock_monitor
+
+ transform = VertexModelMonitoringV2(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ unpack_fn=self.unpack_fn,
+ bigquery_table=self.bq_table,
+ cron="0 0 * * *",
+ )
+
+ with TestPipeline(additional_pipeline_args=["--streaming"]) as p:
+ pcoll = p | beam.Create([PredictionResult(1, 2), PredictionResult(2, 4)])
+ output = pcoll | transform
+ assert_that(
+ output, equal_to([PredictionResult(1, 2), PredictionResult(2, 4)]))
+
+ @mock.patch(
+ "apache_beam.ml.inference.vertex_ai_model_monitoring_v2.WriteToBigQuery")
+ @mock.patch(
+
"vertexai.resources.preview.ml_monitoring.model_monitors.ModelMonitor.create"
+ )
+ def test_streaming_pipeline_expansion_without_cron(
+ self, mock_create, mock_write_to_bq):
+ class FakeWriteTransform(beam.PTransform):
+ def expand(self, pcoll):
+ return pcoll
+
+ mock_write_to_bq.return_value = FakeWriteTransform()
+ mock_monitor = mock.MagicMock()
+ mock_monitor.list_schedules.return_value = [
+ mock.MagicMock(display_name="test-monitor_schedule", cron="0 0 * * *")
+ ]
+ mock_create.return_value = mock_monitor
+
+ transform = VertexModelMonitoringV2(
+ project_id=self.project_id,
+ location=self.location,
+ display_name=self.display_name,
+ model_name=self.model_name,
+ model_version_id=self.model_version_id,
+ model_monitoring_schema=self.schema,
+ training_dataset=self.training_dataset,
+ tabular_objective_spec=self.tabular_objective,
+ target_dataset=self.target_dataset,
+ unpack_fn=self.unpack_fn,
+ bigquery_table=self.bq_table,
+ cron=None,
+ )
+
+ with TestPipeline(additional_pipeline_args=["--streaming"]) as p:
+ pcoll = p | beam.Create([PredictionResult(1, 2), PredictionResult(2, 4)])
+ output = pcoll | transform
+ assert_that(
+ output, equal_to([PredictionResult(1, 2), PredictionResult(2, 4)]))
+
+
+if __name__ == "__main__":
+ unittest.main()