damccorm commented on code in PR #39738: URL: https://github.com/apache/beam/pull/39738#discussion_r3814557654
########## sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py: ########## @@ -0,0 +1,405 @@ +# +# 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, Review Comment: Should we accept arbitrary kwargs? That way if any arguments are added in the future, it provides a workaround even if we don't add it to the base beam implementation ########## sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_it_test.py: ########## @@ -0,0 +1,488 @@ +# +# 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 + +# Disable mTLS fallback if pyOpenSSL is unavailable in local environment +os.environ.setdefault("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") Review Comment: Why do we need this? ########## sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py: ########## @@ -0,0 +1,405 @@ +# +# 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, + ): + 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 + + 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, + ) + except (exceptions.AlreadyExists, exceptions.Conflict): + 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( Review Comment: Do we need any sort of backoff on this if it isn't found? I'm not sure how long monitors take to create, but what happens if you have 2 DoFns, A and B with setup called at the same time. Could you end up with: ``` A -> create monitor (starts) B -> create monitor (fails, conflict) B -> list monitors (finds nothing, throws) A -> create monitor (finishes) ``` ########## sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py: ########## @@ -0,0 +1,405 @@ +# +# 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, + ): + 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 + + 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, + ) + except (exceptions.AlreadyExists, exceptions.Conflict): + 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, + cron: str, + schedule_display_name: str, + 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 process(self, element): + 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, + ) + 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, + ): + """ + 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 * * * *'). + 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 + + 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: + raise ValueError( Review Comment: Within the current approach, could a user have an existing monitoring job and just point this to populate the correct BQ table? Or is there a reason we couldn't support this? The cron piece made me think of this since a cron schedule wouldn't be needed if you configured the job already. This would potentially allow multiple streaming pipelines to feed the same monitoring job (e.g. if you have the same model deployed in multiple regions but wanted a single monitoring job) ########## sdks/python/apache_beam/ml/inference/base.py: ########## @@ -1584,6 +1593,13 @@ def failure_callback(exception: Exception, element: Any): 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) Review Comment: Does the monitoring track error rates as well? If yes, we should probably find a way to route the bad inferences here as well. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
