jrmccluskey commented on code in PR #39738:
URL: https://github.com/apache/beam/pull/39738#discussion_r3815951298


##########
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:
   In this form yes, but with the caveat that the entire configuration would 
need to be completely identical. I took a quick run at allowing the omission of 
a cron schedule to effectively skip the monitoring transform altogether (and 
log a warning for the user that this happened.) We still route the inferences 
to the BQ table, but don't worry about anything else.



-- 
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]

Reply via email to