From d4deed3d59dec90c02ea681b4b8dc33399815069 Mon Sep 17 00:00:00 2001 From: Jaycee Li Date: Thu, 12 Jan 2023 00:38:10 -0800 Subject: [PATCH] feat: Support Model Serialization in Vertex Experiments(sklearn) PiperOrigin-RevId: 501487417 --- google/cloud/aiplatform/__init__.py | 7 + google/cloud/aiplatform/helpers/__init__.py | 4 + .../helpers/container_uri_builders.py | 106 ++- google/cloud/aiplatform/metadata/_models.py | 648 ++++++++++++++++++ google/cloud/aiplatform/metadata/constants.py | 2 +- .../metadata/experiment_run_resource.py | 131 +++- google/cloud/aiplatform/metadata/metadata.py | 88 ++- .../metadata/schema/google/artifact_schema.py | 270 +++++++- .../cloud/aiplatform/metadata/schema/utils.py | 15 +- google/cloud/aiplatform/utils/gcs_utils.py | 34 + setup.py | 2 +- tests/unit/aiplatform/test_helpers.py | 105 ++- tests/unit/aiplatform/test_metadata.py | 83 +++ tests/unit/aiplatform/test_metadata_models.py | 351 ++++++++++ 14 files changed, 1830 insertions(+), 16 deletions(-) create mode 100644 google/cloud/aiplatform/metadata/_models.py create mode 100644 tests/unit/aiplatform/test_metadata_models.py diff --git a/google/cloud/aiplatform/__init__.py b/google/cloud/aiplatform/__init__.py index cb7f08b8aa..53775d7c56 100644 --- a/google/cloud/aiplatform/__init__.py +++ b/google/cloud/aiplatform/__init__.py @@ -91,6 +91,7 @@ log_classification_metrics = ( metadata.metadata._experiment_tracker.log_classification_metrics ) +log_model = metadata.metadata._experiment_tracker.log_model get_experiment_df = metadata.metadata._experiment_tracker.get_experiment_df start_run = metadata.metadata._experiment_tracker.start_run start_execution = metadata.metadata._experiment_tracker.start_execution @@ -98,6 +99,9 @@ log_time_series_metrics = metadata.metadata._experiment_tracker.log_time_series_metrics end_run = metadata.metadata._experiment_tracker.end_run +save_model = metadata._models.save_model +get_experiment_model = metadata.schema.google.artifact_schema.ExperimentModel.get + Experiment = metadata.experiment_resources.Experiment ExperimentRun = metadata.experiment_run_resource.ExperimentRun Artifact = metadata.artifact.Artifact @@ -116,11 +120,14 @@ "log_params", "log_metrics", "log_classification_metrics", + "log_model", "log_time_series_metrics", "get_experiment_df", "get_pipeline_df", "start_run", "start_execution", + "save_model", + "get_experiment_model", "Artifact", "AutoMLImageTrainingJob", "AutoMLTabularTrainingJob", diff --git a/google/cloud/aiplatform/helpers/__init__.py b/google/cloud/aiplatform/helpers/__init__.py index 41fa9e6414..1dbc547fa6 100644 --- a/google/cloud/aiplatform/helpers/__init__.py +++ b/google/cloud/aiplatform/helpers/__init__.py @@ -20,8 +20,12 @@ is_prebuilt_prediction_container_uri = ( container_uri_builders.is_prebuilt_prediction_container_uri ) +_get_closest_match_prebuilt_container_uri = ( + container_uri_builders._get_closest_match_prebuilt_container_uri +) __all__ = ( "get_prebuilt_prediction_container_uri", "is_prebuilt_prediction_container_uri", + "_get_closest_match_prebuilt_container_uri", ) diff --git a/google/cloud/aiplatform/helpers/container_uri_builders.py b/google/cloud/aiplatform/helpers/container_uri_builders.py index 5d728ecd1e..eaabb8b447 100644 --- a/google/cloud/aiplatform/helpers/container_uri_builders.py +++ b/google/cloud/aiplatform/helpers/container_uri_builders.py @@ -1,4 +1,4 @@ -# Copyright 2021 Google LLC +# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,9 +14,11 @@ import re from typing import Optional +import warnings -from google.cloud.aiplatform.constants import prediction from google.cloud.aiplatform import initializer +from google.cloud.aiplatform.constants import prediction +from packaging import version def get_prebuilt_prediction_container_uri( @@ -122,3 +124,103 @@ def is_prebuilt_prediction_container_uri(image_uri: str) -> bool: If the image is prebuilt by Vertex AI prediction. """ return re.fullmatch(prediction.CONTAINER_URI_REGEX, image_uri) is not None + + +# TODO(b/264191784) Deduplicate this method +def _get_closest_match_prebuilt_container_uri( + framework: str, + framework_version: str, + region: Optional[str] = None, + accelerator: str = "cpu", +) -> str: + """Return a pre-built container uri that is suitable for a specific framework and version. + + If there is no exact match for the given version, the closest one that is + higher than the input version will be used. + + Args: + framework (str): + Required. The ML framework of the pre-built container. For example, + `"tensorflow"`, `"xgboost"`, or `"sklearn"` + framework_version (str): + Required. The version of the specified ML framework as a string. + region (str): + Optional. AI region or multi-region. Used to select the correct + Artifact Registry multi-region repository and reduce latency. + Must start with `"us"`, `"asia"` or `"europe"`. + Default is location set by `aiplatform.init()`. + accelerator (str): + Optional. The type of accelerator support provided by container. For + example: `"cpu"` or `"gpu"` + Default is `"cpu"`. + + Returns: + A string representing the pre-built container uri. + + Raises: + ValueError: If the framework doesn't have suitable pre-built container. + """ + URI_MAP = prediction._SERVING_CONTAINER_URI_MAP + DOCS_URI_MESSAGE = ( + f"See {prediction._SERVING_CONTAINER_DOCUMENTATION_URL} " + "for complete list of supported containers" + ) + + # If region not provided, use initializer location + region = region or initializer.global_config.location + region = region.split("-", 1)[0] + framework = framework.lower() + + if not URI_MAP.get(region): + raise ValueError( + f"Unsupported container region `{region}`, supported regions are " + f"{', '.join(URI_MAP.keys())}. " + f"{DOCS_URI_MESSAGE}" + ) + + if not URI_MAP[region].get(framework): + raise ValueError( + f"No containers found for framework `{framework}`. Supported frameworks are " + f"{', '.join(URI_MAP[region].keys())} {DOCS_URI_MESSAGE}" + ) + + if not URI_MAP[region][framework].get(accelerator): + raise ValueError( + f"{framework} containers do not support `{accelerator}` accelerator. Supported accelerators " + f"are {', '.join(URI_MAP[region][framework].keys())}. {DOCS_URI_MESSAGE}" + ) + + framework_version = version.Version(framework_version) + available_version_list = [ + version.Version(available_version) + for available_version in URI_MAP[region][framework][accelerator].keys() + ] + try: + closest_version = min( + [ + available_version + for available_version in available_version_list + if available_version >= framework_version + # manually implement Version.major for packaging < 20.0 + and available_version._version.release[0] + == framework_version._version.release[0] + ] + ) + except ValueError: + raise ValueError( + f"You are using `{framework}` version `{framework_version}`. " + f"Vertex pre-built containers support up to `{framework}` version " + f"`{max(available_version_list)}` and don't assume forward compatibility. " + f"Please build your own custom container. {DOCS_URI_MESSAGE}" + ) from None + + if closest_version != framework_version: + warnings.warn( + f"No exact match for `{framework}` version `{framework_version}`. " + f"Pre-built container for `{framework}` version `{closest_version}` is used. " + f"{DOCS_URI_MESSAGE}" + ) + + final_uri = URI_MAP[region][framework][accelerator].get(str(closest_version)) + + return final_uri diff --git a/google/cloud/aiplatform/metadata/_models.py b/google/cloud/aiplatform/metadata/_models.py new file mode 100644 index 0000000000..202207503f --- /dev/null +++ b/google/cloud/aiplatform/metadata/_models.py @@ -0,0 +1,648 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 Google LLC +# +# Licensed 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 os +import pickle +import tempfile +from typing import Dict, Optional, Sequence, Union + +from google.auth import credentials as auth_credentials +from google.cloud import storage +from google.cloud import aiplatform +from google.cloud.aiplatform import base +from google.cloud.aiplatform import explain +from google.cloud.aiplatform import helpers +from google.cloud.aiplatform import initializer +from google.cloud.aiplatform import models +from google.cloud.aiplatform import utils +from google.cloud.aiplatform.metadata.schema import utils as schema_utils +from google.cloud.aiplatform.metadata.schema.google import ( + artifact_schema as google_artifact_schema, +) +from google.cloud.aiplatform.utils import gcs_utils + + +_LOGGER = base.Logger(__name__) + +_PICKLE_PROTOCOL = 4 +_MAX_INPUT_EXAMPLE_ROWS = 5 +_FRAMEWORK_SPECS = { + "sklearn": { + "save_method": "_save_sklearn_model", + "load_method": "_load_sklearn_model", + "model_file": "model.pkl", + } +} + + +def save_model( + model: "sklearn.base.BaseEstimator", # noqa: F821 + artifact_id: Optional[str] = None, + *, + uri: Optional[str] = None, + input_example: Union[list, dict, "pd.DataFrame", "np.ndarray"] = None, # noqa: F821 + display_name: Optional[str] = None, + metadata_store_id: Optional[str] = "default", + project: Optional[str] = None, + location: Optional[str] = None, + credentials: Optional[auth_credentials.Credentials] = None, +) -> google_artifact_schema.ExperimentModel: + """Saves a ML model into a MLMD artifact. + + Supported model frameworks: sklearn. + + Example usage: + aiplatform.init(project="my-project", location="my-location", staging_bucket="gs://my-bucket") + model = LinearRegression() + model.fit(X, y) + aiplatform.save_model(model, "my-sklearn-model") + + Args: + model (sklearn.base.BaseEstimator): + Required. A machine learning model. + artifact_id (str): + Optional. The resource id of the artifact. This id must be globally unique + in a metadataStore. It may be up to 63 characters, and valid characters + are `[a-z0-9_-]`. The first character cannot be a number or hyphen. + uri (str): + Optional. A gcs directory to save the model file. If not provided, + `gs://default-bucket/timestamp-uuid-frameworkName-model` will be used. + If default staging bucket is not set, a new bucket will be created. + input_example (Union[list, dict, pd.DataFrame, np.ndarray]): + Optional. An example of a valid model input. Will be stored as a yaml file + in the gcs uri. Accepts list, dict, pd.DataFrame, and np.ndarray + The value inside a list must be a scalar or list. The value inside + a dict must be a scalar, list, or np.ndarray. + display_name (str): + Optional. The display name of the artifact. + metadata_store_id (str): + Optional. The portion of the resource name with + the format: + projects/123/locations/us-central1/metadataStores//artifacts/ + If not provided, the MetadataStore's ID will be set to "default". + project (str): + Optional. Project used to create this Artifact. Overrides project set in + aiplatform.init. + location (str): + Optional. Location used to create this Artifact. Overrides location set in + aiplatform.init. + credentials (auth_credentials.Credentials): + Optional. Custom credentials used to create this Artifact. Overrides + credentials set in aiplatform.init. + + Returns: + An ExperimentModel instance. + + Raises: + ValueError: if model type is not supported. + """ + framework_name = framework_version = "" + try: + import sklearn + except ImportError: + pass + else: + if isinstance(model, sklearn.base.BaseEstimator): + framework_name = "sklearn" + framework_version = sklearn.__version__ + + if framework_name not in _FRAMEWORK_SPECS: + raise ValueError( + f"Model type {model.__class__.__module__}.{model.__class__.__name__} not supported." + ) + + save_method = globals()[_FRAMEWORK_SPECS[framework_name]["save_method"]] + model_file = _FRAMEWORK_SPECS[framework_name]["model_file"] + + if not uri: + staging_bucket = initializer.global_config.staging_bucket + # TODO(b/264196887) + if not staging_bucket: + project = project or initializer.global_config.project + location = location or initializer.global_config.location + credentials = credentials or initializer.global_config.credentials + + staging_bucket_name = project + "-vertex-staging-" + location + client = storage.Client(project=project, credentials=credentials) + staging_bucket = storage.Bucket(client=client, name=staging_bucket_name) + if not staging_bucket.exists(): + _LOGGER.info(f'Creating staging bucket "{staging_bucket_name}"') + staging_bucket = client.create_bucket( + bucket_or_name=staging_bucket, + project=project, + location=location, + ) + staging_bucket = f"gs://{staging_bucket_name}" + + unique_name = utils.timestamped_unique_name() + uri = f"{staging_bucket}/{unique_name}-{framework_name}-model" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_model_file = os.path.join(temp_dir, model_file) + save_method(model, temp_model_file) + + if input_example is not None: + _save_input_example(input_example, temp_dir) + predict_schemata = schema_utils.PredictSchemata( + instance_schema_uri=os.path.join(uri, "instance.yaml") + ) + else: + predict_schemata = None + gcs_utils.upload_to_gcs(temp_dir, uri) + + model_artifact = google_artifact_schema.ExperimentModel( + framework_name=framework_name, + framework_version=framework_version, + model_file=model_file, + model_class=f"{model.__class__.__module__}.{model.__class__.__name__}", + predict_schemata=predict_schemata, + artifact_id=artifact_id, + uri=uri, + display_name=display_name, + ) + model_artifact.create( + metadata_store_id=metadata_store_id, + project=project, + location=location, + credentials=credentials, + ) + + return model_artifact + + +def _save_input_example( + input_example: Union[list, dict, "pd.DataFrame", "np.ndarray"], # noqa: F821 + path: str, +): + """Saves an input example into a yaml file in the given path. + + Supported example formats: list, dict, np.ndarray, pd.DataFrame. + + Args: + input_example (Union[list, dict, np.ndarray, pd.DataFrame]): + Required. An input example to save. The value inside a list must be + a scalar or list. The value inside a dict must be a scalar, list, or + np.ndarray. + path (str): + Required. The directory that the example is saved to. + + Raises: + ImportError: if PyYAML or numpy is not installed. + ValueError: if input_example is in a wrong format. + """ + try: + import numpy as np + except ImportError: + raise ImportError( + "numpy is not installed and is required for saving input examples. " + "Please install google-cloud-aiplatform[metadata]." + ) from None + + try: + import yaml + except ImportError: + raise ImportError( + "PyYAML is not installed and is required for saving input examples." + ) from None + + example = {} + if isinstance(input_example, list): + if all(isinstance(x, list) for x in input_example): + example = { + "type": "list", + "data": input_example[:_MAX_INPUT_EXAMPLE_ROWS], + } + elif all(np.isscalar(x) for x in input_example): + example = { + "type": "list", + "data": input_example, + } + else: + raise ValueError("The value inside a list must be a scalar or list.") + + if isinstance(input_example, dict): + if all(isinstance(x, list) for x in input_example.values()): + example = { + "type": "dict", + "data": { + k: v[:_MAX_INPUT_EXAMPLE_ROWS] for k, v in input_example.items() + }, + } + elif all(isinstance(x, np.ndarray) for x in input_example.values()): + example = { + "type": "dict", + "data": { + k: v[:_MAX_INPUT_EXAMPLE_ROWS].tolist() + for k, v in input_example.items() + }, + } + elif all(np.isscalar(x) for x in input_example.values()): + example = {"type": "dict", "data": input_example} + else: + raise ValueError( + "The value inside a dictionary must be a scalar, list, or np.ndarray" + ) + + if isinstance(input_example, np.ndarray): + example = { + "type": "numpy.ndarray", + "data": input_example[:_MAX_INPUT_EXAMPLE_ROWS].tolist(), + } + + try: + import pandas as pd + + if isinstance(input_example, pd.DataFrame): + example = { + "type": "pandas.DataFrame", + "data": input_example.head(_MAX_INPUT_EXAMPLE_ROWS).to_dict("list"), + } + except ImportError: + pass + + if not example: + raise ValueError( + ( + "Input example type not supported. " + "Valid example must be a list, dict, np.ndarray, or pd.DataFrame." + ) + ) + + example_file = os.path.join(path, "instance.yaml") + with open(example_file, "w") as file: + yaml.dump( + {"input_example": example}, file, default_flow_style=None, sort_keys=False + ) + + +def _save_sklearn_model( + model: "sklearn.base.BaseEstimator", # noqa: F821 + path: str, +) -> google_artifact_schema.ExperimentModel: + """Saves a sklearn model. + + Args: + model (sklearn.base.BaseEstimator): + Required. A sklearn model. + path (str): + Required. The local path to save the model. + """ + with open(path, "wb") as f: + pickle.dump(model, f, protocol=_PICKLE_PROTOCOL) + + +def load_model( + model: Union[str, google_artifact_schema.ExperimentModel] +) -> "sklearn.base.BaseEstimator": # noqa: F821 + """Retrieves the original ML model from an ExperimentModel resource. + + Args: + model (Union[str, google_artifact_schema.ExperimentModel]): + Required. The id or ExperimentModel instance for the model. + + Returns: + The original ML model. + + Raises: + ValueError: if model type is not supported. + """ + if isinstance(model, str): + model = aiplatform.get_experiment_model(model) + framework_name = model.framework_name + + if framework_name not in _FRAMEWORK_SPECS: + raise ValueError(f"Model type {framework_name} not supported.") + + load_method = globals()[_FRAMEWORK_SPECS[framework_name]["load_method"]] + model_file = _FRAMEWORK_SPECS[framework_name]["model_file"] + + with tempfile.TemporaryDirectory() as temp_dir: + source_file_uri = os.path.join(model.uri, model_file) + destination_file_path = os.path.join(temp_dir, model_file) + gcs_utils.download_file_from_gcs(source_file_uri, destination_file_path) + loaded_model = load_method(destination_file_path, model) + + return loaded_model + + +def _load_sklearn_model( + model_file: str, + model_artifact: google_artifact_schema.ExperimentModel, +) -> "sklearn.base.BaseEstimator": # noqa: F821 + """Loads a sklearn model from local path. + + Args: + model_file (str): + Required. A local model file to load. + model_artifact (google_artifact_schema.ExperimentModel): + Required. The artifact that saved the model. + Returns: + The sklearn model instance. + + Raises: + ImportError: if sklearn is not installed. + """ + try: + import sklearn + except ImportError: + raise ImportError( + "sklearn is not installed and is required for loading models." + ) from None + + if sklearn.__version__ < model_artifact.framework_version: + _LOGGER.warning( + f"The original model was saved via sklearn {model_artifact.framework_version}. " + f"You are using sklearn {sklearn.__version__}." + "Attempting to load model..." + ) + with open(model_file, "rb") as f: + sk_model = pickle.load(f) + + return sk_model + + +# TODO(b/264893283) +def register_model( + model: Union[str, google_artifact_schema.ExperimentModel], + *, + model_id: Optional[str] = None, + parent_model: Optional[str] = None, + use_gpu: bool = False, + is_default_version: bool = True, + version_aliases: Optional[Sequence[str]] = None, + version_description: Optional[str] = None, + display_name: Optional[str] = None, + description: Optional[str] = None, + labels: Optional[Dict[str, str]] = None, + serving_container_image_uri: Optional[str] = None, + serving_container_predict_route: Optional[str] = None, + serving_container_health_route: Optional[str] = None, + serving_container_command: Optional[Sequence[str]] = None, + serving_container_args: Optional[Sequence[str]] = None, + serving_container_environment_variables: Optional[Dict[str, str]] = None, + serving_container_ports: Optional[Sequence[int]] = None, + instance_schema_uri: Optional[str] = None, + parameters_schema_uri: Optional[str] = None, + prediction_schema_uri: Optional[str] = None, + explanation_metadata: Optional[explain.ExplanationMetadata] = None, + explanation_parameters: Optional[explain.ExplanationParameters] = None, + project: Optional[str] = None, + location: Optional[str] = None, + credentials: Optional[auth_credentials.Credentials] = None, + encryption_spec_key_name: Optional[str] = None, + staging_bucket: Optional[str] = None, + sync: Optional[bool] = True, + upload_request_timeout: Optional[float] = None, +) -> models.Model: + """Register an ExperimentModel to Model Registry and returns a Model representing the registered Model resource. + + Args: + model (Union[str, google_artifact_schema.ExperimentModel]): + Required. The id or ExperimentModel instance for the model. + model_id (str): + Optional. The ID to use for the registered Model, which will + become the final component of the model resource name. + This value may be up to 63 characters, and valid characters + are `[a-z0-9_-]`. The first character cannot be a number or hyphen. + parent_model (str): + Optional. The resource name or model ID of an existing model that the + newly-registered model will be a version of. + Only set this field when uploading a new version of an existing model. + use_gpu (str): + Optional. Whether or not to use GPUs for the serving container. Only + specify this argument when registering a Tensorflow model and + 'serving_container_image_uri' is not specified. + is_default_version (bool): + Optional. When set to True, the newly registered model version will + automatically have alias "default" included. Subsequent uses of + this model without a version specified will use this "default" version. + + When set to False, the "default" alias will not be moved. + Actions targeting the newly-registered model version will need + to specifically reference this version by ID or alias. + + New model uploads, i.e. version 1, will always be "default" aliased. + version_aliases (Sequence[str]): + Optional. User provided version aliases so that a model version + can be referenced via alias instead of auto-generated version ID. + A default version alias will be created for the first version of the model. + + The format is [a-z][a-zA-Z0-9-]{0,126}[a-z0-9] + version_description (str): + Optional. The description of the model version being uploaded. + display_name (str): + Optional. The display name of the Model. The name can be up to 128 + characters long and can be consist of any UTF-8 characters. + description (str): + Optional. The description of the model. + labels (Dict[str, str]): + Optional. The labels with user-defined metadata to + organize your Models. + Label keys and values can be no longer than 64 + characters (Unicode codepoints), can only + contain lowercase letters, numeric characters, + underscores and dashes. International characters + are allowed. + See https://goo.gl/xmQnxf for more information + and examples of labels. + serving_container_image_uri (str): + Optional. The URI of the Model serving container. A pre-built container + + is automatically chosen based on the model's framwork. Set this field to + override the default pre-built container. + serving_container_predict_route (str): + Optional. An HTTP path to send prediction requests to the container, and + which must be supported by it. If not specified a default HTTP path will + be used by Vertex AI. + serving_container_health_route (str): + Optional. An HTTP path to send health check requests to the container, and which + must be supported by it. If not specified a standard HTTP path will be + used by Vertex AI. + serving_container_command (Sequence[str]): + Optional. The command with which the container is run. Not executed within a + shell. The Docker image's ENTRYPOINT is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's + environment. If a variable cannot be resolved, the reference in the + input string will be unchanged. The $(VAR_NAME) syntax can be escaped + with a double $$, ie: $$(VAR_NAME). Escaped references will never be + expanded, regardless of whether the variable exists or not. + serving_container_args (Sequence[str]): + Optional. The arguments to the command. The Docker image's CMD is used if this is + not provided. Variable references $(VAR_NAME) are expanded using the + container's environment. If a variable cannot be resolved, the reference + in the input string will be unchanged. The $(VAR_NAME) syntax can be + escaped with a double $$, ie: $$(VAR_NAME). Escaped references will + never be expanded, regardless of whether the variable exists or not. + serving_container_environment_variables (Dict[str, str]): + Optional. The environment variables that are to be present in the container. + Should be a dictionary where keys are environment variable names + and values are environment variable values for those names. + serving_container_ports (Sequence[int]): + Optional. Declaration of ports that are exposed by the container. This field is + primarily informational, it gives Vertex AI information about the + network connections the container uses. Listing or not a port here has + no impact on whether the port is actually exposed, any port listening on + the default "0.0.0.0" address inside a container will be accessible from + the network. + instance_schema_uri (str): + Optional. Points to a YAML file stored on Google Cloud + Storage describing the format of a single instance, which + are used in + ``PredictRequest.instances``, + ``ExplainRequest.instances`` + and + ``BatchPredictionJob.input_config``. + The schema is defined as an OpenAPI 3.0.2 `Schema + Object `__. + AutoML Models always have this field populated by AI + Platform. Note: The URI given on output will be immutable + and probably different, including the URI scheme, than the + one given on input. The output URI will point to a location + where the user only has a read access. + parameters_schema_uri (str): + Optional. Points to a YAML file stored on Google Cloud + Storage describing the parameters of prediction and + explanation via + ``PredictRequest.parameters``, + ``ExplainRequest.parameters`` + and + ``BatchPredictionJob.model_parameters``. + The schema is defined as an OpenAPI 3.0.2 `Schema + Object `__. + AutoML Models always have this field populated by AI + Platform, if no parameters are supported it is set to an + empty string. Note: The URI given on output will be + immutable and probably different, including the URI scheme, + than the one given on input. The output URI will point to a + location where the user only has a read access. + prediction_schema_uri (str): + Optional. Points to a YAML file stored on Google Cloud + Storage describing the format of a single prediction + produced by this Model, which are returned via + ``PredictResponse.predictions``, + ``ExplainResponse.explanations``, + and + ``BatchPredictionJob.output_config``. + The schema is defined as an OpenAPI 3.0.2 `Schema + Object `__. + AutoML Models always have this field populated by AI + Platform. Note: The URI given on output will be immutable + and probably different, including the URI scheme, than the + one given on input. The output URI will point to a location + where the user only has a read access. + explanation_metadata (aiplatform.explain.ExplanationMetadata): + Optional. Metadata describing the Model's input and output for explanation. + `explanation_metadata` is optional while `explanation_parameters` must be + specified when used. + For more details, see `Ref docs ` + explanation_parameters (aiplatform.explain.ExplanationParameters): + Optional. Parameters to configure explaining for Model's predictions. + For more details, see `Ref docs ` + project (str) + Project to upload this model to. Overrides project set in + aiplatform.init. + location (str) + Location to upload this model to. Overrides location set in + aiplatform.init. + credentials (auth_credentials.Credentials) + Custom credentials to use to upload this model. Overrides credentials + set in aiplatform.init. + encryption_spec_key_name (Optional[str]): + Optional. The Cloud KMS resource identifier of the customer + managed encryption key used to protect the model. Has the + form + ``projects/my-project/locations/my-region/keyRings/my-kr/cryptoKeys/my-key``. + The key needs to be in the same region as where the compute + resource is created. + + If set, this Model and all sub-resources of this Model will be secured by this key. + + Overrides encryption_spec_key_name set in aiplatform.init. + staging_bucket (str): + Optional. Bucket to stage local model artifacts. Overrides + staging_bucket set in aiplatform.init. + sync (bool): + Optional. Whether to execute this method synchronously. If False, + this method will unblock and it will be executed in a concurrent Future. + upload_request_timeout (float): + Optional. The timeout for the upload request in seconds. + + Returns: + model (aiplatform.Model): + Instantiated representation of the registered model resource. + + Raises: + ValueError: If the model doesn't have a pre-built container that is + suitable for its framework and 'serving_container_image_uri' + is not set. + """ + if isinstance(model, str): + model = aiplatform.get_experiment_model(model) + + project = project or model.project + location = location or model.location + credentials = credentials or model.credentials + + artifact_uri = model.uri + framework_name = model.framework_name + framework_version = model.framework_version + + if not serving_container_image_uri: + if framework_name == "tensorflow" and use_gpu: + accelerator = "gpu" + else: + accelerator = "cpu" + serving_container_image_uri = helpers._get_closest_match_prebuilt_container_uri( + framework=framework_name, + framework_version=framework_version, + region=location, + accelerator=accelerator, + ) + + if not display_name: + display_name = models.Model._generate_display_name(f"{framework_name} model") + + return models.Model.upload( + serving_container_image_uri=serving_container_image_uri, + artifact_uri=artifact_uri, + model_id=model_id, + parent_model=parent_model, + is_default_version=is_default_version, + version_aliases=version_aliases, + version_description=version_description, + display_name=display_name, + description=description, + labels=labels, + serving_container_predict_route=serving_container_predict_route, + serving_container_health_route=serving_container_health_route, + serving_container_command=serving_container_command, + serving_container_args=serving_container_args, + serving_container_environment_variables=serving_container_environment_variables, + serving_container_ports=serving_container_ports, + instance_schema_uri=instance_schema_uri, + parameters_schema_uri=parameters_schema_uri, + prediction_schema_uri=prediction_schema_uri, + explanation_metadata=explanation_metadata, + explanation_parameters=explanation_parameters, + project=project, + location=location, + credentials=credentials, + encryption_spec_key_name=encryption_spec_key_name, + staging_bucket=staging_bucket, + sync=sync, + upload_request_timeout=upload_request_timeout, + ) diff --git a/google/cloud/aiplatform/metadata/constants.py b/google/cloud/aiplatform/metadata/constants.py index 320b706e23..3be92db222 100644 --- a/google/cloud/aiplatform/metadata/constants.py +++ b/google/cloud/aiplatform/metadata/constants.py @@ -27,7 +27,7 @@ GOOGLE_CLASSIFICATION_METRICS = "google.ClassificationMetrics" GOOGLE_REGRESSION_METRICS = "google.RegressionMetrics" GOOGLE_FORECASTING_METRICS = "google.ForecastingMetrics" - +GOOGLE_EXPERIMENT_MODEL = "google.ExperimentModel" _EXPERIMENTS_V2_TENSORBOARD_RUN = "google.VertexTensorboardRun" _DEFAULT_SCHEMA_VERSION = "0.0.1" diff --git a/google/cloud/aiplatform/metadata/experiment_run_resource.py b/google/cloud/aiplatform/metadata/experiment_run_resource.py index 67797e6a7d..738ecfb4f3 100644 --- a/google/cloud/aiplatform/metadata/experiment_run_resource.py +++ b/google/cloud/aiplatform/metadata/experiment_run_resource.py @@ -18,12 +18,10 @@ from collections import abc import concurrent.futures import functools -from typing import Callable, Dict, List, Optional, Set, Union, Any +from typing import Any, Callable, Dict, List, Optional, Set, Union from google.api_core import exceptions from google.auth import credentials as auth_credentials -from google.protobuf import timestamp_pb2 - from google.cloud.aiplatform import base from google.cloud.aiplatform import initializer from google.cloud.aiplatform import pipeline_jobs @@ -38,6 +36,7 @@ from google.cloud.aiplatform.metadata import execution from google.cloud.aiplatform.metadata import experiment_resources from google.cloud.aiplatform.metadata import metadata +from google.cloud.aiplatform.metadata import _models from google.cloud.aiplatform.metadata import resource from google.cloud.aiplatform.metadata import utils as metadata_utils from google.cloud.aiplatform.metadata.schema import utils as schema_utils @@ -47,6 +46,8 @@ from google.cloud.aiplatform.tensorboard import tensorboard_resource from google.cloud.aiplatform.utils import rest_utils +from google.protobuf import timestamp_pb2 + _LOGGER = base.Logger(__name__) @@ -1102,6 +1103,94 @@ def log_classification_metrics( artifact_resource_names=[classfication_metrics.resource_name] ) + @_v1_not_supported + def log_model( + self, + model: "sklearn.base.BaseEstimator", # noqa: F821 + artifact_id: Optional[str] = None, + *, + uri: Optional[str] = None, + input_example: Union[ + "list", dict, "pd.DataFrame", "np.ndarray" # noqa: F821 + ] = None, + display_name: Optional[str] = None, + metadata_store_id: Optional[str] = "default", + project: Optional[str] = None, + location: Optional[str] = None, + credentials: Optional[auth_credentials.Credentials] = None, + ) -> google_artifact_schema.ExperimentModel: + """Saves a ML model into a MLMD artifact and log it to this ExperimentRun. + + Supported model frameworks: sklearn. + + Example usage: + model = LinearRegression() + model.fit(X, y) + aiplatform.init( + project="my-project", + location="my-location", + staging_bucket="gs://my-bucket", + experiment="my-exp" + ) + with aiplatform.start_run("my-run"): + aiplatform.log_model(model, "my-sklearn-model") + + Args: + model (sklearn.base.BaseEstimator): + Required. A machine learning model. + artifact_id (str): + Optional. The resource id of the artifact. This id must be globally unique + in a metadataStore. It may be up to 63 characters, and valid characters + are `[a-z0-9_-]`. The first character cannot be a number or hyphen. + uri (str): + Optional. A gcs directory to save the model file. If not provided, + `gs://default-bucket/timestamp-uuid-frameworkName-model` will be used. + If default staging bucket is not set, a new bucket will be created. + input_example (Union[list, dict, pd.DataFrame, np.ndarray]): + Optional. An example of a valid model input. Will be stored as a yaml file + in the gcs uri. Accepts list, dict, pd.DataFrame, and np.ndarray + The value inside a list must be a scalar or list. The value inside + a dict must be a scalar, list, or np.ndarray. + display_name (str): + Optional. The display name of the artifact. + metadata_store_id (str): + Optional. The portion of the resource name with + the format: + projects/123/locations/us-central1/metadataStores//artifacts/ + If not provided, the MetadataStore's ID will be set to "default". + project (str): + Optional. Project used to create this Artifact. Overrides project set in + aiplatform.init. + location (str): + Optional. Location used to create this Artifact. Overrides location set in + aiplatform.init. + credentials (auth_credentials.Credentials): + Optional. Custom credentials used to create this Artifact. Overrides + credentials set in aiplatform.init. + + Returns: + An ExperimentModel instance. + + Raises: + ValueError: if model type is not supported. + """ + experiment_model = _models.save_model( + model=model, + artifact_id=artifact_id, + uri=uri, + input_example=input_example, + display_name=display_name, + metadata_store_id=metadata_store_id, + project=project, + location=location, + credentials=credentials, + ) + + self._metadata_node.add_artifacts_and_executions( + artifact_resource_names=[experiment_model.resource_name] + ) + return experiment_model + @_v1_not_supported def get_time_series_data_frame(self) -> "pd.DataFrame": # noqa: F821 """Returns all time series in this Run as a DataFrame. @@ -1320,6 +1409,42 @@ def get_classification_metrics(self) -> List[Dict[str, Union[str, List]]]: return metrics + @_v1_not_supported + def get_experiment_models(self) -> List[google_artifact_schema.ExperimentModel]: + """Get all ExperimentModel associated to this experiment run. + + Returns: + List of ExperimentModel instances associated this run. + """ + # TODO(b/264194064) Replace this by ExperimentModel.list + artifact_list = artifact.Artifact.list( + filter=metadata_utils._make_filter_string( + in_context=[self.resource_name], + schema_title=google_artifact_schema.ExperimentModel.schema_title, + ), + project=self.project, + location=self.location, + credentials=self.credentials, + ) + + res = [] + for model_artifact in artifact_list: + experiment_model = google_artifact_schema.ExperimentModel( + framework_name="", + framework_version="", + model_file="", + uri="", + ) + experiment_model._gca_resource = model_artifact._gca_resource + experiment_model.project = model_artifact.project + experiment_model.location = model_artifact.location + experiment_model.credentials = model_artifact.credentials + experiment_model.api_client = model_artifact.api_client + + res.append(experiment_model) + + return res + @_v1_not_supported def associate_execution(self, execution: execution.Execution): """Associate an execution to this experiment run. diff --git a/google/cloud/aiplatform/metadata/metadata.py b/google/cloud/aiplatform/metadata/metadata.py index 8245fcd738..8def300d03 100644 --- a/google/cloud/aiplatform/metadata/metadata.py +++ b/google/cloud/aiplatform/metadata/metadata.py @@ -15,7 +15,7 @@ # limitations under the License. # -from typing import Dict, Union, Optional, Any, List +from typing import Any, Dict, List, Optional, Union from google.api_core import exceptions from google.auth import credentials as auth_credentials @@ -29,6 +29,9 @@ from google.cloud.aiplatform.metadata import execution from google.cloud.aiplatform.metadata import experiment_resources from google.cloud.aiplatform.metadata import experiment_run_resource +from google.cloud.aiplatform.metadata.schema.google import ( + artifact_schema as google_artifact_schema, +) from google.cloud.aiplatform.tensorboard import tensorboard_resource from google.cloud.aiplatform_v1.types import execution as execution_v1 @@ -469,6 +472,89 @@ def log_classification_metrics( threshold=threshold, ) + def log_model( + self, + model: "sklearn.base.BaseEstimator", # noqa: F821 + artifact_id: Optional[str] = None, + *, + uri: Optional[str] = None, + input_example: Union[ + list, dict, "pd.DataFrame", "np.ndarray" # noqa: F821 + ] = None, + display_name: Optional[str] = None, + metadata_store_id: Optional[str] = "default", + project: Optional[str] = None, + location: Optional[str] = None, + credentials: Optional[auth_credentials.Credentials] = None, + ) -> google_artifact_schema.ExperimentModel: + """Saves a ML model into a MLMD artifact and log it to this ExperimentRun. + + Supported model frameworks: sklearn. + + Example usage: + model = LinearRegression() + model.fit(X, y) + aiplatform.init( + project="my-project", + location="my-location", + staging_bucket="gs://my-bucket", + experiment="my-exp" + ) + with aiplatform.start_run("my-run"): + aiplatform.log_model(model, "my-sklearn-model") + + Args: + model (sklearn.base.BaseEstimator): + Required. A machine learning model. + artifact_id (str): + Optional. The resource id of the artifact. This id must be globally unique + in a metadataStore. It may be up to 63 characters, and valid characters + are `[a-z0-9_-]`. The first character cannot be a number or hyphen. + uri (str): + Optional. A gcs directory to save the model file. If not provided, + `gs://default-bucket/timestamp-uuid-frameworkName-model` will be used. + If default staging bucket is not set, a new bucket will be created. + input_example (Union[list, dict, pd.DataFrame, np.ndarray]): + Optional. An example of a valid model input. Will be stored as a yaml file + in the gcs uri. Accepts list, dict, pd.DataFrame, and np.ndarray + The value inside a list must be a scalar or list. The value inside + a dict must be a scalar, list, or np.ndarray. + display_name (str): + Optional. The display name of the artifact. + metadata_store_id (str): + Optional. The portion of the resource name with + the format: + projects/123/locations/us-central1/metadataStores//artifacts/ + If not provided, the MetadataStore's ID will be set to "default". + project (str): + Optional. Project used to create this Artifact. Overrides project set in + aiplatform.init. + location (str): + Optional. Location used to create this Artifact. Overrides location set in + aiplatform.init. + credentials (auth_credentials.Credentials): + Optional. Custom credentials used to create this Artifact. Overrides + credentials set in aiplatform.init. + + Returns: + An ExperimentModel instance. + + Raises: + ValueError: if model type is not supported. + """ + self._validate_experiment_and_run(method_name="log_model") + self._experiment_run.log_model( + model=model, + artifact_id=artifact_id, + uri=uri, + input_example=input_example, + display_name=display_name, + metadata_store_id=metadata_store_id, + project=project, + location=location, + credentials=credentials, + ) + def _validate_experiment_and_run(self, method_name: str): """Validates Experiment and Run are set and raises informative error message. diff --git a/google/cloud/aiplatform/metadata/schema/google/artifact_schema.py b/google/cloud/aiplatform/metadata/schema/google/artifact_schema.py index eae4e44ace..b0759b468a 100644 --- a/google/cloud/aiplatform/metadata/schema/google/artifact_schema.py +++ b/google/cloud/aiplatform/metadata/schema/google/artifact_schema.py @@ -15,12 +15,15 @@ # limitations under the License. import copy -from typing import Optional, Dict, List +from typing import Optional, Dict, List, Sequence from google.auth import credentials as auth_credentials +from google.cloud.aiplatform import explain from google.cloud.aiplatform.compat.types import artifact as gca_artifact +from google.cloud.aiplatform.metadata import _models from google.cloud.aiplatform.metadata.schema import base_artifact from google.cloud.aiplatform.metadata.schema import utils +from google.cloud.aiplatform.models import Model # The artifact property key for the resource_name _ARTIFACT_PROPERTY_KEY_RESOURCE_NAME = "resourceName" @@ -738,3 +741,268 @@ def framework_version(self) -> Optional[str]: @property def model_class(self) -> Optional[str]: return self.metadata.get("modelClass") + + def load_model(self) -> "sklearn.base.BaseEstimator": # noqa: F821 + """Retrieves the original ML model from an ExperimentModel. + + Example usage: + experiment_model = aiplatform.get_experiment_model("my-sklearn-model") + sk_model = experiment_model.load_model() + pred_y = model.predict(test_X) + + Returns: + The original ML model. + + Raises: + ValueError: if model type is not supported. + """ + return _models.load_model(self) + + def register_model( + self, + *, + model_id: Optional[str] = None, + parent_model: Optional[str] = None, + use_gpu: bool = False, + is_default_version: bool = True, + version_aliases: Optional[Sequence[str]] = None, + version_description: Optional[str] = None, + display_name: Optional[str] = None, + description: Optional[str] = None, + labels: Optional[Dict[str, str]] = None, + serving_container_image_uri: Optional[str] = None, + serving_container_predict_route: Optional[str] = None, + serving_container_health_route: Optional[str] = None, + serving_container_command: Optional[Sequence[str]] = None, + serving_container_args: Optional[Sequence[str]] = None, + serving_container_environment_variables: Optional[Dict[str, str]] = None, + serving_container_ports: Optional[Sequence[int]] = None, + instance_schema_uri: Optional[str] = None, + parameters_schema_uri: Optional[str] = None, + prediction_schema_uri: Optional[str] = None, + explanation_metadata: Optional[explain.ExplanationMetadata] = None, + explanation_parameters: Optional[explain.ExplanationParameters] = None, + project: Optional[str] = None, + location: Optional[str] = None, + credentials: Optional[auth_credentials.Credentials] = None, + encryption_spec_key_name: Optional[str] = None, + staging_bucket: Optional[str] = None, + sync: Optional[bool] = True, + upload_request_timeout: Optional[float] = None, + ) -> Model: + """Register an ExperimentModel to Model Registry and returns a Model representing the registered Model resource. + + Example usage: + experiment_model = aiplatform.get_experiment_model("my-sklearn-model") + registered_model = experiment_model.register_model() + registered_model.deploy(endpoint=my_endpoint) + + Args: + model_id (str): + Optional. The ID to use for the registered Model, which will + become the final component of the model resource name. + This value may be up to 63 characters, and valid characters + are `[a-z0-9_-]`. The first character cannot be a number or hyphen. + parent_model (str): + Optional. The resource name or model ID of an existing model that the + newly-registered model will be a version of. + Only set this field when uploading a new version of an existing model. + use_gpu (str): + Optional. Whether or not to use GPUs for the serving container. Only + specify this argument when registering a Tensorflow model and + 'serving_container_image_uri' is not specified. + is_default_version (bool): + Optional. When set to True, the newly registered model version will + automatically have alias "default" included. Subsequent uses of + this model without a version specified will use this "default" version. + + When set to False, the "default" alias will not be moved. + Actions targeting the newly-registered model version will need + to specifically reference this version by ID or alias. + + New model uploads, i.e. version 1, will always be "default" aliased. + version_aliases (Sequence[str]): + Optional. User provided version aliases so that a model version + can be referenced via alias instead of auto-generated version ID. + A default version alias will be created for the first version of the model. + + The format is [a-z][a-zA-Z0-9-]{0,126}[a-z0-9] + version_description (str): + Optional. The description of the model version being uploaded. + display_name (str): + Optional. The display name of the Model. The name can be up to 128 + characters long and can be consist of any UTF-8 characters. + description (str): + Optional. The description of the model. + labels (Dict[str, str]): + Optional. The labels with user-defined metadata to + organize your Models. + Label keys and values can be no longer than 64 + characters (Unicode codepoints), can only + contain lowercase letters, numeric characters, + underscores and dashes. International characters + are allowed. + See https://goo.gl/xmQnxf for more information + and examples of labels. + serving_container_image_uri (str): + Optional. The URI of the Model serving container. A pre-built container + + is automatically chosen based on the model's framwork. Set this field to + override the default pre-built container. + serving_container_predict_route (str): + Optional. An HTTP path to send prediction requests to the container, and + which must be supported by it. If not specified a default HTTP path will + be used by Vertex AI. + serving_container_health_route (str): + Optional. An HTTP path to send health check requests to the container, and which + must be supported by it. If not specified a standard HTTP path will be + used by Vertex AI. + serving_container_command (Sequence[str]): + Optional. The command with which the container is run. Not executed within a + shell. The Docker image's ENTRYPOINT is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's + environment. If a variable cannot be resolved, the reference in the + input string will be unchanged. The $(VAR_NAME) syntax can be escaped + with a double $$, ie: $$(VAR_NAME). Escaped references will never be + expanded, regardless of whether the variable exists or not. + serving_container_args (Sequence[str]): + Optional. The arguments to the command. The Docker image's CMD is used if this is + not provided. Variable references $(VAR_NAME) are expanded using the + container's environment. If a variable cannot be resolved, the reference + in the input string will be unchanged. The $(VAR_NAME) syntax can be + escaped with a double $$, ie: $$(VAR_NAME). Escaped references will + never be expanded, regardless of whether the variable exists or not. + serving_container_environment_variables (Dict[str, str]): + Optional. The environment variables that are to be present in the container. + Should be a dictionary where keys are environment variable names + and values are environment variable values for those names. + serving_container_ports (Sequence[int]): + Optional. Declaration of ports that are exposed by the container. This field is + primarily informational, it gives Vertex AI information about the + network connections the container uses. Listing or not a port here has + no impact on whether the port is actually exposed, any port listening on + the default "0.0.0.0" address inside a container will be accessible from + the network. + instance_schema_uri (str): + Optional. Points to a YAML file stored on Google Cloud + Storage describing the format of a single instance, which + are used in + ``PredictRequest.instances``, + ``ExplainRequest.instances`` + and + ``BatchPredictionJob.input_config``. + The schema is defined as an OpenAPI 3.0.2 `Schema + Object `__. + AutoML Models always have this field populated by AI + Platform. Note: The URI given on output will be immutable + and probably different, including the URI scheme, than the + one given on input. The output URI will point to a location + where the user only has a read access. + parameters_schema_uri (str): + Optional. Points to a YAML file stored on Google Cloud + Storage describing the parameters of prediction and + explanation via + ``PredictRequest.parameters``, + ``ExplainRequest.parameters`` + and + ``BatchPredictionJob.model_parameters``. + The schema is defined as an OpenAPI 3.0.2 `Schema + Object `__. + AutoML Models always have this field populated by AI + Platform, if no parameters are supported it is set to an + empty string. Note: The URI given on output will be + immutable and probably different, including the URI scheme, + than the one given on input. The output URI will point to a + location where the user only has a read access. + prediction_schema_uri (str): + Optional. Points to a YAML file stored on Google Cloud + Storage describing the format of a single prediction + produced by this Model, which are returned via + ``PredictResponse.predictions``, + ``ExplainResponse.explanations``, + and + ``BatchPredictionJob.output_config``. + The schema is defined as an OpenAPI 3.0.2 `Schema + Object `__. + AutoML Models always have this field populated by AI + Platform. Note: The URI given on output will be immutable + and probably different, including the URI scheme, than the + one given on input. The output URI will point to a location + where the user only has a read access. + explanation_metadata (aiplatform.explain.ExplanationMetadata): + Optional. Metadata describing the Model's input and output for explanation. + `explanation_metadata` is optional while `explanation_parameters` must be + specified when used. + For more details, see `Ref docs ` + explanation_parameters (aiplatform.explain.ExplanationParameters): + Optional. Parameters to configure explaining for Model's predictions. + For more details, see `Ref docs ` + project: Optional[str]=None, + Project to upload this model to. Overrides project set in + aiplatform.init. + location: Optional[str]=None, + Location to upload this model to. Overrides location set in + aiplatform.init. + credentials: Optional[auth_credentials.Credentials]=None, + Custom credentials to use to upload this model. Overrides credentials + set in aiplatform.init. + encryption_spec_key_name (Optional[str]): + Optional. The Cloud KMS resource identifier of the customer + managed encryption key used to protect the model. Has the + form + ``projects/my-project/locations/my-region/keyRings/my-kr/cryptoKeys/my-key``. + The key needs to be in the same region as where the compute + resource is created. + + If set, this Model and all sub-resources of this Model will be secured by this key. + + Overrides encryption_spec_key_name set in aiplatform.init. + staging_bucket (str): + Optional. Bucket to stage local model artifacts. Overrides + staging_bucket set in aiplatform.init. + sync (bool): + Optional. Whether to execute this method synchronously. If False, + this method will unblock and it will be executed in a concurrent Future. + upload_request_timeout (float): + Optional. The timeout for the upload request in seconds. + + Returns: + model (aiplatform.Model): + Instantiated representation of the registered model resource. + + Raises: + ValueError: If the model doesn't have a pre-built container that is + suitable for its framework and 'serving_container_image_uri' + is not set. + """ + return _models.register_model( + model=self, + model_id=model_id, + parent_model=parent_model, + use_gpu=use_gpu, + is_default_version=is_default_version, + version_aliases=version_aliases, + version_description=version_description, + display_name=display_name, + description=description, + labels=labels, + serving_container_image_uri=serving_container_image_uri, + serving_container_predict_route=serving_container_predict_route, + serving_container_health_route=serving_container_health_route, + serving_container_command=serving_container_command, + serving_container_args=serving_container_args, + serving_container_environment_variables=serving_container_environment_variables, + serving_container_ports=serving_container_ports, + instance_schema_uri=instance_schema_uri, + parameters_schema_uri=parameters_schema_uri, + prediction_schema_uri=prediction_schema_uri, + explanation_metadata=explanation_metadata, + explanation_parameters=explanation_parameters, + project=project, + location=location, + credentials=credentials, + encryption_spec_key_name=encryption_spec_key_name, + staging_bucket=staging_bucket, + sync=sync, + upload_request_timeout=upload_request_timeout, + ) diff --git a/google/cloud/aiplatform/metadata/schema/utils.py b/google/cloud/aiplatform/metadata/schema/utils.py index 3016fd4d56..555e9380b5 100644 --- a/google/cloud/aiplatform/metadata/schema/utils.py +++ b/google/cloud/aiplatform/metadata/schema/utils.py @@ -45,9 +45,9 @@ class PredictSchemata: The schema is defined as an OpenAPI 3.0.2 `Schema Object. """ - instance_schema_uri: str - parameters_schema_uri: str - prediction_schema_uri: str + instance_schema_uri: Optional[str] = None + parameters_schema_uri: Optional[str] = None + prediction_schema_uri: Optional[str] = None def to_dict(self): """ML metadata schema dictionary representation of this DataClass. @@ -57,9 +57,12 @@ def to_dict(self): A dictionary that represents the PredictSchemata class. """ results = {} - results["instanceSchemaUri"] = self.instance_schema_uri - results["parametersSchemaUri"] = self.parameters_schema_uri - results["predictionSchemaUri"] = self.prediction_schema_uri + if self.instance_schema_uri: + results["instanceSchemaUri"] = self.instance_schema_uri + if self.parameters_schema_uri: + results["parametersSchemaUri"] = self.parameters_schema_uri + if self.prediction_schema_uri: + results["predictionSchemaUri"] = self.prediction_schema_uri return results diff --git a/google/cloud/aiplatform/utils/gcs_utils.py b/google/cloud/aiplatform/utils/gcs_utils.py index 94eff7aa9c..8a68c35a76 100644 --- a/google/cloud/aiplatform/utils/gcs_utils.py +++ b/google/cloud/aiplatform/utils/gcs_utils.py @@ -259,3 +259,37 @@ def create_gcs_bucket_for_pipeline_artifacts_if_it_does_not_exist( ) pipelines_bucket.set_iam_policy(bucket_iam_policy) return output_artifacts_gcs_dir + + +def download_file_from_gcs( + source_file_uri: str, + destination_file_path: str, + project: Optional[str] = None, + credentials: Optional[auth_credentials.Credentials] = None, +): + """Downloads a GCS file to local path. + + Args: + source_file_uri (str): + Required. GCS URI of the file to download. + destination_file_path (str): + Required. local path where the data should be downloaded. + project (str): + Optional. Google Cloud Project that contains the staging bucket. + credentials (auth_credentials.Credentials): + Optional. The custom credentials to use when making API calls. + If not provided, default credentials will be used. + + Raises: + RuntimeError: When destination_path does not exist. + GoogleCloudError: When the download process fails. + """ + project = project or initializer.global_config.project + credentials = credentials or initializer.global_config.credentials + + storage_client = storage.Client(project=project, credentials=credentials) + source_blob = storage.Blob.from_string(source_file_uri, client=storage_client) + + _logger.debug(f'Downloading "{source_file_uri}" to "{destination_file_path}"') + + source_blob.download_to_filename(filename=destination_file_path) diff --git a/setup.py b/setup.py index 56cd6bf4f2..2a9253b1f2 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ version = version["__version__"] tensorboard_extra_require = ["tensorflow >=2.3.0, <3.0.0dev"] -metadata_extra_require = ["pandas >= 1.0.0"] +metadata_extra_require = ["pandas >= 1.0.0", "numpy>=1.15.0"] xai_extra_require = ["tensorflow >=2.3.0, <3.0.0dev"] lit_extra_require = [ "tensorflow >= 2.3.0, <3.0.0dev", diff --git a/tests/unit/aiplatform/test_helpers.py b/tests/unit/aiplatform/test_helpers.py index 9f3437abbe..8bc593ba64 100644 --- a/tests/unit/aiplatform/test_helpers.py +++ b/tests/unit/aiplatform/test_helpers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2021 Google LLC +# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -196,3 +196,106 @@ def test_is_prebuilt_prediction_container_uri(self, image_uri, expected): result = helpers.is_prebuilt_prediction_container_uri(image_uri) assert result == expected + + @pytest.mark.parametrize( + "args, expected_uri", + [ + ( + ("tensorflow", "2.6", None, None), + "us-docker.pkg.dev/vertex-ai/prediction/tf2-cpu.2-6:latest", + ), + ( + ("tensorflow", "1.13", "europe-west4", None), + "europe-docker.pkg.dev/vertex-ai/prediction/tf-cpu.1-15:latest", + ), + ( + ("tensorflow", "2.7.1", None, "gpu"), + "us-docker.pkg.dev/vertex-ai/prediction/tf2-gpu.2-8:latest", + ), + ( + ("sklearn", "0.24", "asia", "cpu"), + "asia-docker.pkg.dev/vertex-ai/prediction/sklearn-cpu.0-24:latest", + ), + ( + ("sklearn", "0.21.2", None, None), + "us-docker.pkg.dev/vertex-ai/prediction/sklearn-cpu.0-22:latest", + ), + ( + ("xgboost", "1.2.1", None, None), + "us-docker.pkg.dev/vertex-ai/prediction/xgboost-cpu.1-3:latest", + ), + ( + ("xgboost", "0.90", "europe", None), + "europe-docker.pkg.dev/vertex-ai/prediction/xgboost-cpu.0-90:latest", + ), + ], + ) + def test_get_closest_match_prebuilt_container_uri(self, args, expected_uri): + uri = helpers._get_closest_match_prebuilt_container_uri( + **self._build_predict_uri_kwargs(args) + ) + + assert uri == expected_uri + + def test_get_closest_match_prebuilt_container_uri_with_init_location(self): + uri = aiplatform.helpers._get_closest_match_prebuilt_container_uri( + "tensorflow", "2.6" + ) + # SDK default location is us-central1 + assert uri.startswith("us-docker.pkg.dev") + + aiplatform.init(location="asia-northeast3") + uri = aiplatform.helpers._get_closest_match_prebuilt_container_uri( + "tensorflow", "2.6" + ) + assert uri.startswith("asia-docker.pkg.dev") + + aiplatform.init(location="europe-west2") + uri = aiplatform.helpers._get_closest_match_prebuilt_container_uri( + "xgboost", "0.90" + ) + assert uri.startswith("europe-docker.pkg.dev") + + @pytest.mark.parametrize( + "args, expected_error_msg", + [ + ( + ("lightgbm", "3.0", None, None), + "No containers found for framework `lightgbm`. Supported frameworks are", + ), + ( + ("tensorflow", "9.15", None, None), + ( + "You are using `tensorflow` version `9.15`. " + "Vertex pre-built containers support up to `tensorflow` version " + ), + ), + ( + # Make sure region error supercedes version error + ("tensorflow", "9.15", "pluto", None), + "Unsupported container region `pluto`, supported regions are ", + ), + ( + ("tensorflow", "2.2", "narnia", None), + "Unsupported container region `narnia`, supported regions are ", + ), + ( + ("sklearn", "0.24", "asia", "gpu"), + "sklearn containers do not support `gpu` accelerator. Supported accelerators are cpu.", + ), + ( + # Make sure framework error supercedes accelerator error + ("onnx", "1.9", None, "gpu"), + "No containers found for framework `onnx`. Supported frameworks are", + ), + ], + ) + def test_get_closest_match_prebuilt_container_uri_error( + self, args, expected_error_msg + ): + with pytest.raises(ValueError) as err: + helpers._get_closest_match_prebuilt_container_uri( + **self._build_predict_uri_kwargs(args) + ) + + assert err.match(expected_error_msg) diff --git a/tests/unit/aiplatform/test_metadata.py b/tests/unit/aiplatform/test_metadata.py index 2305f07946..f20626caa3 100644 --- a/tests/unit/aiplatform/test_metadata.py +++ b/tests/unit/aiplatform/test_metadata.py @@ -19,6 +19,9 @@ from unittest import mock from unittest.mock import patch, call +import numpy as np +from sklearn.linear_model import LinearRegression + import pytest from google.api_core import exceptions from google.api_core import operation @@ -76,6 +79,8 @@ _TEST_RUN = "run-1" _TEST_OTHER_RUN = "run-2" _TEST_DISPLAY_NAME = "test-display-name" +_TEST_CREDENTIALS = mock.Mock(spec=credentials.AnonymousCredentials()) +_TEST_BUCKET_NAME = "gs://test-bucket" # resource attributes _TEST_METADATA = {"test-param1": 1, "test-param2": "test-value", "test-param3": True} @@ -109,6 +114,8 @@ _TEST_ARTIFACT_NAME = f"{_TEST_PARENT}/artifacts/{_TEST_ARTIFACT_ID}" _TEST_OTHER_ARTIFACT_ID = f"{_TEST_EXPERIMENT}-{_TEST_OTHER_RUN}-metrics" _TEST_OTHER_ARTIFACT_NAME = f"{_TEST_PARENT}/artifacts/{_TEST_OTHER_ARTIFACT_ID}" +_TEST_MODEL_ID = "test-model" +_TEST_MODEL_NAME = f"{_TEST_PARENT}/artifacts/{_TEST_MODEL_ID}" # parameters _TEST_PARAM_KEY_1 = "learning_rate" @@ -521,6 +528,47 @@ def _assert_frame_equal_with_sorted_columns(dataframe_1, dataframe_2): ) +@pytest.fixture +def mock_storage_blob_upload_from_filename(): + with patch( + "google.cloud.storage.Blob.upload_from_filename" + ) as mock_blob_upload_from_filename, patch( + "google.cloud.storage.Bucket.exists", return_value=True + ): + yield mock_blob_upload_from_filename + + +_TEST_EXPERIMENT_MODEL_ARTIFACT = GapicArtifact( + name=_TEST_MODEL_NAME, + display_name=_TEST_DISPLAY_NAME, + schema_title=constants.GOOGLE_EXPERIMENT_MODEL, + schema_version=constants._DEFAULT_SCHEMA_VERSION, + state=GapicArtifact.State.LIVE, +) + + +@pytest.fixture +def create_experiment_model_artifact_mock(): + with patch.object( + MetadataServiceClient, "create_artifact" + ) as create_experiment_model_artifact_mock: + create_experiment_model_artifact_mock.return_value = ( + _TEST_EXPERIMENT_MODEL_ARTIFACT + ) + yield create_experiment_model_artifact_mock + + +@pytest.fixture +def get_experiment_model_artifact_mock(): + with patch.object( + MetadataServiceClient, "get_artifact" + ) as get_experiment_model_artifact_mock: + get_experiment_model_artifact_mock.return_value = ( + _TEST_EXPERIMENT_MODEL_ARTIFACT + ) + yield get_experiment_model_artifact_mock + + @pytest.mark.usefixtures("google_auth_mock") class TestMetadata: def setup_method(self): @@ -1234,6 +1282,41 @@ def test_log_classification_metrics( executions=None, ) + @pytest.mark.usefixtures( + "get_metadata_store_mock", + "get_experiment_mock", + "create_experiment_run_context_mock", + "add_context_children_mock", + "mock_storage_blob_upload_from_filename", + "create_experiment_model_artifact_mock", + "get_experiment_model_artifact_mock", + "get_metadata_store_mock", + ) + def test_log_model( + self, + add_context_artifacts_and_executions_mock, + ): + train_x = np.array([[1, 1], [1, 2], [2, 2], [2, 3]]) + train_y = np.dot(train_x, np.array([1, 2])) + 3 + model = LinearRegression() + model.fit(train_x, train_y) + + aiplatform.init( + project=_TEST_PROJECT, + location=_TEST_LOCATION, + staging_bucket=_TEST_BUCKET_NAME, + credentials=_TEST_CREDENTIALS, + experiment=_TEST_EXPERIMENT, + ) + aiplatform.start_run(_TEST_RUN) + aiplatform.log_model(model, _TEST_MODEL_ID) + + add_context_artifacts_and_executions_mock.assert_called_once_with( + context=_TEST_EXPERIMENT_RUN_CONTEXT_NAME, + artifacts=[_TEST_MODEL_NAME], + executions=None, + ) + @pytest.mark.usefixtures( "get_metadata_store_mock", "get_experiment_mock", diff --git a/tests/unit/aiplatform/test_metadata_models.py b/tests/unit/aiplatform/test_metadata_models.py new file mode 100644 index 0000000000..6c7b125cb7 --- /dev/null +++ b/tests/unit/aiplatform/test_metadata_models.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 Google LLC +# +# Licensed 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 datetime +import pickle +from importlib import reload +from unittest import mock +from unittest.mock import patch +import uuid + +from google.auth import credentials as auth_credentials +from google.cloud import aiplatform +from google.cloud.aiplatform import base +from google.cloud.aiplatform import initializer +from google.cloud.aiplatform.metadata import constants +from google.cloud.aiplatform.metadata import metadata +from google.cloud.aiplatform.metadata import _models +from google.cloud.aiplatform.models import Model +from google.cloud.aiplatform_v1 import Artifact as GapicArtifact +from google.cloud.aiplatform_v1 import MetadataStore as GapicMetadataStore +from google.cloud.aiplatform_v1 import MetadataServiceClient +import numpy as np +import pytest +import sklearn +from sklearn.linear_model import LinearRegression + + +# project +_TEST_PROJECT = "test-project" +_TEST_LOCATION = "us-central1" +_TEST_BUCKET_NAME = "gs://test-bucket" +_TEST_PARENT = ( + f"projects/{_TEST_PROJECT}/locations/{_TEST_LOCATION}/metadataStores/default" +) +_TEST_CREDENTIALS = mock.Mock(spec=auth_credentials.AnonymousCredentials()) + + +# artifact +_TEST_ARTIFACT_ID = "test-model-id" +_TEST_URI = "gs://test-uri" +_TEST_DISPLAY_NAME = "test-model-display-name" + +_TEST_ARTIFACT_ID = "test-model-id" +_TEST_ARTIFACT_NAME = f"{_TEST_PARENT}/artifacts/{_TEST_ARTIFACT_ID}" + +_TEST_TIMESTAMP = "2022-11-30-00-00-00" +_TEST_DATETIME = datetime.datetime.strptime(_TEST_TIMESTAMP, "%Y-%m-%d-%H-%M-%S") + +_TEST_UUID = uuid.UUID("fa2db23f-1b13-412d-beea-94602448e4ce") + +_TEST_INPUT_EXAMPLE = np.array([[1, 1], [1, 2], [2, 2], [2, 3]]) + +_TEST_MODEL_NAME = ( + f"projects/{_TEST_PROJECT}/locations/{_TEST_LOCATION}/models/{_TEST_ARTIFACT_ID}" +) + + +@pytest.fixture +def mock_datetime_now(monkeypatch): + class DateTime: + @classmethod + def now(cls): + return _TEST_DATETIME + + monkeypatch.setattr(datetime, "datetime", DateTime) + + +@pytest.fixture +def mock_uuid(): + with patch.object(uuid, "uuid4", return_value=_TEST_UUID) as mock_uuid: + yield mock_uuid + + +@pytest.fixture +def mock_storage_blob_upload_from_filename(): + with patch( + "google.cloud.storage.Blob.upload_from_filename" + ) as mock_blob_upload_from_filename, patch( + "google.cloud.storage.Bucket.exists", return_value=True + ): + yield mock_blob_upload_from_filename + + +@pytest.fixture +def mock_storage_blob_download_to_filename(): + def create_model_file(filename): + train_x = np.array([[1, 1], [1, 2], [2, 2], [2, 3]]) + train_y = np.dot(train_x, np.array([1, 2])) + 3 + model = LinearRegression() + model.fit(train_x, train_y) + with open(filename, "wb") as model_file: + pickle.dump(model, model_file) + + with patch( + "google.cloud.storage.Blob.download_to_filename", wraps=create_model_file + ) as mock_blob_download_to_filename, patch( + "google.cloud.storage.Bucket.exists", return_value=True + ): + yield mock_blob_download_to_filename + + +_TEST_EXPERIMENT_MODEL_ARTIFACT = GapicArtifact( + name=_TEST_ARTIFACT_NAME, + uri=_TEST_URI, + display_name=_TEST_DISPLAY_NAME, + schema_title=constants.GOOGLE_EXPERIMENT_MODEL, + schema_version=constants._DEFAULT_SCHEMA_VERSION, + state=GapicArtifact.State.LIVE, + metadata={ + "frameworkName": "sklearn", + "frameworkVersion": "1.0", + "modelFile": "model.pkl", + "modelClass": "sklearn.linear_model._base.LinearRegression", + }, +) + + +@pytest.fixture +def create_experiment_model_artifact_mock(): + with patch.object(MetadataServiceClient, "create_artifact") as create_artifact_mock: + create_artifact_mock.return_value = _TEST_EXPERIMENT_MODEL_ARTIFACT + yield create_artifact_mock + + +@pytest.fixture +def get_experiment_model_artifact_mock(): + with patch.object(MetadataServiceClient, "get_artifact") as get_artifact_mock: + get_artifact_mock.return_value = _TEST_EXPERIMENT_MODEL_ARTIFACT + yield get_artifact_mock + + +@pytest.fixture +def model_upload_mock(): + with patch.object(Model, "upload") as upload_model_mock: + yield upload_model_mock + + +@pytest.fixture +def get_metadata_store_mock(): + with patch.object( + MetadataServiceClient, "get_metadata_store" + ) as get_metadata_store_mock: + get_metadata_store_mock.return_value = GapicMetadataStore(name=_TEST_PARENT) + yield get_metadata_store_mock + + +class TestModels: + def setup_method(self): + reload(initializer) + reload(metadata) + reload(aiplatform) + + def teardown_method(self): + initializer.global_pool.shutdown(wait=True) + + @pytest.mark.usefixtures( + "mock_datetime_now", + "mock_uuid", + "get_metadata_store_mock", + ) + def test_save_model_sklearn( + self, + mock_storage_blob_upload_from_filename, + create_experiment_model_artifact_mock, + get_experiment_model_artifact_mock, + ): + train_x = np.array([[1, 1], [1, 2], [2, 2], [2, 3]]) + train_y = np.dot(train_x, np.array([1, 2])) + 3 + model = LinearRegression() + model.fit(train_x, train_y) + + aiplatform.init( + project=_TEST_PROJECT, + location=_TEST_LOCATION, + staging_bucket=_TEST_BUCKET_NAME, + credentials=_TEST_CREDENTIALS, + ) + + aiplatform.save_model(model, _TEST_ARTIFACT_ID) + + # Verify that the model file is correctly uploaded to gcs + upload_file_path = mock_storage_blob_upload_from_filename.call_args[1][ + "filename" + ] + assert upload_file_path.endswith("model.pkl") + + # Verify the model artifact is created correctly + expected_artifact = GapicArtifact( + uri=f"{_TEST_BUCKET_NAME}/{_TEST_TIMESTAMP}-{_TEST_UUID.hex[:5]}-sklearn-model", + schema_title=constants.GOOGLE_EXPERIMENT_MODEL, + schema_version=constants._DEFAULT_SCHEMA_VERSION, + metadata={ + "frameworkName": "sklearn", + "frameworkVersion": sklearn.__version__, + "modelFile": "model.pkl", + "modelClass": "sklearn.linear_model._base.LinearRegression", + }, + state=GapicArtifact.State.LIVE, + ) + create_experiment_model_artifact_mock.assert_called_once_with( + parent=_TEST_PARENT, + artifact=expected_artifact, + artifact_id=_TEST_ARTIFACT_ID, + ) + + get_experiment_model_artifact_mock.assert_called_once_with( + name=_TEST_ARTIFACT_NAME, retry=base._DEFAULT_RETRY + ) + + @pytest.mark.usefixtures( + "mock_storage_blob_upload_from_filename", + "get_experiment_model_artifact_mock", + "get_metadata_store_mock", + ) + def test_save_model_with_all_args( + self, + create_experiment_model_artifact_mock, + ): + train_x = np.array([[1, 1], [1, 2], [2, 2], [2, 3]]) + train_y = np.dot(train_x, np.array([1, 2])) + 3 + model = LinearRegression() + model.fit(train_x, train_y) + + aiplatform.init( + project=_TEST_PROJECT, + location=_TEST_LOCATION, + staging_bucket=_TEST_BUCKET_NAME, + credentials=_TEST_CREDENTIALS, + ) + + aiplatform.save_model( + model=model, + artifact_id=_TEST_ARTIFACT_ID, + uri=_TEST_URI, + display_name=_TEST_DISPLAY_NAME, + input_example=_TEST_INPUT_EXAMPLE, + ) + + # Verify the model artifact is created correctly + expected_artifact = GapicArtifact( + display_name=_TEST_DISPLAY_NAME, + uri=_TEST_URI, + schema_title=constants.GOOGLE_EXPERIMENT_MODEL, + schema_version=constants._DEFAULT_SCHEMA_VERSION, + metadata={ + "frameworkName": "sklearn", + "frameworkVersion": sklearn.__version__, + "modelFile": "model.pkl", + "modelClass": "sklearn.linear_model._base.LinearRegression", + "predictSchemata": {"instanceSchemaUri": f"{_TEST_URI}/instance.yaml"}, + }, + state=GapicArtifact.State.LIVE, + ) + create_experiment_model_artifact_mock.assert_called_once_with( + parent=_TEST_PARENT, + artifact=expected_artifact, + artifact_id=_TEST_ARTIFACT_ID, + ) + + def test_load_model_sklearn( + self, mock_storage_blob_download_to_filename, get_experiment_model_artifact_mock + ): + aiplatform.init( + project=_TEST_PROJECT, + location=_TEST_LOCATION, + staging_bucket=_TEST_BUCKET_NAME, + credentials=_TEST_CREDENTIALS, + ) + + model = _models.load_model(_TEST_ARTIFACT_ID) + + # Verify that the correct model artifact is retrieved by its ID + get_experiment_model_artifact_mock.assert_called_once_with( + name=_TEST_ARTIFACT_NAME, retry=base._DEFAULT_RETRY + ) + + # Verify that the model file is downloaded correctly + download_file_path = mock_storage_blob_download_to_filename.call_args[1][ + "filename" + ] + assert download_file_path.endswith("model.pkl") + + # Verify the loaded model + assert model.__class__.__name__ == "LinearRegression" + + def test_register_model_sklearn( + self, model_upload_mock, get_experiment_model_artifact_mock + ): + aiplatform.init( + project=_TEST_PROJECT, + location=_TEST_LOCATION, + staging_bucket=_TEST_BUCKET_NAME, + credentials=_TEST_CREDENTIALS, + ) + + _models.register_model( + model=_TEST_ARTIFACT_ID, + display_name=_TEST_DISPLAY_NAME, + ) + + # Verify that the correct model artifact is retrieved by its ID + get_experiment_model_artifact_mock.assert_called_once_with( + name=_TEST_ARTIFACT_NAME, retry=base._DEFAULT_RETRY + ) + # register_model API calls Model.upload internally to register the model + # Since Model.upload is tested in "test_models.py", here we only need to + # make sure register_model is sending the right args to Model.upload + model_upload_mock.assert_called_once_with( + serving_container_image_uri="us-docker.pkg.dev/vertex-ai/prediction/sklearn-cpu.1-0:latest", + artifact_uri=_TEST_URI, + model_id=None, + parent_model=None, + is_default_version=True, + version_aliases=None, + version_description=None, + display_name=_TEST_DISPLAY_NAME, + description=None, + labels=None, + serving_container_predict_route=None, + serving_container_health_route=None, + serving_container_command=None, + serving_container_args=None, + serving_container_environment_variables=None, + serving_container_ports=None, + instance_schema_uri=None, + parameters_schema_uri=None, + prediction_schema_uri=None, + explanation_metadata=None, + explanation_parameters=None, + project=_TEST_PROJECT, + location=_TEST_LOCATION, + credentials=_TEST_CREDENTIALS, + encryption_spec_key_name=None, + staging_bucket=None, + sync=True, + upload_request_timeout=None, + )