From 73c2cf0003a3741e00300a9a23507fa28d59e6c5 Mon Sep 17 00:00:00 2001 From: Kiran Dama <69480841+sfc-gh-kdama@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:33:58 -0700 Subject: [PATCH] Release snowflake-ml-python: 1.0.11 (#61) Co-authored-by: Snowflake Authors --- .bazelrc | 5 + CHANGELOG.md | 18 + CONTRIBUTING.md | 51 + README.md | 67 +- bazel/environments/conda-env.yml | 4 + bazel/environments/fetch_conda_env_config.bzl | 6 + .../parse_and_generate_requirements.py | 17 +- ci/RunBazelAction.sh | 2 + ci/conda_recipe/meta.yaml | 4 +- codegen/sklearn_wrapper_generator.py | 52 +- codegen/sklearn_wrapper_template.py_template | 132 ++- ...nsformer_autogen_test_template.py_template | 36 +- mypy.ini | 30 +- requirements.txt | 2 + requirements.yml | 11 +- .../utils/snowpark_dataframe_utils.py | 52 + snowflake/ml/dataset/BUILD.bazel | 1 + snowflake/ml/dataset/dataset.py | 50 +- snowflake/ml/feature_store/feature_store.py | 37 +- .../customer_demo/Basic_Feature_Demo.ipynb | 50 +- .../customer_demo/Basic_Feature_Demo.pdf | Bin 205679 -> 209795 bytes .../Time_Series_Feature_Demo.ipynb | 42 +- .../Time_Series_Feature_Demo.pdf | Bin 245915 -> 249931 bytes .../feature_store_case_sensitivity_test.py | 29 +- .../tests/feature_store_large_scale_test.py | 6 +- .../feature_store/tests/feature_store_test.py | 12 +- snowflake/ml/model/BUILD.bazel | 1 + .../image_builds/docker_context.py | 3 + .../image_builds/server_image_builder.py | 4 +- .../templates/dockerfile_template | 10 +- .../test_fixtures/dockerfile_test_fixture | 3 +- .../dockerfile_test_fixture_with_CUDA | 3 +- .../dockerfile_test_fixture_with_model | 6 +- .../_deploy_client/snowservice/deploy.py | 9 +- .../service_spec_template_with_model | 1 - snowflake/ml/model/_handlers/BUILD.bazel | 16 + snowflake/ml/model/_handlers/llm.py | 178 ++++ snowflake/ml/model/_signatures/core.py | 17 + snowflake/ml/model/_signatures/core_test.py | 8 +- snowflake/ml/model/models/BUILD.bazel | 12 + snowflake/ml/model/models/llm.py | 75 ++ snowflake/ml/model/models/llm_test.py | 37 + snowflake/ml/model/type_hints.py | 2 + snowflake/ml/modeling/_internal/BUILD.bazel | 8 + .../modeling/_internal/estimator_protocols.py | 17 +- .../ml/modeling/_internal/estimator_utils.py | 25 + .../modeling/_internal/snowpark_handlers.py | 586 ++++++----- .../_internal/snowpark_handlers_test.py | 67 ++ .../ml/modeling/impute/simple_imputer.py | 89 +- snowflake/ml/modeling/metrics/ranking.py | 21 +- snowflake/ml/modeling/metrics/regression.py | 21 +- .../_internal/_grid_search_cv.py | 73 +- .../_internal/_randomized_search_cv.py | 76 +- snowflake/ml/modeling/pipeline/pipeline.py | 21 + .../ml/modeling/preprocessing/normalizer.py | 41 +- snowflake/ml/registry/BUILD.bazel | 20 +- snowflake/ml/registry/_artifact_manager.py | 156 +++ ..._ml_artifact_test.py => _artifact_test.py} | 116 +-- snowflake/ml/registry/_ml_artifact.py | 181 ---- snowflake/ml/registry/_schema.py | 5 +- .../ml/registry/_schema_upgrade_plans.py | 34 + snowflake/ml/registry/artifact.py | 46 + snowflake/ml/registry/model_registry.py | 162 ++-- snowflake/ml/registry/model_registry_test.py | 23 +- ...t to Snowpark Container Service Demo.ipynb | 916 ++++++++---------- .../notebooks/Finetune_Registry.ipynb | 423 ++++++++ .../notebooks/Model Packaging Example.ipynb | 119 +-- snowflake/ml/requirements.bzl | 2 +- snowflake/ml/version.bzl | 2 +- tests/conftest.py | 3 + .../integ/snowflake/ml/_internal/BUILD.bazel | 13 + .../ml/_internal/grid_search_integ_test.py | 15 +- .../_internal/randomized_search_integ_test.py | 19 +- .../ml/_internal/search_single_node_test.py | 125 +++ .../snowflake/ml/extra_tests/BUILD.bazel | 10 + .../ml/extra_tests/decimal_type_test.py | 56 ++ .../ml/extra_tests/grid_search_test.py | 34 +- tests/integ/snowflake/ml/model/BUILD.bazel | 18 + .../ml/model/model_badcase_integ_test.py | 2 +- .../ml/model/spcs_llm_model_integ_test.py | 109 +++ .../model/warehouse_model_integ_test_utils.py | 4 +- .../snowflake/ml/modeling/framework/utils.py | 17 +- .../modeling/metrics/accuracy_score_test.py | 21 +- .../modeling/metrics/confusion_matrix_test.py | 19 +- .../metrics/d2_absolute_error_score_test.py | 27 +- .../modeling/metrics/d2_pinball_score_test.py | 31 +- .../metrics/explained_variance_score_test.py | 30 +- .../ml/modeling/metrics/f1_score_test.py | 33 +- .../ml/modeling/metrics/fbeta_score_test.py | 36 +- .../ml/modeling/metrics/log_loss_test.py | 28 +- .../metrics/mean_absolute_error_test.py | 27 +- .../mean_absolute_percentage_error_test.py | 27 +- .../metrics/mean_squared_error_test.py | 30 +- .../ml/modeling/metrics/metrics_utils_test.py | 12 +- .../metrics/precision_recall_curve_test.py | 18 +- .../precision_recall_fscore_support_test.py | 40 +- .../modeling/metrics/precision_score_test.py | 33 +- .../ml/modeling/metrics/recall_score_test.py | 33 +- .../ml/modeling/metrics/roc_auc_score_test.py | 39 +- .../ml/modeling/metrics/roc_curve_test.py | 25 +- .../preprocessing/k_bins_discretizer_test.py | 24 +- .../preprocessing/one_hot_encoder_test.py | 38 +- .../preprocessing/standard_scaler_test.py | 42 +- tests/integ/snowflake/ml/registry/BUILD.bazel | 14 +- .../model_registry_basic_integ_test.py | 81 +- .../ml/registry/model_registry_compat_test.py | 62 ++ .../ml/registry/model_registry_integ_test.py | 54 +- ...el_registry_schema_evolution_integ_test.py | 4 +- ...el_registry_snowservice_integ_test_base.py | 3 +- .../integ/snowflake/ml/test_utils/BUILD.bazel | 1 + .../ml/test_utils/common_test_base.py | 241 +++-- .../snowflake/ml/test_utils/model_factory.py | 17 +- .../ml/test_utils/spcs_integ_test_base.py | 3 + .../snowflake/ml/test_utils/test_env_utils.py | 55 +- 114 files changed, 3918 insertions(+), 2006 deletions(-) create mode 100644 snowflake/ml/model/_handlers/llm.py create mode 100644 snowflake/ml/model/models/llm.py create mode 100644 snowflake/ml/model/models/llm_test.py create mode 100644 snowflake/ml/modeling/_internal/snowpark_handlers_test.py create mode 100644 snowflake/ml/registry/_artifact_manager.py rename snowflake/ml/registry/{_ml_artifact_test.py => _artifact_test.py} (56%) delete mode 100644 snowflake/ml/registry/_ml_artifact.py create mode 100644 snowflake/ml/registry/artifact.py create mode 100644 snowflake/ml/registry/notebooks/Finetune_Registry.ipynb create mode 100644 tests/integ/snowflake/ml/_internal/search_single_node_test.py create mode 100644 tests/integ/snowflake/ml/extra_tests/decimal_type_test.py create mode 100644 tests/integ/snowflake/ml/model/spcs_llm_model_integ_test.py create mode 100644 tests/integ/snowflake/ml/registry/model_registry_compat_test.py diff --git a/.bazelrc b/.bazelrc index 62e8ede3..01f7ddac 100644 --- a/.bazelrc +++ b/.bazelrc @@ -14,6 +14,7 @@ coverage --instrumentation_filter="-//tests[/:]" build:_build --platforms //bazel/platforms:snowflake_conda_env --host_platform //bazel/platforms:snowflake_conda_env --repo_env=BAZEL_CONDA_ENV_NAME=build build:_sf_only --platforms //bazel/platforms:snowflake_conda_env --host_platform //bazel/platforms:snowflake_conda_env --repo_env=BAZEL_CONDA_ENV_NAME=sf_only build:_extended --platforms //bazel/platforms:extended_conda_env --host_platform //bazel/platforms:extended_conda_env --repo_env=BAZEL_CONDA_ENV_NAME=extended +build:_extended_oss --platforms //bazel/platforms:extended_conda_env --host_platform //bazel/platforms:extended_conda_env --repo_env=BAZEL_CONDA_ENV_NAME=extended_oss # Public definitions @@ -35,6 +36,7 @@ run:pre_build --config=_build --config=py3.8 # Config to run type check build:typecheck --aspects @rules_mypy//:mypy.bzl%mypy_aspect --output_groups=mypy --config=_extended --config=py3.8 +build:typecheck_oss --aspects @rules_mypy//:mypy.bzl%mypy_aspect --output_groups=mypy --config=_extended_oss --config=py3.8 # Config to build the doc build:docs --config=_sf_only --config=py3.8 @@ -44,3 +46,6 @@ build:docs --config=_sf_only --config=py3.8 test:extended --config=_extended run:extended --config=_extended cquery:extended --config=_extended +test:extended_oss --config=_extended_oss +run:extended_oss --config=_extended_oss +cquery:extended_oss --config=_extended_oss diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fe80bd..6b8d8869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Release History +## 1.0.11 + +### New Features + +- Model Registry: Add log_artifact() public method. +- Model Development: Add support for `kneighbors`. + +### Behavior Changes + +- Model Registry: Change log_model() argument from TrainingDataset to List of Artifact. +- Model Registry: Change get_training_dataset() to get_artifact(). + +### Bug Fixes + +- Model Development: Fix support for XGBoost and LightGBM models using SKLearn Grid Search and Randomized Search model selectors. +- Model Development: DecimalType is now supported as a DataType. +- Model Development: Fix metrics compatibility with Snowpark Dataframes that use Snowflake identifiers + ## 1.0.10 ### Behavior Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c2560e4..727b8eb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -347,6 +347,57 @@ When you add a new test file, you should always ensure the existence of a `if __ the test file will not be instructed by bazel. We have a test wrapper [here](./bazel/test_wrapper.sh) to ensure that the test will fail if you forget that part. +## Integration test + +### Test in Store Procedure + +To test if your code is working in store procedure or not simply, you could work based on `CommonTestBase` in +`tests/integ/snowflake/ml/test_utils/common_test_base.py`. An example of such test could be found in +`tests/integ/snowflake/ml/_internal/file_utils_integ_test.py`. + +To write a such test, you need to + +1. Let your test case inherit from `common_test_base.CommonTestBase`. +1. Remove all Snowpark Session creation in your test, and use `self.session` to access the session if needed. +1. If you write your own `setUp` and `tearDown` method, remember to call `super().setUp()` or `super().tearDown().` +1. Decorate your test method with `common_test_base.CommonTestBase.sproc_test()`. If you want your test running in +store procedure only rather than both locally and in store procedure, set `local=False`. If you don't want to test +with caller's rights, set `test_callers_rights=False`. (Owner's rights store procedure is always tested) + + **Attention**: Depending on your configurations, 1-3 sub-tests will be run in your test method. + Sub-test means that `setUp` and `tearDown` won't run every sub-test and will only run once before and + after the whole test method. So it is important to make your test case self-contained. + +### Compatibility Test + +To test if your code is compatible with previous version simply, you could work based on `CommonTestBase` in +`tests/integ/snowflake/ml/test_utils/common_test_base.py`. An example of such test could be found in +`tests/integ/snowflake/ml/registry/model_registry_compat_test.py`. + +To write a such test, you need to + +1. Let your test case inherit from `common_test_base.CommonTestBase`. +1. Remove all Snowpark Session creation in your test, and use `self.session` to access the session if needed. +1. If you write your own `setUp` and `tearDown` method, remember to call `super().setUp()` or `super().tearDown().` +1. Write a factory method in your test class that return a tuple of a function and its parameters as a tuple. The +function will be run as a store procedure in the environment with previous version of library. + + **Note**: Since the function will be created as a store procedure, the first argument must be a Snowpark Session. + The arguments tuple you provided via the factory method does not require to include the session object. + + **Note**: To avoid any objects from current environment affecting the result, instead of using `cloudpickle` to + pickle the function, the function will be created as a Python file and registered as a store procedure. This means + you cannot use any object outside of the function, and if you want to import anything, you need to import inside + the function definition. So it would help if you make your prepare function as simple as possible. + +1. Decorate your test method with `common_test_base.CommonTestBase.compatibility_test`, providing the factory method +you created in the above step, optional version range to test with, as well as additional package requirements. + + **Attention**: For every version available in the server and within the version range, a sub-test will be run that + contains a run of prepare function in the store procedure and a run of the method. Sub-test means that `setUp` and + `tearDown` won't run every sub-test and will only run once before and after the whole test method. So it is + important to make your test case self-contained. + ## `pre-commit` Pull requests against the main branch are subject to `pre-commit` checks. Those checks enforce the code style. diff --git a/README.md b/README.md index 7ebcbaac..d4acf9c2 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,32 @@ Snowpark ML is a set of tools including SDKs and underlying infrastructure to build and deploy machine learning models. With Snowpark ML, you can pre-process data, train, manage and deploy ML models all within Snowflake, using a single SDK, - and benefit from Snowflake’s proven performance, scalability, stability and governance at every stage of the Machine - Learning workflow. +and benefit from Snowflake’s proven performance, scalability, stability and governance at every stage of the Machine +Learning workflow. ## Key Components of Snowpark ML The Snowpark ML Python SDK provides a number of APIs to support each stage of an end-to-end Machine Learning development - and deployment process, and includes two key components. +and deployment process, and includes two key components. ### Snowpark ML Development [Public Preview] -A collection of python APIs to enable efficient model development directly in Snowflake: +[Snowpark ML Development](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index#snowpark-ml-development) +provides a collection of python APIs enabling efficient ML model development directly in Snowflake: -1. Modeling API (snowflake.ml.modeling) for data preprocessing, feature engineering and model training in Snowflake. -This includes snowflake.ml.modeling.preprocessing for scalable data transformations on large data sets utilizing the -compute resources of underlying Snowpark Optimized High Memory Warehouses, and a large collection of ML model -development classes based on sklearn, xgboost, and lightgbm. See the private preview limited access docs (Preprocessing, - Modeling for more details on these. +1. Modeling API (`snowflake.ml.modeling`) for data preprocessing, feature engineering and model training in Snowflake. +This includes the `snowflake.ml.modeling.preprocessing` module for scalable data transformations on large data sets +utilizing the compute resources of underlying Snowpark Optimized High Memory Warehouses, and a large collection of ML +model development classes based on sklearn, xgboost, and lightgbm. 1. Framework Connectors: Optimized, secure and performant data provisioning for Pytorch and Tensorflow frameworks in their native data loader formats. ### Snowpark ML Ops [Private Preview] -Snowpark MLOps complements the Snowpark ML Development API, and provides model management capabilities along with -integrated deployment into Snowflake. Currently, the API consists of +[Snowpark MLOps](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index#snowpark-ml-ops) complements the +Snowpark ML Development API, and provides model management capabilities along with integrated deployment into Snowflake. +Currently, the API consists of: 1. FileSet API: FileSet provides a Python fsspec-compliant API for materializing data into a Snowflake internal stage from a query or Snowpark Dataframe along with a number of convenience APIs. @@ -37,26 +38,48 @@ Snowflake Warehouses as vectorized UDFs. During PrPr, we are iterating on API without backward compatibility guarantees. It is better to recreate your registry everytime you update the package. This means, at this time, you cannot use the registry for production use. -- [Documentation](https://docs.snowflake.com/developer-guide/snowpark-ml) - ## Getting started ### Have your Snowflake account ready If you don't have a Snowflake account yet, you can [sign up for a 30-day free trial account](https://signup.snowflake.com/). -### Create a Python virtual environment +### Installation + +Follow the [installation instructions](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index#installing-snowpark-ml) +in the Snowflake documentation. -Python version 3.8, 3.9 & 3.10 are supported. You can use [miniconda](https://docs.conda.io/en/latest/miniconda.html), -[anaconda](https://www.anaconda.com/), or [virtualenv](https://docs.python.org/3/tutorial/venv.html) to create a virtual - environment. +Python versions 3.8, 3.9 & 3.10 are supported. You can use [miniconda](https://docs.conda.io/en/latest/miniconda.html) or +[anaconda](https://www.anaconda.com/) to create a Conda environment (recommended), +or [virtualenv](https://docs.python.org/3/tutorial/venv.html) to create a virtual environment. -To have the best experience when using this library, [creating a local conda environment with the Snowflake channel]( - https://docs.snowflake.com/en/developer-guide/udf/python/udf-python-packages.html#local-development-and-testing) -is recommended. +### Conda channels -### Install the library to the Python virtual environment +The [Snowflake Conda Channel](https://repo.anaconda.com/pkgs/snowflake/) contains the official snowpark ML package releases. +The recommended approach is to install `snowflake-ml-python` this conda channel: ```sh -pip install snowflake-ml-python +conda install \ + -c https://repo.anaconda.com/pkgs/snowflake \ + --override-channels \ + snowflake-ml-python +``` + +See [the developer guide](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index) for installation instructions. + +The latest version of the `snowpark-ml-python` package is also published in a conda channel in this repository. Package versions +in this channel may not yet be present in the official Snowflake conda channel. + +Install `snowflake-ml-python` from this channel with the following (being sure to replace `` with the +desired version, e.g. `1.0.10`): + +```bash +conda install \ + -c https://raw.githubusercontent.com/snowflakedb/snowflake-ml-python/conda/releases/ \ + -c https://repo.anaconda.com/pkgs/snowflake \ + --override-channels \ + snowflake-ml-python== ``` + +Note that until a `snowflake-ml-python` package version is available in the official Snowflake conda channel, there may +be compatibility issues. Server-side functionality that `snowflake-ml-python` depends on may not yet be released. diff --git a/bazel/environments/conda-env.yml b/bazel/environments/conda-env.yml index 288a6497..9277ffd3 100644 --- a/bazel/environments/conda-env.yml +++ b/bazel/environments/conda-env.yml @@ -58,3 +58,7 @@ dependencies: - types-requests==2.30.0.0 - typing-extensions==4.5.0 - xgboost==1.7.3 + - pip + - pip: + - --extra-index-url https://pypi.org/simple + - peft==0.5.0 diff --git a/bazel/environments/fetch_conda_env_config.bzl b/bazel/environments/fetch_conda_env_config.bzl index 0bba6623..edd1cb0c 100644 --- a/bazel/environments/fetch_conda_env_config.bzl +++ b/bazel/environments/fetch_conda_env_config.bzl @@ -16,6 +16,12 @@ def _fetch_conda_env_config_impl(rctx): "compatible_target": ["@SnowML//bazel/platforms:extended_conda_channels"], "environment": "@//bazel/environments:conda-env.yml", }, + # `extended_oss` is the extended env for OSS repo which is a strict subset of `extended`. + # It's intended for development without dev VPN. + "extended_oss": { + "compatible_target": ["@SnowML//bazel/platforms:extended_conda_channels"], + "environment": "@//bazel/environments:conda-env.yml", + }, "sf_only": { "compatible_target": ["@SnowML//bazel/platforms:snowflake_conda_channel"], "environment": "@//bazel/environments:conda-env-snowflake.yml", diff --git a/bazel/requirements/parse_and_generate_requirements.py b/bazel/requirements/parse_and_generate_requirements.py index 5b4e0965..fca25bd7 100644 --- a/bazel/requirements/parse_and_generate_requirements.py +++ b/bazel/requirements/parse_and_generate_requirements.py @@ -1,6 +1,7 @@ import argparse import collections import contextlib +import copy import functools import itertools import json @@ -146,6 +147,9 @@ def generate_dev_pinned_string( version = req_info.get("dev_version_conda", req_info.get("dev_version", None)) if version is None: raise ValueError("No pinned version exists.") + if env == "conda-only": + if "dev_version_conda" in req_info or "dev_version" in req_info: + return None from_channel = req_info.get("from_channel", None) if version == "": version_str = "" @@ -158,6 +162,9 @@ def generate_dev_pinned_string( version = req_info.get("dev_version_pypi", req_info.get("dev_version", None)) if version is None: raise ValueError("No pinned version exists.") + if env == "pip-only": + if "dev_version_conda" in req_info or "dev_version" in req_info: + return None if version == "": version_str = "" else: @@ -341,9 +348,15 @@ def generate_requirements( sorted(filter(None, map(lambda req_info: generate_dev_pinned_string(req_info, "conda"), requirements))) ) - extended_env: List[Union[str, MutableMapping[str, Sequence[str]]]] = extended_env_conda # type: ignore[assignment] + extended_env: List[Union[str, MutableMapping[str, Sequence[str]]]] = copy.deepcopy( + extended_env_conda # type: ignore[arg-type] + ) + # Relative order needs to be maintained here without sorting. + # For external pip-only packages, we want to it able to access pypi.org index, + # while for internal pip-only packages, nexus is the only viable index. + # Relative order is here to prevent nexus index overriding public index. pip_only_reqs = list( - sorted(filter(None, map(lambda req_info: generate_dev_pinned_string(req_info, "pip-only"), requirements))) + filter(None, map(lambda req_info: generate_dev_pinned_string(req_info, "pip-only"), requirements)) ) if pip_only_reqs: extended_env.extend(["pip", {"pip": pip_only_reqs}]) diff --git a/ci/RunBazelAction.sh b/ci/RunBazelAction.sh index 5a9bdc0f..435b1844 100755 --- a/ci/RunBazelAction.sh +++ b/ci/RunBazelAction.sh @@ -158,6 +158,7 @@ elif [[ "${action}" = "coverage" ]]; then "${cache_test_results}" \ --combined_report=lcov \ "${coverage_tag_filter}" \ + --experimental_collect_code_coverage_for_generated_files \ --target_pattern_file "${sf_only_test_targets_file}" sf_only_bazel_exit_code=$? @@ -170,6 +171,7 @@ elif [[ "${action}" = "coverage" ]]; then "${cache_test_results}" \ --combined_report=lcov \ "${coverage_tag_filter}" \ + --experimental_collect_code_coverage_for_generated_files \ --target_pattern_file "${extended_test_targets_file}" extended_bazel_exit_code=$? diff --git a/ci/conda_recipe/meta.yaml b/ci/conda_recipe/meta.yaml index e0e272fc..0da5ff39 100644 --- a/ci/conda_recipe/meta.yaml +++ b/ci/conda_recipe/meta.yaml @@ -17,7 +17,7 @@ build: noarch: python package: name: snowflake-ml-python - version: 1.0.10 + version: 1.0.11 requirements: build: - python @@ -49,7 +49,7 @@ requirements: - mlflow>=2.1.0,<2.4 - sentencepiece>=0.1.95,<0.2 - shap==0.42.1 - - tensorflow>=2.9,<3 + - tensorflow>=2.9,<3,!=2.12.0 - tokenizers>=0.10,<1 - torchdata>=0.4,<1 - transformers>=4.29.2,<5 diff --git a/codegen/sklearn_wrapper_generator.py b/codegen/sklearn_wrapper_generator.py index 001a059c..f3b7d681 100644 --- a/codegen/sklearn_wrapper_generator.py +++ b/codegen/sklearn_wrapper_generator.py @@ -21,18 +21,18 @@ input_cols: Optional[Union[str, List[str]]] A string or list of strings representing column names that contain features. If this parameter is not specified, all columns in the input DataFrame except - the columns specified by label_cols and sample-weight_col parameters are + the columns specified by label_cols and sample_weight_col parameters are considered input columns. label_cols: Optional[Union[str, List[str]]] A string or list of strings representing column names that contain labels. This is a required param for estimators, as there is no way to infer these columns. If this parameter is not specified, then object is fitted without - labels(Like a transformer). + labels (like a transformer). output_cols: Optional[Union[str, List[str]]] A string or list of strings representing column names that will store the - output of predict and transform operations. The length of output_cols mus + output of predict and transform operations. The length of output_cols must match the expected number of output columns from the specific estimator or transformer class used. If this parameter is not specified, output column names are derived by @@ -41,7 +41,7 @@ be set explicitly for transformers. sample_weight_col: Optional[str] - A string representing the column name containing the examples’ weights. + A string representing the column name containing the sample weights. This argument is only required when working with weighted datasets. drop_input_cols: Optional[bool], default=False @@ -60,8 +60,8 @@ class WrapperGeneratorFactory: """ - Reads a estimator class descriptor and generates a WrapperGenerator object which will have - aprropriate fields to fill a template string. + Reads an estimator class descriptor and generates a WrapperGenerator object which will have + appropriate fields to fill a template string. Example ------- @@ -187,6 +187,18 @@ def _is_multioutput_estimator_obj(class_object: Tuple[str, type]) -> bool: """ return WrapperGeneratorFactory._is_class_of_type(class_object[1], "_MultiOutputEstimator") + @staticmethod + def _is_k_neighbors_obj(class_object: Tuple[str, type]) -> bool: + """Check if the given estimator is a k-neighbors estimator. + + Args: + class_object: Meta class object which needs to be checked. + + Returns: + True if the class inherits from KNeighborsMixin, otherwise False. + """ + return WrapperGeneratorFactory._is_class_of_type(class_object[1], "KNeighborsMixin") + @staticmethod def _is_xgboost(module_name: str) -> bool: """Checks if the given module belongs to XGBoost package. @@ -505,9 +517,9 @@ def __init__(self, module_name: str, class_object: Tuple[str, type]) -> None: self.predict_docstring = "" self.predict_proba_docstring = "" self.score_docstring = "" - self.predict_proba_docstring = "" self.predict_log_proba_docstring = "" self.decision_function_docstring = "" + self.kneighbors_docstring = "" # Import strings self.estimator_imports = "" @@ -568,6 +580,7 @@ def _populate_flags(self) -> None: self._is_transformer = WrapperGeneratorFactory._is_transformer_obj(self.class_object) self._is_multioutput = WrapperGeneratorFactory._is_multioutput_obj(self.class_object) self._is_multioutput_estimator = WrapperGeneratorFactory._is_multioutput_estimator_obj(self.class_object) + self._is_k_neighbors = WrapperGeneratorFactory._is_k_neighbors_obj(self.class_object) self._is_heterogeneous_ensemble = WrapperGeneratorFactory._is_heterogeneous_ensemble_obj(self.class_object) self._is_stacking_ensemble = WrapperGeneratorFactory._is_stacking_ensemble_obj(self.class_object) self._is_voting_ensemble = WrapperGeneratorFactory._is_voting_ensemble_obj(self.class_object) @@ -629,7 +642,16 @@ def _populate_class_doc_fields(self) -> None: self.estimator_class_docstring = class_docstring def _populate_function_doc_fields(self) -> None: - _METHODS = ["fit", "predict", "predict_log_proba", "predict_proba", "decision_function", "transform", "score"] + _METHODS = [ + "fit", + "predict", + "predict_log_proba", + "predict_proba", + "decision_function", + "transform", + "score", + "kneighbors", + ] _CLASS_FUNC = {name: func for name, func in inspect.getmembers(self.class_object[1])} for _each_method in _METHODS: if _each_method in _CLASS_FUNC.keys(): @@ -660,6 +682,7 @@ def _populate_function_doc_fields(self) -> None: self.predict_log_proba_docstring = self.estimator_function_docstring["predict_log_proba"] self.decision_function_docstring = self.estimator_function_docstring["decision_function"] self.score_docstring = self.estimator_function_docstring["score"] + self.kneighbors_docstring = self.estimator_function_docstring["kneighbors"] def _populate_class_names(self) -> None: self.original_class_name = self.class_object[0] @@ -827,9 +850,13 @@ def generate(self) -> "SklearnWrapperGenerator": # Populate all the common values super().generate() + is_model_selector = WrapperGeneratorFactory._is_class_of_type(self.class_object[1], "BaseSearchCV") + # Populate SKLearn specific values self.estimator_imports_list.extend(["import sklearn", f"import {self.root_module_name}"]) - self.wrapper_provider_class = "SklearnWrapperProvider" + self.wrapper_provider_class = ( + "SklearnModelSelectionWrapperProvider" if is_model_selector else "SklearnWrapperProvider" + ) self.score_sproc_imports = ["sklearn"] if "random_state" in self.original_init_signature.parameters.keys(): @@ -926,10 +953,6 @@ def generate(self) -> "SklearnWrapperGenerator": if self._is_hist_gradient_boosting_regressor: self.test_estimator_input_args_list.extend(["min_samples_leaf=1", "max_leaf_nodes=100"]) - # TODO(snandamuri): Replace cloudpickle with joblib after latest version of joblib is added to snowflake conda. - self.deps = ( - "f'numpy=={np.__version__}', f'scikit-learn=={sklearn.__version__}', f'cloudpickle=={cp.__version__}'" - ) self.supported_export_method = "to_sklearn" self.unsupported_export_methods = ["to_xgboost", "to_lightgbm"] self._construct_string_from_lists() @@ -962,7 +985,6 @@ def generate(self) -> "XGBoostWrapperGenerator": # TODO(snandamuri): Replace cloudpickle with joblib after latest version of joblib is added to snowflake conda. self.supported_export_method = "to_xgboost" self.unsupported_export_methods = ["to_sklearn", "to_lightgbm"] - self.deps = "f'numpy=={np.__version__}', f'xgboost=={xgboost.__version__}', f'cloudpickle=={cp.__version__}'" self._construct_string_from_lists() return self @@ -990,8 +1012,6 @@ def generate(self) -> "LightGBMWrapperGenerator": self.score_sproc_imports = ["lightgbm"] self.wrapper_provider_class = "LightGBMWrapperProvider" - # TODO(snandamuri): Replace cloudpickle with joblib after latest version of joblib is added to snowflake conda. - self.deps = "f'numpy=={np.__version__}', f'lightgbm=={lightgbm.__version__}', f'cloudpickle=={cp.__version__}'" self.supported_export_method = "to_lightgbm" self.unsupported_export_methods = ["to_sklearn", "to_xgboost"] self._construct_string_from_lists() diff --git a/codegen/sklearn_wrapper_template.py_template b/codegen/sklearn_wrapper_template.py_template index e123a3ca..280d7cfe 100644 --- a/codegen/sklearn_wrapper_template.py_template +++ b/codegen/sklearn_wrapper_template.py_template @@ -53,7 +53,9 @@ class {transform.original_class_name}(BaseTransformer): {transform.estimator_init_signature} ) -> None: super().__init__() - deps: Set[str] = set([{transform.deps}]) + + {transform.estimator_init_member_args} + deps = set({transform.wrapper_provider_class}().dependencies) {transform.estimator_args_gathering_calls} self._deps = list(deps) {transform.estimator_args_transform_calls} @@ -66,7 +68,6 @@ class {transform.original_class_name}(BaseTransformer): {transform.sklearn_init_arguments} ) self._model_signature_dict: Optional[Dict[str, ModelSignature]] = None - {transform.estimator_init_member_args} # If user used snowpark dataframe during fit, here it stores the snowpark input_cols, otherwise the processed input_cols self._snowpark_cols: Optional[List[str]] = self.input_cols self._handlers: FitPredictHandlers = HandlersImpl(class_name={transform.original_class_name}.__class__.__name__, subproject=_SUBPROJECT, autogenerated=True, wrapper_provider={transform.wrapper_provider_class}()) @@ -193,6 +194,8 @@ class {transform.original_class_name}(BaseTransformer): inference_method: str, expected_output_cols_list: List[str], expected_output_cols_type: str = "", + *args: Any, + **kwargs: Any, ) -> DataFrame: """Util method to create UDF and run batch inference. """ @@ -225,7 +228,9 @@ class {transform.original_class_name}(BaseTransformer): self.input_cols, self._get_pass_through_columns(dataset), expected_output_cols_list, - expected_output_cols_type + expected_output_cols_type, + *args, + **kwargs, ) @@ -233,7 +238,9 @@ class {transform.original_class_name}(BaseTransformer): self, dataset: pd.DataFrame, inference_method: str, - expected_output_cols_list: List[str] + expected_output_cols_list: List[str], + *args: Any, + **kwargs: Any, ) -> pd.DataFrame: output_cols = expected_output_cols_list.copy() @@ -278,34 +285,41 @@ class {transform.original_class_name}(BaseTransformer): input_df = dataset[columns_to_select] input_df.columns = features_required_by_estimator - transformed_numpy_array = getattr(estimator, inference_method)( - input_df - ) + inference_res = getattr(estimator, inference_method)(input_df, *args, **kwargs) if ( - isinstance(transformed_numpy_array, list) - and len(transformed_numpy_array) > 0 - and isinstance(transformed_numpy_array[0], np.ndarray) + isinstance(inference_res, list) + and len(inference_res) > 0 + and isinstance(inference_res[0], np.ndarray) ): - # In case of multioutput estimators, predict_proba(), decision_function(), etc., functions return - # a list of ndarrays. We need to concatenate them. + # In case of multioutput estimators, predict_proba, decision_function etc., functions return a list of + # ndarrays. We need to concatenate them. # First compute output column names - if len(output_cols) == len(transformed_numpy_array): + if len(output_cols) == len(inference_res): actual_output_cols = [] - for idx, np_arr in enumerate(transformed_numpy_array): + for idx, np_arr in enumerate(inference_res): for i in range(1 if len(np_arr.shape) <= 1 else np_arr.shape[1]): actual_output_cols.append(f"{{output_cols[idx]}}_{{i}}") output_cols = actual_output_cols # Concatenate np arrays - transformed_numpy_array = np.concatenate(transformed_numpy_array, axis=1) + transformed_numpy_array = np.concatenate(inference_res, axis=1) + elif ( + isinstance(inference_res, tuple) + and len(inference_res) > 0 + and isinstance(inference_res[0], np.ndarray) + ): + # In case of kneighbors, functions return a tuple of ndarrays. + transformed_numpy_array = np.stack(inference_res, axis=1) + else: + transformed_numpy_array = inference_res - if len(transformed_numpy_array.shape) == 3: + if (len(transformed_numpy_array.shape) == 3) and inference_method != "kneighbors": # VotingClassifier will return results of shape (n_classifiers, n_samples, n_classes) # when voting = "soft" and flatten_transform = False. We can't handle unflatten transforms, # so we ignore flatten_transform flag and flatten the results. - transformed_numpy_array = np.hstack(transformed_numpy_array) + transformed_numpy_array = np.hstack(transformed_numpy_array) # type: ignore[call-overload] if len(transformed_numpy_array.shape) == 1: transformed_numpy_array = np.reshape(transformed_numpy_array, (-1, 1)) @@ -325,11 +339,28 @@ class {transform.original_class_name}(BaseTransformer): actual_output_cols.append(f"{{output_cols[0]}}_{{i}}") output_cols = actual_output_cols - if self._drop_input_cols: - dataset = pd.DataFrame(data=transformed_numpy_array, columns=output_cols) + if inference_method == "kneighbors": + if (len(transformed_numpy_array.shape) == 3): # return_distance=True + shape = transformed_numpy_array.shape + data = [transformed_numpy_array[:, i, :].tolist() for i in range(shape[1])] + kneighbors_df = pd.DataFrame({{output_cols[i]: data[i] for i in range(shape[1])}}) + else: # return_distance=False + kneighbors_df = pd.DataFrame( + {{output_cols[0]: [ + transformed_numpy_array[i, :].tolist() for i in range(transformed_numpy_array.shape[0]) + ]}} + ) + + if self._drop_input_cols: + dataset = kneighbors_df + else: + dataset = pd.concat([dataset, kneighbors_df], axis=1) else: - dataset = dataset.copy() - dataset[output_cols] = transformed_numpy_array + if self._drop_input_cols: + dataset = pd.DataFrame(data=transformed_numpy_array, columns=output_cols) + else: + dataset = dataset.copy() + dataset[output_cols] = transformed_numpy_array return dataset @available_if(original_estimator_has_callable("predict")) # type: ignore[misc] @@ -423,11 +454,14 @@ class {transform.original_class_name}(BaseTransformer): return output_df - def _get_output_column_names(self, output_cols_prefix: str) -> List[str]: + def _get_output_column_names(self, output_cols_prefix: str, output_cols: Optional[List[str]] = None) -> List[str]: """ Returns the list of output columns for predict_proba(), decision_function(), etc.. functions. Returns a list with output_cols_prefix as the only element if the estimator is not a classifier. """ output_cols_prefix = identifier.resolve_identifier(output_cols_prefix) + if output_cols: + return [f"{{output_cols_prefix}}{{identifier.resolve_identifier(c)}}" for c in output_cols] + if getattr(self._sklearn_object, "classes_", None) is None: return [output_cols_prefix] @@ -442,10 +476,10 @@ class {transform.original_class_name}(BaseTransformer): # For binary classification, there is only one output column for each class # ndarray as the two classes are complementary. if len(cl) == 2: - output_cols.append(f'{{output_cols_prefix}}_{{i}}_{{cl[0]}}') + output_cols.append(f'{{output_cols_prefix}}{{i}}_{{cl[0]}}') else: output_cols.extend([ - f'{{output_cols_prefix}}_{{i}}_{{c}}' for c in cl.tolist() + f'{{output_cols_prefix}}{{i}}_{{c}}' for c in cl.tolist() ]) return output_cols return [] @@ -612,6 +646,56 @@ class {transform.original_class_name}(BaseTransformer): return score + @available_if(original_estimator_has_callable("kneighbors")) # type: ignore[misc] + @telemetry.send_api_usage_telemetry( + project=_PROJECT, + subproject=_SUBPROJECT, + custom_tags=dict([("autogen", True)]), + ) + @telemetry.add_stmt_params_to_df( + project=_PROJECT, + subproject=_SUBPROJECT, + custom_tags=dict([("autogen", True)]), + ) + def kneighbors( + self, + dataset: Union[DataFrame, pd.DataFrame], + n_neighbors: Optional[int] = None, + return_distance: bool = True, + output_cols_prefix: str = "kneighbors_", + ) -> Union[DataFrame, pd.DataFrame]: + """{transform.kneighbors_docstring} + output_cols_prefix: str + Prefix for the response columns + + Returns: + Output dataset with results of the K-neighbors for the samples in input dataset. + """ + super()._check_dataset_type(dataset) + output_cols = ["neigh_ind"] + if return_distance: + output_cols.insert(0, "neigh_dist") + if isinstance(dataset, DataFrame): + # TODO: Solve inconsistent neigh_ind with sklearn due to different precisions in case of close distances. + output_df = self._batch_inference( + dataset=dataset, + inference_method="kneighbors", + expected_output_cols_list=self._get_output_column_names(output_cols_prefix, output_cols), + expected_output_cols_type="array", + n_neighbors=n_neighbors, + return_distance=return_distance, + ) + elif isinstance(dataset, pd.DataFrame): + output_df = self._sklearn_inference( + dataset=dataset, + inference_method="kneighbors", + expected_output_cols_list=self._get_output_column_names(output_cols_prefix, output_cols), + n_neighbors=n_neighbors, + return_distance=return_distance, + ) + + return output_df + def _get_model_signatures(self, dataset: Union[DataFrame, pd.DataFrame]) -> None: self._model_signature_dict = dict() diff --git a/codegen/transformer_autogen_test_template.py_template b/codegen/transformer_autogen_test_template.py_template index fffedd41..9025ff57 100644 --- a/codegen/transformer_autogen_test_template.py_template +++ b/codegen/transformer_autogen_test_template.py_template @@ -181,7 +181,7 @@ class {transform.test_class_name}(TestCase): else: np.testing.assert_allclose(actual_arr, sklearn_numpy_arr, rtol=1.e-1, atol=1.e-2) - expected_methods = ["predict_proba", "predict_log_proba", "decision_function"] + expected_methods = ["predict_proba", "predict_log_proba", "decision_function", "kneighbors"] for m in expected_methods: assert not ( callable(getattr(sklearn_reg, m, None)) @@ -196,20 +196,46 @@ class {transform.test_class_name}(TestCase): actual_inference_result = getattr(reg, m)(dataset=input_df_pandas, output_cols_prefix="OUTPUT_") actual_output_cols = [c for c in actual_inference_result.columns if c.find("OUTPUT_") >= 0] - actual_inference_result = actual_inference_result[actual_output_cols].to_numpy() + if {transform._is_k_neighbors} and m == "kneighbors": + if inference_with_udf: + actual_inference_result[actual_output_cols] = actual_inference_result[ + actual_output_cols + ].applymap(lambda x: json.loads(x)) + actual_inference_result = actual_inference_result[actual_output_cols].to_numpy() + if actual_inference_result.shape[1] > 1: # return_distance=True + actual_inference_result = np.array(actual_inference_result.tolist()) + else: # return_distance=False + actual_inference_result = np.vstack([np.array(res[0]) for res in actual_inference_result]) + else: + actual_inference_result = actual_inference_result[actual_output_cols].to_numpy() sklearn_inference_result = getattr(sklearn_reg, m)(input_df_pandas[input_cols]) if isinstance(sklearn_inference_result, list): - # Incase of multioutput estimators predict_proba, decision_function, etc., returns a list of + # Incase of multioutput estimators predict_proba, decision_function etc., returns a list of # ndarrays as output. We need to concatenate them to compare with snowflake output. sklearn_inference_result = np.concatenate(sklearn_inference_result, axis=1) + elif isinstance(sklearn_inference_result, tuple): + # Incase of kneighbors, returns a tuple of ndarrays as output. + sklearn_inference_result = np.stack(sklearn_inference_result, axis=1) elif len(sklearn_inference_result.shape) == 1: # Some times sklearn retuns results as 1D array of shape (n_samples,), but snowfkale always retunrs # response as 2D array of shape (n_samples, 1). Flatten the snowflake response to compare results. actual_inference_result = actual_inference_result.flatten() - np.testing.assert_allclose( - actual_inference_result, sklearn_inference_result, rtol=1.e-1, atol=1.e-2) + if ( + {transform._is_k_neighbors} + and m == "kneighbors" + and len(actual_inference_result.shape) == 3 + ): # return_distance=True + # Only compare neigh_dist, as different precisions cause neigh_ind to differ in case of close + # distances. + np.testing.assert_allclose( + actual_inference_result[:, 0, :], sklearn_inference_result[:, 0, :], rtol=1.e-1, atol=1.e-2 + ) + else: + np.testing.assert_allclose( + actual_inference_result, sklearn_inference_result, rtol=1.e-1, atol=1.e-2 + ) if callable(getattr(sklearn_reg, "score", None)) and callable(getattr(reg, "score", None)): score_argspec = inspect.getfullargspec(sklearn_reg.score) diff --git a/mypy.ini b/mypy.ini index b520c715..a634503b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -9,24 +9,38 @@ explicit_package_bases = True # This is default but vscode plugin may be old namespace_packages = True -# Enables the following checks. These are meant to be a subset of the checks enabled by --strict. Notably, -# --disallow_subclassing_any is not enabled. -warn_unused_configs = True +# Enables the following checks. These are meant to be a subset of the checks enabled by --strict. +check_untyped_defs = True + disallow_any_generics = True -disallow_untyped_calls = True -disallow_untyped_defs = True disallow_incomplete_defs = True -check_untyped_defs = True +# We inherit from classes like fsspec.AbstractFileSystem, absltest.TestCases which are considered of type Any. +#disallow_subclassing_any = True +disallow_untyped_calls = True disallow_untyped_decorators = True +disallow_untyped_defs = True + +warn_no_return = True warn_redundant_casts = True -warn_unused_ignores = True warn_return_any = True -no_implicit_reexport = True +# It seems, today we have lots of these. +#warn_unreachable = True +warn_unused_configs = True +warn_unused_ignores = True + +implicit_reexport = False strict_equality = True extra_checks = True enable_incomplete_feature = Unpack +pretty = True +show_absolute_path = True +show_column_numbers = True +show_error_codes = True +show_error_context = True +verbosity = 0 + exclude = (?x)( (^.*\/experimental\/.*)|(^bazel-.*) # ignore everything in the `/experimental/` directory ) diff --git a/requirements.txt b/requirements.txt index b9c04935..e005708e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # DO NOT EDIT! # Generate by running 'bazel run --config=pre_build //bazel/requirements:sync_requirements' +--extra-index-url https://pypi.org/simple absl-py==1.3.0 accelerate==0.22.0 anyio==3.5.0 @@ -24,6 +25,7 @@ networkx==2.8.4 numpy==1.24.3 packaging==23.0 pandas==1.5.3 +peft==0.5.0 protobuf==3.20.3 pytest==7.4.0 pytimeparse==1.1.8 diff --git a/requirements.yml b/requirements.yml index 1747fa73..f34af4af 100644 --- a/requirements.yml +++ b/requirements.yml @@ -217,7 +217,7 @@ - name: tensorflow dev_version_conda: 2.10.0 dev_version_pypi: 2.13.1 - version_requirements: '>=2.9,<3' + version_requirements: '>=2.9,<3,!=2.12.0' requirements_extra_tags: - tensorflow - name: tokenizers @@ -261,3 +261,12 @@ - name: pytimeparse dev_version: 1.1.8 version_requirements: '>=1.1.8,<2' + +# Below are pip only external packages +- name_pypi: --extra-index-url https://pypi.org/simple + dev_version_pypi: '' +- name_pypi: peft + dev_version_pypi: 0.5.0 + version_requirements_pypi: '>=0.5.0,<1' + requirements_extra_tags: + - llm diff --git a/snowflake/ml/_internal/utils/snowpark_dataframe_utils.py b/snowflake/ml/_internal/utils/snowpark_dataframe_utils.py index 59c8f8ac..9362a97a 100644 --- a/snowflake/ml/_internal/utils/snowpark_dataframe_utils.py +++ b/snowflake/ml/_internal/utils/snowpark_dataframe_utils.py @@ -1,4 +1,5 @@ import logging +import warnings from snowflake import snowpark from snowflake.snowpark import functions, types @@ -58,3 +59,54 @@ def cast_snowpark_dataframe(df: snowpark.DataFrame) -> snowpark.DataFrame: selected_cols.append(functions.col(src)) df = df.select(selected_cols) return df + + +def cast_snowpark_dataframe_column_types(df: snowpark.DataFrame) -> snowpark.DataFrame: + """Cast columns in the dataframe to types that are compatible with pandas DataFrame. + + It assists modeling API (fit, predict, ...) in performing implicit data casting. + The reason for casting: snowpark dataframe would transform as pandas dataframe + to compute within sproc. + + Args: + df: A snowpark dataframe. + + Returns: + A snowpark dataframe whose data type has been casted. + """ + fields = df.schema.fields + selected_cols = [] + for field in fields: + src = field.column_identifier.quoted_name + # Handle DecimalType: Numbers up to 38 digits, with an optional precision and scale + # By default, precision is 38 and scale is 0 (i.e. NUMBER(38, 0)) + if isinstance(field.datatype, types.DecimalType): + # If datatype has scale; convert into float/double type + # In snowflake, DOUBLE is the same as FLOAT, provides precision up to 18. + if field.datatype.scale: + dest_dtype: types.DataType = types.DoubleType() + warnings.warn( + f"Warning: The Decimal({field.datatype.precision}, {field.datatype.scale}) data type" + " is being automatically converted to DoubleType in the Snowpark DataFrame. " + "This automatic conversion may lead to potential precision loss and rounding errors. " + "If you wish to prevent this conversion, you should manually perform " + "the necessary data type conversion." + ) + else: + # IntegerType default as NUMBER(38, 0), but + # snowpark dataframe would automatically transform to LongType in function `convert_sf_to_sp_type` + # To align with snowpark, set all the decimal without scale as LongType + dest_dtype = types.LongType() + warnings.warn( + f"Warning: The Decimal({field.datatype.precision}, 0) data type" + " is being automatically converted to LongType in the Snowpark DataFrame. " + "This automatic conversion may lead to potential precision loss and rounding errors. " + "If you wish to prevent this conversion, you should manually perform " + "the necessary data type conversion." + ) + selected_cols.append(functions.cast(functions.col(src), dest_dtype).alias(src)) + # TODO: add more type handling or error message + else: + selected_cols.append(functions.col(src)) + df = df.select(selected_cols) + return df diff --git a/snowflake/ml/dataset/BUILD.bazel b/snowflake/ml/dataset/BUILD.bazel index c2bd2e7a..6dc0b288 100644 --- a/snowflake/ml/dataset/BUILD.bazel +++ b/snowflake/ml/dataset/BUILD.bazel @@ -9,5 +9,6 @@ py_library( ], deps = [ "//snowflake/ml/_internal/utils:query_result_checker", + "//snowflake/ml/registry:artifact_manager", ], ) diff --git a/snowflake/ml/dataset/dataset.py b/snowflake/ml/dataset/dataset.py index 4ccb6f51..c683b523 100644 --- a/snowflake/ml/dataset/dataset.py +++ b/snowflake/ml/dataset/dataset.py @@ -2,8 +2,8 @@ import time from dataclasses import dataclass from typing import Any, Dict, List, Optional -from uuid import uuid4 +from snowflake.ml.registry.artifact import Artifact, ArtifactType from snowflake.snowpark import DataFrame, Session @@ -42,9 +42,9 @@ def to_json(self) -> str: # but we retrieve it as an object. Snowpark serialization is inconsistent with # our deserialization. A fix is let artifact table stores string and callers # handles both serialization and deserialization. - "spine_query": _wrap_embedded_str(self.spine_query), - "connection_params": _wrap_embedded_str(json.dumps(self.connection_params)), - "features": _wrap_embedded_str(json.dumps(self.features)), + "spine_query": self.spine_query, + "connection_params": json.dumps(self.connection_params), + "features": json.dumps(self.features), } return json.dumps(state_dict) @@ -58,7 +58,7 @@ def from_json(cls, json_str: str) -> "FeatureStoreMetadata": ) -class Dataset: +class Dataset(Artifact): """Metadata of dataset.""" def __init__( @@ -95,19 +95,10 @@ def __init__( self.label_cols = label_cols self.feature_store_metadata = feature_store_metadata self.desc = desc - - self.id = uuid4().hex.upper() self.owner = session.sql("SELECT CURRENT_USER()").collect()[0]["CURRENT_USER()"] - self.version = DATASET_SCHEMA_VERSION - - @property - def name(self) -> str: - """Get name of this dataset. It returns snapshot table name if it exists. Otherwise returns empty string. + self.schema_version = DATASET_SCHEMA_VERSION - Returns: - A string name. - """ - return self.snapshot_table if self.snapshot_table is not None else "" + super().__init__(type=ArtifactType.DATASET, spec=self.to_json()) def load_features(self) -> Optional[List[str]]: if self.feature_store_metadata is not None: @@ -115,6 +106,14 @@ def load_features(self) -> Optional[List[str]]: else: return None + def features_df(self) -> DataFrame: + result = self.df + if self.timestamp_col is not None: + result = result.drop(self.timestamp_col) + if self.label_cols is not None: + result = result.drop(self.label_cols) + return result + def to_json(self) -> str: if len(self.df.queries["queries"]) != 1: raise ValueError( @@ -124,18 +123,17 @@ def to_json(self) -> str: ) state_dict = { - "df_query": self.df.queries["queries"][0], - "id": self.id, + "df_query": _wrap_embedded_str(self.df.queries["queries"][0]), "generation_timestamp": self.generation_timestamp, "owner": self.owner, - "materialized_table": _get_val_or_null(self.materialized_table), - "snapshot_table": _get_val_or_null(self.snapshot_table), - "timestamp_col": _get_val_or_null(self.timestamp_col), + "materialized_table": _wrap_embedded_str(_get_val_or_null(self.materialized_table)), + "snapshot_table": _wrap_embedded_str(_get_val_or_null(self.snapshot_table)), + "timestamp_col": _wrap_embedded_str(_get_val_or_null(self.timestamp_col)), "label_cols": _get_val_or_null(self.label_cols), - "feature_store_metadata": self.feature_store_metadata.to_json() + "feature_store_metadata": _wrap_embedded_str(self.feature_store_metadata.to_json()) if self.feature_store_metadata is not None else "null", - "version": self.version, + "schema_version": self.schema_version, "desc": self.desc, } return json.dumps(state_dict) @@ -150,13 +148,11 @@ def from_json(cls, json_str: str, session: Session) -> "Dataset": FeatureStoreMetadata.from_json(fs_meta_json) if fs_meta_json != "null" else None ) - uid = json_dict.pop("id") - version = json_dict.pop("version") + schema_version = json_dict.pop("schema_version") owner = json_dict.pop("owner") result = cls(session, **json_dict) - result.id = uid - result.version = version + result.schema_version = schema_version result.owner = owner return result diff --git a/snowflake/ml/feature_store/feature_store.py b/snowflake/ml/feature_store/feature_store.py index a2beb080..3916abea 100644 --- a/snowflake/ml/feature_store/feature_store.py +++ b/snowflake/ml/feature_store/feature_store.py @@ -140,24 +140,25 @@ def __init__( "SCHEMAS": (f"DATABASE {self._config.database}", "SCHEMA"), "TAGS": (self._config.full_schema_path, None), "TASKS": (self._config.full_schema_path, "TASK"), + "WAREHOUSES": (None, None), } - try: - self._session.sql(f"DESC WAREHOUSE {self._config.default_warehouse}").collect() - except Exception as e: + # DESC WAREHOUSE requires MONITOR privilege on the warehouse which is a high privilege + # some users not usually have. + warehouse_result = self._find_object("WAREHOUSES", self._config.default_warehouse) + if len(warehouse_result) == 0: raise snowml_exceptions.SnowflakeMLException( - error_code=error_codes.SNOWML_NOT_FOUND, - original_exception=ValueError(f"Cannot find warehouse {default_warehouse}: {e}"), - ) from e + error_code=error_codes.NOT_FOUND, + original_exception=ValueError(f"Cannot find warehouse {self._config.default_warehouse}"), + ) if creation_mode == CreationMode.FAIL_IF_NOT_EXIST: - try: - self._session.sql(f"DESC SCHEMA {self._config.full_schema_path}").collect() - except Exception as e: + schema_result = self._find_object("SCHEMAS", self._config.schema) + if len(schema_result) == 0: raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.NOT_FOUND, original_exception=ValueError(f"Feature store {name} does not exist."), - ) from e + ) else: try: self._session.sql(f"CREATE DATABASE IF NOT EXISTS {self._config.database}").collect( @@ -1454,12 +1455,16 @@ def _fetch_column_descs(self, obj_type: str, obj_name: str) -> Dict[str, str]: return descs def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: - """Try to find an object in a given place with respect to case-sensitivity. - If object name is not quoted, then it's case insensitive. Otherwise it's case sensitive. + """Try to find an object by given type and name pattern. Args: - object_type: Type of the object. Could be TABLE, TAG etc. - object_name_pattern: Name match pattern of object. Could be either quoted or not. + object_type: Type of the object. Could be TABLES, TAGS etc. + object_name_pattern: Name match pattern of object. It obeys snowflake identifier requirements. + and can be used with SQL wildcard character '%'. + Examples: + 1. object_name_pattern="bar" will return objects with lowercase name: bar. + 2. object_name_pattern=BAR will return objects with case-insensitive name: bar. + 3. object_name_pattern=BAR% will return objects with name starts with case-insensitive: bar. Raises: SnowflakeMLException: [RuntimeError] Failed to find resource. @@ -1474,6 +1479,7 @@ def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: unesc_object_name = object_name_pattern object_name = "" elif object_name_pattern[-1] == "%": + assert '"' not in object_name_pattern, "wildcard search doesn't support double quotes" unesc_object_name = object_name_pattern object_name = unesc_object_name[:-1] else: @@ -1485,7 +1491,8 @@ def _find_object(self, object_type: str, object_name_pattern: str) -> List[Row]: fs_objects = [] tag_free_object_types = ["TAGS", "SCHEMAS"] try: - all_rows = self._session.sql(f"SHOW {object_type} LIKE '{unesc_object_name}' IN {search_space}").collect( + search_scope = f"IN {search_space}" if search_space is not None else "" + all_rows = self._session.sql(f"SHOW {object_type} LIKE '{unesc_object_name}' {search_scope}").collect( statement_params=self._telemetry_stmp ) if object_name_pattern == "%" and object_type not in tag_free_object_types and len(all_rows) > 0: diff --git a/snowflake/ml/feature_store/notebooks/customer_demo/Basic_Feature_Demo.ipynb b/snowflake/ml/feature_store/notebooks/customer_demo/Basic_Feature_Demo.ipynb index 7f6af1b0..a2447da8 100644 --- a/snowflake/ml/feature_store/notebooks/customer_demo/Basic_Feature_Demo.ipynb +++ b/snowflake/ml/feature_store/notebooks/customer_demo/Basic_Feature_Demo.ipynb @@ -5,9 +5,9 @@ "id": "0bb54abc", "metadata": {}, "source": [ - "Version: 0.1.1\n", + "Version: 0.1.2\n", "\n", - "Updated date: 10/11/2023" + "Updated date: 10/18/2023" ] }, { @@ -73,7 +73,7 @@ "id": "494e1503", "metadata": {}, "source": [ - "Following sections will use below database and schema name to store test data and feature store objects. You can rename with your own name if needed. " + "Below cell creates temporary database, schema and warehouse for this notebook. All temporary resources will be deleted at the end of this notebook. You can rename with your own name if needed. " ] }, { @@ -91,13 +91,16 @@ "FS_DEMO_SCHEMA = \"AWESOME_FS_BASIC_FEATURES\"\n", "# Model registry database name.\n", "MR_DEMO_DB = f\"FEATURE_STORE_BASIC_FEATURE_NOTEBOOK_MR_DEMO\"\n", + "# warehouse name used in this notebook.\n", + "FS_DEMO_WH = \"FEATURE_STORE_BASIC_FEATURE_NOTEBOOK_DEMO\"\n", "\n", "session.sql(f\"DROP DATABASE IF EXISTS {FS_DEMO_DB}\").collect()\n", "session.sql(f\"DROP DATABASE IF EXISTS {MR_DEMO_DB}\").collect()\n", "session.sql(f\"CREATE DATABASE IF NOT EXISTS {FS_DEMO_DB}\").collect()\n", "session.sql(f\"\"\"\n", " CREATE SCHEMA IF NOT EXISTS \n", - " {FS_DEMO_DB}.{TEST_DATASET_SCHEMA}\"\"\").collect()" + " {FS_DEMO_DB}.{TEST_DATASET_SCHEMA}\"\"\").collect()\n", + "session.sql(f\"CREATE WAREHOUSE IF NOT EXISTS {FS_DEMO_WH}\").collect()" ] }, { @@ -168,15 +171,11 @@ "metadata": {}, "outputs": [], "source": [ - "session.sql(f\"\"\"CREATE OR REPLACE WAREHOUSE PUBLIC WITH \n", - " WAREHOUSE_SIZE='XSMALL'\n", - " \"\"\").collect()\n", - "\n", "fs = FeatureStore(\n", " session=session, \n", " database=FS_DEMO_DB, \n", " name=FS_DEMO_SCHEMA, \n", - " default_warehouse=\"PUBLIC\",\n", + " default_warehouse=FS_DEMO_WH,\n", " creation_mode=CreationMode.CREATE_IF_NOT_EXIST,\n", ")" ] @@ -202,7 +201,7 @@ "source": [ "entity = Entity(name=\"WINE\", join_keys=[\"WINE_ID\"])\n", "fs.register_entity(entity)\n", - "fs.list_entities().to_pandas()" + "fs.list_entities().show()" ] }, { @@ -227,7 +226,7 @@ "outputs": [], "source": [ "source_df = session.table(f\"{FS_DEMO_DB}.{TEST_DATASET_SCHEMA}.WINE_DATA\")\n", - "source_df.to_pandas()" + "source_df.show()" ] }, { @@ -265,7 +264,7 @@ " 'MY_NEW_FEATURE',\n", " ]\n", ")\n", - "feature_df.to_pandas()" + "feature_df.show()" ] }, { @@ -325,7 +324,7 @@ "outputs": [], "source": [ "# Examine the FeatureView content\n", - "fs.read_feature_view(fv).to_pandas()" + "fs.read_feature_view(fv).show()" ] }, { @@ -425,7 +424,7 @@ "spine_df = session.table(f\"{FS_DEMO_DB}.{TEST_DATASET_SCHEMA}.WINE_DATA\")\n", "spine_df = addIdColumn(source_df, \"WINE_ID\")\n", "spine_df = spine_df.select(\"WINE_ID\", \"QUALITY\")\n", - "spine_df.to_pandas()" + "spine_df.show()" ] }, { @@ -473,7 +472,7 @@ "source": [ "## Train a model\n", "\n", - "Now let's training a simple random forest model with snowflake.ml library, and evaluate the prediction accuracy." + "Now let's training a simple random forest model with sklearn library, and evaluate the prediction accuracy." ] }, { @@ -580,6 +579,14 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "83fb2531", + "metadata": {}, + "source": [ + "Register the dataset into model registry with `log_artifact`. Artifact is a generalized concept of ML pipeline outputs that are needed for subsequent execution. Refer to https://docs.snowflake.com/LIMITEDACCESS/snowflake-ml-model-registry for more details about the API." + ] + }, { "cell_type": "code", "execution_count": null, @@ -595,6 +602,14 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "2dd12070", + "metadata": {}, + "source": [ + "Now you can log the model together with the registered artifact (which is a dataset here)." + ] + }, { "cell_type": "code", "execution_count": null, @@ -636,7 +651,7 @@ "registered_artifact = registry.get_artifact(\n", " artifact_ref.name, \n", " artifact_ref.version)\n", - "registered_dataset = Dataset.from_json(registered_artifact.spec, session)\n", + "registered_dataset = Dataset.from_json(registered_artifact._spec, session)\n", "test_df = spine_df.limit(3).select(\"WINE_ID\")\n", "\n", "enriched_df = fs.retrieve_feature_values(\n", @@ -679,7 +694,8 @@ "outputs": [], "source": [ "session.sql(f\"DROP DATABASE IF EXISTS {FS_DEMO_DB}\").collect()\n", - "session.sql(f\"DROP DATABASE IF EXISTS {MR_DEMO_DB}\").collect()" + "session.sql(f\"DROP DATABASE IF EXISTS {MR_DEMO_DB}\").collect()\n", + "session.sql(f\"DROP WAREHOUSE IF EXISTS {FS_DEMO_WH}\").collect()" ] } ], diff --git a/snowflake/ml/feature_store/notebooks/customer_demo/Basic_Feature_Demo.pdf b/snowflake/ml/feature_store/notebooks/customer_demo/Basic_Feature_Demo.pdf index 00a95424576a723849615eaf7e469f02a8e1209e..59ed9703586623e4332e95c4cc94324f5f86f43c 100644 GIT binary patch delta 82757 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

uLy>b-*q`%=@eI&AgZMVV6LwN}>c7qFJ?6 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 delta 87682 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 MflF3a-`wE;13>QuX8-^I diff --git a/snowflake/ml/feature_store/tests/feature_store_case_sensitivity_test.py b/snowflake/ml/feature_store/tests/feature_store_case_sensitivity_test.py index 13134a84..723fec56 100644 --- a/snowflake/ml/feature_store/tests/feature_store_case_sensitivity_test.py +++ b/snowflake/ml/feature_store/tests/feature_store_case_sensitivity_test.py @@ -53,7 +53,7 @@ def setUpClass(cls) -> None: def tearDownClass(cls) -> None: for fs in cls._active_fs: fs.clear() - cls._session.sql(f"DROP SCHEMA IF EXISTS {fs._config.schema}").collect() + cls._session.sql(f"DROP SCHEMA IF EXISTS {fs._config.full_schema_path}").collect() cls._session.sql(f"DROP TABLE IF EXISTS {cls._mock_table}").collect() cls._session.close() @@ -108,7 +108,7 @@ def test_feature_store_location(self, database: str, schema: str) -> None: @parameterized.parameters(WAREHOUSE_NAMES) # type: ignore[misc] def test_warehouse_names(self, warehouse: str) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_WAREHOUSE_NAMES") self._session.sql(f"CREATE WAREHOUSE IF NOT EXISTS {warehouse} WITH WAREHOUSE_SIZE='XSMALL'").collect() @@ -190,7 +190,7 @@ def generate_unique_name(names: List[str]) -> List[str]: # 5. delete_entity @parameterized.parameters(TEST_NAMES) # type: ignore[misc] def test_entity_names(self, equi_names: List[str], diff_names: List[str]) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_ENTITY_NAMES") fs = FeatureStore( self._session, FS_INTEG_TEST_DB, @@ -235,7 +235,7 @@ def test_entity_names(self, equi_names: List[str], diff_names: List[str]) -> Non # 5. FeatureView.timestamp_col @parameterized.parameters(TEST_NAMES) # type: ignore[misc] def test_join_keys_and_ts_col(self, equi_names: List[str], diff_names: List[str]) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_JOIN_KEYS_AND_TS_COL") fs = FeatureStore( self._session, FS_INTEG_TEST_DB, @@ -292,7 +292,7 @@ def test_feature_view_names_and_versions_combination( equi_full_names: List[Tuple[str, str]], diff_full_names: List[Tuple[str, str]], ) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_FEATURE_VIEW_NAMES") fs = FeatureStore( self._session, FS_INTEG_TEST_DB, @@ -363,6 +363,25 @@ def test_feature_view_names_and_versions_combination( version = diff_name[1] fs.get_feature_view(fv_name, version) + @parameterized.parameters(TEST_NAMES) # type: ignore[misc] + def test_find_objects(self, equi_names: List[str], diff_names: List[str]) -> None: + current_schema = create_random_schema(self._session, "TEST_FIND_OBJECTS") + fs = FeatureStore( + self._session, + FS_INTEG_TEST_DB, + current_schema, + FS_INTEG_TEST_DEFAULT_WAREHOUSE, + creation_mode=CreationMode.CREATE_IF_NOT_EXIST, + ) + self._active_fs.append(fs) + + self._session.sql(f"CREATE SCHEMA IF NOT EXISTS {FS_INTEG_TEST_DB}.{equi_names[0]}").collect() + for name in equi_names: + self.assertEqual(len(fs._find_object("SCHEMAS", name)), 1) + for name in diff_names: + self.assertEqual(len(fs._find_object("SCHEMAS", name)), 0) + self._session.sql(f"DROP SCHEMA IF EXISTS {FS_INTEG_TEST_DB}.{equi_names[0]}").collect() + if __name__ == "__main__": absltest.main() diff --git a/snowflake/ml/feature_store/tests/feature_store_large_scale_test.py b/snowflake/ml/feature_store/tests/feature_store_large_scale_test.py index 8047e36d..1f351f27 100644 --- a/snowflake/ml/feature_store/tests/feature_store_large_scale_test.py +++ b/snowflake/ml/feature_store/tests/feature_store_large_scale_test.py @@ -37,11 +37,11 @@ def setUpClass(self) -> None: def tearDownClass(self) -> None: for fs in self._active_feature_store: fs.clear() - self._session.sql(f"DROP SCHEMA IF EXISTS {fs._config.schema}").collect() + self._session.sql(f"DROP SCHEMA IF EXISTS {fs._config.full_schema_path}").collect() self._session.close() def _create_feature_store(self, name: Optional[str] = None) -> FeatureStore: - current_schema = create_random_schema(self._session, "TEST_SHEMA") if name is None else name + current_schema = create_random_schema(self._session, "FS_LARGE_SCALE_TEST") if name is None else name fs = FeatureStore( self._session, FS_INTEG_TEST_DB, @@ -91,7 +91,7 @@ def addIdColumn(df: DataFrame, id_column_name: str) -> DataFrame: self._session.sql(f"DROP TABLE {cloned_wine_data}").collect() def test_external_table(self) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_EXTERNAL_TABLE") fs = self._create_feature_store(current_schema) e_loc = Entity("LOCATION", ["PULOCATIONID"]) diff --git a/snowflake/ml/feature_store/tests/feature_store_test.py b/snowflake/ml/feature_store/tests/feature_store_test.py index 6080c297..0161d0bc 100644 --- a/snowflake/ml/feature_store/tests/feature_store_test.py +++ b/snowflake/ml/feature_store/tests/feature_store_test.py @@ -85,7 +85,7 @@ def _create_mock_table(self, name: str) -> str: return table_full_path def _create_feature_store(self, name: Optional[str] = None) -> FeatureStore: - current_schema = create_random_schema(self._session, "TEST_SHEMA") if name is None else name + current_schema = create_random_schema(self._session, "FS_TEST") if name is None else name fs = FeatureStore( self._session, FS_INTEG_TEST_DB, @@ -123,7 +123,7 @@ def test_invalid_warehouse(self) -> None: FeatureStore( session=self._session, database=FS_INTEG_TEST_DB, - name=create_random_schema(self._session, "TEST_SHEMA"), + name=create_random_schema(self._session, "TEST_INVALID_WAREHOUSE"), default_warehouse=schema_name, creation_mode=CreationMode.CREATE_IF_NOT_EXIST, ) @@ -150,7 +150,7 @@ def test_create_if_not_exist_failure(self) -> None: # Schema still exist even feature store creation failed. res = self._session.sql(f"SHOW SCHEMAS LIKE '{schema_name}' in DATABASE {FS_INTEG_TEST_DB}").collect() self.assertEqual(len(res), 1) - self._session.sql(f"DROP SCHEMA IF EXISTS {schema_name}").collect() + self._session.sql(f"DROP SCHEMA IF EXISTS {FS_INTEG_TEST_DB}.{schema_name}").collect() def test_create_if_not_exist_system_error(self) -> None: mock_session = create_mock_session( @@ -959,7 +959,7 @@ def test_list_feature_views_system_error(self) -> None: fs.list_feature_views(entity_name="foo") def test_create_and_cleanup_tags(self) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_CREATE_AND_CLEANUP_TAGS") fs = FeatureStore( self._session, FS_INTEG_TEST_DB, @@ -974,7 +974,7 @@ def test_create_and_cleanup_tags(self) -> None: ).collect() self.assertEqual(len(res), 1) - self._session.sql(f"DROP SCHEMA IF EXISTS {current_schema}").collect() + self._session.sql(f"DROP SCHEMA IF EXISTS {FS_INTEG_TEST_DB}.{current_schema}").collect() row_list = self._session.sql( f"SHOW TAGS LIKE '{FEATURE_VIEW_ENTITY_TAG}' IN DATABASE {fs._config.database}" @@ -1154,7 +1154,7 @@ def test_generate_dataset(self) -> None: ) def test_clear_feature_store_in_existing_schema(self) -> None: - current_schema = create_random_schema(self._session, "TEST_SHEMA") + current_schema = create_random_schema(self._session, "TEST_CLEAR_FEATURE_STORE_IN_EXISTING_SCHEMA") # create some objects outside of feature store domain, later will check if they still exists after fs.clear() full_schema_path = f"{FS_INTEG_TEST_DB}.{current_schema}" diff --git a/snowflake/ml/model/BUILD.bazel b/snowflake/ml/model/BUILD.bazel index b060efde..600c0004 100644 --- a/snowflake/ml/model/BUILD.bazel +++ b/snowflake/ml/model/BUILD.bazel @@ -131,6 +131,7 @@ py_library( "//snowflake/ml/_internal/exceptions", "//snowflake/ml/model/_handlers:custom", "//snowflake/ml/model/_handlers:huggingface_pipeline", + "//snowflake/ml/model/_handlers:llm", "//snowflake/ml/model/_handlers:mlflow", "//snowflake/ml/model/_handlers:pytorch", "//snowflake/ml/model/_handlers:sklearn", diff --git a/snowflake/ml/model/_deploy_client/image_builds/docker_context.py b/snowflake/ml/model/_deploy_client/image_builds/docker_context.py index bbd5d562..3e9c2924 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/docker_context.py +++ b/snowflake/ml/model/_deploy_client/image_builds/docker_context.py @@ -91,8 +91,10 @@ def _generate_docker_file(self) -> None: assert len(get_res_list) == 1, f"Single zip file should be returned, but got {len(get_res_list)} files." local_zip_file_path = os.path.basename(get_res_list[0].file) copy_model_statement = f"COPY {local_zip_file_path} {absolute_path}" + extra_env_statement = f"ENV MODEL_ZIP_STAGE_PATH={absolute_path}" else: copy_model_statement = "" + extra_env_statement = "" with open(docker_file_path, "w", encoding="utf-8") as dockerfile, open( docker_file_template, encoding="utf-8" @@ -113,6 +115,7 @@ def _generate_docker_file(self) -> None: # https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-virtual.html "cuda_override_env": cuda_version_str, "copy_model_statement": copy_model_statement, + "extra_env_statement": extra_env_statement, } ) dockerfile.write(dockerfile_content) diff --git a/snowflake/ml/model/_deploy_client/image_builds/server_image_builder.py b/snowflake/ml/model/_deploy_client/image_builds/server_image_builder.py index 21ab330f..74a028b7 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/server_image_builder.py +++ b/snowflake/ml/model/_deploy_client/image_builds/server_image_builder.py @@ -201,9 +201,9 @@ def _construct_and_upload_job_spec(self, base_image: str, kaniko_shell_script_st ) def _launch_kaniko_job(self, spec_stage_location: str) -> None: - logger.debug("Submitting job for building docker image with kaniko") + logger.info("Submitting SPCS job for building docker image.") job_id = self.client.create_job(compute_pool=self.compute_pool, spec_stage_location=spec_stage_location) - logger.debug(f"Kaniko job id is {job_id}") + logger.info(f"Server image building SPCS job id is {job_id}.") # Given image build can take a while, we set a generous timeout to be 1 hour. self.client.block_until_resource_is_ready( resource_name=job_id, diff --git a/snowflake/ml/model/_deploy_client/image_builds/templates/dockerfile_template b/snowflake/ml/model/_deploy_client/image_builds/templates/dockerfile_template index 070d02ab..a448e284 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/templates/dockerfile_template +++ b/snowflake/ml/model/_deploy_client/image_builds/templates/dockerfile_template @@ -4,8 +4,6 @@ FROM ${base_image} as build COPY ${model_env_folder}/conda.yaml conda.yaml COPY ${model_env_folder}/requirements.txt requirements.txt -${copy_model_statement} - # Set MAMBA_DOCKERFILE_ACTIVATE=1 to activate the conda environment during build time. ARG MAMBA_DOCKERFILE_ACTIVATE=1 @@ -17,11 +15,13 @@ RUN --mount=type=cache,target=/opt/conda/pkgs CONDA_OVERRIDE_CUDA="${cuda_overri python -m pip install -r requirements.txt && \ micromamba clean -afy -# Bitsandbytes uses this ENVVAR to determine CUDA library location -ENV CONDA_PREFIX=/opt/conda - COPY ${inference_server_dir} ./${inference_server_dir} COPY ${entrypoint_script} ./${entrypoint_script} +${copy_model_statement} + +# Bitsandbytes uses this ENVVAR to determine CUDA library location +ENV CONDA_PREFIX=/opt/conda +${extra_env_statement} USER root RUN if id mambauser >/dev/null 2>&1; then \ diff --git a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture index a03cd460..2130d55b 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture +++ b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture @@ -9,10 +9,11 @@ RUN --mount=type=cache,target=/opt/conda/pkgs CONDA_OVERRIDE_CUDA="" \ python -m pip install "uvicorn[standard]" gunicorn starlette==0.30.0 && \ python -m pip install -r requirements.txt && \ micromamba clean -afy -ENV CONDA_PREFIX=/opt/conda COPY inference_server ./inference_server COPY gunicorn_run.sh ./gunicorn_run.sh +ENV CONDA_PREFIX=/opt/conda + USER root RUN if id mambauser >/dev/null 2>&1; then \ diff --git a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_CUDA b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_CUDA index b393b172..29c67a59 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_CUDA +++ b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_CUDA @@ -9,10 +9,11 @@ RUN --mount=type=cache,target=/opt/conda/pkgs CONDA_OVERRIDE_CUDA="11.7" \ python -m pip install "uvicorn[standard]" gunicorn starlette==0.30.0 && \ python -m pip install -r requirements.txt && \ micromamba clean -afy -ENV CONDA_PREFIX=/opt/conda COPY inference_server ./inference_server COPY gunicorn_run.sh ./gunicorn_run.sh +ENV CONDA_PREFIX=/opt/conda + USER root RUN if id mambauser >/dev/null 2>&1; then \ diff --git a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_model b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_model index adff8f90..f0719f73 100644 --- a/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_model +++ b/snowflake/ml/model/_deploy_client/image_builds/test_fixtures/dockerfile_test_fixture_with_model @@ -3,18 +3,18 @@ FROM mambaorg/micromamba:1.4.3 as build COPY env/conda.yaml conda.yaml COPY env/requirements.txt requirements.txt - -COPY model.zip /model_repo/model.zip ARG MAMBA_DOCKERFILE_ACTIVATE=1 RUN --mount=type=cache,target=/opt/conda/pkgs CONDA_OVERRIDE_CUDA="11.7" \ micromamba install -y -n base -f conda.yaml && \ python -m pip install "uvicorn[standard]" gunicorn starlette==0.30.0 && \ python -m pip install -r requirements.txt && \ micromamba clean -afy -ENV CONDA_PREFIX=/opt/conda COPY inference_server ./inference_server COPY gunicorn_run.sh ./gunicorn_run.sh +COPY model.zip /model_repo/model.zip +ENV CONDA_PREFIX=/opt/conda +ENV MODEL_ZIP_STAGE_PATH=/model_repo/model.zip USER root RUN if id mambauser >/dev/null 2>&1; then \ diff --git a/snowflake/ml/model/_deploy_client/snowservice/deploy.py b/snowflake/ml/model/_deploy_client/snowservice/deploy.py index f096bd38..919c8e7d 100644 --- a/snowflake/ml/model/_deploy_client/snowservice/deploy.py +++ b/snowflake/ml/model/_deploy_client/snowservice/deploy.py @@ -461,6 +461,7 @@ def _prepare_and_upload_artifacts_to_stage(self, image: str) -> str: } if self.options.model_in_image: del substitutes["model_stage"] + del substitutes["model_zip_stage_path"] content = string.Template(template.read()).substitute(substitutes) content_dict = yaml.safe_load(content) if self.options.use_gpu: @@ -526,11 +527,9 @@ def _deploy_workflow(self, image: str) -> Tuple[str, str]: if self.options.use_gpu: for model_blob_meta in self.model_meta.models.values(): if model_blob_meta.model_type == "huggingface_pipeline": - batch_size = int(model_blob_meta.options.get("batch_size", 1)) - if max_batch_rows is None: - max_batch_rows = batch_size - else: - max_batch_rows = min(batch_size, max_batch_rows) + max_batch_rows = int(model_blob_meta.options.get("batch_size", 1)) + if model_blob_meta.model_type == "llm": + max_batch_rows = int(model_blob_meta.options.get("batch_size", 1)) service_function_sql = client.create_or_replace_service_function( service_func_name=self.service_func_name, diff --git a/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template_with_model b/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template_with_model index cd4671cb..1035d80a 100644 --- a/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template_with_model +++ b/snowflake/ml/model/_deploy_client/snowservice/templates/service_spec_template_with_model @@ -3,7 +3,6 @@ spec: - name: ${inference_server_container_name} image: ${image} env: - MODEL_ZIP_STAGE_PATH: ${model_zip_stage_path} TARGET_METHOD: ${target_method} NUM_WORKERS: ${num_workers} SNOWML_USE_GPU: ${use_gpu} diff --git a/snowflake/ml/model/_handlers/BUILD.bazel b/snowflake/ml/model/_handlers/BUILD.bazel index f0ef7019..158867fb 100644 --- a/snowflake/ml/model/_handlers/BUILD.bazel +++ b/snowflake/ml/model/_handlers/BUILD.bazel @@ -148,3 +148,19 @@ py_library( "//snowflake/ml/model/models:huggingface_pipeline", ], ) + +py_library( + name = "llm", + srcs = ["llm.py"], + deps = [ + ":_base", + "//snowflake/ml/_internal:env_utils", + "//snowflake/ml/_internal:file_utils", + "//snowflake/ml/model:_model_meta", + "//snowflake/ml/model:custom_model", + "//snowflake/ml/model:model_signature", + "//snowflake/ml/model:type_hints", + "//snowflake/ml/model/_signatures:core", + "//snowflake/ml/model/models:llm_model", + ], +) diff --git a/snowflake/ml/model/_handlers/llm.py b/snowflake/ml/model/_handlers/llm.py new file mode 100644 index 00000000..94a3fa06 --- /dev/null +++ b/snowflake/ml/model/_handlers/llm.py @@ -0,0 +1,178 @@ +import os +from typing import Optional, cast + +import cloudpickle +import pandas as pd +from packaging import requirements +from typing_extensions import TypeGuard, Unpack + +from snowflake.ml._internal import env_utils, file_utils +from snowflake.ml.model import ( + _model_meta as model_meta_api, + custom_model, + type_hints as model_types, +) +from snowflake.ml.model._handlers import _base +from snowflake.ml.model._signatures import core +from snowflake.ml.model.models import llm + + +class _LLMHandler(_base._ModelHandler[llm.LLM]): + handler_type = "llm" + MODEL_BLOB_DIR = "model" + LLM_META = "llm_meta" + is_auto_signature = True + + @staticmethod + def can_handle( + model: model_types.SupportedModelType, + ) -> TypeGuard[llm.LLM]: + return isinstance(model, llm.LLM) + + @staticmethod + def cast_model( + model: model_types.SupportedModelType, + ) -> llm.LLM: + assert isinstance(model, llm.LLM) + return cast(llm.LLM, model) + + @staticmethod + def _save_model( + name: str, + model: llm.LLM, + model_meta: model_meta_api.ModelMetadata, + model_blobs_dir_path: str, + sample_input: Optional[model_types.SupportedDataType] = None, + is_sub_model: Optional[bool] = False, + **kwargs: Unpack[model_types.BaseModelSaveOption], + ) -> None: + assert not is_sub_model, "LLM can not be sub-model." + model_blob_path = os.path.join(model_blobs_dir_path, name) + os.makedirs(model_blob_path, exist_ok=True) + model_blob_dir_path = os.path.join(model_blob_path, _LLMHandler.MODEL_BLOB_DIR) + model_meta.cuda_version = model_meta_api._DEFAULT_CUDA_VERSION + sig = core.ModelSignature( + inputs=[ + core.FeatureSpec(name="input", dtype=core.DataType.STRING), + ], + outputs=[ + core.FeatureSpec(name="generated_text", dtype=core.DataType.STRING), + ], + ) + model_meta._signatures = {"infer": sig} + assert os.path.isdir(model.model_id_or_path), "Only model dir is supported for now." + file_utils.copytree(model.model_id_or_path, model_blob_dir_path) + with open( + os.path.join(model_blob_dir_path, _LLMHandler.LLM_META), + "wb", + ) as f: + cloudpickle.dump(model, f) + + base_meta = model_meta_api._ModelBlobMetadata( + name=name, + model_type=_LLMHandler.handler_type, + path=_LLMHandler.MODEL_BLOB_DIR, + options={ + "batch_size": str(model.max_batch_size), + }, + ) + model_meta.models[name] = base_meta + pkgs_requirements = [ + model_meta_api.Dependency(conda_name="transformers", pip_req="transformers"), + model_meta_api.Dependency(conda_name="pytorch", pip_req="torch==2.0.1"), + ] + if model.model_type == llm.SupportedLLMType.LLAMA_MODEL_TYPE: + pkgs_requirements = [ + model_meta_api.Dependency(conda_name="sentencepiece", pip_req="sentencepiece"), + model_meta_api.Dependency(conda_name="protobuf", pip_req="protobuf"), + *pkgs_requirements, + ] + model_meta._include_if_absent(pkgs_requirements) + # Recent peft versions are only available in PYPI. + env_utils.append_requirement_list( + model_meta._pip_requirements, + requirements.Requirement("peft==0.5.0"), + ) + + @staticmethod + def _load_model( + name: str, + model_meta: model_meta_api.ModelMetadata, + model_blobs_dir_path: str, + **kwargs: Unpack[model_types.ModelLoadOption], + ) -> llm.LLM: + model_blob_path = os.path.join(model_blobs_dir_path, name) + if not hasattr(model_meta, "models"): + raise ValueError("Ill model metadata found.") + model_blobs_metadata = model_meta.models + if name not in model_blobs_metadata: + raise ValueError(f"Blob of model {name} does not exist.") + model_blob_metadata = model_blobs_metadata[name] + model_blob_filename = model_blob_metadata.path + model_blob_dir_path = os.path.join(model_blob_path, model_blob_filename) + assert model_blob_dir_path, "It must be a directory." + with open(os.path.join(model_blob_dir_path, _LLMHandler.LLM_META), "rb") as f: + m = cloudpickle.load(f) + assert isinstance(m, llm.LLM) + # Switch to local path + m.model_id_or_path = model_blob_dir_path + return m + + @staticmethod + def _load_as_custom_model( + name: str, + model_meta: model_meta_api.ModelMetadata, + model_blobs_dir_path: str, + **kwargs: Unpack[model_types.ModelLoadOption], + ) -> custom_model.CustomModel: + raw_model = _LLMHandler._load_model( + name, + model_meta, + model_blobs_dir_path, + **kwargs, + ) + import peft + import transformers + + hub_kwargs = { + "revision": raw_model.revision, + "token": raw_model.token, + } + model_dir_path = raw_model.model_id_or_path + hf_model = peft.AutoPeftModelForCausalLM.from_pretrained( # type: ignore[attr-defined] + model_dir_path, + device_map="auto", + torch_dtype="auto", + **hub_kwargs, + ) + peft_config = peft.PeftConfig.from_pretrained(model_dir_path) # type: ignore[attr-defined] + base_model_path = peft_config.base_model_name_or_path + tokenizer = transformers.AutoTokenizer.from_pretrained( + base_model_path, + padding_side="right", + use_fast=False, + **hub_kwargs, + ) + hf_model.eval() + + if not tokenizer.pad_token: + tokenizer.pad_token = tokenizer.eos_token + # TODO(lhw): migrate away from hf pipeline + pipe = transformers.pipeline( + task="text-generation", + model=hf_model, + tokenizer=tokenizer, + batch_size=raw_model.max_batch_size, + ) + + class _LLMCustomModel(custom_model.CustomModel): + @custom_model.inference_api + def infer(self, X: pd.DataFrame) -> pd.DataFrame: + input_data = X.to_dict("list")["input"] + res = pipe(input_data, return_full_text=False) + # TODO(lhw): Assume single beam only. + return pd.DataFrame({"generated_text": [output[0]["generated_text"] for output in res]}) + + llm_custom = _LLMCustomModel(custom_model.ModelContext()) + + return llm_custom diff --git a/snowflake/ml/model/_signatures/core.py b/snowflake/ml/model/_signatures/core.py index 2f5287d0..1df982fc 100644 --- a/snowflake/ml/model/_signatures/core.py +++ b/snowflake/ml/model/_signatures/core.py @@ -1,4 +1,5 @@ import textwrap +import warnings from abc import ABC, abstractmethod from enum import Enum from typing import ( @@ -140,7 +141,23 @@ def from_snowpark_type(cls, snowpark_type: spt.DataType) -> "DataType": # Fallback for decimal type. if isinstance(snowpark_type, spt.DecimalType): if snowpark_type.scale == 0: + warnings.warn( + f"Warning: Type {snowpark_type}" + " is being automatically converted to INT64 in the Snowpark DataFrame. " + "This automatic conversion may lead to potential precision loss and rounding errors. " + "If you wish to prevent this conversion, you should manually perform " + "the necessary data type conversion." + ) return DataType.INT64 + else: + warnings.warn( + f"Warning: Type {snowpark_type}" + " is being automatically converted to DOUBLE in the Snowpark DataFrame. " + "This automatic conversion may lead to potential precision loss and rounding errors. " + "If you wish to prevent this conversion, you should manually perform " + "the necessary data type conversion." + ) + return DataType.DOUBLE raise snowml_exceptions.SnowflakeMLException( error_code=error_codes.NOT_IMPLEMENTED, original_exception=NotImplementedError(f"Type {snowpark_type} is not supported as a DataType."), diff --git a/snowflake/ml/model/_signatures/core_test.py b/snowflake/ml/model/_signatures/core_test.py index f62329e5..897ac23f 100644 --- a/snowflake/ml/model/_signatures/core_test.py +++ b/snowflake/ml/model/_signatures/core_test.py @@ -25,13 +25,7 @@ def test_snowpark_type(self) -> None: self.assertEqual(core.DataType.FLOAT, core.DataType.from_snowpark_type(spt.FloatType())) self.assertEqual(core.DataType.DOUBLE, core.DataType.from_snowpark_type(spt.DoubleType())) - with exception_utils.assert_snowml_exceptions( - self, - expected_original_error_type=NotImplementedError, - expected_regex="Type .+ is not supported as a DataType.", - ): - core.DataType.from_snowpark_type(spt.DecimalType(38, 6)) - + self.assertEqual(core.DataType.DOUBLE, core.DataType.from_snowpark_type(spt.DecimalType(38, 6))) self.assertEqual(core.DataType.BOOL, core.DataType.from_snowpark_type(spt.BooleanType())) self.assertEqual(core.DataType.STRING, core.DataType.from_snowpark_type(spt.StringType())) self.assertEqual(core.DataType.BYTES, core.DataType.from_snowpark_type(spt.BinaryType())) diff --git a/snowflake/ml/model/models/BUILD.bazel b/snowflake/ml/model/models/BUILD.bazel index e452497b..bbe59851 100644 --- a/snowflake/ml/model/models/BUILD.bazel +++ b/snowflake/ml/model/models/BUILD.bazel @@ -7,8 +7,20 @@ py_library( srcs = ["huggingface_pipeline.py"], ) +py_library( + name = "llm_model", + srcs = ["llm.py"], +) + py_test( name = "huggingface_pipeline_test", srcs = ["huggingface_pipeline_test.py"], deps = [":huggingface_pipeline"], ) + +py_test( + name = "llm_test", + srcs = ["llm_test.py"], + compatible_with_snowpark = False, + deps = [":llm_model"], +) diff --git a/snowflake/ml/model/models/llm.py b/snowflake/ml/model/models/llm.py new file mode 100644 index 00000000..52488852 --- /dev/null +++ b/snowflake/ml/model/models/llm.py @@ -0,0 +1,75 @@ +import os +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Set + +_PEFT_CONFIG_NAME = "adapter_config.json" + + +class SupportedLLMType(Enum): + LLAMA_MODEL_TYPE = "llama" + OPT_MODEL_TYPE = "opt" + + @classmethod + def valid_values(cls) -> Set[str]: + return {member.value for member in cls} + + +@dataclass(frozen=True) +class LLMOptions: + """ + This is the option class for LLM. + + Args: + revision: Revision of HF model. Defaults to None. + token: The token to use as HTTP bearer authorization for remote files. Defaults to None. + max_batch_size: Max batch size allowed for single inferenced. Defaults to 1. + """ + + revision: Optional[str] = field(default=None) + token: Optional[str] = field(default=None) + max_batch_size: int = field(default=1) + + +class LLM: + def __init__( + self, + model_id_or_path: str, + *, + options: Optional[LLMOptions] = None, + ) -> None: + """ + + Args: + model_id_or_path: Local dir to PEFT weights. + options: Options for LLM. Defaults to be None. + + Raises: + ValueError: When unsupported. + """ + if not (os.path.isdir(model_id_or_path) and os.path.isfile(os.path.join(model_id_or_path, _PEFT_CONFIG_NAME))): + raise ValueError("Peft config is not found.") + import peft + import transformers + + if not options: + options = LLMOptions() + + hub_kwargs = { + "revision": options.revision, + "token": options.token, + } + peft_config = peft.PeftConfig.from_pretrained(model_id_or_path, **hub_kwargs) # type: ignore[attr-defined] + if peft_config.peft_type != peft.PeftType.LORA: # type: ignore[attr-defined] + raise ValueError("Only LORA is supported.") + if peft_config.task_type != peft.TaskType.CAUSAL_LM: # type: ignore[attr-defined] + raise ValueError("Only CAUSAL_LM is supported.") + base_model = peft_config.base_model_name_or_path + base_config = transformers.AutoConfig.from_pretrained(base_model, **hub_kwargs) + assert base_config.model_type in SupportedLLMType.valid_values(), f"{base_config.model_type} is not supported." + + self.model_id_or_path = model_id_or_path + self.token = options.token + self.revision = options.revision + self.max_batch_size = options.max_batch_size + self.model_type = base_config.model_type diff --git a/snowflake/ml/model/models/llm_test.py b/snowflake/ml/model/models/llm_test.py new file mode 100644 index 00000000..ae453518 --- /dev/null +++ b/snowflake/ml/model/models/llm_test.py @@ -0,0 +1,37 @@ +import os +import tempfile + +from absl.testing import absltest + +from snowflake.ml.model.models import llm + + +class LLMTest(absltest.TestCase): + @classmethod + def setUpClass(self) -> None: + self.cache_dir = tempfile.TemporaryDirectory() + self._original_hf_home = os.getenv("HF_HOME", None) + os.environ["HF_HOME"] = self.cache_dir.name + + @classmethod + def tearDownClass(self) -> None: + if self._original_hf_home: + os.environ["HF_HOME"] = self._original_hf_home + else: + del os.environ["HF_HOME"] + self.cache_dir.cleanup() + + def test_llm(self) -> None: + import peft + + ft_model = peft.AutoPeftModelForCausalLM.from_pretrained( # type: ignore[attr-defined] + "peft-internal-testing/tiny-OPTForCausalLM-lora", + device_map="auto", + ) + tmp_dir = self.create_tempdir().full_path + ft_model.save_pretrained(tmp_dir) + llm.LLM(model_id_or_path=tmp_dir) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/model/type_hints.py b/snowflake/ml/model/type_hints.py index ba747b11..9c58e344 100644 --- a/snowflake/ml/model/type_hints.py +++ b/snowflake/ml/model/type_hints.py @@ -20,6 +20,7 @@ import snowflake.ml.model.custom_model import snowflake.ml.model.models.huggingface_pipeline + import snowflake.ml.model.models.llm import snowflake.snowpark from snowflake.ml.modeling.framework import base # noqa: F401 @@ -70,6 +71,7 @@ "mlflow.pyfunc.PyFuncModel", "transformers.Pipeline", "snowflake.ml.model.models.huggingface_pipeline.HuggingFacePipelineModel", + "snowflake.ml.model.models.llm.LLM", ] SupportedModelType = Union[ diff --git a/snowflake/ml/modeling/_internal/BUILD.bazel b/snowflake/ml/modeling/_internal/BUILD.bazel index 3d28e22e..7e663da9 100644 --- a/snowflake/ml/modeling/_internal/BUILD.bazel +++ b/snowflake/ml/modeling/_internal/BUILD.bazel @@ -46,3 +46,11 @@ py_test( ":estimator_utils", ], ) + +py_test( + name = "snowpark_handlers_test", + srcs = ["snowpark_handlers_test.py"], + deps = [ + ":estimator_utils", + ], +) diff --git a/snowflake/ml/modeling/_internal/estimator_protocols.py b/snowflake/ml/modeling/_internal/estimator_protocols.py index a1acde86..d6cf59a9 100644 --- a/snowflake/ml/modeling/_internal/estimator_protocols.py +++ b/snowflake/ml/modeling/_internal/estimator_protocols.py @@ -1,6 +1,7 @@ -from typing import List, Optional, Protocol +from typing import Any, Dict, List, Optional, Protocol, Union import pandas as pd +from sklearn import model_selection from snowflake.snowpark import DataFrame, Session @@ -115,3 +116,17 @@ def score_snowpark( sample_weight_col: Optional[str], ) -> float: raise NotImplementedError + + def fit_search_snowpark( + self, + param_list: Union[Dict[str, Any], List[Dict[str, Any]]], + dataset: DataFrame, + session: Session, + estimator: Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV], + dependencies: List[str], + udf_imports: List[str], + input_cols: List[str], + label_cols: List[str], + sample_weight_col: Optional[str], + ) -> Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV]: + raise NotImplementedError diff --git a/snowflake/ml/modeling/_internal/estimator_utils.py b/snowflake/ml/modeling/_internal/estimator_utils.py index 5b345535..dbe6b567 100644 --- a/snowflake/ml/modeling/_internal/estimator_utils.py +++ b/snowflake/ml/modeling/_internal/estimator_utils.py @@ -7,6 +7,7 @@ from snowflake.ml._internal.exceptions import error_codes, exceptions from snowflake.ml.modeling.framework._utils import to_native_format from snowflake.ml.modeling.framework.base import BaseTransformer +from snowflake.snowpark import Session def validate_sklearn_args(args: Dict[str, Tuple[Any, Any, bool]], klass: type) -> Dict[str, Any]: @@ -107,3 +108,27 @@ def check(self: BaseTransformer) -> TypeGuard[Callable[..., object]]: return callable(getattr(self._sklearn_object, attr, None)) return check + + +def if_single_node(session: Session) -> bool: + """Retrieve the current session's warehouse type and warehouse size, and depends on those information + to identify if it is single node or not + + Args: + session (Session): session object that is used by user currently + + Returns: + bool: single node or not. True stands for yes. + """ + warehouse_name = session.get_current_warehouse() + if warehouse_name: + warehouse_name = warehouse_name.replace('"', "") + df = session.sql(f"SHOW WAREHOUSES like '{warehouse_name}';")['"type"', '"size"'].collect()[0] + # filter out the conditions when it is single node + single_node: bool = (df[0] == "SNOWPARK-OPTIMIZED" and df[1] == "Medium") or ( + df[0] == "STANDARD" and df[1] == "X-Small" + ) + return single_node + # If current session cannot retrieve the warehouse name back, + # Default as True; Let HPO fall back to stored procedure implementation + return True diff --git a/snowflake/ml/modeling/_internal/snowpark_handlers.py b/snowflake/ml/modeling/_internal/snowpark_handlers.py index add12188..b78aded3 100644 --- a/snowflake/ml/modeling/_internal/snowpark_handlers.py +++ b/snowflake/ml/modeling/_internal/snowpark_handlers.py @@ -5,12 +5,13 @@ import os import posixpath import sys -from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from uuid import uuid4 import cloudpickle as cp import numpy as np import pandas as pd +import sklearn from scipy.stats import rankdata from sklearn import model_selection @@ -21,7 +22,7 @@ exceptions, modeling_error_messages, ) -from snowflake.ml._internal.utils import identifier +from snowflake.ml._internal.utils import identifier, snowpark_dataframe_utils from snowflake.ml._internal.utils.query_result_checker import SqlResultValidator from snowflake.ml._internal.utils.temp_file_utils import ( cleanup_temp_files, @@ -37,7 +38,7 @@ TempObjectType, random_name_for_temp_object, ) -from snowflake.snowpark.functions import col, pandas_udf, sproc, udtf +from snowflake.snowpark.functions import col, pandas_udf, sproc from snowflake.snowpark.stored_procedure import StoredProcedure from snowflake.snowpark.types import ( FloatType, @@ -53,7 +54,9 @@ class WrapperProvider: - imports: List[str] = [] + def __init__(self) -> None: + self.imports: List[str] = [] + self.dependencies: List[str] = [] def get_fit_wrapper_function( self, @@ -134,15 +137,63 @@ def fit_wrapper_function( class SklearnWrapperProvider(WrapperProvider): - imports: List[str] = ["sklearn"] + def __init__(self) -> None: + import sklearn + + self.imports: List[str] = ["sklearn"] + + # TODO(snandamuri): Replace cloudpickle with joblib after latest version of joblib is added to snowflake conda. + self.dependencies: List[str] = [ + f"numpy=={np.__version__}", + f"scikit-learn=={sklearn.__version__}", + f"cloudpickle=={cp.__version__}", + ] class XGBoostWrapperProvider(WrapperProvider): - imports: List[str] = ["xgboost"] + def __init__(self) -> None: + import xgboost + + self.imports: List[str] = ["xgboost"] + self.dependencies = [ + f"numpy=={np.__version__}", + f"xgboost=={xgboost.__version__}", + f"cloudpickle=={cp.__version__}", + ] class LightGBMWrapperProvider(WrapperProvider): - imports: List[str] = ["lightgbm"] + def __init__(self) -> None: + import lightgbm + + self.imports: List[str] = ["lightgbm"] + self.dependencies = [ + f"numpy=={np.__version__}", + f"lightgbm=={lightgbm.__version__}", + f"cloudpickle=={cp.__version__}", + ] + + +class SklearnModelSelectionWrapperProvider(WrapperProvider): + def __init__(self) -> None: + import xgboost + + self.imports: List[str] = ["sklearn", "xgboost"] + self.dependencies = [ + f"numpy=={np.__version__}", + f"scikit-learn=={sklearn.__version__}", + f"cloudpickle=={cp.__version__}", + f"xgboost=={xgboost.__version__}", + ] + + # Only include lightgbm in the dependencies if it is installed. + try: + import lightgbm + except ModuleNotFoundError: + pass + else: + self.imports.append("lightgbm") + self.dependencies.append(f"lightgbm=={lightgbm.__version__}") def _get_rand_id() -> str: @@ -223,6 +274,8 @@ def fit_snowpark( label_cols: List[str], sample_weight_col: Optional[str], ) -> Any: + dataset = snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) + # If we are already in a stored procedure, no need to kick off another one. if SNOWML_SPROC_ENV in os.environ: statement_params = telemetry.get_function_usage_statement_params( @@ -330,10 +383,13 @@ def batch_inference( pass_through_columns: List[str], expected_output_cols_list: List[str], expected_output_cols_type: str = "", + *args: Any, + **kwargs: Any, ) -> DataFrame: # Register vectorized UDF for batch inference batch_inference_udf_name = random_name_for_temp_object(TempObjectType.FUNCTION) snowpark_cols = dataset.select(input_cols).columns + dataset = snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) statement_params = telemetry.get_function_usage_statement_params( project=_PROJECT, @@ -379,36 +435,43 @@ def vec_batch_infer(ds: PandasSeries[dict]) -> PandasSeries[dict]: # type: igno else: # Just rename the column names to unquoted identifiers. input_df.columns = snowpark_cols # Replace the quoted columns identifier with unquoted column ids. - transformed_numpy_array = getattr(estimator, inference_method)(input_df) - if ( - isinstance(transformed_numpy_array, list) - and len(transformed_numpy_array) > 0 - and isinstance(transformed_numpy_array[0], np.ndarray) + inference_res = getattr(estimator, inference_method)(input_df, *args, **kwargs) + if isinstance(inference_res, list) and len(inference_res) > 0 and isinstance(inference_res[0], np.ndarray): + # In case of multioutput estimators, predict_proba, decision_function etc., functions return a list of + # ndarrays. We need to concatenate them. + transformed_numpy_array = np.concatenate(inference_res, axis=1) + elif ( + isinstance(inference_res, tuple) and len(inference_res) > 0 and isinstance(inference_res[0], np.ndarray) ): - # In case of multioutput estimators, predict_proba(), decision_function(), etc., functions return - # a list of ndarrays. We need to concatenate them. - transformed_numpy_array = np.concatenate(transformed_numpy_array, axis=1) + # In case of kneighbors, functions return a tuple of ndarrays. + transformed_numpy_array = np.stack(inference_res, axis=1) + else: + transformed_numpy_array = inference_res - if len(transformed_numpy_array.shape) == 3: + if (len(transformed_numpy_array.shape) == 3) and inference_method != "kneighbors": # VotingClassifier will return results of shape (n_classifiers, n_samples, n_classes) # when voting = "soft" and flatten_transform = False. We can't handle unflatten transforms, # so we ignore flatten_transform flag and flatten the results. - transformed_numpy_array = np.hstack(transformed_numpy_array) - - if len(transformed_numpy_array.shape) > 1 and transformed_numpy_array.shape[1] != len( - expected_output_cols_list - ): - # HeterogeneousEnsemble's transform method produce results with variying shapes - # from (n_samples, n_estimators) to (n_samples, n_estimators * n_classes). - # It is hard to predict the response shape without using fragile introspection logic. - # So, to avoid that we are packing the results into a dataframe of shape (n_samples, 1) with - # each element being a list. - if len(expected_output_cols_list) != 1: - raise TypeError( - "expected_output_cols_list must be same length as transformed array or " "should be of length 1" + transformed_numpy_array = np.hstack(transformed_numpy_array) # type: ignore[call-overload] + + if len(transformed_numpy_array.shape) > 1: + if transformed_numpy_array.shape[1] != len(expected_output_cols_list): + # HeterogeneousEnsemble's transform method produce results with variying shapes + # from (n_samples, n_estimators) to (n_samples, n_estimators * n_classes). + # It is hard to predict the response shape without using fragile introspection logic. + # So, to avoid that we are packing the results into a dataframe of shape (n_samples, 1) with + # each element being a list. + if len(expected_output_cols_list) != 1: + raise TypeError( + "expected_output_cols_list must be same length as transformed array or " + "should be of length 1" + ) + series = pd.Series(transformed_numpy_array.tolist()) + transformed_pandas_df = pd.DataFrame(series, columns=expected_output_cols_list) + else: + transformed_pandas_df = pd.DataFrame( + transformed_numpy_array.tolist(), columns=expected_output_cols_list ) - series = pd.Series(transformed_numpy_array.tolist()) - transformed_pandas_df = pd.DataFrame(series, columns=expected_output_cols_list) else: transformed_pandas_df = pd.DataFrame(transformed_numpy_array, columns=expected_output_cols_list) @@ -502,6 +565,7 @@ def score_snowpark( label_cols: List[str], sample_weight_col: Optional[str], ) -> float: + dataset = snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) if SNOWML_SPROC_ENV in os.environ: statement_params = telemetry.get_function_usage_statement_params( project=_PROJECT, @@ -636,7 +700,7 @@ def score_wrapper_sproc( return score - def _fit_search_snowpark( + def fit_search_snowpark( self, param_list: Union[Dict[str, Any], List[Dict[str, Any]]], dataset: DataFrame, @@ -647,33 +711,33 @@ def _fit_search_snowpark( input_cols: List[str], label_cols: List[str], sample_weight_col: Optional[str], - ) -> Dict[str, Union[float, Dict[str, Any]]]: + ) -> Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV]: from itertools import product import cachetools from sklearn.base import is_classifier from sklearn.calibration import check_cv - from snowflake.ml._internal.utils.snowpark_dataframe_utils import ( - cast_snowpark_dataframe, - ) - # Create one stage for data and for estimators. temp_stage_name = random_name_for_temp_object(TempObjectType.STAGE) temp_stage_creation_query = f"CREATE OR REPLACE TEMP STAGE {temp_stage_name};" session.sql(temp_stage_creation_query).collect() # Stage data. - dataset = cast_snowpark_dataframe(dataset) + dataset = snowpark_dataframe_utils.cast_snowpark_dataframe(dataset) remote_file_path = f"{temp_stage_name}/{temp_stage_name}.parquet" dataset.write.copy_into_location( # type:ignore[call-overload] remote_file_path, file_format_type="parquet", header=True, overwrite=True ) imports = [f"@{row.name}" for row in session.sql(f"LIST @{temp_stage_name}").collect()] + # Store GridSearchCV's refit variable. If user set it as False, we don't need to refit it again + refit_bool = estimator.refit # Create a temp file and dump the score to that file. estimator_file_name = get_temp_file_path() with open(estimator_file_name, mode="w+b") as local_estimator_file_obj: + # Set GridSearchCV refit as False and fit it again after retrieving the best param + estimator.refit = False cp.dump(estimator, local_estimator_file_obj) stage_estimator_file_name = posixpath.join(temp_stage_name, os.path.basename(estimator_file_name)) statement_params = telemetry.get_function_usage_statement_params( @@ -697,11 +761,13 @@ def _fit_search_snowpark( estimator_location = put_result[0].target imports.append(f"@{temp_stage_name}/{estimator_location}") - cv_sproc_name = random_name_for_temp_object(TempObjectType.PROCEDURE) + search_sproc_name = random_name_for_temp_object(TempObjectType.PROCEDURE) + random_udtf_name = random_name_for_temp_object(TempObjectType.FUNCTION) + random_table_name = random_name_for_temp_object(TempObjectType.TABLE) @sproc( # type: ignore[misc] is_permanent=False, - name=cv_sproc_name, + name=search_sproc_name, packages=dependencies + ["snowflake-snowpark-python", "pyarrow", "fastparquet"], # type: ignore[arg-type] replace=True, session=session, @@ -709,7 +775,7 @@ def _fit_search_snowpark( imports=imports, # type: ignore[arg-type] statement_params=statement_params, ) - def preprocess_cv_ind( + def _distributed_search( session: Session, imports: List[str], stage_estimator_file_name: str, @@ -717,8 +783,11 @@ def preprocess_cv_ind( label_cols: List[str], statement_params: Dict[str, str], ) -> str: + import copy import os import tempfile + import time + from typing import Iterator, List import cloudpickle as cp import pandas as pd @@ -754,216 +823,261 @@ def preprocess_cv_ind( estimator = cp.load(local_estimator_file_obj) cv_orig = check_cv(estimator.cv, y, classifier=is_classifier(estimator.estimator)) - indices = [[train, test] for train, test in cv_orig.split(X, y)] + indices = [test for _, test in cv_orig.split(X, y)] + indices_df = pd.DataFrame({"TEST": indices}) + indices_df = session.create_dataframe(indices_df) + + remote_file_path = f"{temp_stage_name}/indices.parquet" + indices_df.write.copy_into_location( + remote_file_path, file_format_type="parquet", header=True, overwrite=True + ) + imports.extend([f"@{row.name}" for row in session.sql(f"LIST @{temp_stage_name}/indices").collect()]) + + indices_len = len(indices) + + assert estimator is not None + params_to_evaluate = [] + for param_to_eval in list(param_list): + for k, v in param_to_eval.items(): # type: ignore[attr-defined] + param_to_eval[k] = [v] # type: ignore[index] + params_to_evaluate.append([param_to_eval]) + + @cachetools.cached(cache={}) + def _load_data_into_udf() -> Tuple[ + Dict[str, pd.DataFrame], + Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV], + pd.DataFrame, + int, + ]: + import pyarrow.parquet as pq + + data_files = [ + filename + for filename in os.listdir(sys._xoptions["snowflake_import_directory"]) + if filename.startswith(temp_stage_name) + ] + partial_df = [ + pq.read_table(os.path.join(sys._xoptions["snowflake_import_directory"], file_name)).to_pandas() + for file_name in data_files + ] + df = pd.concat(partial_df, ignore_index=True) + + # load estimator + local_estimator_file_path = os.path.join( + sys._xoptions["snowflake_import_directory"], f"{estimator_location}" + ) + with open(local_estimator_file_path, mode="rb") as local_estimator_file_obj: + estimator = cp.load(local_estimator_file_obj) + + # load indices + indices_files = [ + filename + for filename in os.listdir(sys._xoptions["snowflake_import_directory"]) + if filename.startswith("indices") + ] + indices_partial_df = [ + pq.read_table(os.path.join(sys._xoptions["snowflake_import_directory"], file_name)).to_pandas() + for file_name in indices_files + ] + indices = pd.concat(indices_partial_df, ignore_index=True) + + argspec = inspect.getfullargspec(estimator.fit) + args = {"X": df[input_cols]} + + if label_cols: + label_arg_name = "Y" if "Y" in argspec.args else "y" + args[label_arg_name] = df[label_cols].squeeze() + + if sample_weight_col is not None and "sample_weight" in argspec.args: + args["sample_weight"] = df[sample_weight_col].squeeze() + return args, estimator, indices, len(df) + + class SearchCV: + def __init__(self) -> None: + args, estimator, indices, data_length = _load_data_into_udf() + self.args = args + self.estimator = estimator + self.indices = indices + self.data_length = data_length + + def process( + self, params: List[dict], idx: int # type:ignore[type-arg] + ) -> Iterator[Tuple[float, str, str]]: + if hasattr(estimator, "param_grid"): + self.estimator.param_grid = params + else: + self.estimator.param_distributions = params + full_indices = np.array([i for i in range(self.data_length)]) + test_indice = json.loads(self.indices["TEST"][idx]) + train_indice = np.setdiff1d(full_indices, test_indice) + self.estimator.cv = [(train_indice, test_indice)] + self.estimator.fit(**self.args) + binary_cv_results = None + with io.BytesIO() as f: + cp.dump(self.estimator.cv_results_, f) + f.seek(0) + binary_cv_results = f.getvalue().hex() + yield (self.estimator.best_score_, json.dumps(self.estimator.best_params_), binary_cv_results) + + def end_partition(self) -> None: + ... + + session.udtf.register( + SearchCV, + output_schema=StructType( + [ + StructField("BEST_SCORE", FloatType()), + StructField("BEST_PARAMS", StringType()), + StructField("CV_RESULTS", StringType()), + ] + ), + input_types=[VariantType(), IntegerType()], + name=random_udtf_name, + packages=dependencies + ["pyarrow", "fastparquet"], # type: ignore[arg-type] + replace=True, + is_permanent=False, + imports=imports, # type: ignore[arg-type] + statement_params=statement_params, + ) - local_cv_file = tempfile.NamedTemporaryFile(delete=True) - local_cv_file_name = local_cv_file.name - local_cv_file.close() - with open(local_cv_file_name, mode="w+b") as local_cv_file_obj: - cp.dump(indices, local_cv_file_obj) + HP_TUNING = F.table_function(random_udtf_name) + + idx_length = int(indices_len) + params_length = len(params_to_evaluate) + idxs = [i for i in range(idx_length)] + params, param_indices = [], [] + for param, param_idx in product(params_to_evaluate, idxs): + params.append(param) + param_indices.append(param_idx) + + pd_df = pd.DataFrame( + { + "PARAMS": params, + "TRAIN_IND": param_indices, + "PARAM_INDEX": [i for i in range(idx_length * params_length)], + } + ) + df = session.create_dataframe(pd_df) + results = df.select( + F.cast(df["PARAM_INDEX"], IntegerType()).as_("PARAM_INDEX"), + (HP_TUNING(df["PARAMS"], df["TRAIN_IND"]).over(partition_by=df["PARAM_INDEX"])), + ) - put_result = session.file.put( - local_cv_file_name, + results.write.saveAsTable(random_table_name, mode="overwrite", table_type="temporary") + table_result = session.table(random_table_name).sort(col("PARAM_INDEX")) + + # cv_result maintains the original order + cv_results_ = dict() + for i, val in enumerate(table_result.select("CV_RESULTS").collect()): + # retrieved string had one more double quote in the front and end of the string. + # use [1:-1] to remove the extra double quotes + hex_str = bytes.fromhex(val[0]) + with io.BytesIO(hex_str) as f_reload: + each_cv_result = cp.load(f_reload) + for k, v in each_cv_result.items(): + cur_cv = i % idx_length + key = k + if k == "split0_test_score": + key = f"split{cur_cv}_test_score" + elif k.startswith("param"): + if cur_cv != 0: + key = False + if key: + if key not in cv_results_: + cv_results_[key] = v + else: + cv_results_[key] = np.concatenate([cv_results_[key], v]) + + # Use numpy to re-calculate all the information in cv_results_ again + # Generally speaking, reshape all the results into the (3, idx_length, params_length) shape, + # and average them by the idx_length; + # idx_length is the number of cv folds; params_length is the number of parameter combinations + fit_score_test_matrix = np.stack( + ( + np.reshape(cv_results_["mean_fit_time"], (idx_length, -1)), # idx_length x params_length + np.reshape(cv_results_["mean_score_time"], (idx_length, -1)), + np.reshape( + np.concatenate([cv_results_[f"split{cur_cv}_test_score"] for cur_cv in range(idx_length)]), + (idx_length, -1), + ), + ) + ) + mean_fit_score_test_matrix = np.mean(fit_score_test_matrix, axis=1) + std_fit_score_test_matrix = np.std(fit_score_test_matrix, axis=1) + cv_results_["std_fit_time"] = std_fit_score_test_matrix[0] + cv_results_["mean_fit_time"] = mean_fit_score_test_matrix[0] + cv_results_["std_score_time"] = std_fit_score_test_matrix[1] + cv_results_["mean_score_time"] = mean_fit_score_test_matrix[1] + cv_results_["std_test_score"] = std_fit_score_test_matrix[2] + cv_results_["mean_test_score"] = mean_fit_score_test_matrix[2] + # re-compute the ranking again with mean_test_score + cv_results_["rank_test_score"] = rankdata(-cv_results_["mean_test_score"], method="min") + # best param is the highest ranking (which is 1) and we choose the first time ranking 1 appeared + best_param_index = np.where(cv_results_["rank_test_score"] == 1)[0][0] + + estimator.best_params_ = cv_results_["params"][best_param_index] + estimator.best_score_ = cv_results_["mean_test_score"][best_param_index] + estimator.cv_results_ = cv_results_ + + if refit_bool: + estimator.best_estimator_ = copy.deepcopy( + copy.deepcopy(estimator.estimator).set_params(**estimator.best_params_) + ) + # Let the sproc use all cores to refit. + estimator.n_jobs = -1 if not estimator.n_jobs else estimator.n_jobs + + # process the input as args + argspec = inspect.getfullargspec(estimator.fit) + args = {"X": X} + if label_cols: + label_arg_name = "Y" if "Y" in argspec.args else "y" + args[label_arg_name] = y + if sample_weight_col is not None and "sample_weight" in argspec.args: + args["sample_weight"] = df[sample_weight_col].squeeze() + estimator.refit = True + refit_start_time = time.time() + estimator.best_estimator_.fit(**args) + refit_end_time = time.time() + estimator.refit_time_ = refit_end_time - refit_start_time + + local_result_file = tempfile.NamedTemporaryFile(delete=True) + local_result_file_name = local_result_file.name + local_result_file.close() + + with open(local_result_file_name, mode="w+b") as local_result_file_obj: + cp.dump(estimator, local_result_file_obj) + + session.file.put( + local_result_file_name, temp_stage_name, auto_compress=False, overwrite=True, + statement_params=statement_params, ) - ind_location = put_result[0].target - return ind_location + "|" + str(len(indices)) - ind_location, indices_len = preprocess_cv_ind( + # Note: you can add something like + "|" + str(df) to the return string + # to pass debug information to the caller. + return str(os.path.basename(local_result_file_name)) + + sproc_export_file_name = _distributed_search( session, imports, stage_estimator_file_name, input_cols, label_cols, statement_params, - ).split("|") - imports.append(f"@{temp_stage_name}/{ind_location}") - - # Create estimators with subset of param grid. - # TODO: Decide how to choose parallelization factor. - assert estimator is not None - params_to_evaluate = [] - for param_to_eval in list(param_list): - for k, v in param_to_eval.items(): # type: ignore[attr-defined] - param_to_eval[k] = [v] # type: ignore[index] - params_to_evaluate.append([param_to_eval]) - - @cachetools.cached(cache={}) - def _load_data_into_udf() -> Tuple[ - Dict[str, pd.DataFrame], - Union[model_selection.GridSearchCV, model_selection.RandomizedSearchCV], - List[List[int]], - ]: - import pyarrow.parquet as pq - - data_files = [ - filename - for filename in os.listdir(sys._xoptions["snowflake_import_directory"]) - if filename.startswith(temp_stage_name) - ] - partial_df = [ - pq.read_table(os.path.join(sys._xoptions["snowflake_import_directory"], file_name)).to_pandas() - for file_name in data_files - ] - df = pd.concat(partial_df, ignore_index=True) - - # load estimator - local_estimator_file_path = os.path.join( - sys._xoptions["snowflake_import_directory"], f"{estimator_location}" - ) - with open(local_estimator_file_path, mode="rb") as local_estimator_file_obj: - estimator = cp.load(local_estimator_file_obj) - - # load index file - local_ind_file_path = os.path.join(sys._xoptions["snowflake_import_directory"], f"{ind_location}") - with open(local_ind_file_path, mode="rb") as local_ind_file_obj: - indices = cp.load(local_ind_file_obj) - - argspec = inspect.getfullargspec(estimator.fit) - args = {"X": df[input_cols]} - - if label_cols: - label_arg_name = "Y" if "Y" in argspec.args else "y" - args[label_arg_name] = df[label_cols].squeeze() - - if sample_weight_col is not None and "sample_weight" in argspec.args: - args["sample_weight"] = df[sample_weight_col].squeeze() - return args, estimator, indices - - random_udtf_name = random_name_for_temp_object(TempObjectType.TABLE_FUNCTION) - statement_params = telemetry.get_function_usage_statement_params( - project=_PROJECT, - subproject=self._subproject, - function_name=telemetry.get_statement_params_full_func_name( - inspect.currentframe(), self.__class__.__name__ - ), - api_calls=[udtf], ) - @udtf( # type: ignore[arg-type] - output_schema=StructType( - [ - StructField("BEST_SCORE", FloatType()), - StructField("BEST_PARAMS", StringType()), - StructField("CV_RESULTS", StringType()), - ] - ), - input_types=[VariantType(), IntegerType()], - name=random_udtf_name, - packages=dependencies + ["pyarrow", "fastparquet"], # type: ignore[arg-type] - replace=True, - is_permanent=False, - imports=imports, # type: ignore[arg-type] + local_estimator_path = get_temp_file_path() + session.file.get( + posixpath.join(temp_stage_name, sproc_export_file_name), + local_estimator_path, statement_params=statement_params, - session=session, - ) - class SearchCV: - def __init__(self) -> None: - args, estimator, indices = _load_data_into_udf() - self.args = args - self.estimator = estimator - self.indices = indices - - def process( - self, params: List[dict], idx: int # type:ignore[type-arg] - ) -> Iterator[Tuple[float, str, str]]: - if hasattr(estimator, "param_grid"): - self.estimator.param_grid = params - else: - self.estimator.param_distributions = params - - self.estimator.cv = [(self.indices[idx][0], self.indices[idx][1])] - self.estimator.fit(**self.args) - # TODO: handle the case of estimator size > maximum column size or to just serialize and return score. - binary_cv_results = None - with io.BytesIO() as f: - cp.dump(self.estimator.cv_results_, f) - f.seek(0) - binary_cv_results = f.getvalue().hex() - yield (self.estimator.best_score_, json.dumps(self.estimator.best_params_), binary_cv_results) - - def end_partition(self) -> None: - ... - - HP_TUNING = F.table_function(random_udtf_name) - - idx_length = int(indices_len) - params_length = len(params_to_evaluate) - idxs = [i for i in range(idx_length)] - params, param_indices = [], [] - for param, param_idx in product(params_to_evaluate, idxs): - params.append(param) - param_indices.append(param_idx) - - pd_df = pd.DataFrame( - { - "PARAMS": params, - "TRAIN_IND": param_indices, - "PARAM_INDEX": [i for i in range(idx_length * params_length)], - } - ) - df = session.create_dataframe(pd_df) - results = df.select( - F.cast(df["PARAM_INDEX"], IntegerType()).as_("PARAM_INDEX"), - (HP_TUNING(df["PARAMS"], df["TRAIN_IND"]).over(partition_by=df["PARAM_INDEX"])), ) - random_table_name = random_name_for_temp_object(TempObjectType.TABLE) - results.write.saveAsTable(random_table_name, mode="overwrite", table_type="temporary") - table_result = session.table(random_table_name).sort(col("PARAM_INDEX")) - - # cv_result maintains the original order - cv_results_ = dict() - for i, val in enumerate(table_result.select("CV_RESULTS").collect()): - # retrieved string had one more double quote in the front and end of the string. - # use [1:-1] to remove the extra double quotes - hex_str = bytes.fromhex(val[0]) - with io.BytesIO(hex_str) as f_reload: - each_cv_result = cp.load(f_reload) - for k, v in each_cv_result.items(): - cur_cv = i % idx_length - key = k - if k == "split0_test_score": - key = f"split{cur_cv}_test_score" - elif k.startswith("param"): - if cur_cv != 0: - key = False - if key: - if key not in cv_results_: - cv_results_[key] = v - else: - cv_results_[key] = np.concatenate([cv_results_[key], v]) - - # Use numpy to re-calculate all the information in cv_results_ again - # Generally speaking, reshape all the results into the (3, idx_length, params_length) shape, - # and average them by the idx_length; - # idx_length is the number of cv folds; params_length is the number of parameter combinations - fit_score_test_matrix = np.stack( - ( - np.reshape(cv_results_["mean_fit_time"], (idx_length, -1)), # idx_length x params_length - np.reshape(cv_results_["mean_score_time"], (idx_length, -1)), - np.reshape( - np.concatenate([cv_results_[f"split{cur_cv}_test_score"] for cur_cv in range(idx_length)]), - (idx_length, -1), - ), - ) - ) - mean_fit_score_test_matrix = np.mean(fit_score_test_matrix, axis=1) - std_fit_score_test_matrix = np.std(fit_score_test_matrix, axis=1) - cv_results_["std_fit_time"] = std_fit_score_test_matrix[0] - cv_results_["mean_fit_time"] = mean_fit_score_test_matrix[0] - cv_results_["std_score_time"] = std_fit_score_test_matrix[1] - cv_results_["mean_score_time"] = mean_fit_score_test_matrix[1] - cv_results_["std_test_score"] = std_fit_score_test_matrix[2] - cv_results_["mean_test_score"] = mean_fit_score_test_matrix[2] - # re-compute the ranking again with mean_test_score - cv_results_["rank_test_score"] = rankdata(-cv_results_["mean_test_score"], method="min") - # best param is the highest ranking (which is 1) and we choose the first time ranking 1 appeared - best_param_index = np.where(cv_results_["rank_test_score"] == 1)[0][0] - - # assign the parameter to a dict - best_param = cv_results_["params"][best_param_index] - best_score = cv_results_["mean_test_score"][best_param_index] - return {"best_param": best_param, "best_score": best_score, "cv_results": cv_results_} + with open(os.path.join(local_estimator_path, sproc_export_file_name), mode="r+b") as result_file_obj: + fit_estimator = cp.load(result_file_obj) + + cleanup_temp_files([local_estimator_path]) + + return fit_estimator diff --git a/snowflake/ml/modeling/_internal/snowpark_handlers_test.py b/snowflake/ml/modeling/_internal/snowpark_handlers_test.py new file mode 100644 index 00000000..e17803b7 --- /dev/null +++ b/snowflake/ml/modeling/_internal/snowpark_handlers_test.py @@ -0,0 +1,67 @@ +from typing import Any +from unittest import mock + +from absl.testing import absltest, parameterized + +from snowflake.ml.modeling._internal.snowpark_handlers import ( + LightGBMWrapperProvider, + SklearnModelSelectionWrapperProvider, + SklearnWrapperProvider, + XGBoostWrapperProvider, +) + + +class SnowparkHandlersUnitTest(parameterized.TestCase): + def test_sklearn_model_selection_wrapper_provider_lightgbm_installed(self) -> None: + orig_import = __import__ + + def import_mock(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "lightgbm": + lightgbm_mock = mock.MagicMock() + lightgbm_mock.__version__ = "1" + return lightgbm_mock + return orig_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=import_mock): + provider = SklearnModelSelectionWrapperProvider() + + self.assertEqual(provider.imports, ["sklearn", "xgboost", "lightgbm"]) + + def test_sklearn_model_selection_wrapper_provider_lightgbm_not_installed(self) -> None: + orig_import = __import__ + + def import_mock(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "lightgbm": + raise ModuleNotFoundError + return orig_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=import_mock): + provider = SklearnModelSelectionWrapperProvider() + + self.assertEqual(provider.imports, ["sklearn", "xgboost"]) + + def test_xgboost_wrapper_provider(self) -> None: + provider = XGBoostWrapperProvider() + self.assertEqual(provider.imports, ["xgboost"]) + + def test_sklearn_wrapper_provider(self) -> None: + provider = SklearnWrapperProvider() + self.assertEqual(provider.imports, ["sklearn"]) + + def test_lightgbm_wrapper_provider(self) -> None: + orig_import = __import__ + + def import_mock(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "lightgbm": + lightgbm_mock = mock.MagicMock() + lightgbm_mock.__version__ = "1" + return lightgbm_mock + return orig_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=import_mock): + provider = LightGBMWrapperProvider() + self.assertEqual(provider.imports, ["lightgbm"]) + + +if __name__ == "__main__": + absltest.main() diff --git a/snowflake/ml/modeling/impute/simple_imputer.py b/snowflake/ml/modeling/impute/simple_imputer.py index d9fac1a3..1b05255d 100644 --- a/snowflake/ml/modeling/impute/simple_imputer.py +++ b/snowflake/ml/modeling/impute/simple_imputer.py @@ -73,7 +73,55 @@ ] +# TODO(thoyt): Implement logic for `add_indicator` parameter and `indicator_` attribute.Requires +# `snowflake.ml.impute.MissingIndicator` to be implemented. class SimpleImputer(base.BaseTransformer): + """ + Univariate imputer for completing missing values with simple strategies. + Note that the `add_indicator` parameter is not implemented. For more details on this class, see + [sklearn.impute.SimpleImputer](https://scikit-learn.org/stable/modules/generated/sklearn.impute.SimpleImputer.html). + + Args: + missing_values: int, float, str, np.nan or None, default=np.nan. + The values to treat as missing and impute during transform. + + strategy: str, default="mean". + The imputation strategy. + + * If "mean", replace missing values using the mean along each column. + Can only be used with numeric data. + * If "median", replace missing values using the median along each column. + Can only be used with numeric data. + * If "most_frequent", replace missing using the most frequent value along each column. + Can be used with strings or numeric data. + If there is more than one such value, only the smallest is returned. + * If "constant", replace the missing values with `fill_value`. Can be used with strings or numeric data. + + fill_value: Optional[str] + When `strategy == "constant"`, `fill_value` is used to replace all occurrences of `missing_values`. + For string or object data types, `fill_value` must be a string. If `None`, `fill_value` will be 0 when + imputing numerical data and `missing_value` for strings and object data types. + input_cols: Optional[Union[str, List[str]]] + Columns to use as inputs during fit and transform. + output_cols: Optional[Union[str, List[str]]] + A string or list of strings representing column names that will store the output of transform operation. + The length of `output_cols` must equal the length of `input_cols`. + drop_input_cols: bool, default=False + Remove input columns from output if set `True`. + + Attributes: + statistics_: dict {input_col: stats_value} + Dict containing the imputation fill value for each feature. Computing statistics can result in `np.nan` + values. During `transform`, features corresponding to `np.nan` statistics will be discarded. + n_features_in_: int + Number of features seen during `fit`. + feature_names_in_: ndarray of shape (n_features_in,) + Names of features seen during `fit`. + + Raises: + SnowflakeMLException: If strategy is invalid, or if fill value is specified for strategy that isn't "constant". + """ + def __init__( self, *, @@ -84,47 +132,6 @@ def __init__( output_cols: Optional[Union[str, Iterable[str]]] = None, drop_input_cols: Optional[bool] = False, ) -> None: - """ - Univariate imputer for completing missing values with simple strategies. - Note that the `add_indicator` param/functionality is not implemented. - - Args: - missing_values: The values to treat as missing and impute during transform. - strategy: The imputation strategy. - * If "mean", replace missing values using the mean along each column. - Can only be used with numeric data. - * If "median", replace missing values using the median along each column. - Can only be used with numeric data. - * If "most_frequent", replace missing using the most frequent value along each column. - Can be used with strings or numeric data. - If there is more than one such value, only the smallest is returned. - * If "constant", replace the missing values with `fill_value`. Can be used with strings or numeric data. - fill_value: - When `strategy == "constant"`, `fill_value` is used to replace all occurrences of `missing_values`. - For string or object data types, `fill_value` must be a string. If `None`, `fill_value` will be 0 when - imputing numerical data and `missing_value` for strings and object data types. - input_cols: - Columns to use as inputs during fit or transform. - output_cols: - New column labels for the columns that will contain the output of a transform. - drop_input_cols: Remove input columns from output if set True. False by default. - - Attributes: - statistics_: dict {input_col: stats_value} - Dict containing the imputation fill value for each feature. Computing statistics can result in `np.nan` - values. During `transform`, features corresponding to `np.nan` statistics will be discarded. - n_features_in_: int - Number of features seen during `fit`. - feature_names_in_: ndarray of shape (n_features_in,) - Names of features seen during `fit`. - - TODO(thoyt): Implement logic for `add_indicator` parameter and `indicator_` attribute. Requires - `snowflake.ml.impute.MissingIndicator` to be implemented. - - Raises: - SnowflakeMLException: If strategy is invalid, or if fill value is specified for strategy that isn't - "constant". - """ super().__init__(drop_input_cols=drop_input_cols) if strategy in STRATEGY_TO_STATE_DICT: self.strategy = strategy diff --git a/snowflake/ml/modeling/metrics/ranking.py b/snowflake/ml/modeling/metrics/ranking.py index 1b5f6b77..4abb3e4c 100644 --- a/snowflake/ml/modeling/metrics/ranking.py +++ b/snowflake/ml/modeling/metrics/ranking.py @@ -79,8 +79,10 @@ def precision_recall_curve( sproc_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.PROCEDURE) sklearn_release = version.parse(sklearn.__version__).release statement_params = telemetry.get_statement_params(_PROJECT, _SUBPROJECT) + cols = metrics_utils.flatten_cols([y_true_col_name, probas_pred_col_name, sample_weight_col_name]) queries = df[cols].queries["queries"] + pickled_result_module = cloudpickle.dumps(result) @F.sproc( # type: ignore[misc] @@ -99,7 +101,10 @@ def precision_recall_curve( def precision_recall_curve_anon_sproc(session: snowpark.Session) -> bytes: for query in queries[:-1]: _ = session.sql(query).collect(statement_params=statement_params) - df = session.sql(queries[-1]).to_pandas(statement_params=statement_params) + sp_df = session.sql(queries[-1]) + df = sp_df.to_pandas(statement_params=statement_params) + df.columns = sp_df.columns + y_true = df[y_true_col_name] probas_pred = df[probas_pred_col_name] sample_weight = df[sample_weight_col_name] if sample_weight_col_name else None @@ -215,8 +220,10 @@ class scores must correspond to the order of ``labels``, sproc_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.PROCEDURE) sklearn_release = version.parse(sklearn.__version__).release statement_params = telemetry.get_statement_params(_PROJECT, _SUBPROJECT) + cols = metrics_utils.flatten_cols([y_true_col_names, y_score_col_names, sample_weight_col_name]) queries = df[cols].queries["queries"] + pickled_result_module = cloudpickle.dumps(result) @F.sproc( # type: ignore[misc] @@ -235,7 +242,10 @@ class scores must correspond to the order of ``labels``, def roc_auc_score_anon_sproc(session: snowpark.Session) -> bytes: for query in queries[:-1]: _ = session.sql(query).collect(statement_params=statement_params) - df = session.sql(queries[-1]).to_pandas(statement_params=statement_params) + sp_df = session.sql(queries[-1]) + df = sp_df.to_pandas(statement_params=statement_params) + df.columns = sp_df.columns + y_true = df[y_true_col_names] y_score = df[y_score_col_names] sample_weight = df[sample_weight_col_name] if sample_weight_col_name else None @@ -306,8 +316,10 @@ def roc_curve( sproc_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.PROCEDURE) sklearn_release = version.parse(sklearn.__version__).release statement_params = telemetry.get_statement_params(_PROJECT, _SUBPROJECT) + cols = metrics_utils.flatten_cols([y_true_col_name, y_score_col_name, sample_weight_col_name]) queries = df[cols].queries["queries"] + pickled_result_module = cloudpickle.dumps(result) @F.sproc( # type: ignore[misc] @@ -326,7 +338,10 @@ def roc_curve( def roc_curve_anon_sproc(session: snowpark.Session) -> bytes: for query in queries[:-1]: _ = session.sql(query).collect(statement_params=statement_params) - df = session.sql(queries[-1]).to_pandas(statement_params=statement_params) + sp_df = session.sql(queries[-1]) + df = sp_df.to_pandas(statement_params=statement_params) + df.columns = sp_df.columns + y_true = df[y_true_col_name] y_score = df[y_score_col_name] sample_weight = df[sample_weight_col_name] if sample_weight_col_name else None diff --git a/snowflake/ml/modeling/metrics/regression.py b/snowflake/ml/modeling/metrics/regression.py index 517fa520..c71459c4 100644 --- a/snowflake/ml/modeling/metrics/regression.py +++ b/snowflake/ml/modeling/metrics/regression.py @@ -65,8 +65,10 @@ def d2_absolute_error_score( sproc_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.PROCEDURE) sklearn_release = version.parse(sklearn.__version__).release statement_params = telemetry.get_statement_params(_PROJECT, _SUBPROJECT) + cols = metrics_utils.flatten_cols([y_true_col_names, y_pred_col_names, sample_weight_col_name]) queries = df[cols].queries["queries"] + pickled_snowflake_result = cloudpickle.dumps(result) @F.sproc( # type: ignore[misc] @@ -85,7 +87,10 @@ def d2_absolute_error_score( def d2_absolute_error_score_anon_sproc(session: snowpark.Session) -> bytes: for query in queries[:-1]: _ = session.sql(query).collect(statement_params=statement_params) - df = session.sql(queries[-1]).to_pandas(statement_params=statement_params) + sp_df = session.sql(queries[-1]) + df = sp_df.to_pandas(statement_params=statement_params) + df.columns = sp_df.columns + y_true = df[y_true_col_names] y_pred = df[y_pred_col_names] sample_weight = df[sample_weight_col_name] if sample_weight_col_name else None @@ -151,8 +156,10 @@ def d2_pinball_score( sproc_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.PROCEDURE) sklearn_release = version.parse(sklearn.__version__).release statement_params = telemetry.get_statement_params(_PROJECT, _SUBPROJECT) + cols = metrics_utils.flatten_cols([y_true_col_names, y_pred_col_names, sample_weight_col_name]) queries = df[cols].queries["queries"] + pickled_result_module = cloudpickle.dumps(result) @F.sproc( # type: ignore[misc] @@ -171,7 +178,10 @@ def d2_pinball_score( def d2_pinball_score_anon_sproc(session: snowpark.Session) -> bytes: for query in queries[:-1]: _ = session.sql(query).collect(statement_params=statement_params) - df = session.sql(queries[-1]).to_pandas(statement_params=statement_params) + sp_df = session.sql(queries[-1]) + df = sp_df.to_pandas(statement_params=statement_params) + df.columns = sp_df.columns + y_true = df[y_true_col_names] y_pred = df[y_pred_col_names] sample_weight = df[sample_weight_col_name] if sample_weight_col_name else None @@ -255,8 +265,10 @@ def explained_variance_score( sproc_name = snowpark_utils.random_name_for_temp_object(snowpark_utils.TempObjectType.PROCEDURE) sklearn_release = version.parse(sklearn.__version__).release statement_params = telemetry.get_statement_params(_PROJECT, _SUBPROJECT) + cols = metrics_utils.flatten_cols([y_true_col_names, y_pred_col_names, sample_weight_col_name]) queries = df[cols].queries["queries"] + pickled_result_module = cloudpickle.dumps(result) @F.sproc( # type: ignore[misc] @@ -275,7 +287,10 @@ def explained_variance_score( def explained_variance_score_anon_sproc(session: snowpark.Session) -> bytes: for query in queries[:-1]: _ = session.sql(query).collect(statement_params=statement_params) - df = session.sql(queries[-1]).to_pandas(statement_params=statement_params) + sp_df = session.sql(queries[-1]) + df = sp_df.to_pandas(statement_params=statement_params) + df.columns = sp_df.columns + y_true = df[y_true_col_names] y_pred = df[y_pred_col_names] sample_weight = df[sample_weight_col_name] if sample_weight_col_name else None diff --git a/snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py b/snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py index 6d252c38..00a1283d 100644 --- a/snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py +++ b/snowflake/ml/modeling/model_selection/_internal/_grid_search_cv.py @@ -1,4 +1,7 @@ -import copy +# +# This code is auto-generated using the sklearn_wrapper_template.py_template template. +# Do not modify the auto-generated code(except automatic reformatting by precommit hooks). +# from typing import Any, Dict, Iterable, List, Optional, Set, Union from uuid import uuid4 @@ -25,6 +28,7 @@ from snowflake.ml.modeling._internal.estimator_protocols import CVHandlers from snowflake.ml.modeling._internal.estimator_utils import ( gather_dependencies, + if_single_node, original_estimator_has_callable, transform_snowml_obj_to_sklearn_obj, validate_sklearn_args, @@ -42,6 +46,7 @@ # and converting module name from underscore to CamelCase # e.g. sklearn.linear_model -> LinearModel. _SUBPROJECT = "ModelSelection" +DEFAULT_UDTF_NJOBS = 3 class GridSearchCV(BaseTransformer): @@ -344,37 +349,41 @@ def _fit_snowpark(self, dataset: DataFrame) -> None: dataset = dataset.select(selected_cols) assert self._sklearn_object is not None - # Set GridSearchCV refit as False and fit it again after retrieving the best param - self._sklearn_object.refit = False - result_dict = self._handlers._fit_search_snowpark( - param_list=ParameterGrid(self._sklearn_object.param_grid), - dataset=dataset, - session=session, - estimator=self._sklearn_object, - dependencies=self._get_dependencies(), - udf_imports=["sklearn"], - input_cols=self.input_cols, - label_cols=self.label_cols, - sample_weight_col=self.sample_weight_col, - ) - - self._sklearn_object.best_params_ = result_dict["best_param"] - self._sklearn_object.best_score_ = result_dict["best_score"] - self._sklearn_object.cv_results_ = result_dict["cv_results"] - - self._sklearn_object.best_estimator_ = copy.deepcopy( - copy.deepcopy(self._sklearn_object.estimator).set_params(**self._sklearn_object.best_params_) - ) - self._sklearn_object.refit = True - self._sklearn_object.best_estimator_ = self._handlers.fit_snowpark( - dataset=dataset, - session=session, - estimator=self._sklearn_object.best_estimator_, - dependencies=["snowflake-snowpark-python"] + self._get_dependencies(), - input_cols=self.input_cols, - label_cols=self.label_cols, - sample_weight_col=self.sample_weight_col, - ) + single_node = if_single_node(session) + if not single_node: + # Set the default value of the `n_jobs` attribute for the estimator. + # If minus one is set, it will not be abided by in the UDTF, so we set that to the default value as well. + if hasattr(self._sklearn_object.estimator, "n_jobs") and self._sklearn_object.estimator.n_jobs in [ + None, + -1, + ]: + self._sklearn_object.estimator.n_jobs = DEFAULT_UDTF_NJOBS + self._sklearn_object = self._handlers.fit_search_snowpark( + param_list=ParameterGrid(self._sklearn_object.param_grid), + dataset=dataset, + session=session, + estimator=self._sklearn_object, + dependencies=self._get_dependencies(), + udf_imports=["sklearn"], + input_cols=self.input_cols, + label_cols=self.label_cols, + sample_weight_col=self.sample_weight_col, + ) + else: + # Fall back with stored procedure implementation + # set the parallel factor to default to minus one, to fully accelerate the cores in single node + if self._sklearn_object.n_jobs is None: + self._sklearn_object.n_jobs = -1 + + self._sklearn_object = self._handlers.fit_snowpark( + dataset, + session, + self._sklearn_object, + ["snowflake-snowpark-python"] + self._get_dependencies(), + self.input_cols, + self.label_cols, + self.sample_weight_col, + ) def _get_pass_through_columns(self, dataset: DataFrame) -> List[str]: if self._drop_input_cols: diff --git a/snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py b/snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py index deb6da0f..1849c881 100644 --- a/snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py +++ b/snowflake/ml/modeling/model_selection/_internal/_randomized_search_cv.py @@ -1,4 +1,3 @@ -import copy from typing import Any, Dict, Iterable, List, Optional, Set, Union from uuid import uuid4 @@ -26,6 +25,7 @@ from snowflake.ml.modeling._internal.estimator_protocols import CVHandlers from snowflake.ml.modeling._internal.estimator_utils import ( gather_dependencies, + if_single_node, original_estimator_has_callable, transform_snowml_obj_to_sklearn_obj, validate_sklearn_args, @@ -43,6 +43,7 @@ # and converting module name from underscore to CamelCase # e.g. sklearn.linear_model -> LinearModel. _SUBPROJECT = "ModelSelection" +DEFAULT_UDTF_NJOBS = 3 class RandomizedSearchCV(BaseTransformer): @@ -360,40 +361,45 @@ def _fit_snowpark(self, dataset: DataFrame) -> None: dataset = dataset.select(selected_cols) assert self._sklearn_object is not None - self._sklearn_object.refit = False - result_dict = self._handlers._fit_search_snowpark( - param_list=ParameterSampler( - self._sklearn_object.param_distributions, - n_iter=self._sklearn_object.n_iter, - random_state=self._sklearn_object.random_state, - ), - dataset=dataset, - session=session, - estimator=self._sklearn_object, - dependencies=self._get_dependencies(), - udf_imports=["sklearn"], - input_cols=self.input_cols, - label_cols=self.label_cols, - sample_weight_col=self.sample_weight_col, - ) - - self._sklearn_object.best_params_ = result_dict["best_param"] - self._sklearn_object.best_score_ = result_dict["best_score"] - self._sklearn_object.cv_results_ = result_dict["cv_results"] - - self._sklearn_object.best_estimator_ = copy.deepcopy( - copy.deepcopy(self._sklearn_object.estimator).set_params(**self._sklearn_object.best_params_) - ) - self._sklearn_object.refit = True - self._sklearn_object.best_estimator_ = self._handlers.fit_snowpark( - dataset=dataset, - session=session, - estimator=self._sklearn_object.best_estimator_, - dependencies=["snowflake-snowpark-python"] + self._get_dependencies(), - input_cols=self.input_cols, - label_cols=self.label_cols, - sample_weight_col=self.sample_weight_col, - ) + single_node = if_single_node(session) + if not single_node: + # Set the default value of the `n_jobs` attribute for the estimator. + # If minus one is set, it will not be abided by in the UDTF, so we set that to the default value as well. + if hasattr(self._sklearn_object.estimator, "n_jobs") and self._sklearn_object.estimator.n_jobs in [ + None, + -1, + ]: + self._sklearn_object.estimator.n_jobs = DEFAULT_UDTF_NJOBS + self._sklearn_object = self._handlers.fit_search_snowpark( + param_list=ParameterSampler( + self._sklearn_object.param_distributions, + n_iter=self._sklearn_object.n_iter, + random_state=self._sklearn_object.random_state, + ), + dataset=dataset, + session=session, + estimator=self._sklearn_object, + dependencies=self._get_dependencies(), + udf_imports=["sklearn"], + input_cols=self.input_cols, + label_cols=self.label_cols, + sample_weight_col=self.sample_weight_col, + ) + else: + # Fall back with stored procedure implementation + # set the parallel factor to default to minus one, to fully accelerate the cores in single node + if self._sklearn_object.n_jobs is None: + self._sklearn_object.n_jobs = -1 + + self._sklearn_object = self._handlers.fit_snowpark( + dataset, + session, + self._sklearn_object, + ["snowflake-snowpark-python"] + self._get_dependencies(), + self.input_cols, + self.label_cols, + self.sample_weight_col, + ) def _get_pass_through_columns(self, dataset: DataFrame) -> List[str]: if self._drop_input_cols: diff --git a/snowflake/ml/modeling/pipeline/pipeline.py b/snowflake/ml/modeling/pipeline/pipeline.py index 3d7c38b3..e7327a5b 100644 --- a/snowflake/ml/modeling/pipeline/pipeline.py +++ b/snowflake/ml/modeling/pipeline/pipeline.py @@ -12,6 +12,7 @@ from snowflake import snowpark from snowflake.ml._internal import telemetry from snowflake.ml._internal.exceptions import error_codes, exceptions +from snowflake.ml._internal.utils import snowpark_dataframe_utils from snowflake.ml.model.model_signature import ModelSignature, _infer_signature from snowflake.ml.modeling.framework import _utils, base @@ -237,6 +238,11 @@ def fit(self, dataset: Union[snowpark.DataFrame, pd.DataFrame]) -> "Pipeline": """ self._validate_steps() + dataset = ( + snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) + if isinstance(dataset, snowpark.DataFrame) + else dataset + ) transformed_dataset = self._fit_transform_dataset(dataset) estimator = self._get_estimator() @@ -268,6 +274,11 @@ def transform(self, dataset: Union[snowpark.DataFrame, pd.DataFrame]) -> Union[s Transformed data. Output datatype will be same as input datatype. """ self._enforce_fit() + dataset = ( + snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) + if isinstance(dataset, snowpark.DataFrame) + else dataset + ) transformed_dataset = self._transform_dataset(dataset=dataset) estimator = self._get_estimator() @@ -301,6 +312,11 @@ def fit_transform( """ self._validate_steps() + dataset = ( + snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) + if isinstance(dataset, snowpark.DataFrame) + else dataset + ) transformed_dataset = self._fit_transform_dataset(dataset=dataset) @@ -340,6 +356,11 @@ def fit_predict(self, dataset: Union[snowpark.DataFrame, pd.DataFrame]) -> Union """ self._validate_steps() + dataset = ( + snowpark_dataframe_utils.cast_snowpark_dataframe_column_types(dataset) + if isinstance(dataset, snowpark.DataFrame) + else dataset + ) transformed_dataset = self._fit_transform_dataset(dataset=dataset) diff --git a/snowflake/ml/modeling/preprocessing/normalizer.py b/snowflake/ml/modeling/preprocessing/normalizer.py index ecd00ba9..d2c89f43 100644 --- a/snowflake/ml/modeling/preprocessing/normalizer.py +++ b/snowflake/ml/modeling/preprocessing/normalizer.py @@ -14,6 +14,30 @@ class Normalizer(base.BaseTransformer): + """ + Normalize samples individually to each row's unit norm. + + Each sample (i.e. each row of the data matrix) with at least one + non-zero component is rescaled independently of other samples so + that its norm (l1, l2 or inf) equals one. + + Args: + norm: str, default="l2" + The norm to use to normalize each non-zero sample. If norm='max' + is used, values will be rescaled by the maximum of the absolute + values. It must be one of 'l1', 'l2', or 'max'. + + input_cols: Optional[Union[str, List[str]]] + Columns to use as inputs during transform. + + output_cols: Optional[Union[str, List[str]]] + A string or list of strings representing column names that will store the output of transform operation. + The length of `output_cols` must equal the length of `input_cols`. + + drop_input_cols: bool, default=False + Remove input columns from output if set `True`. + """ + def __init__( self, *, @@ -22,21 +46,6 @@ def __init__( output_cols: Optional[Union[str, Iterable[str]]] = None, drop_input_cols: Optional[bool] = False, ) -> None: - """ - Normalize samples individually to each row's unit norm. - - Each sample (i.e. each row of the data matrix) with at least one - nonzero component is rescaled independently of other samples so - that its norm (l1, l2 or inf) equals one. - - Args: - norm: The norm to use to normalize each non zero sample. If norm='max' - is used, values will be rescaled by the maximum of the absolute - values. It must be one of 'l1', 'l2', or 'max'. - input_cols: Single or multiple input columns. - output_cols: Single or multiple output columns. - drop_input_cols: Remove input columns from output if set True. False by default. - """ super().__init__(drop_input_cols=drop_input_cols) self.norm = norm self._is_fitted = False @@ -79,7 +88,7 @@ def fit(self, dataset: Union[snowpark.DataFrame, pd.DataFrame]) -> "Normalizer": ) def transform(self, dataset: Union[snowpark.DataFrame, pd.DataFrame]) -> Union[snowpark.DataFrame, pd.DataFrame]: """ - Scale each nonzero row of the input dataset to the unit norm. + Scale each non-zero row of the input dataset to the unit norm. Args: dataset: Input dataset. diff --git a/snowflake/ml/registry/BUILD.bazel b/snowflake/ml/registry/BUILD.bazel index fdf918ba..479176b3 100644 --- a/snowflake/ml/registry/BUILD.bazel +++ b/snowflake/ml/registry/BUILD.bazel @@ -4,9 +4,12 @@ package(default_visibility = ["//visibility:public"]) py_library( name = "model_registry", - srcs = ["model_registry.py"], + srcs = [ + "artifact.py", + "model_registry.py", + ], deps = [ - ":_ml_artifact", + ":artifact_manager", ":schema", "//snowflake/ml/_internal:telemetry", "//snowflake/ml/_internal/utils:formatting", @@ -49,8 +52,11 @@ py_library( ) py_library( - name = "_ml_artifact", - srcs = ["_ml_artifact.py"], + name = "artifact_manager", + srcs = [ + "_artifact_manager.py", + "artifact.py", + ], deps = [ ":schema", "//snowflake/ml/_internal/utils:formatting", @@ -59,10 +65,10 @@ py_library( ) py_test( - name = "_ml_artifact_test", - srcs = ["_ml_artifact_test.py"], + name = "_artifact_test", + srcs = ["_artifact_test.py"], deps = [ - ":_ml_artifact", + ":artifact_manager", "//snowflake/ml/_internal/utils:identifier", "//snowflake/ml/test_utils:mock_data_frame", "//snowflake/ml/test_utils:mock_session", diff --git a/snowflake/ml/registry/_artifact_manager.py b/snowflake/ml/registry/_artifact_manager.py new file mode 100644 index 00000000..4927e0aa --- /dev/null +++ b/snowflake/ml/registry/_artifact_manager.py @@ -0,0 +1,156 @@ +from typing import Optional, cast + +from snowflake import connector, snowpark +from snowflake.ml._internal.utils import formatting, table_manager +from snowflake.ml.registry import _initial_schema, artifact + + +class ArtifactManager: + """It manages artifacts in model registry.""" + + def __init__( + self, + session: snowpark.Session, + database_name: str, + schema_name: str, + ) -> None: + """Initializer of artifact manager. + + Args: + session: Session object to communicate with Snowflake. + database_name: Desired name of the model registry database. + schema_name: Desired name of the schema used by this model registry inside the database. + """ + self._session = session + self._database_name = database_name + self._schema_name = schema_name + self._fully_qualified_table_name = table_manager.get_fully_qualified_table_name( + self._database_name, self._schema_name, _initial_schema._ARTIFACT_TABLE_NAME + ) + + def exists( + self, + artifact_name: str, + artifact_version: Optional[str] = None, + ) -> bool: + """Validate if an artifact exists. + + Args: + artifact_name: Name of artifact. + artifact_version: Version of artifact. + + Returns: + bool: True if the artifact exists, False otherwise. + """ + selected_artifact = self.get(artifact_name, artifact_version).collect() + + assert ( + len(selected_artifact) < 2 + ), f"""Multiple records found for artifact with name/version: {artifact_name}/{artifact_version}!""" + + return len(selected_artifact) == 1 + + def add( + self, + artifact: artifact.Artifact, + artifact_id: str, + artifact_name: str, + artifact_version: Optional[str] = None, + ) -> artifact.Artifact: + """ + Add a new artifact. + + Args: + artifact: artifact object. + artifact_id: id of artifact. + artifact_name: name of artifact. + artifact_version: version of artifact. + + Returns: + A reference to artifact. + """ + if artifact_version is None: + artifact_version = "" + assert artifact_id != "", "Artifact id can't be empty." + + new_artifact = { + "ID": artifact_id, + "TYPE": artifact.type.value, + "NAME": artifact_name, + "VERSION": artifact_version, + "CREATION_ROLE": self._session.get_current_role(), + "CREATION_TIME": formatting.SqlStr("CURRENT_TIMESTAMP()"), + "ARTIFACT_SPEC": artifact._spec, + } + + # TODO: Consider updating the METADATA table for artifact history tracking as well. + table_manager.insert_table_entry(self._session, self._fully_qualified_table_name, new_artifact) + artifact._log(name=artifact_name, version=artifact_version, id=artifact_id) + return artifact + + def delete( + self, + artifact_name: str, + artifact_version: Optional[str] = None, + error_if_not_exist: bool = False, + ) -> None: + """ + Remove an artifact. + + Args: + artifact_name: Name of artifact. + artifact_version: Version of artifact. + error_if_not_exist: Whether to raise errors if the target entry doesn't exist. Default to be false. + + Raises: + DataError: If error_if_not_exist is true and the artifact doesn't exist in the database. + RuntimeError: If the artifact deletion failed. + """ + if not self.exists(artifact_name, artifact_version): + if error_if_not_exist: + raise connector.DataError( + f"Artifact {artifact_name}/{artifact_version} doesn't exist. Deletion failed." + ) + else: + return + + if artifact_version is None: + artifact_version = "" + delete_query = f"""DELETE FROM {self._fully_qualified_table_name} + WHERE NAME='{artifact_name}' AND VERSION='{artifact_version}' + """ + + # TODO: Consider updating the METADATA table for artifact history tracking as well. + try: + self._session.sql(delete_query).collect() + except Exception as e: + raise RuntimeError(f"Delete artifact {artifact_name}/{artifact_version} failed due to {e}") + + def get( + self, + artifact_name: str, + artifact_version: Optional[str] = None, + ) -> snowpark.DataFrame: + """Retrieve the Snowpark dataframe of the artifact matching the provided artifact id and type. + + Given that ID and TYPE act as a compound primary key for the artifact table, + the resulting dataframe should have at most, one row. + + Args: + artifact_name: Name of artifact. + artifact_version: Version of artifact. + + Returns: + A Snowpark dataframe representing the artifacts that match the given constraints. + + WARNING: + The returned DataFrame is writable and shouldn't be made accessible to users. + """ + if artifact_version is None: + artifact_version = "" + + artifacts = self._session.sql(f"SELECT * FROM {self._fully_qualified_table_name}") + target_artifact = artifacts.filter(snowpark.Column("NAME") == artifact_name).filter( + snowpark.Column("VERSION") == artifact_version + ) + return cast(snowpark.DataFrame, target_artifact) diff --git a/snowflake/ml/registry/_ml_artifact_test.py b/snowflake/ml/registry/_artifact_test.py similarity index 56% rename from snowflake/ml/registry/_ml_artifact_test.py rename to snowflake/ml/registry/_artifact_test.py index a684408e..2202c813 100644 --- a/snowflake/ml/registry/_ml_artifact_test.py +++ b/snowflake/ml/registry/_artifact_test.py @@ -5,7 +5,7 @@ from snowflake import connector, snowpark from snowflake.ml._internal.utils import identifier, table_manager -from snowflake.ml.registry import _ml_artifact +from snowflake.ml.registry import _artifact_manager, artifact from snowflake.ml.test_utils import mock_data_frame, mock_session _DATABASE_NAME = identifier.get_inferred_name("_SYSTEM_MODEL_REGISTRY") @@ -55,52 +55,34 @@ def _get_select_artifact(self) -> List[snowpark.Row]: return [ snowpark.Row( id="FAKE_ID", - type=_ml_artifact.ArtifactType.TESTTYPE, - name="FAKE_NAME", - version=None, + type=artifact.ArtifactType.TESTTYPE, creation_time=datetime.datetime(2022, 11, 4, 17, 1, 30, 153000), creation_role="OWNER_ROLE", artifact_spec={}, ) ] - def test_if_artifact_table_exists(self) -> None: - for mock_df, expected_res in [ - (mock_data_frame.MockDataFrame(self._get_show_tables_success(name=_TABLE_NAME)), True), - (mock_data_frame.MockDataFrame([]), False), - ]: - with self.subTest(): - self._session.add_mock_sql( - query=f"SHOW TABLES LIKE '{_TABLE_NAME}' IN {_DATABASE_NAME}.{_SCHEMA_NAME}", - result=mock_df, - ) - self.assertEqual( - _ml_artifact.if_artifact_table_exists( - cast(snowpark.Session, self._session), _DATABASE_NAME, _SCHEMA_NAME - ), - expected_res, - ) - def test_if_artifact_exists(self) -> None: for mock_df_collect, expected_res in [ (self._get_select_artifact(), True), ([], False), ]: with self.subTest(): - artifact_id = "FAKE_ID" - artifact_type = _ml_artifact.ArtifactType.TESTTYPE + artifact_name = "FAKE_ID" + artifact_version = "FAKE_VERSION" expected_df = mock_data_frame.MockDataFrame() expected_df.add_operation("filter") expected_df.add_operation("filter") expected_df.add_collect_result(cast(List[snowpark.Row], mock_df_collect)) self._session.add_mock_sql(query=f"SELECT * FROM {_FULLY_QUALIFIED_TABLE_NAME}", result=expected_df) self.assertEqual( - _ml_artifact.if_artifact_exists( - cast(snowpark.Session, self._session), - _DATABASE_NAME, - _SCHEMA_NAME, - artifact_id, - artifact_type, + _artifact_manager.ArtifactManager( + session=cast(snowpark.Session, self._session), + database_name=_DATABASE_NAME, + schema_name=_SCHEMA_NAME, + ).exists( + artifact_name, + artifact_version, ), expected_res, ) @@ -108,15 +90,8 @@ def test_if_artifact_exists(self) -> None: def test_add_artifact(self) -> None: artifact_id = "FAKE_ID" artifact_name = "FAKE_NAME" - artifact_version = "1.0.0" - artifact_spec = {"description": "mock description"} - - # Mock the get_artifact call - expected_df = mock_data_frame.MockDataFrame() - expected_df.add_operation("filter") - expected_df.add_operation("filter") - expected_df.add_collect_result([]) - self._session.add_mock_sql(query=f"SELECT * FROM {_FULLY_QUALIFIED_TABLE_NAME}", result=expected_df) + artifact_version = "FAKE_VERSION" + art_obj = artifact.Artifact(type=artifact.ArtifactType.TESTTYPE, spec='{"description": "mock description"}') # Mock the insertion call self._session.add_operation("get_current_role", result="current_role") @@ -124,59 +99,58 @@ def test_add_artifact(self) -> None: f"INSERT INTO {_FULLY_QUALIFIED_TABLE_NAME}" " ( ARTIFACT_SPEC,CREATION_ROLE,CREATION_TIME,ID,NAME,TYPE,VERSION )" " SELECT" - " OBJECT_CONSTRUCT('description','mock description'),'current_role',CURRENT_TIMESTAMP()," - "'FAKE_ID','FAKE_NAME','TESTTYPE','1.0.0' " + " '{\"description\": \"mock description\"}','current_role',CURRENT_TIMESTAMP()," + "'FAKE_ID','FAKE_NAME', 'TESTTYPE', 'FAKE_VERSION' " ) self._session.add_mock_sql( query=insert_query, result=mock_data_frame.MockDataFrame([snowpark.Row(**{"number of rows inserted": 1})]), ) - _ml_artifact.add_artifact( - cast(snowpark.Session, self._session), - _DATABASE_NAME, - _SCHEMA_NAME, - artifact_id, - _ml_artifact.ArtifactType.TESTTYPE, - artifact_name, - artifact_version, - artifact_spec, + _artifact_manager.ArtifactManager( + session=cast(snowpark.Session, self._session), + database_name=_DATABASE_NAME, + schema_name=_SCHEMA_NAME, + ).add( + artifact=art_obj, + artifact_id=artifact_id, + artifact_name=artifact_name, + artifact_version=artifact_version, ) def test_delete_artifact(self) -> None: for error_if_not_exist in [True, False]: with self.subTest(): if error_if_not_exist: - artifact_id = "FAKE_ID" + artifact_name = "FAKE_NAME" + artifact_version = "FAKE_VERSION" expected_df = mock_data_frame.MockDataFrame() expected_df.add_operation("filter") expected_df.add_operation("filter") expected_df.add_collect_result([]) self._session.add_mock_sql(query=f"SELECT * FROM {_FULLY_QUALIFIED_TABLE_NAME}", result=expected_df) with self.assertRaises(connector.DataError): - _ml_artifact.delete_artifact( - cast(snowpark.Session, self._session), - _DATABASE_NAME, - _SCHEMA_NAME, - artifact_id, - _ml_artifact.ArtifactType.TESTTYPE, + _artifact_manager.ArtifactManager( + session=cast(snowpark.Session, self._session), + database_name=_DATABASE_NAME, + schema_name=_SCHEMA_NAME, + ).delete( + artifact_name, + artifact_version, True, ) else: - # Mock the delete call - insert_query = ( - f"DELETE FROM {_FULLY_QUALIFIED_TABLE_NAME}" - f" WHERE ID='{artifact_id}' AND TYPE='{_ml_artifact.ArtifactType.TESTTYPE.name}'" - ) - self._session.add_mock_sql( - query=insert_query, - result=mock_data_frame.MockDataFrame([snowpark.Row(**{"number of rows deleted": 1})]), - ) - _ml_artifact.delete_artifact( - cast(snowpark.Session, self._session), - _DATABASE_NAME, - _SCHEMA_NAME, - artifact_id, - _ml_artifact.ArtifactType.TESTTYPE, + expected_df = mock_data_frame.MockDataFrame() + expected_df.add_operation("filter") + expected_df.add_operation("filter") + expected_df.add_collect_result([]) + self._session.add_mock_sql(query=f"SELECT * FROM {_FULLY_QUALIFIED_TABLE_NAME}", result=expected_df) + _artifact_manager.ArtifactManager( + session=cast(snowpark.Session, self._session), + database_name=_DATABASE_NAME, + schema_name=_SCHEMA_NAME, + ).delete( + artifact_name, + artifact_version, ) diff --git a/snowflake/ml/registry/_ml_artifact.py b/snowflake/ml/registry/_ml_artifact.py deleted file mode 100644 index fca5de41..00000000 --- a/snowflake/ml/registry/_ml_artifact.py +++ /dev/null @@ -1,181 +0,0 @@ -import enum -from typing import Any, Dict, Optional, cast - -from snowflake import connector, snowpark -from snowflake.ml._internal.utils import formatting, table_manager -from snowflake.ml.registry import _initial_schema - - -# Set of allowed artifact types. -class ArtifactType(enum.Enum): - TESTTYPE = "TESTTYPE" # A placeholder type just for unit test - DATASET = "DATASET" - - -def if_artifact_table_exists( - session: snowpark.Session, - database_name: str, - schema_name: str, -) -> bool: - """ - Verify the existence of the artifact table. - - Args: - session: Snowpark session object to communicate with Snowflake. - database_name: Desired name of the model registry database. - schema_name: Desired name of the schema used by this model registry inside the database. - - Returns: - bool: True if the artifact table exists, False otherwise. - """ - qualified_schema_name = table_manager.get_fully_qualified_schema_name(database_name, schema_name) - return table_manager.validate_table_exist(session, _initial_schema._ARTIFACT_TABLE_NAME, qualified_schema_name) - - -def if_artifact_exists( - session: snowpark.Session, database_name: str, schema_name: str, artifact_id: str, artifact_type: ArtifactType -) -> bool: - """Validate if a specific artifact record exists in the artifact table. - - Args: - session: Session object to communicate with Snowflake. - database_name: Desired name of the model registry database. - schema_name: Desired name of the schema used by this model registry inside the database. - artifact_id: Unique identifier of the target artifact. - artifact_type: Type of the target artifact - - Returns: - bool: True if the artifact exists, False otherwise. - """ - selected_artifact = _get_artifact(session, database_name, schema_name, artifact_id, artifact_type).collect() - - assert ( - len(selected_artifact) < 2 - ), f"Multiple records found for the specified artifact (ID: {artifact_id}, TYPE: {artifact_type.name})!" - - return len(selected_artifact) == 1 - - -def add_artifact( - session: snowpark.Session, - database_name: str, - schema_name: str, - artifact_id: str, - artifact_type: ArtifactType, - artifact_name: str, - artifact_version: Optional[str], - artifact_spec: Dict[str, Any], -) -> None: - """ - Insert a new artifact record into the designated artifact table. - - Args: - session: Session object to communicate with Snowflake. - database_name: Desired name of the model registry database. - schema_name: Desired name of the schema used by this model registry inside the database. - artifact_id: Unique identifier for the artifact. - artifact_type: Type of the artifact. - artifact_name: Name of the artifact. - artifact_version: Version of the artifact if applicable. - artifact_spec: Specifications related to the artifact. - - Raises: - TypeError: If the given artifact type isn't valid. - DataError: If the given artifact already exists in the database. - """ - if not isinstance(artifact_type, ArtifactType): - raise TypeError(f"{artifact_type} isn't a recognized artifact type.") - - if if_artifact_exists(session, database_name, schema_name, artifact_id, artifact_type): - raise connector.DataError( - f"artifact with ID {artifact_id} and TYPE {artifact_type.name} already exists. Unable to add the artifact." - ) - - fully_qualified_table_name = table_manager.get_fully_qualified_table_name( - database_name, schema_name, _initial_schema._ARTIFACT_TABLE_NAME - ) - - new_artifact = { - "ID": artifact_id, - "TYPE": artifact_type.name, - "NAME": artifact_name, - "VERSION": artifact_version, - "CREATION_ROLE": session.get_current_role(), - "CREATION_TIME": formatting.SqlStr("CURRENT_TIMESTAMP()"), - "ARTIFACT_SPEC": artifact_spec, - } - - # TODO: Consider updating the METADATA table for artifact history tracking as well. - table_manager.insert_table_entry(session, fully_qualified_table_name, new_artifact) - - -def delete_artifact( - session: snowpark.Session, - database_name: str, - schema_name: str, - artifact_id: str, - artifact_type: ArtifactType, - error_if_not_exist: bool = False, -) -> None: - """ - Remove an artifact record from the designated artifact table. - - Args: - session: Session object to communicate with Snowflake. - database_name: Desired name of the model registry database. - schema_name: Desired name of the schema used by this model registry inside the database. - artifact_id: Unique identifier for the artifact to be deleted. - artifact_type: Type of the artifact to be deleted. - error_if_not_exist: Whether to raise errors if the target entry doesn't exist. Default to be false. - - Raises: - DataError: If error_if_not_exist is true and the artifact doesn't exist in the database. - RuntimeError: If the artifact deletion failed. - """ - if error_if_not_exist and not if_artifact_exists(session, database_name, schema_name, artifact_id, artifact_type): - raise connector.DataError( - f"Artifact with ID '{artifact_id}' and TYPE '{artifact_type.name}' doesn't exist. Deletion not possible." - ) - - fully_qualified_table_name = table_manager.get_fully_qualified_table_name( - database_name, schema_name, _initial_schema._ARTIFACT_TABLE_NAME - ) - - delete_query = f"DELETE FROM {fully_qualified_table_name} WHERE ID='{artifact_id}' AND TYPE='{artifact_type.name}'" - - # TODO: Consider updating the METADATA table for artifact history tracking as well. - try: - session.sql(delete_query).collect() - except Exception as e: - raise RuntimeError(f"Delete ML artifact (ID: {artifact_id}, TYPE: {artifact_type.name}) failed due to {e}") - - -def _get_artifact( - session: snowpark.Session, database_name: str, schema_name: str, artifact_id: str, artifact_type: ArtifactType -) -> snowpark.DataFrame: - """Retrieve the Snowpark dataframe of the artifact matching the provided artifact id and type. - - Given that ID and TYPE act as a compound primary key for the artifact table, the resulting dataframe should have, - at most, one row. - - Args: - session: Session object to communicate with Snowflake. - database_name: Desired name of the model registry database. - schema_name: Desired name of the schema used by this model registry inside the database. - artifact_id: Unique identifier of the target artifact. - artifact_type: Type of the target artifact - - Returns: - A Snowpark dataframe representing the artifacts that match the given constraints. - - WARNING: - The returned DataFrame is writable and shouldn't be made accessible to users. - """ - full_table_path = table_manager.get_fully_qualified_table_name( - database_name, schema_name, _initial_schema._ARTIFACT_TABLE_NAME - ) - artifacts = session.sql(f"SELECT * FROM {full_table_path}") - target_artifact = artifacts.filter(snowpark.Column("ID") == artifact_id).filter( - snowpark.Column("TYPE") == artifact_type.name - ) - return cast(snowpark.DataFrame, target_artifact) diff --git a/snowflake/ml/registry/_schema.py b/snowflake/ml/registry/_schema.py index 40f01fbe..9b5e1609 100644 --- a/snowflake/ml/registry/_schema.py +++ b/snowflake/ml/registry/_schema.py @@ -4,7 +4,7 @@ # BUMP THIS VERSION WHENEVER YOU CHANGE ANY SCHEMA TABLES. # ALSO UPDATE SCHEMA UPGRADE PLANS. -_CURRENT_SCHEMA_VERSION = 2 +_CURRENT_SCHEMA_VERSION = 3 _REGISTRY_TABLE_SCHEMA: List[Tuple[str, str]] = [ ("CREATION_CONTEXT", "VARCHAR"), @@ -52,7 +52,7 @@ ("VERSION", "VARCHAR"), ("CREATION_ROLE", "VARCHAR"), ("CREATION_TIME", "TIMESTAMP_TZ"), - ("ARTIFACT_SPEC", "OBJECT"), + ("ARTIFACT_SPEC", "VARCHAR"), # Below is out-of-line constraints of Snowflake table. # See https://docs.snowflake.com/en/sql-reference/sql/create-table ("PRIMARY KEY", "(ID, TYPE) RELY"), @@ -76,6 +76,7 @@ # NOTE, all version from _INITIAL_VERSION + 1 till _CURRENT_SCHEMA_VERSION must exists. 1: _schema_upgrade_plans.AddTrainingDatasetIdIfNotExists, 2: _schema_upgrade_plans.ReplaceTrainingDatasetIdWithArtifactIds, + 3: _schema_upgrade_plans.ChangeArtifactSpecFromObjectToVarchar, } assert len(_SCHEMA_UPGRADE_PLANS) == _CURRENT_SCHEMA_VERSION - _initial_schema._INITIAL_VERSION diff --git a/snowflake/ml/registry/_schema_upgrade_plans.py b/snowflake/ml/registry/_schema_upgrade_plans.py index 93205f8a..fa79e539 100644 --- a/snowflake/ml/registry/_schema_upgrade_plans.py +++ b/snowflake/ml/registry/_schema_upgrade_plans.py @@ -80,3 +80,37 @@ def upgrade(self) -> None: ADD COLUMN {new_column} ARRAY """ ).collect(statement_params=self._statement_params) + + +class ChangeArtifactSpecFromObjectToVarchar(BaseSchemaUpgradePlans): + """Change artifact spec type from object to varchar. It's fine to drop the column as it's empty.""" + + def __init__( + self, + session: snowpark.Session, + database_name: str, + schema_name: str, + statement_params: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(session, database_name, schema_name, statement_params) + + def upgrade(self) -> None: + full_schema_path = f"{self._database}.{self._schema}" + update_col = "ARTIFACT_SPEC" + self._session.sql( + f"""ALTER TABLE {full_schema_path}.{_initial_schema._ARTIFACT_TABLE_NAME} + DROP COLUMN {update_col} + """ + ).collect(statement_params=self._statement_params) + + self._session.sql( + f"""ALTER TABLE {full_schema_path}.{_initial_schema._ARTIFACT_TABLE_NAME} + ADD COLUMN {update_col} VARCHAR + """ + ).collect(statement_params=self._statement_params) + + self._session.sql( + f"""COMMENT ON COLUMN {full_schema_path}.{_initial_schema._ARTIFACT_TABLE_NAME}.{update_col} IS + 'This column is VARCHAR but supposed to store a valid JSON object' + """ + ).collect(statement_params=self._statement_params) diff --git a/snowflake/ml/registry/artifact.py b/snowflake/ml/registry/artifact.py new file mode 100644 index 00000000..f6aff3d5 --- /dev/null +++ b/snowflake/ml/registry/artifact.py @@ -0,0 +1,46 @@ +import enum +from typing import Optional + + +# Set of allowed artifact types. +class ArtifactType(enum.Enum): + TESTTYPE = "TESTTYPE" # A placeholder type just for unit test + DATASET = "DATASET" + + +class Artifact: + """ + A reference to artifact. + + Properties: + id: A globally unique id represents this artifact. + spec: Specification of artifact in json format. + type: Type of artifact. + name: Name of artifact. + version: Version of artifact. + """ + + def __init__(self, type: ArtifactType, spec: str) -> None: + """Create an artifact. + + Args: + type: type of artifact. + spec: specification in json format. + """ + self.type: ArtifactType = type + self.name: Optional[str] = None + self.version: Optional[str] = None + self._spec: str = spec + self._id: Optional[str] = None + + def _log(self, name: str, version: str, id: str) -> None: + """Additional information when this artifact is logged. + + Args: + name: name of artifact. + version: version of artifact. + id: A global unique id represents this artifact. + """ + self.name = name + self.version = version + self._id = id diff --git a/snowflake/ml/registry/model_registry.py b/snowflake/ml/registry/model_registry.py index b1a1251d..c5c52257 100644 --- a/snowflake/ml/registry/model_registry.py +++ b/snowflake/ml/registry/model_registry.py @@ -36,7 +36,12 @@ model_signature, type_hints as model_types, ) -from snowflake.ml.registry import _initial_schema, _ml_artifact, _schema_version_manager +from snowflake.ml.registry import ( + _artifact_manager, + _initial_schema, + _schema_version_manager, + artifact, +) from snowflake.snowpark._internal import utils as snowpark_utils if TYPE_CHECKING: @@ -231,7 +236,10 @@ def _create_registry_views( {artifact_table_name}.* FROM {registry_table_name} LEFT JOIN {artifact_table_name} - ON (ARRAY_CONTAINS({artifact_table_name}.ID::VARIANT, {registry_table_name}.ARTIFACT_IDS)) + ON (ARRAY_CONTAINS( + {artifact_table_name}.ID::VARIANT, + {registry_table_name}.ARTIFACT_IDS) + ) """ ).collect(statement_params=statement_params) @@ -313,6 +321,7 @@ def __init__( self._artifact_view = identifier.concat_names([self._artifact_table, "_VIEW"]) self._session = session self._svm = _schema_version_manager.SchemaVersionManager(self._session, self._name, self._schema) + self._artifact_manager = _artifact_manager.ArtifactManager(self._session, self._name, self._schema) # A in-memory deployment info cache to store information of temporary deployments # TODO(zhe): Use a temporary table to replace the in-memory cache. @@ -800,7 +809,7 @@ def _register_model_with_id( output_spec: Optional[Dict[str, str]] = None, description: Optional[str] = None, tags: Optional[Dict[str, str]] = None, - dataset: Optional[dataset.Dataset] = None, + artifacts: Optional[List[artifact.Artifact]] = None, ) -> None: """Helper function to register model metadata. @@ -820,9 +829,10 @@ def _register_model_with_id( description: A description for the model. The description can be changed later. tags: Key-value pairs of tags to be set for this model. Tags can be modified after model registration. - dataset: An object contains dataset metadata. + artifacts: A list of artifact references. Raises: + ValueError: Artifact ids not found in model registry. DataError: The given model already exists. DatabaseError: Unable to register the model properties into table. """ @@ -838,24 +848,11 @@ def _register_model_with_id( new_model["CREATION_ROLE"] = self._session.get_current_role() new_model["CREATION_ENVIRONMENT_SPEC"] = {"python": ".".join(map(str, sys.version_info[:3]))} - if dataset is not None: - is_artifact_exists = _ml_artifact.if_artifact_exists( - self._session, self._name, self._schema, dataset.id, _ml_artifact.ArtifactType.DATASET - ) - if not is_artifact_exists: - _ml_artifact.add_artifact( - session=self._session, - database_name=self._name, - schema_name=self._schema, - artifact_id=dataset.id, - artifact_type=_ml_artifact.ArtifactType.DATASET, - artifact_name=dataset.name, - artifact_version=dataset.version, - artifact_spec=json.loads(dataset.to_json()), - ) - new_model["ARTIFACT_IDS"] = [dataset.id] - else: - new_model["ARTIFACT_IDS"] = [] + if artifacts is not None: + for atf in artifacts: + if not self._artifact_manager.exists(atf.name if atf.name is not None else "", atf.version): + raise ValueError(f"Artifact {atf.name}/{atf.version} not found in model registry.") + new_model["ARTIFACT_IDS"] = [art._id for art in artifacts] existing_model_nums = self._list_selected_models(model_name=model_name, model_version=model_version).count() if existing_model_nums: @@ -1266,6 +1263,42 @@ def get_metrics(self, model_name: str, model_version: str) -> Dict[str, object]: else: return dict() + @telemetry.send_api_usage_telemetry( + project=_TELEMETRY_PROJECT, + subproject=_TELEMETRY_SUBPROJECT, + ) + @snowpark._internal.utils.private_preview(version="1.0.10") + def log_artifact( + self, + artifact: artifact.Artifact, + name: str, + version: Optional[str] = None, + ) -> artifact.Artifact: + """Upload and register an artifact to the Model Registry. + + Args: + artifact: artifact object. + name: name of artifact. + version: version of artifact. + + Raises: + DataError: Artifact with same name and version already exists. + + Returns: + Return a reference to the artifact. + """ + + if self._artifact_manager.exists(name, version): + raise connector.DataError(f"Artifact {name}/{version} already exists.") + + artifact_id = self._get_new_unique_identifier() + return self._artifact_manager.add( + artifact=artifact, + artifact_id=artifact_id, + artifact_name=name, + artifact_version=version, + ) + # Combined Registry and Repository operations. @telemetry.send_api_usage_telemetry( project=_TELEMETRY_PROJECT, @@ -1284,7 +1317,7 @@ def log_model( pip_requirements: Optional[List[str]] = None, signatures: Optional[Dict[str, model_signature.ModelSignature]] = None, sample_input_data: Optional[Any] = None, - dataset: Optional[dataset.Dataset] = None, + artifacts: Optional[List[artifact.Artifact]] = None, code_paths: Optional[List[str]] = None, options: Optional[model_types.BaseModelSaveOption] = None, ) -> Optional["ModelReference"]: @@ -1304,18 +1337,21 @@ def log_model( pip_requirements: List of PIP package specs. Model will not be able to deploy to the warehouse if there is pip requirements. signatures: Signatures of the model, which is a mapping from target method name to signatures of input and - output, which could be inferred by calling `infer_signature` method with sample input data dataset. - sample_input_data: Sample of the input data for the model. - dataset: A dataset metadata object. + output, which could be inferred by calling `infer_signature` method with sample input data. + sample_input_data: Sample of the input data for the model. If artifacts contains a feature store + generated dataset, then sample_input_data is not needed. If both sample_input_data and dataset provided + , then sample_input_data will be used to infer model signature. + artifacts: A list of artifact ids, which are generated from log_artifact(). code_paths: Directory of code to import when loading and deploying the model. options: Additional options when saving the model. Raises: - DataError: Raised when the given model exists. - ValueError: Raised in following cases: - 1) both sample_input_data and dataset are provided; - 2) signatures and sample_input_data/dataset are both not provided and - model is not a snowflake estimator. + DataError: Raised when: + 1) the given model already exists; + 2) given artifacts does not exists in this registry. + ValueError: Raised when: # noqa: DAR402 + 1) Signatures, sample_input_data and artifact(dataset) are both not provided and model is not a + snowflake estimator. Exception: Raised when there is any error raised when saving the model. Returns: @@ -1329,15 +1365,17 @@ def log_model( self._model_identifier_is_nonempty_or_raise(model_name, model_version) - if sample_input_data is not None and dataset is not None: - raise ValueError("Only one of sample_input_data and dataset should be provided.") + if artifacts is not None: + for atf in artifacts: + if not self._artifact_manager.exists(atf.name if atf.name is not None else "", atf.version): + raise connector.DataError(f"Artifact {atf.name}/{atf.version} does not exists.") - if dataset is not None: - sample_input_data = dataset.df - if dataset.timestamp_col is not None: - sample_input_data = sample_input_data.drop(dataset.timestamp_col) - if dataset.label_cols is not None: - sample_input_data = sample_input_data.drop(dataset.label_cols) + if sample_input_data is None and artifacts is not None: + for atf in artifacts: + if atf.type == artifact.ArtifactType.DATASET: + ds = self.get_artifact(atf.name if atf.name is not None else "", atf.version) + sample_input_data = ds.features_df() + break existing_model_nums = self._list_selected_models(model_name=model_name, model_version=model_version).count() if existing_model_nums: @@ -1377,7 +1415,7 @@ def log_model( uri=uri.get_uri_from_snowflake_stage_path(model_stage_file_path), description=description, tags=tags, - dataset=dataset, + artifacts=artifacts, ) return ModelReference(registry=self, model_name=model_name, model_version=model_version) @@ -1621,24 +1659,33 @@ def get_deployment(self, model_name: str, model_version: str, *, deployment_name project=_TELEMETRY_PROJECT, subproject=_TELEMETRY_SUBPROJECT, ) - @snowpark._internal.utils.private_preview(version="1.0.1") - def get_dataset(self, model_name: str, model_version: str) -> Optional[dataset.Dataset]: - """Get dataset of the model with the given (model name + model version). + @snowpark._internal.utils.private_preview(version="1.0.11") + def get_artifact(self, name: str, version: Optional[str] = None) -> Optional[artifact.Artifact]: + """Get artifact with the given (name, version). Args: - model_name: Model Name string. - model_version: Model Version string. + name: Name of artifact. + version: Version of artifact. Returns: - Dataset of the model or none if not found. + A reference to artifact if found, otherwise none. """ - artifacts = ( - self.list_artifacts(model_name, model_version) - .filter(snowpark.Column("TYPE") == _ml_artifact.ArtifactType.DATASET.value) - .collect() - ) + artifacts = self._artifact_manager.get( + name, + version, + ).collect() + + if len(artifacts) == 0: + return None + + atf = artifacts[0] + if atf["TYPE"] == artifact.ArtifactType.DATASET.value: + ds = dataset.Dataset.from_json(atf["ARTIFACT_SPEC"], self._session) + ds._log(name=atf["NAME"], version=atf["VERSION"], id=atf["ID"]) + return ds - return dataset.Dataset.from_json(artifacts[0]["ARTIFACT_SPEC"], self._session) if len(artifacts) != 0 else None + assert f"Unrecognized artifact type: {atf['TYPE']}" + return None @telemetry.send_api_usage_telemetry( project=_TELEMETRY_PROJECT, @@ -2019,9 +2066,10 @@ def create_model_registry( statement_params, ) finally: - # Restore the db & schema to the original ones - if old_db is not None and old_db != session.get_current_database(): - session.use_database(old_db) - if old_schema is not None and old_schema != session.get_current_schema(): - session.use_schema(old_schema) + if not snowpark_utils.is_in_stored_procedure(): # type: ignore[no-untyped-call] + # Restore the db & schema to the original ones + if old_db is not None and old_db != session.get_current_database(): + session.use_database(old_db) + if old_schema is not None and old_schema != session.get_current_schema(): + session.use_schema(old_schema) return True diff --git a/snowflake/ml/registry/model_registry_test.py b/snowflake/ml/registry/model_registry_test.py index a3d3fd35..2487b95e 100644 --- a/snowflake/ml/registry/model_registry_test.py +++ b/snowflake/ml/registry/model_registry_test.py @@ -564,7 +564,9 @@ def setup_create_views_call(self) -> None: {_ARTIFACTS_TABLE_NAME}.* FROM {_REGISTRY_TABLE_NAME} LEFT JOIN {_ARTIFACTS_TABLE_NAME} - ON (ARRAY_CONTAINS({_ARTIFACTS_TABLE_NAME}.ID::VARIANT, {_REGISTRY_TABLE_NAME}.ARTIFACT_IDS)) + ON (ARRAY_CONTAINS( + {_ARTIFACTS_TABLE_NAME}.ID::VARIANT, + {_REGISTRY_TABLE_NAME}.ARTIFACT_IDS)) """ ), result=mock_data_frame.MockDataFrame( @@ -614,6 +616,23 @@ def setup_schema_upgrade_calls(self, statement_params: Dict[str, str]) -> None: query=(f"ALTER TABLE {reg_table_full_path} ADD COLUMN ARTIFACT_IDS ARRAY"), result=mock_data_frame.MockDataFrame([snowpark.Row(status="Statement executed successfully.")]), ) + art_table_full_path = f"{_DATABASE_NAME}.{_SCHEMA_NAME}.{_ARTIFACTS_TABLE_NAME}" + self.add_session_mock_sql( + query=(f"ALTER TABLE {art_table_full_path} DROP COLUMN ARTIFACT_SPEC"), + result=mock_data_frame.MockDataFrame([snowpark.Row(status="Statement executed successfully.")]), + ) + self.add_session_mock_sql( + query=(f"ALTER TABLE {art_table_full_path} ADD COLUMN ARTIFACT_SPEC VARCHAR"), + result=mock_data_frame.MockDataFrame([snowpark.Row(status="Statement executed successfully.")]), + ) + self.add_session_mock_sql( + query=( + f"""COMMENT ON COLUMN {art_table_full_path}.ARTIFACT_SPEC IS + 'This column is VARCHAR but supposed to store a valid JSON object'""" + ), + result=mock_data_frame.MockDataFrame([snowpark.Row(status="Statement executed successfully.")]), + ) + # end schema upgrade plans self._mock_desc_registry_table(statement_params) self._mock_desc_metadata_table(statement_params) @@ -1150,7 +1169,7 @@ def test_log_model(self) -> None: uri=uri.get_uri_from_snowflake_stage_path(model_path), description="description", tags=None, - dataset=None, + artifacts=None, ) self._mock_show_version_table_exists({}) diff --git a/snowflake/ml/registry/notebooks/Deployment to Snowpark Container Service Demo.ipynb b/snowflake/ml/registry/notebooks/Deployment to Snowpark Container Service Demo.ipynb index 84526ff8..bdd99926 100644 --- a/snowflake/ml/registry/notebooks/Deployment to Snowpark Container Service Demo.ipynb +++ b/snowflake/ml/registry/notebooks/Deployment to Snowpark Container Service Demo.ipynb @@ -1,540 +1,382 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "a45960e1", - "metadata": {}, - "source": [ - "# Deployment to Snowpark Container Service Demo" - ] - }, - { - "cell_type": "markdown", - "id": "aa7a329a", - "metadata": {}, - "source": [ - "## Prerequisite\n", - "\n", - "- Install and have a running Docker Client (required only for PrPr for client-side image build)" - ] - }, - { - "cell_type": "markdown", - "id": "3b50d774", - "metadata": {}, - "source": [ - "## Train a model with Snowpark ML API " - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "18a75d71", - "metadata": {}, - "outputs": [], - "source": [ - "from typing import Tuple\n", - "from snowflake.ml.modeling import linear_model\n", - "from sklearn import datasets\n", - "import pandas as pd\n", - "import numpy as np\n", - "\n", - "def prepare_logistic_model() -> Tuple[linear_model.LogisticRegression, pd.DataFrame]:\n", - " iris = datasets.load_iris()\n", - " df = pd.DataFrame(data=np.c_[iris[\"data\"], iris[\"target\"]], columns=iris[\"feature_names\"] + [\"target\"])\n", - " df.columns = [s.replace(\" (CM)\", \"\").replace(\" \", \"\") for s in df.columns.str.upper()]\n", - "\n", - " input_cols = [\"SEPALLENGTH\", \"SEPALWIDTH\", \"PETALLENGTH\", \"PETALWIDTH\"]\n", - " label_cols = \"TARGET\"\n", - " output_cols = \"PREDICTED_TARGET\"\n", - "\n", - " estimator = linear_model.LogisticRegression(\n", - " input_cols=input_cols, output_cols=output_cols, label_cols=label_cols, random_state=0, max_iter=1000\n", - " ).fit(df)\n", - "\n", - " return estimator, df.drop(columns=label_cols).head(10)" - ] - }, - { - "cell_type": "markdown", - "id": "db6734fa", - "metadata": {}, - "source": [ - "## Start Snowpark Session" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "58dd3604", - "metadata": {}, - "outputs": [], - "source": [ - "from snowflake.ml.utils.connection_params import SnowflakeLoginOptions\n", - "from snowflake.snowpark import Session\n", - "\n", - "session = Session.builder.configs(SnowflakeLoginOptions()).create()\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27dfbc42", - "metadata": {}, - "outputs": [], - "source": [ - "from snowflake.ml.registry import model_registry\n", - "from snowflake.ml._internal.utils import identifier\n", - "\n", - "db = identifier._get_unescaped_name(session.get_current_database())\n", - "schema = identifier._get_unescaped_name(session.get_current_schema())\n", - "\n", - "# will be a no-op if registry already exists\n", - "model_registry.create_model_registry(session=session, database_name=db, schema_name=schema) \n", - "registry = model_registry.ModelRegistry(session=session, database_name=db, schema_name=schema)" - ] - }, - { - "cell_type": "markdown", - "id": "38e0a975", - "metadata": {}, - "source": [ - "## Register SnowML Model" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "574e7a43", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:snowflake.snowpark:ModelRegistry.log_model() is in private preview since 0.2.0. Do not use it in production. \n", - "WARNING:snowflake.snowpark:ModelRegistry.list_models() is in private preview since 0.2.0. Do not use it in production. \n" - ] - }, - { - "data": { - "text/plain": [ - "'0aa236602be711ee89915ac3f3b698e1'" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "logistic_model, test_features = prepare_logistic_model()\n", - "model_name = \"snowpark_ml_logistic\"\n", - "model_version = \"v1\"\n", - "\n", - "model_ref = registry.log_model(\n", - " model_name=model_name,\n", - " model_version=model_version,\n", - " model=logistic_model,\n", - " sample_input_data=test_features,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "054a3862", - "metadata": {}, - "source": [ - "## Model Deployment to Snowpark Container Service" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "72ff114f", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:root:Building the Docker image and deploying to Snowpark Container Service. This process may take a few minutes.\n", - "WARNING:root:Image successfully built! To prevent the need for rebuilding the Docker image in future deployments, simply specify 'prebuilt_snowflake_image': 'temptest002038-servicesnow.registry-dev.snowflakecomputing.com/inference_container_db/inference_container_schema/snowml_repo/42374efe274011eea4ff5ac3f3b698e1:latest' in the options field of the deploy() function\n" - ] - } - ], - "source": [ - "from snowflake.ml.model import deploy_platforms\n", - "from snowflake import snowpark\n", - "\n", - "compute_pool = \"MY_COMPUTE_POOL\" # Pre-created compute pool\n", - "deployment_name = \"LOGISTIC_FUNC\" # Name of the resulting UDF\n", - "\n", - "model_ref.deploy(\n", - " deployment_name=deployment_name, \n", - " platform=deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES,\n", - " target_method=\"predict\",\n", - " options={\n", - " \"compute_pool\": compute_pool\n", - " }\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "1c754e72", - "metadata": {}, - "source": [ - "## Batch Prediction on Snowpark Container Service" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "a5c02328", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "

\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
SEPALLENGTHSEPALWIDTHPETALLENGTHPETALWIDTHPREDICTED_TARGET
05.13.51.40.20.0
14.93.01.40.20.0
24.73.21.30.20.0
34.63.11.50.20.0
45.03.61.40.20.0
55.43.91.70.40.0
64.63.41.40.30.0
75.03.41.50.20.0
84.42.91.40.20.0
94.93.11.50.10.0
\n", - "
" - ], - "text/plain": [ - " SEPALLENGTH SEPALWIDTH PETALLENGTH PETALWIDTH PREDICTED_TARGET\n", - "0 5.1 3.5 1.4 0.2 0.0\n", - "1 4.9 3.0 1.4 0.2 0.0\n", - "2 4.7 3.2 1.3 0.2 0.0\n", - "3 4.6 3.1 1.5 0.2 0.0\n", - "4 5.0 3.6 1.4 0.2 0.0\n", - "5 5.4 3.9 1.7 0.4 0.0\n", - "6 4.6 3.4 1.4 0.3 0.0\n", - "7 5.0 3.4 1.5 0.2 0.0\n", - "8 4.4 2.9 1.4 0.2 0.0\n", - "9 4.9 3.1 1.5 0.1 0.0" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model_ref.predict(deployment_name, test_features)" - ] - }, - { - "cell_type": "markdown", - "id": "9f8c6ce5", - "metadata": {}, - "source": [ - "## Train a HuggingFace Model (cross-encoder/nli-MiniLM2-L6-H768)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "809d5e98", - "metadata": {}, - "outputs": [], - "source": [ - "from transformers import pipeline\n", - "from snowflake.ml.model import custom_model\n", - "\n", - "def prepare_cross_encoder_model() -> Tuple[custom_model.CustomModel, pd.DataFrame]:\n", - " \"\"\"\n", - " Pretrained cross encoder model from huggingface.\n", - " \"\"\"\n", - " classifier = pipeline(\"zero-shot-classification\", model='cross-encoder/nli-MiniLM2-L6-H768') \n", - " candidate_labels = ['customer support', 'product experience', 'account issues']\n", - "\n", - " class HuggingFaceModel(custom_model.CustomModel):\n", - " def __init__(self, context: custom_model.ModelContext) -> None:\n", - " super().__init__(context)\n", - " \n", - " @custom_model.inference_api\n", - " def predict(self, input_df: pd.DataFrame) -> pd.DataFrame: \n", - " sequences_to_classify = input_df.values.flatten().tolist()\n", - " data = [classifier(sequence, candidate_labels) for sequence in sequences_to_classify]\n", - " max_score_labels = []\n", - " for record in data:\n", - " max_score_label = max(zip(record['labels'], record['scores']), key=lambda x: x[1])[0]\n", - " max_score_labels.append(max_score_label) \n", - " return pd.DataFrame({\"output\": max_score_labels})\n", - "\n", - " cross_encoder_model = HuggingFaceModel(custom_model.ModelContext())\n", - " test_data = pd.DataFrame([\"The interface gets frozen very often\"])\n", - "\n", - " return cross_encoder_model, test_data" - ] - }, - { - "cell_type": "markdown", - "id": "67d6a7d2", - "metadata": {}, - "source": [ - "## Register Cross Encoder Model" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9dd84f88", - "metadata": {}, - "outputs": [], - "source": [ - "from snowflake.ml.registry import model_registry\n", - "\n", - "model, test_features = prepare_cross_encoder_model()\n", - "model_name = \"cross_encoder_model\"\n", - "model_version = \"v1\"\n", - "\n", - "model_ref = registry.log_model(\n", - " model_name=model_name,\n", - " model_version=model_version,\n", - " model=model,\n", - " sample_input_data=test_features,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "c6db686e", - "metadata": {}, - "source": [ - "## Model Deployment to Snowpark Container Service (GPU)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "701152f7", - "metadata": {}, - "outputs": [], - "source": [ - "from snowflake.ml.model import deploy_platforms\n", - "\n", - "compute_pool = \"MY_COMPUTE_POOL\" # Pre-created\n", - "deployment_name = \"CROSS_ENCODER\" # Name of the resulting UDF\n", - "\n", - "model_ref.deploy(\n", - " deployment_name=deployment_name, \n", - " platform=deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES,\n", - " target_method=\"predict\",\n", - " options={\n", - " \"compute_pool\": compute_pool,\n", - " \"num_gpus\": 1\n", - " }\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "7b0fba61", - "metadata": {}, - "source": [ - "## Zero-Shot Classification" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "id": "936840df", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " input_feature_0\n", - "0 The interface gets frozen very often\n" - ] - } - ], - "source": [ - "print(test_features)" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "id": "302daaf9", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
output
0product experience
\n", - "
" - ], - "text/plain": [ - " output\n", - "0 product experience" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model_ref.predict(deployment_name, test_features)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python [conda env:local_snowml] *", - "language": "python", - "name": "conda-env-local_snowml-py" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.17" - } + "cells": [ + { + "cell_type": "markdown", + "id": "a45960e1", + "metadata": {}, + "source": [ + "# Deployment to Snowpark Container Service Demo" + ] + }, + { + "cell_type": "markdown", + "id": "aa7a329a", + "metadata": {}, + "source": [ + "### Snowflake-ML-Python Installation" + ] + }, + { + "cell_type": "markdown", + "id": "cb3d7a96", + "metadata": {}, + "source": [ + "- Please refer to our [landing page](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index) to install `snowflake-ml-python`." + ] + }, + { + "cell_type": "markdown", + "id": "3b50d774", + "metadata": {}, + "source": [ + "## Train a model with Snowpark ML API " + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "18a75d71", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Tuple\n", + "from snowflake.ml.modeling import linear_model\n", + "from sklearn import datasets\n", + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "def prepare_logistic_model() -> Tuple[linear_model.LogisticRegression, pd.DataFrame]:\n", + " iris = datasets.load_iris()\n", + " df = pd.DataFrame(data=np.c_[iris[\"data\"], iris[\"target\"]], columns=iris[\"feature_names\"] + [\"target\"])\n", + " df.columns = [s.replace(\" (CM)\", \"\").replace(\" \", \"\") for s in df.columns.str.upper()]\n", + "\n", + " input_cols = [\"SEPALLENGTH\", \"SEPALWIDTH\", \"PETALLENGTH\", \"PETALWIDTH\"]\n", + " label_cols = \"TARGET\"\n", + " output_cols = \"PREDICTED_TARGET\"\n", + "\n", + " estimator = linear_model.LogisticRegression(\n", + " input_cols=input_cols, output_cols=output_cols, label_cols=label_cols, random_state=0, max_iter=1000\n", + " ).fit(df)\n", + "\n", + " return estimator, df.drop(columns=label_cols).head(10)" + ] + }, + { + "cell_type": "markdown", + "id": "db6734fa", + "metadata": {}, + "source": [ + "## Start Snowpark Session" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "58dd3604", + "metadata": {}, + "outputs": [], + "source": [ + "from snowflake.ml.utils.connection_params import SnowflakeLoginOptions\n", + "from snowflake.snowpark import Session\n", + "\n", + "session = Session.builder.configs(SnowflakeLoginOptions()).create()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27dfbc42", + "metadata": {}, + "outputs": [], + "source": [ + "from snowflake.ml.registry import model_registry\n", + "from snowflake.ml._internal.utils import identifier\n", + "\n", + "db = identifier._get_unescaped_name(session.get_current_database())\n", + "schema = identifier._get_unescaped_name(session.get_current_schema())\n", + "\n", + "# will be a no-op if registry already exists\n", + "model_registry.create_model_registry(session=session, database_name=db, schema_name=schema) \n", + "registry = model_registry.ModelRegistry(session=session, database_name=db, schema_name=schema)" + ] + }, + { + "cell_type": "markdown", + "id": "38e0a975", + "metadata": {}, + "source": [ + "## Register SnowML Model" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "574e7a43", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:snowflake.snowpark:ModelRegistry.log_model() is in private preview since 0.2.0. Do not use it in production. \n", + "WARNING:snowflake.snowpark:ModelRegistry.list_models() is in private preview since 0.2.0. Do not use it in production. \n" + ] }, - "nbformat": 4, - "nbformat_minor": 5 + { + "data": { + "text/plain": [ + "'0aa236602be711ee89915ac3f3b698e1'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "logistic_model, test_features = prepare_logistic_model()\n", + "model_name = \"snowpark_ml_logistic\"\n", + "model_version = \"v1\"\n", + "\n", + "model_ref = registry.log_model(\n", + " model_name=model_name,\n", + " model_version=model_version,\n", + " model=logistic_model,\n", + " sample_input_data=test_features,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "054a3862", + "metadata": {}, + "source": [ + "## Model Deployment to Snowpark Container Service" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "72ff114f", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:root:Building the Docker image and deploying to Snowpark Container Service. This process may take a few minutes.\n", + "WARNING:root:Image successfully built! To prevent the need for rebuilding the Docker image in future deployments, simply specify 'prebuilt_snowflake_image': 'temptest002038-servicesnow.registry-dev.snowflakecomputing.com/inference_container_db/inference_container_schema/snowml_repo/42374efe274011eea4ff5ac3f3b698e1:latest' in the options field of the deploy() function\n" + ] + } + ], + "source": [ + "from snowflake.ml.model import deploy_platforms\n", + "from snowflake import snowpark\n", + "\n", + "compute_pool = \"MY_COMPUTE_POOL\" # Pre-created compute pool\n", + "deployment_name = \"LOGISTIC_FUNC\" # Name of the resulting UDF\n", + "\n", + "model_ref.deploy(\n", + " deployment_name=deployment_name, \n", + " platform=deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES,\n", + " target_method=\"predict\",\n", + " options={\n", + " \"compute_pool\": compute_pool,\n", + " #num_gpus: 1 # Specify the number of GPUs for GPU inferenc\n", + " }\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1c754e72", + "metadata": {}, + "source": [ + "## Batch Prediction on Snowpark Container Service" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a5c02328", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
SEPALLENGTHSEPALWIDTHPETALLENGTHPETALWIDTHPREDICTED_TARGET
05.13.51.40.20.0
14.93.01.40.20.0
24.73.21.30.20.0
34.63.11.50.20.0
45.03.61.40.20.0
55.43.91.70.40.0
64.63.41.40.30.0
75.03.41.50.20.0
84.42.91.40.20.0
94.93.11.50.10.0
\n", + "
" + ], + "text/plain": [ + " SEPALLENGTH SEPALWIDTH PETALLENGTH PETALWIDTH PREDICTED_TARGET\n", + "0 5.1 3.5 1.4 0.2 0.0\n", + "1 4.9 3.0 1.4 0.2 0.0\n", + "2 4.7 3.2 1.3 0.2 0.0\n", + "3 4.6 3.1 1.5 0.2 0.0\n", + "4 5.0 3.6 1.4 0.2 0.0\n", + "5 5.4 3.9 1.7 0.4 0.0\n", + "6 4.6 3.4 1.4 0.3 0.0\n", + "7 5.0 3.4 1.5 0.2 0.0\n", + "8 4.4 2.9 1.4 0.2 0.0\n", + "9 4.9 3.1 1.5 0.1 0.0" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model_ref.predict(deployment_name, test_features)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12991f07", + "metadata": {}, + "outputs": [], + "source": [ + "model_ref.delete_deployment(deployment_name=deployment_name)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09f337d2", + "metadata": {}, + "outputs": [], + "source": [ + "model_ref.delete_model()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [conda env:local_snowml]", + "language": "python", + "name": "conda-env-local_snowml-py" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 5 } diff --git a/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb b/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb new file mode 100644 index 00000000..6037a7d3 --- /dev/null +++ b/snowflake/ml/registry/notebooks/Finetune_Registry.ipynb @@ -0,0 +1,423 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fa0e355f", + "metadata": {}, + "source": [ + "1. Create a conda python3.8 conda env\n", + "`conda create --name snowml python=3.8`\n", + "\n", + "2. You need to install these packages locally\n", + " * peft \n", + " * transformers\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ed66db9", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "!pip install /Users/halu/snowml/bazel-bin/snowflake/ml/snowflake_ml_python-1.0.10-py3-none-any.whl" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "292e9f48", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], + "source": [ + "from IPython.display import display, HTML\n", + "display(HTML(\"\"))\n", + "\n", + "%load_ext autoreload\n", + "%autoreload 2\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "7585077b", + "metadata": {}, + "outputs": [], + "source": [ + "from snowflake.snowpark import Session\n", + "from snowflake.ml.utils.connection_params import SnowflakeLoginOptions" + ] + }, + { + "cell_type": "markdown", + "id": "7a0294ba", + "metadata": {}, + "source": [ + "Connection config available at ~/.snowsql/config" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f876232e", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "SnowflakeLoginOptions() is in private preview since 0.2.0. Do not use it in production. \n" + ] + } + ], + "source": [ + "session = Session.builder.configs(SnowflakeLoginOptions('connections.demo')).create()" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "c6aee8c9", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "('\"HALU_FT\"', '\"PUBLIC\"')" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.get_current_database(), session.get_current_schema()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "72c16c14", + "metadata": {}, + "outputs": [], + "source": [ + "REGISTRY_DATABASE_NAME = \"HALU_MR\"\n", + "REGISTRY_SCHEMA_NAME = \"PUBLIC\"" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c420807b", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:snowflake.snowpark:create_model_registry() is in private preview since 0.2.0. Do not use it in production. \n", + "WARNING:absl:The schema HALU_MR.PUBLIC already exists. Skipping creation.\n" + ] + } + ], + "source": [ + "from snowflake.ml.registry import model_registry\n", + "\n", + "model_registry.create_model_registry(\n", + " session=session, database_name=REGISTRY_DATABASE_NAME, schema_name=REGISTRY_SCHEMA_NAME\n", + ")\n", + "registry = model_registry.ModelRegistry(\n", + " session=session, database_name=REGISTRY_DATABASE_NAME, schema_name=REGISTRY_SCHEMA_NAME\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "0adc9637", + "metadata": {}, + "outputs": [], + "source": [ + "from snowflake.ml.model.models import llm" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "18323af6", + "metadata": {}, + "outputs": [], + "source": [ + "options = llm.LLMOptions(token=\"....\")\n", + "model = llm.LLM(\n", + " model_id_or_path=\"/Users/halu/Downloads/halu_peft_ft\",\n", + " options=options\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "dac3fc56", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:snowflake.snowpark:ModelRegistry.log_model() is in private preview since 0.2.0. Do not use it in production. \n", + "WARNING:snowflake.snowpark:ModelRegistry.list_models() is in private preview since 0.2.0. Do not use it in production. \n" + ] + } + ], + "source": [ + "svc_model = registry.log_model(\n", + " model_name='halu_ft_model_1',\n", + " model_version='v1',\n", + " model=model,\n", + " options={\"embed_local_ml_library\": True},\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "b17b1fbb", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:snowflake.ml.model._deploy_client.snowservice.deploy:Similar environment detected. Using existing image sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/4b7980a43a1ff656d23b9401e4471bcd4f021d39:latest to skip image build. To disable this feature, set 'force_image_build=True' in deployment options\n" + ] + }, + { + "data": { + "text/plain": [ + "{'name': 'HALU_MR.PUBLIC.halu_ft_deploy_1',\n", + " 'platform': ,\n", + " 'target_method': 'infer',\n", + " 'signature': ModelSignature(\n", + " inputs=[\n", + " FeatureSpec(dtype=DataType.STRING, name='input')\n", + " ],\n", + " outputs=[\n", + " FeatureSpec(dtype=DataType.STRING, name='generated_text')\n", + " ]\n", + " ),\n", + " 'options': {'compute_pool': 'BUILD_2023_POOL',\n", + " 'num_gpus': 1,\n", + " 'image_repo': 'sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo',\n", + " 'enable_remote_image_build': True,\n", + " 'model_in_image': True},\n", + " 'details': {'image_name': 'sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/4b7980a43a1ff656d23b9401e4471bcd4f021d39:latest',\n", + " 'service_spec': 'spec:\\n container:\\n - env:\\n NUM_WORKERS: 1\\n SNOWML_USE_GPU: true\\n TARGET_METHOD: infer\\n image: sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo/4b7980a43a1ff656d23b9401e4471bcd4f021d39:latest\\n name: inference-server\\n readinessProbe:\\n path: /health\\n port: 5000\\n resources:\\n limits:\\n nvidia.com/gpu: 1\\n requests:\\n nvidia.com/gpu: 1\\n volumeMounts:\\n - mountPath: /local/user/vol1\\n name: vol1\\n endpoint:\\n - name: predict\\n port: 5000\\n volume:\\n - name: vol1\\n source: local\\n',\n", + " 'service_function_sql': \"\\n CREATE OR REPLACE FUNCTION HALU_MR.PUBLIC.halu_ft_deploy_1(input OBJECT)\\n RETURNS OBJECT\\n SERVICE=HALU_MR.PUBLIC.service_d289e6506e3111eeb21b769aea86b514\\n ENDPOINT=predict\\n MAX_BATCH_ROWS = 1\\n AS '/predict'\\n \"}}" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from snowflake.ml.model import deploy_platforms\n", + "\n", + "deployment_options = {\n", + " \"compute_pool\": 'BUILD_2023_POOL',\n", + " \"num_gpus\": 1,\n", + " \"image_repo\": 'sfengineering-servicesnow.registry.snowflakecomputing.com/halu_ft_db/public/haul_repo',\n", + " \"enable_remote_image_build\": True,\n", + " \"model_in_image\": True,\n", + "}\n", + " \n", + "deploy_info = svc_model.deploy(\n", + " deployment_name=\"halu_ft_deploy_1\",\n", + " platform=deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES,\n", + " permanent=True,\n", + " options=deployment_options\n", + ")\n", + "deploy_info" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "b25baf1c", + "metadata": {}, + "outputs": [], + "source": [ + "sample = \"\"\"\n", + "[INST] <>\n", + "Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n", + "<>\n", + "### Instruction:\n", + "Extract JSON response with 'location' and 'toy_list' as keys.\n", + "'location': Location string of the caller.\n", + "'toy_list\": List of toy names from the caller.\n", + "### Input:\n", + " \"frosty: Hello, good friend! You're talking to Frosty! What's your name?\n", + "caller: My name's Oliver. And I'm calling from Perth.\n", + "frosty: Nice to meet you, Oliver from Perth! So, what's on your wish list this year?\n", + "caller: I want a mickey, please.\n", + "frosty: Look forward to some Mickey adventures!\"\n", + "[/INST]\n", + "\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "2a84b44b", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "db0e8d17", + "metadata": {}, + "outputs": [], + "source": [ + "input_df = pd.DataFrame({'input': [sample, sample, sample]})" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "3a98eabd", + "metadata": {}, + "outputs": [], + "source": [ + "res = svc_model.predict(\n", + " deployment_name='halu_ft_deploy_1',\n", + " data=input_df\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "f32e6498", + "metadata": {}, + "outputs": [], + "source": [ + "pd.set_option('display.max_colwidth', None)" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "a467afb6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
generated_text
0{\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}
1{\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}
2{\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}
\n", + "
" + ], + "text/plain": [ + " generated_text\n", + "0 {\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}\n", + "1 {\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}\n", + "2 {\"toy_list\": [\"Fisher-Price Little People Mickey and Friends\"], \"location\": \"Perth\"}" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "res" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/snowflake/ml/registry/notebooks/Model Packaging Example.ipynb b/snowflake/ml/registry/notebooks/Model Packaging Example.ipynb index 21e2bca2..3a4f1e59 100644 --- a/snowflake/ml/registry/notebooks/Model Packaging Example.ipynb +++ b/snowflake/ml/registry/notebooks/Model Packaging Example.ipynb @@ -1,7 +1,6 @@ { "cells": [ { - "attachments": {}, "cell_type": "markdown", "id": "5de3eb26", "metadata": {}, @@ -10,7 +9,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "197efd00", "metadata": {}, @@ -19,25 +17,63 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "6ce97b36", "metadata": {}, "source": [ - "### Install `snowflake-ml-python` locally" + "### Snowflake-ML-Python Installation" ] }, { - "attachments": {}, "cell_type": "markdown", "id": "1117c596", "metadata": {}, "source": [ - "Please refer to our [landing page](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index) to install `snowflake-ml-python`." + "- Please refer to our [landing page](https://docs.snowflake.com/en/developer-guide/snowpark-ml/index) to install `snowflake-ml-python`." + ] + }, + { + "cell_type": "markdown", + "id": "7ed8032a", + "metadata": {}, + "source": [ + "### Local Installation" + ] + }, + { + "cell_type": "markdown", + "id": "741c249e", + "metadata": {}, + "source": [ + "- transformers>=4.31.0 (For GPT-2 and LLAMA 2 model inference example)\n", + "- tokenizers>=0.13.3 (For LLAMA 2 model inference example)\n", + "- tensorflow (For GPT-2 Example)\n", + "- xgboost==1.7.6 (For XGBoost GPU inference example)" + ] + }, + { + "cell_type": "markdown", + "id": "2bde8397", + "metadata": {}, + "source": [ + "### Additional Requirements" + ] + }, + { + "cell_type": "markdown", + "id": "2647a880", + "metadata": {}, + "source": [ + "- SPCS compute pool with at least 1 GPU (For all GPU inference on SPCS examples below)\n", + "\n", + "- Requested access to use LLama 2 model through HuggingFace (For LLAMA 2 model inference example)\n", + "\n", + "- A HuggingFace token with read access (For LLAMA 2 model inference example)\n", + "\n", + "- Download the News Category Dataset from https://www.kaggle.com/datasets/rmisra/news-category-dataset (For LLAMA 2 model inference example)" ] }, { - "attachments": {}, "cell_type": "markdown", "id": "99e58d8c", "metadata": {}, @@ -83,7 +119,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "1ac32c6f", "metadata": {}, @@ -119,7 +154,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "dfa9ab88", "metadata": {}, @@ -128,7 +162,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "b0a0c8a8", "metadata": {}, @@ -165,7 +198,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "d76e14a1", "metadata": {}, @@ -174,7 +206,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "c592d46c", "metadata": {}, @@ -183,7 +214,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "378eb3ba", "metadata": {}, @@ -236,7 +266,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "dda57d0b", "metadata": {}, @@ -256,7 +285,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "317e7843", "metadata": {}, @@ -265,7 +293,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "3b482561", "metadata": {}, @@ -309,7 +336,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "45c75e28", "metadata": {}, @@ -318,7 +344,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "a8d496db", "metadata": {}, @@ -356,7 +381,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "6c1f3c07", "metadata": {}, @@ -392,7 +416,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "dc2e2f5e", "metadata": {}, @@ -401,17 +424,6 @@ ] }, { - "attachments": {}, - "cell_type": "markdown", - "id": "f2224cc7", - "metadata": {}, - "source": [ - "Requirements:\n", - "- `transformers` and `tensorflow` installed locally." - ] - }, - { - "attachments": {}, "cell_type": "markdown", "id": "9bc58b66", "metadata": {}, @@ -434,7 +446,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "03454cba", "metadata": {}, @@ -469,7 +480,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "333118b7", "metadata": {}, @@ -530,7 +540,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "e111b527", "metadata": {}, @@ -539,7 +548,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "c27ed16a", "metadata": {}, @@ -582,7 +590,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "e634f4c1", "metadata": {}, @@ -591,7 +598,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "fc0f289d", "metadata": {}, @@ -623,7 +629,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "b44a55b7", "metadata": {}, @@ -632,7 +637,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "05e45630", "metadata": {}, @@ -657,7 +661,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "771cad94", "metadata": {}, @@ -742,7 +745,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "d4d25ee7", "metadata": {}, @@ -779,7 +781,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "2e9446fc", "metadata": {}, @@ -803,7 +804,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "5948b7c8", "metadata": {}, @@ -824,7 +824,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "e560bd8d", "metadata": {}, @@ -844,7 +843,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "08614b16", "metadata": {}, @@ -866,7 +864,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "d1e99456", "metadata": {}, @@ -906,7 +903,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "6b4eabe1", "metadata": {}, @@ -915,7 +911,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "be5ecdb5", "metadata": {}, @@ -941,16 +936,6 @@ "### Deploy to SPCS and using GPU for inference" ] }, - { - "cell_type": "markdown", - "id": "08bce3c3", - "metadata": {}, - "source": [ - "Requirements:\n", - "- `xgboost==1.7.6` installed locally.\n", - "- a SPCS compute pool with at least 1 GPU." - ] - }, { "cell_type": "code", "execution_count": null, @@ -998,18 +983,6 @@ "## Using LLM with HuggingFace Pipeline" ] }, - { - "cell_type": "markdown", - "id": "cd99cd28", - "metadata": {}, - "source": [ - "Requirements:\n", - "- `transformers>=4.31.0` and `tokenizers>=0.13.3` installed locally.\n", - "- a HuggingFace token with read access.\n", - "- a SPCS compute pool with at least 1 GPU.\n", - "- News Category Dataset from https://www.kaggle.com/datasets/rmisra/news-category-dataset" - ] - }, { "cell_type": "markdown", "id": "07bb4d94", @@ -1101,6 +1074,7 @@ " token=\"...\", # Put your HuggingFace token here.\n", " return_full_text=False,\n", " max_new_tokens=100,\n", + " batch_size=1,\n", ")" ] }, @@ -1153,7 +1127,6 @@ " options={\n", " \"compute_pool\": \"...\",\n", " \"num_gpus\": 1,\n", - " \"enable_remote_image_build\": True,\n", " },\n", ")" ] @@ -1400,7 +1373,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -1414,7 +1387,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.18" + "version": "3.8.12" }, "vscode": { "interpreter": { diff --git a/snowflake/ml/requirements.bzl b/snowflake/ml/requirements.bzl index c8c545d1..618e7401 100755 --- a/snowflake/ml/requirements.bzl +++ b/snowflake/ml/requirements.bzl @@ -1,6 +1,6 @@ # DO NOT EDIT! # Generate by running 'bazel run --config=pre_build //bazel/requirements:sync_requirements' -EXTRA_REQUIREMENTS = {"all": ["lightgbm==3.3.5", "mlflow>=2.1.0,<2.4", "sentencepiece>=0.1.95,<0.2", "shap==0.42.1", "tensorflow>=2.9,<3", "tokenizers>=0.10,<1", "torchdata>=0.4,<1", "transformers>=4.29.2,<5"], "lightgbm": ["lightgbm==3.3.5"], "mlflow": ["mlflow>=2.1.0,<2.4"], "shap": ["shap==0.42.1"], "tensorflow": ["tensorflow>=2.9,<3"], "torch": ["torchdata>=0.4,<1"], "transformers": ["sentencepiece>=0.1.95,<0.2", "tokenizers>=0.10,<1", "transformers>=4.29.2,<5"]} +EXTRA_REQUIREMENTS = {"all": ["lightgbm==3.3.5", "mlflow>=2.1.0,<2.4", "peft>=0.5.0,<1", "sentencepiece>=0.1.95,<0.2", "shap==0.42.1", "tensorflow>=2.9,<3,!=2.12.0", "tokenizers>=0.10,<1", "torchdata>=0.4,<1", "transformers>=4.29.2,<5"], "lightgbm": ["lightgbm==3.3.5"], "llm": ["peft>=0.5.0,<1"], "mlflow": ["mlflow>=2.1.0,<2.4"], "shap": ["shap==0.42.1"], "tensorflow": ["tensorflow>=2.9,<3,!=2.12.0"], "torch": ["torchdata>=0.4,<1"], "transformers": ["sentencepiece>=0.1.95,<0.2", "tokenizers>=0.10,<1", "transformers>=4.29.2,<5"]} REQUIREMENTS = ["absl-py>=0.15,<2", "anyio>=3.5.0,<4", "cachetools>=3.1.1,<5", "cloudpickle>=2.0.0", "fsspec[http]>=2022.11,<2024", "numpy>=1.23,<2", "packaging>=20.9,<24", "pandas>=1.0.0,<2", "pytimeparse>=1.1.8,<2", "pyyaml>=6.0,<7", "s3fs>=2022.11,<2024", "scikit-learn>=1.2.1,<1.4", "scipy>=1.9,<2", "snowflake-connector-python[pandas]>=3.0.4,<4", "snowflake-snowpark-python>=1.5.1,<2", "sqlparse>=0.4,<1", "typing-extensions>=4.1.0,<5", "xgboost>=1.7.3,<2"] diff --git a/snowflake/ml/version.bzl b/snowflake/ml/version.bzl index cdeb4317..9dfd36a1 100644 --- a/snowflake/ml/version.bzl +++ b/snowflake/ml/version.bzl @@ -1,2 +1,2 @@ # This is parsed by regex in conda reciper meta file. Make sure not to break it. -VERSION = "1.0.10" +VERSION = "1.0.11" diff --git a/tests/conftest.py b/tests/conftest.py index 2b7d7b96..959a94e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ +import inspect import os from unittest import mock +import cloudpickle as cp import pytest from snowflake.snowpark._internal.utils import TempObjectType @@ -17,4 +19,5 @@ def random_name_for_temp_object_mock(): with mock.patch( "snowflake.ml.modeling._internal.snowpark_handlers.random_name_for_temp_object", _random_name_for_temp_object ) as _fixture: + cp.register_pickle_by_value(inspect.getmodule(_random_name_for_temp_object)) yield _fixture diff --git a/tests/integ/snowflake/ml/_internal/BUILD.bazel b/tests/integ/snowflake/ml/_internal/BUILD.bazel index 9acf5aa9..dd7be706 100644 --- a/tests/integ/snowflake/ml/_internal/BUILD.bazel +++ b/tests/integ/snowflake/ml/_internal/BUILD.bazel @@ -71,3 +71,16 @@ py_test( "//snowflake/ml/utils:connection_params", ], ) + +py_test( + name = "search_single_node_test", + srcs = ["search_single_node_test.py"], + shard_count = 4, + deps = [ + "//snowflake/ml/modeling/_internal:estimator_utils", + "//snowflake/ml/modeling/model_selection/_internal:_grid_search_cv", + "//snowflake/ml/modeling/model_selection/_internal:_randomized_search_cv", + "//snowflake/ml/modeling/xgboost:xgb_classifier", + "//snowflake/ml/utils:connection_params", + ], +) diff --git a/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py b/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py index 65b85be4..a2bd5ad0 100644 --- a/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py +++ b/tests/integ/snowflake/ml/_internal/grid_search_integ_test.py @@ -1,3 +1,5 @@ +from unittest import mock + import inflection import numpy as np from absl.testing.absltest import TestCase, main @@ -35,7 +37,9 @@ def _compare_cv_results(self, cv_result_1, cv_result_2) -> None: np.testing.assert_allclose(v, cv_result_2[k], rtol=1.0e-1, atol=1.0e-2) # Do not compare the fit time - def test_fit_and_compare_results(self) -> None: + @mock.patch("snowflake.ml.modeling.model_selection._internal._grid_search_cv.if_single_node") + def test_fit_and_compare_results(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = True # falls back to HPO implementation input_df_pandas = load_diabetes(as_frame=True).frame input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] @@ -56,13 +60,20 @@ def test_fit_and_compare_results(self) -> None: actual_arr = reg.predict(input_df).to_pandas().sort_values(by="INDEX")[output_cols].to_numpy() sklearn_numpy_arr = sklearn_reg.predict(input_df_pandas[input_cols]) + # the result of SnowML grid search cv should behave the same as sklearn's assert reg._sklearn_object.best_params_ == sklearn_reg.best_params_ np.testing.assert_allclose(reg._sklearn_object.best_score_, sklearn_reg.best_score_) self._compare_cv_results(reg._sklearn_object.cv_results_, sklearn_reg.cv_results_) np.testing.assert_allclose(actual_arr.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) - def test_fit_xgboost(self) -> None: + # Test on fitting on snowpark Dataframe, and predict on pandas dataframe + actual_arr_pd = reg.predict(input_df.to_pandas()).sort_values(by="INDEX")[output_cols].to_numpy() + np.testing.assert_allclose(actual_arr_pd.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) + + @mock.patch("snowflake.ml.modeling.model_selection._internal._grid_search_cv.if_single_node") + def test_fit_xgboost(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = True # falls back to HPO implementation input_df_pandas = load_iris(as_frame=True).frame input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] diff --git a/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py b/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py index 6eff1f9c..eac60515 100644 --- a/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py +++ b/tests/integ/snowflake/ml/_internal/randomized_search_integ_test.py @@ -1,3 +1,5 @@ +from unittest import mock + import inflection import numpy as np from absl.testing.absltest import TestCase, main @@ -34,7 +36,9 @@ def _compare_cv_results(self, cv_result_1, cv_result_2) -> None: np.testing.assert_allclose(v, cv_result_2[k], rtol=1.0e-1, atol=1.0e-2) # Do not compare the fit time - def test_fit_and_compare_results(self) -> None: + @mock.patch("snowflake.ml.modeling.model_selection._internal._randomized_search_cv.if_single_node") + def test_fit_and_compare_results(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = True # falls back to HPO implementation input_df_pandas = load_iris(as_frame=True).frame input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] @@ -67,12 +71,19 @@ def test_fit_and_compare_results(self) -> None: actual_arr = reg.predict(input_df).to_pandas().sort_values(by="INDEX")[output_cols].to_numpy() sklearn_numpy_arr = sklearn_reg.predict(input_df_pandas[input_cols]) - np.testing.assert_allclose(reg._sklearn_object.best_score_, sklearn_reg.best_score_) - assert reg._sklearn_object.best_params_ == sklearn_reg.best_params_ - self._compare_cv_results(reg._sklearn_object.cv_results_, sklearn_reg.cv_results_) + sk_obj = reg.to_sklearn() + + # the result of SnowML grid search cv should behave the same as sklearn's + np.testing.assert_allclose(sk_obj.best_score_, sklearn_reg.best_score_) + assert sk_obj.best_params_ == sklearn_reg.best_params_ + self._compare_cv_results(sk_obj.cv_results_, sklearn_reg.cv_results_) np.testing.assert_allclose(actual_arr.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) + # Test on fitting on snowpark Dataframe, and predict on pandas dataframe + actual_arr_pd = reg.predict(input_df.to_pandas()).sort_values(by="INDEX")[output_cols].to_numpy() + np.testing.assert_allclose(actual_arr_pd.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) + if __name__ == "__main__": main() diff --git a/tests/integ/snowflake/ml/_internal/search_single_node_test.py b/tests/integ/snowflake/ml/_internal/search_single_node_test.py new file mode 100644 index 00000000..827b7ccd --- /dev/null +++ b/tests/integ/snowflake/ml/_internal/search_single_node_test.py @@ -0,0 +1,125 @@ +from unittest import mock + +import inflection +from absl.testing import absltest +from sklearn.datasets import load_iris + +from snowflake.ml.modeling.model_selection._internal import ( + GridSearchCV, + RandomizedSearchCV, +) +from snowflake.ml.modeling.xgboost import XGBClassifier +from snowflake.ml.utils.connection_params import SnowflakeLoginOptions +from snowflake.snowpark import Session + + +class SearchSingleNodeTest(absltest.TestCase): + def setUp(self) -> None: + self._session = Session.builder.configs(SnowflakeLoginOptions()).create() + + def tearDown(self) -> None: + self._session.close() + + @mock.patch("snowflake.ml.modeling.model_selection._internal._grid_search_cv.if_single_node") + def test_single_node_grid(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = True + input_df_pandas = load_iris(as_frame=True).frame + input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] + input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] + label_col = [c for c in input_df_pandas.columns if c.startswith("TARGET")] + input_df_pandas["INDEX"] = input_df_pandas.reset_index().index + input_df = self._session.create_dataframe(input_df_pandas) + + parameters = { + "learning_rate": [0.1], # reduce the parameters into one to accelerate the test process + } + + estimator = XGBClassifier() + reg = GridSearchCV(estimator=estimator, param_grid=parameters, cv=2, verbose=True) + reg.set_input_cols(input_cols) + output_cols = ["OUTPUT_" + c for c in label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(label_col) + reg.fit(input_df) + + self.assertEqual(reg.to_sklearn(), reg._sklearn_object) + + self.assertEqual(reg._sklearn_object.n_jobs, -1) + + @mock.patch("snowflake.ml.modeling.model_selection._internal._randomized_search_cv.if_single_node") + def test_single_node_random(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = True + input_df_pandas = load_iris(as_frame=True).frame + input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] + input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] + label_col = [c for c in input_df_pandas.columns if c.startswith("TARGET")] + input_df_pandas["INDEX"] = input_df_pandas.reset_index().index + input_df = self._session.create_dataframe(input_df_pandas) + + parameters = { + "learning_rate": [0.1], # reduce the parameters into one to accelerate the test process + } + + estimator = XGBClassifier() + reg = RandomizedSearchCV(estimator=estimator, param_distributions=parameters, cv=2, verbose=True) + reg.set_input_cols(input_cols) + output_cols = ["OUTPUT_" + c for c in label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(label_col) + reg.fit(input_df) + + self.assertEqual(reg.to_sklearn(), reg._sklearn_object) + + self.assertEqual(reg._sklearn_object.n_jobs, -1) + + @mock.patch("snowflake.ml.modeling.model_selection._internal._grid_search_cv.if_single_node") + def test_not_single_node_grid(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = False + input_df_pandas = load_iris(as_frame=True).frame + input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] + input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] + label_col = [c for c in input_df_pandas.columns if c.startswith("TARGET")] + input_df_pandas["INDEX"] = input_df_pandas.reset_index().index + input_df = self._session.create_dataframe(input_df_pandas) + + parameters = { + "learning_rate": [0.1], + } + + estimator = XGBClassifier() + reg = GridSearchCV(estimator=estimator, param_grid=parameters, cv=2, verbose=True) + reg.set_input_cols(input_cols) + output_cols = ["OUTPUT_" + c for c in label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(label_col) + reg.fit(input_df) + + self.assertEqual(reg._sklearn_object.estimator.n_jobs, 3) + + @mock.patch("snowflake.ml.modeling.model_selection._internal._randomized_search_cv.if_single_node") + def test_not_single_node_random(self, mock_if_single_node) -> None: + mock_if_single_node.return_value = False + input_df_pandas = load_iris(as_frame=True).frame + input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] + input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] + label_col = [c for c in input_df_pandas.columns if c.startswith("TARGET")] + input_df_pandas["INDEX"] = input_df_pandas.reset_index().index + input_df = self._session.create_dataframe(input_df_pandas) + + parameters = { + "learning_rate": [0.1], # reduce the parameters into one to accelerate the test process + } + + estimator = XGBClassifier() + reg = RandomizedSearchCV(estimator=estimator, param_distributions=parameters, cv=2, verbose=True) + reg.set_input_cols(input_cols) + output_cols = ["OUTPUT_" + c for c in label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(label_col) + reg.fit(input_df) + + self.assertEqual(reg._sklearn_object.estimator.n_jobs, 3) + + +if __name__ == "__main__": + absltest.main() diff --git a/tests/integ/snowflake/ml/extra_tests/BUILD.bazel b/tests/integ/snowflake/ml/extra_tests/BUILD.bazel index aa746846..623b67eb 100644 --- a/tests/integ/snowflake/ml/extra_tests/BUILD.bazel +++ b/tests/integ/snowflake/ml/extra_tests/BUILD.bazel @@ -18,6 +18,7 @@ py_test( "//snowflake/ml/modeling/linear_model:logistic_regression", "//snowflake/ml/modeling/model_selection:grid_search_cv", "//snowflake/ml/modeling/svm:svr", + "//snowflake/ml/modeling/xgboost:xgb_regressor", "//snowflake/ml/utils:connection_params", ], ) @@ -122,3 +123,12 @@ py_test( "//snowflake/ml/utils:connection_params", ], ) + +py_test( + name = "decimal_type_test", + srcs = ["decimal_type_test.py"], + deps = [ + "//snowflake/ml/modeling/linear_model:linear_regression", + "//snowflake/ml/utils:connection_params", + ], +) diff --git a/tests/integ/snowflake/ml/extra_tests/decimal_type_test.py b/tests/integ/snowflake/ml/extra_tests/decimal_type_test.py new file mode 100644 index 00000000..7b4f8959 --- /dev/null +++ b/tests/integ/snowflake/ml/extra_tests/decimal_type_test.py @@ -0,0 +1,56 @@ +from typing import List, Tuple + +import inflection +import numpy as np +import pandas as pd +from absl.testing.absltest import TestCase, main +from sklearn.datasets import load_diabetes +from sklearn.linear_model import LinearRegression as SkLinearRegression + +from snowflake.ml.modeling.linear_model import LinearRegression +from snowflake.ml.utils.connection_params import SnowflakeLoginOptions +from snowflake.snowpark import DataFrame, Session, functions, types + + +class DecimalTypeTest(TestCase): + def setUp(self) -> None: + """Creates Snowpark and Snowflake environments for testing.""" + self._session = Session.builder.configs(SnowflakeLoginOptions()).create() + + def tearDown(self) -> None: + self._session.close() + + def _get_test_dataset(self) -> Tuple[pd.DataFrame, DataFrame, List[str], List[str]]: + input_df_pandas = load_diabetes(as_frame=True).frame + # Normalize column names + input_df_pandas.columns = [inflection.parameterize(c, "_").upper() for c in input_df_pandas.columns] + input_cols = [c for c in input_df_pandas.columns if not c.startswith("TARGET")] + label_col = [c for c in input_df_pandas.columns if c.startswith("TARGET")] + input_df = self._session.create_dataframe(input_df_pandas) + # casting every columns as decimal type + fields = input_df.schema.fields + selected_cols = [] + for field in fields: + src = field.column_identifier.quoted_name + dest = types.DecimalType(15, 10) + selected_cols.append(functions.cast(functions.col(src), dest).alias(src)) + input_df = input_df.select(selected_cols) + return (input_df_pandas, input_df, input_cols, label_col) + + def test_decimal_type(self) -> None: + input_df_pandas, input_df, input_cols, label_cols = self._get_test_dataset() + + sklearn_reg = SkLinearRegression() + reg = LinearRegression(input_cols=input_cols, label_cols=label_cols) + + sklearn_reg.fit(input_df_pandas[input_cols], input_df_pandas[label_cols]) + reg.fit(input_df) + + actual_results = reg.predict(input_df_pandas)[reg.get_output_cols()].to_numpy() + sklearn_results = sklearn_reg.predict(input_df_pandas[input_cols]) + + np.testing.assert_allclose(actual_results.flatten(), sklearn_results.flatten()) + + +if __name__ == "__main__": + main() diff --git a/tests/integ/snowflake/ml/extra_tests/grid_search_test.py b/tests/integ/snowflake/ml/extra_tests/grid_search_test.py index 2634e788..601b3c7a 100644 --- a/tests/integ/snowflake/ml/extra_tests/grid_search_test.py +++ b/tests/integ/snowflake/ml/extra_tests/grid_search_test.py @@ -5,9 +5,11 @@ from sklearn.model_selection import GridSearchCV as SkGridSearchCV from sklearn.svm import SVR as SkSVR from snowflake.ml.modeling.linear_model.logistic_regression import LogisticRegression +from xgboost import XGBRegressor as xgboost_regressor from snowflake.ml.modeling.model_selection import GridSearchCV from snowflake.ml.modeling.svm import SVR +from snowflake.ml.modeling.xgboost import XGBRegressor from snowflake.ml.utils.connection_params import SnowflakeLoginOptions from snowflake.snowpark import Session @@ -28,20 +30,27 @@ def test_fit_and_compare_results(self) -> None: input_df_pandas["INDEX"] = input_df_pandas.reset_index().index input_df = self._session.create_dataframe(input_df_pandas) - sklearn_reg = SkGridSearchCV(estimator=SkSVR(), param_grid={"C": [1, 10], "kernel": ("linear", "rbf")}) - reg = GridSearchCV(estimator=SVR(), param_grid={"C": [1, 10], "kernel": ("linear", "rbf")}) - reg.set_input_cols(input_cols) - output_cols = ["OUTPUT_" + c for c in label_col] - reg.set_output_cols(output_cols) - reg.set_label_cols(label_col) + for Estimator, SKEstimator, params in [ + (SVR, SkSVR, {"C": [1, 10], "kernel": ("linear", "rbf")}), + (XGBRegressor, xgboost_regressor, {"n_estimators": [5, 10]}), + ]: + with self.subTest(): + sklearn_reg = SkGridSearchCV(estimator=SKEstimator(), param_grid=params) + reg = GridSearchCV(estimator=Estimator(), param_grid=params) + reg.set_input_cols(input_cols) + output_cols = ["OUTPUT_" + c for c in label_col] + reg.set_output_cols(output_cols) + reg.set_label_cols(label_col) - reg.fit(input_df) - sklearn_reg.fit(X=input_df_pandas[input_cols], y=input_df_pandas[label_col].squeeze()) + reg.fit(input_df) + sklearn_reg.fit(X=input_df_pandas[input_cols], y=input_df_pandas[label_col].squeeze()) - actual_arr = reg.predict(input_df).to_pandas().sort_values(by="INDEX")[output_cols].astype("float64").to_numpy() - sklearn_numpy_arr = sklearn_reg.predict(input_df_pandas[input_cols]) + actual_arr = ( + reg.predict(input_df).to_pandas().sort_values(by="INDEX")[output_cols].astype("float64").to_numpy() + ) + sklearn_numpy_arr = sklearn_reg.predict(input_df_pandas[input_cols]) - np.testing.assert_allclose(actual_arr.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) + np.testing.assert_allclose(actual_arr.flatten(), sklearn_numpy_arr.flatten(), rtol=1.0e-1, atol=1.0e-2) def test_invalid_alias_pattern(self) -> None: """ @@ -71,9 +80,6 @@ def test_invalid_alias_pattern(self) -> None: reg.set_output_cols(output_cols) reg.set_label_cols(label_col) - q = reg.fit(input_df).predict_proba(input_df).queries["queries"][-1] - print(q) - reg.fit(input_df).predict_proba(input_df).collect() diff --git a/tests/integ/snowflake/ml/model/BUILD.bazel b/tests/integ/snowflake/ml/model/BUILD.bazel index b1e012e4..f7e8b307 100644 --- a/tests/integ/snowflake/ml/model/BUILD.bazel +++ b/tests/integ/snowflake/ml/model/BUILD.bazel @@ -152,3 +152,21 @@ py_test( "//tests/integ/snowflake/ml/test_utils:db_manager", ], ) + +#TODO(halu): Needs support of pip package for build & test +#py_test( +# name = "spcs_llm_model_integ_test", +# timeout = "eternal", # 3600s, GPU image takes very long to build.. +# srcs = ["spcs_llm_model_integ_test.py"], +# compatible_with_snowpark = False, +# deps = [ +# ":warehouse_model_integ_test_utils", +# "//snowflake/ml/_internal:env_utils", +# "//snowflake/ml/model:type_hints", +# "//snowflake/ml/model/models:llm_model", +# "//snowflake/ml/utils:connection_params", +# "//tests/integ/snowflake/ml/test_utils:db_manager", +# "//tests/integ/snowflake/ml/test_utils:spcs_integ_test_base", +# "//tests/integ/snowflake/ml/test_utils:test_env_utils", +# ], +#) diff --git a/tests/integ/snowflake/ml/model/model_badcase_integ_test.py b/tests/integ/snowflake/ml/model/model_badcase_integ_test.py index 5cbb402e..c1c9133b 100644 --- a/tests/integ/snowflake/ml/model/model_badcase_integ_test.py +++ b/tests/integ/snowflake/ml/model/model_badcase_integ_test.py @@ -99,7 +99,7 @@ def test_custom_demo_model(self) -> None: model_stage_file_path=posixpath.join(tmp_stage, "custom_demo_model.zip"), model=lm, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python") ], sample_input=pd_df, metadata={"author": "halu", "version": "1"}, diff --git a/tests/integ/snowflake/ml/model/spcs_llm_model_integ_test.py b/tests/integ/snowflake/ml/model/spcs_llm_model_integ_test.py new file mode 100644 index 00000000..e86cca8f --- /dev/null +++ b/tests/integ/snowflake/ml/model/spcs_llm_model_integ_test.py @@ -0,0 +1,109 @@ +# import os +# import tempfile +# import uuid + +# import pandas as pd +# from absl.testing import absltest + +# from snowflake.ml.model import ( +# _deployer, +# _model as model_api, +# deploy_platforms, +# type_hints as model_types, +# ) +# from snowflake.ml.model.models import llm +# from tests.integ.snowflake.ml.test_utils import ( +# db_manager, +# spcs_integ_test_base, +# test_env_utils, +# ) + + +# class TestSPCSLLMModelInteg(spcs_integ_test_base.SpcsIntegTestBase): +# @classmethod +# def setUpClass(cls) -> None: +# super().setUpClass() +# cls.cache_dir = tempfile.TemporaryDirectory() +# cls._original_hf_home = os.getenv("HF_HOME", None) +# os.environ["HF_HOME"] = cls.cache_dir.name + +# @classmethod +# def tearDownClass(cls) -> None: +# super().tearDownClass() +# if cls._original_hf_home: +# os.environ["HF_HOME"] = cls._original_hf_home +# else: +# del os.environ["HF_HOME"] +# cls.cache_dir.cleanup() + +# def setUp(self) -> None: +# # Set up a unique id for each artifact, in addition to the class-level prefix. This is particularly useful +# # when differentiating artifacts generated between different test cases, such as service function names. +# self.uid = uuid.uuid4().hex[:4] + +# def test_text_generation_pipeline( +# self, +# ) -> None: +# import peft + +# ft_model = peft.AutoPeftModelForCausalLM.from_pretrained( +# "peft-internal-testing/tiny-OPTForCausalLM-lora", +# device_map="auto", +# ) +# tmpdir = self.create_tempdir().full_path +# ft_model.save_pretrained(tmpdir) +# model = llm.LLM( +# model_id_or_path=tmpdir, +# ) + +# x_df = pd.DataFrame( +# [["Hello world"]], +# ) +# cls = TestSPCSLLMModelInteg +# stage_path = f"@{cls._TEST_STAGE}/{self.uid}/model.zip" +# deployment_stage_path = f"@{cls._TEST_STAGE}/{self.uid}" +# model_api.save_model( # type: ignore[call-overload] +# name="model", +# session=self._session, +# model_stage_file_path=stage_path, +# model=model, +# options={"embed_local_ml_library": True}, +# conda_dependencies=[ +# test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python"), +# ], +# ) +# svc_func_name = db_manager.TestObjectNameGenerator.get_snowml_test_object_name( +# self._RUN_ID, +# f"func_{self.uid}", +# ) +# deployment_options: model_types.SnowparkContainerServiceDeployOptions = { +# "compute_pool": cls._TEST_GPU_COMPUTE_POOL, +# "num_gpus": 1, +# # TODO(halu): Create an separate testing registry. +# # Creating new registry for each single test is costly since no cache hit would ever occurs. +# "image_repo": "sfengineering-mlplatformtest.registry.snowflakecomputing.com/" +# "regtest_db/regtest_schema/halu_test", +# "enable_remote_image_build": True, +# "model_in_image": True, +# } + +# deploy_info = _deployer.deploy( +# name=svc_func_name, +# session=cls._session, +# model_stage_file_path=stage_path, +# deployment_stage_path=deployment_stage_path, +# model_id=svc_func_name, +# platform=deploy_platforms.TargetPlatform.SNOWPARK_CONTAINER_SERVICES, +# options={ +# **deployment_options, # type: ignore[arg-type] +# }, # type: ignore[call-overload] +# ) +# assert deploy_info is not None +# res = _deployer.predict(session=cls._session, deployment=deploy_info, X=x_df) +# self.assertIn("generated_text", res) +# self.assertEqual(len(res["generated_text"]), 1) +# self.assertNotEmpty(res["generated_text"][0]) + + +# if __name__ == "__main__": +# absltest.main() diff --git a/tests/integ/snowflake/ml/model/warehouse_model_integ_test_utils.py b/tests/integ/snowflake/ml/model/warehouse_model_integ_test_utils.py index 842ddedc..9e20b8c5 100644 --- a/tests/integ/snowflake/ml/model/warehouse_model_integ_test_utils.py +++ b/tests/integ/snowflake/ml/model/warehouse_model_integ_test_utils.py @@ -34,12 +34,12 @@ def base_test_case( version_args: Dict[str, Any] = {} tmp_stage = db._session.get_session_stage() conda_dependencies = [ - test_env_utils.get_latest_package_versions_in_server(db._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(db._session, "snowflake-snowpark-python") ] if additional_dependencies: conda_dependencies.extend(additional_dependencies) # We only test when the test is added before the current version available in the server. - snowml_req_str = test_env_utils.get_latest_package_versions_in_server(db._session, "snowflake-ml-python") + snowml_req_str = test_env_utils.get_latest_package_version_spec_in_server(db._session, "snowflake-ml-python") if permanent_deploy: permanent_deploy_args = {"permanent_udf_stage_location": f"@{full_qual_stage}/"} diff --git a/tests/integ/snowflake/ml/modeling/framework/utils.py b/tests/integ/snowflake/ml/modeling/framework/utils.py index 36b8efc1..cfe5ed98 100644 --- a/tests/integ/snowflake/ml/modeling/framework/utils.py +++ b/tests/integ/snowflake/ml/modeling/framework/utils.py @@ -141,7 +141,7 @@ class DataType(Enum): def gen_fuzz_data( rows: int, types: List[DataType], low: Union[int, List[int]] = MIN_INT, high: Union[int, List[int]] = MAX_INT -) -> Tuple[List[Any], List[str]]: +) -> Tuple[List[Any], List[str], List[str]]: """ Generate random data based on input column types and row count. First column in the result data will be an ID column for indexing. @@ -160,6 +160,7 @@ def gen_fuzz_data( """ data: List[npt.NDArray[Any]] = [np.arange(1, rows + 1, 1)] names = ["ID"] + snowflake_identifiers = ["ID"] for idx, t in enumerate(types): _low = low if isinstance(low, int) else low[idx] @@ -170,9 +171,11 @@ def gen_fuzz_data( data.append(np.random.uniform(_low, _high, rows)) else: raise ValueError(f"Unsupported data type {t}") - names.append(f"COL_{idx}") + names.append(f"col_{idx}") + snowflake_identifiers.append(f'"col_{idx}"') + data = np.core.records.fromarrays(data, names=names).tolist() # type: ignore[call-overload] - return np.core.records.fromarrays(data, names=names).tolist(), names # type: ignore[call-overload] + return data, names, snowflake_identifiers def get_df( @@ -181,12 +184,14 @@ def get_df( schema: List[str], fillna: Optional[Union[object, ArrayLike]] = None, ) -> Tuple[pd.DataFrame, DataFrame]: - """Create pandas dataframe and Snowpark dataframes from input data. + """Create pandas dataframe and Snowpark dataframes from input data. The schema passed should be + a pandas schema, which will be converted to a schema using snowflake identifiers when `session.create_dataframe` + is called. Args: session: Snowpark session object. data: List of input data to convert to dataframe. - schema: Schema for dataframe to be created. + schema: The pandas schema for dataframe to be created. fillna: Value to fill for NA values in the input data. Returns: @@ -196,6 +201,8 @@ def get_df( if fillna is not None: df_pandas.fillna(value=fillna, inplace=True) df = session.create_dataframe(df_pandas) + df_pandas.columns = df.columns + return df_pandas, df diff --git a/tests/integ/snowflake/ml/modeling/metrics/accuracy_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/accuracy_score_test.py index b30e9c92..95ac3d32 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/accuracy_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/accuracy_score_test.py @@ -1,6 +1,5 @@ from typing import Any, Dict -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -12,23 +11,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class AccuracyScoreTest(parameterized.TestCase): @@ -57,8 +56,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_score = snowml_metrics.accuracy_score( @@ -91,8 +89,7 @@ def test_normalized(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for normalize in params["normalize"]: actual_score = snowml_metrics.accuracy_score( diff --git a/tests/integ/snowflake/ml/modeling/metrics/confusion_matrix_test.py b/tests/integ/snowflake/ml/modeling/metrics/confusion_matrix_test.py index c454a72d..c48b402a 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/confusion_matrix_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/confusion_matrix_test.py @@ -10,15 +10,15 @@ from snowflake.ml.utils import connection_params from tests.integ.snowflake.ml.modeling.framework import utils -_DATA, _SCHEMA = utils.gen_fuzz_data( +_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=100, types=[utils.DataType.INTEGER] * 2 + [utils.DataType.FLOAT], low=-1, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_SAMPLE_WEIGHT_COL = _SCHEMA[3] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[3] class ConfusionMatrixTest(parameterized.TestCase): @@ -35,8 +35,7 @@ def tearDown(self) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - input_df = self._session.create_dataframe(_DATA, schema=_SCHEMA) - pandas_df = input_df.to_pandas() + pandas_df, input_df = utils.get_df(self._session, _DATA, _PD_SCHEMA) for labels in params["labels"]: actual_cm = snowml_metrics.confusion_matrix( @@ -56,8 +55,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: {"params": {"sample_weight_col_name": [None, _SAMPLE_WEIGHT_COL]}}, ) def test_sample_weight(self, params: Dict[str, Any]) -> None: - input_df = self._session.create_dataframe(_DATA, schema=_SCHEMA) - pandas_df = input_df.to_pandas() + pandas_df, input_df = utils.get_df(self._session, _DATA, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_cm = snowml_metrics.confusion_matrix( @@ -78,8 +76,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"normalize": ["true", "pred", "all", None]}}, ) def test_normalize(self, params: Dict[str, Any]) -> None: - input_df = self._session.create_dataframe(_DATA, schema=_SCHEMA) - pandas_df = input_df.to_pandas() + pandas_df, input_df = utils.get_df(self._session, _DATA, _PD_SCHEMA) for normalize in params["normalize"]: actual_cm = snowml_metrics.confusion_matrix( @@ -101,7 +98,7 @@ def test_normalize(self, params: Dict[str, Any]) -> None: {"params": {"normalize": "invalid"}}, ) def test_invalid_params(self, params: Dict[str, Any]) -> None: - input_df = self._session.create_dataframe(_DATA, schema=_SCHEMA) + input_df = self._session.create_dataframe(_DATA, schema=_SF_SCHEMA) if "labels" in params: with self.assertRaises(ValueError): diff --git a/tests/integ/snowflake/ml/modeling/metrics/d2_absolute_error_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/d2_absolute_error_score_test.py index 1600c144..b61fef1c 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/d2_absolute_error_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/d2_absolute_error_score_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,23 +13,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.d2_absolute_error_score( @@ -90,8 +88,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"multioutput": ["raw_values", "uniform_average", [0.2, 1.0, 1.66]]}}, ) def test_multioutput(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) for multioutput in params["multioutput"]: actual_loss = snowml_metrics.d2_absolute_error_score( @@ -108,8 +105,7 @@ def test_multioutput(self, params: Dict[str, Any]) -> None: np.testing.assert_allclose(actual_loss, sklearn_loss) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.d2_absolute_error_score( df=input_df, @@ -124,8 +120,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.regression.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_loss = snowml_metrics.d2_absolute_error_score( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/d2_pinball_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/d2_pinball_score_test.py index 2d20cedc..e36839a7 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/d2_pinball_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/d2_pinball_score_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,23 +13,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,8 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.d2_pinball_score( @@ -102,8 +101,7 @@ def test_alpha(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for alpha in params["alpha"]: actual_loss = snowml_metrics.d2_pinball_score( @@ -123,8 +121,7 @@ def test_alpha(self, params: Dict[str, Any]) -> None: {"params": {"multioutput": ["raw_values", "uniform_average", [0.2, 1.0, 1.66]]}}, ) def test_multioutput(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) for multioutput in params["multioutput"]: actual_loss = snowml_metrics.d2_pinball_score( @@ -141,8 +138,7 @@ def test_multioutput(self, params: Dict[str, Any]) -> None: np.testing.assert_allclose(actual_loss, sklearn_loss) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.d2_pinball_score( df=input_df, @@ -157,8 +153,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.regression.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_loss = snowml_metrics.d2_pinball_score( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/explained_variance_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/explained_variance_score_test.py index cd307a16..9a79db02 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/explained_variance_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/explained_variance_score_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,23 +13,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.explained_variance_score( @@ -90,8 +88,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"multioutput": ["raw_values", "uniform_average", [0.2, 1.0, 1.66]]}}, ) def test_multioutput(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) for multioutput in params["multioutput"]: actual_loss = snowml_metrics.explained_variance_score( @@ -123,8 +120,7 @@ def test_force_finite(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for force_finite in params["force_finite"]: actual_loss = snowml_metrics.explained_variance_score( @@ -141,8 +137,7 @@ def test_force_finite(self, params: Dict[str, Any]) -> None: self.assertAlmostEqual(sklearn_loss, actual_loss) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.explained_variance_score( df=input_df, @@ -157,8 +152,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.regression.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_loss = snowml_metrics.explained_variance_score( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/f1_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/f1_score_test.py index 4e50cee9..9341bfbe 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/f1_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/f1_score_test.py @@ -1,7 +1,6 @@ from typing import Any, Dict import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import exceptions, metrics as sklearn_metrics @@ -13,23 +12,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class F1ScoreTest(parameterized.TestCase): @@ -46,8 +45,7 @@ def tearDown(self) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_f = snowml_metrics.f1_score( @@ -69,8 +67,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_f = snowml_metrics.f1_score( @@ -92,8 +89,7 @@ def test_pos_label(self, params: Dict[str, Any]) -> None: {"params": {"average": [None, "micro", "macro", "weighted"]}}, ) def test_average_multiclass(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for average in params["average"]: actual_f = snowml_metrics.f1_score( @@ -119,8 +115,7 @@ def test_average_multiclass(self, params: Dict[str, Any]) -> None: }, ) def test_average_binary(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for idx, average in enumerate(params["average"]): y_true = params["y_true"][idx] @@ -154,8 +149,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_f = snowml_metrics.f1_score( @@ -181,8 +175,7 @@ def test_zero_division(self, params: Dict[str, Any]) -> None: data = [ [0, 0, 0, 0, 0, 0], ] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for zero_division in params["zero_division"]: if zero_division == "warn": diff --git a/tests/integ/snowflake/ml/modeling/metrics/fbeta_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/fbeta_score_test.py index 226f6eff..f40cc576 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/fbeta_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/fbeta_score_test.py @@ -1,7 +1,6 @@ from typing import Any, Dict import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import exceptions, metrics as sklearn_metrics @@ -13,23 +12,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class FbetaScoreTest(parameterized.TestCase): @@ -58,8 +57,7 @@ def test_beta(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for beta in params["beta"]: actual_f = snowml_metrics.fbeta_score( @@ -81,8 +79,7 @@ def test_beta(self, params: Dict[str, Any]) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_f = snowml_metrics.fbeta_score( @@ -106,8 +103,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_f = snowml_metrics.fbeta_score( @@ -131,8 +127,7 @@ def test_pos_label(self, params: Dict[str, Any]) -> None: {"params": {"average": [None, "micro", "macro", "weighted"]}}, ) def test_average_multiclass(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for average in params["average"]: actual_f = snowml_metrics.fbeta_score( @@ -160,8 +155,7 @@ def test_average_multiclass(self, params: Dict[str, Any]) -> None: }, ) def test_average_binary(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for idx, average in enumerate(params["average"]): y_true = params["y_true"][idx] @@ -197,8 +191,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_f = snowml_metrics.fbeta_score( @@ -226,8 +219,7 @@ def test_zero_division(self, params: Dict[str, Any]) -> None: data = [ [0, 0, 0, 0, 0, 0], ] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for zero_division in params["zero_division"]: if zero_division == "warn": diff --git a/tests/integ/snowflake/ml/modeling/metrics/log_loss_test.py b/tests/integ/snowflake/ml/modeling/metrics/log_loss_test.py index 42672bea..f0242535 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/log_loss_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/log_loss_test.py @@ -1,6 +1,5 @@ from typing import Any, Dict -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -12,14 +11,14 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] + [utils.DataType.FLOAT] * 4 -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=[2, 1, 1, 1, 1], ) -_BINARY_Y_TRUE_COL = _SCHEMA[1] -_BINARY_Y_PRED_COL = _SCHEMA[2] +_BINARY_Y_TRUE_COL = _SF_SCHEMA[1] +_BINARY_Y_PRED_COL = _SF_SCHEMA[2] _MULTICLASS_DATA = [ [0, 2, 0.29, 0.49, 0.22, 0.18], [1, 0, 0.33, 0.16, 0.51, 0.69], @@ -28,9 +27,9 @@ [4, 1, 0.82, 0.12, 0.06, 0.91], [5, 2, 0.08, 0.46, 0.46, 0.76], ] -_MULTICLASS_Y_TRUE_COL = _SCHEMA[1] -_MULTICLASS_Y_PRED_COLS = [_SCHEMA[2], _SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_MULTICLASS_Y_TRUE_COL = _SF_SCHEMA[1] +_MULTICLASS_Y_PRED_COLS = [_SF_SCHEMA[2], _SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,7 @@ def test_eps(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for eps in params["eps"]: actual_loss = snowml_metrics.log_loss( @@ -101,8 +99,7 @@ def test_normalize(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for normalize in params["normalize"]: actual_loss = snowml_metrics.log_loss( @@ -134,8 +131,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.log_loss( @@ -156,8 +152,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_loss = snowml_metrics.log_loss( @@ -174,8 +169,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: self.assertAlmostEqual(sklearn_loss, actual_loss) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.log_loss( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_error_test.py b/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_error_test.py index f858fe2c..44e73cfa 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_error_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_error_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,23 +13,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.mean_absolute_error( @@ -90,8 +88,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"multioutput": ["raw_values", "uniform_average", [0.2, 1.0, 1.66]]}}, ) def test_multioutput(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) for multioutput in params["multioutput"]: actual_loss = snowml_metrics.mean_absolute_error( @@ -108,8 +105,7 @@ def test_multioutput(self, params: Dict[str, Any]) -> None: np.testing.assert_allclose(actual_loss, sklearn_loss) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.mean_absolute_error( df=input_df, @@ -124,8 +120,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.regression.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_loss = snowml_metrics.mean_absolute_error( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_percentage_error_test.py b/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_percentage_error_test.py index 6d0676e1..7b657228 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_percentage_error_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/mean_absolute_percentage_error_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,23 +13,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.mean_absolute_percentage_error( @@ -90,8 +88,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"multioutput": ["raw_values", "uniform_average", [0.2, 1.0, 1.66]]}}, ) def test_multioutput(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) for multioutput in params["multioutput"]: actual_loss = snowml_metrics.mean_absolute_percentage_error( @@ -108,8 +105,7 @@ def test_multioutput(self, params: Dict[str, Any]) -> None: np.testing.assert_allclose(actual_loss, sklearn_loss, rtol=0.000001) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.mean_absolute_percentage_error( df=input_df, @@ -124,8 +120,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.regression.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_loss = snowml_metrics.mean_absolute_percentage_error( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/mean_squared_error_test.py b/tests/integ/snowflake/ml/modeling/metrics/mean_squared_error_test.py index 996338c2..4a93650a 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/mean_squared_error_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/mean_squared_error_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,23 +13,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -68,8 +67,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_loss = snowml_metrics.mean_squared_error( @@ -90,8 +88,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"multioutput": ["raw_values", "uniform_average", [0.2, 1.0, 1.66]]}}, ) def test_multioutput(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) for multioutput in params["multioutput"]: actual_loss = snowml_metrics.mean_squared_error( @@ -123,8 +120,7 @@ def test_squared(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for squared in params["squared"]: actual_loss = snowml_metrics.mean_squared_error( @@ -141,8 +137,7 @@ def test_squared(self, params: Dict[str, Any]) -> None: self.assertAlmostEqual(sklearn_loss, actual_loss) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_loss = snowml_metrics.mean_squared_error( df=input_df, @@ -157,8 +152,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.regression.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_loss = snowml_metrics.mean_squared_error( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/metrics_utils_test.py b/tests/integ/snowflake/ml/modeling/metrics/metrics_utils_test.py index 49a3a607..6d638664 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/metrics_utils_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/metrics_utils_test.py @@ -1,5 +1,4 @@ import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main @@ -10,15 +9,15 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class MetricsUtilsTest(parameterized.TestCase): @@ -38,8 +37,7 @@ def tearDown(self) -> None: normalize=(False, True), ) def test_weighted_sum(self, df, sample_weight_col_name, sample_score_col_name, normalize) -> None: - pandas_df = pd.DataFrame(df, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, df, _PD_SCHEMA) snowpark_weight_col = input_df[sample_weight_col_name] if sample_weight_col_name else None actual_sum = metrics_utils.weighted_sum( diff --git a/tests/integ/snowflake/ml/modeling/metrics/precision_recall_curve_test.py b/tests/integ/snowflake/ml/modeling/metrics/precision_recall_curve_test.py index d5691e64..8c8b7d2f 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/precision_recall_curve_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/precision_recall_curve_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,15 +13,15 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] + [utils.DataType.FLOAT] * 2 -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=[2, 1, 1], ) -_Y_TRUE_COL = _SCHEMA[1] -_PROBAS_PRED_COL = _SCHEMA[2] -_SAMPLE_WEIGHT_COL = _SCHEMA[3] +_Y_TRUE_COL = _SF_SCHEMA[1] +_PROBAS_PRED_COL = _SF_SCHEMA[2] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[3] class PrecisionRecallCurveTest(parameterized.TestCase): @@ -39,8 +38,7 @@ def tearDown(self) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_precision, actual_recall, actual_thresholds = snowml_metrics.precision_recall_curve( @@ -62,8 +60,7 @@ def test_pos_label(self, params: Dict[str, Any]) -> None: {"params": {"sample_weight_col_name": [None, _SAMPLE_WEIGHT_COL]}}, ) def test_sample_weight(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_precision, actual_recall, actual_thresholds = snowml_metrics.precision_recall_curve( @@ -84,8 +81,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: @mock.patch("snowflake.ml.modeling.metrics.ranking.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_precision, actual_recall, actual_thresholds = snowml_metrics.precision_recall_curve( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/precision_recall_fscore_support_test.py b/tests/integ/snowflake/ml/modeling/metrics/precision_recall_fscore_support_test.py index f5928778..f4d1c06c 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/precision_recall_fscore_support_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/precision_recall_fscore_support_test.py @@ -1,7 +1,6 @@ from typing import Any, Dict import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import exceptions, metrics as sklearn_metrics @@ -13,23 +12,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class PrecisionRecallFscoreSupportTest(parameterized.TestCase): @@ -58,8 +57,7 @@ def test_beta(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for beta in params["beta"]: actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( @@ -82,8 +80,7 @@ def test_beta(self, params: Dict[str, Any]) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( @@ -106,8 +103,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( @@ -142,8 +138,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( @@ -167,8 +162,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"average": [None, "micro", "macro", "weighted"]}}, ) def test_average_multiclass(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for average in params["average"]: actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( @@ -195,8 +189,8 @@ def test_average_multiclass(self, params: Dict[str, Any]) -> None: sample_weight_col_name=(None, _SAMPLE_WEIGHT_COL), ) def test_average_binary_samples(self, y_true, y_pred, average, sample_weight_col_name) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) + actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( df=input_df, y_true_col_names=y_true, @@ -221,8 +215,7 @@ def test_zero_division(self, params: Dict[str, Any]) -> None: [0, 0, 0, 0, 0, 0], [1, 1, 0, 0, 0, 0], ] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for zero_division in params["zero_division"]: if zero_division == "warn": @@ -265,8 +258,7 @@ def test_zero_division(self, params: Dict[str, Any]) -> None: def test_no_sample(self) -> None: data = [] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) actual_p, actual_r, actual_f, actual_s = snowml_metrics.precision_recall_fscore_support( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/precision_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/precision_score_test.py index fe22be7b..5ec6d5a2 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/precision_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/precision_score_test.py @@ -1,7 +1,6 @@ from typing import Any, Dict import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import exceptions, metrics as sklearn_metrics @@ -13,23 +12,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class PrecisionScoreTest(parameterized.TestCase): @@ -46,8 +45,7 @@ def tearDown(self) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_p = snowml_metrics.precision_score( @@ -69,8 +67,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_p = snowml_metrics.precision_score( @@ -104,8 +101,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_p = snowml_metrics.precision_score( @@ -128,8 +124,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"average": [None, "micro", "macro", "weighted"]}}, ) def test_average_multiclass(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for average in params["average"]: actual_p = snowml_metrics.precision_score( @@ -155,8 +150,7 @@ def test_average_multiclass(self, params: Dict[str, Any]) -> None: }, ) def test_average_binary(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for idx, average in enumerate(params["average"]): y_true = params["y_true"][idx] @@ -182,8 +176,7 @@ def test_zero_division(self, params: Dict[str, Any]) -> None: [0, 0, 0, 0, 0, 0], [1, 1, 0, 0, 0, 0], ] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for zero_division in params["zero_division"]: if zero_division == "warn": diff --git a/tests/integ/snowflake/ml/modeling/metrics/recall_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/recall_score_test.py index 59075ac3..ad1b219e 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/recall_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/recall_score_test.py @@ -1,7 +1,6 @@ from typing import Any, Dict import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import exceptions, metrics as sklearn_metrics @@ -13,23 +12,23 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] * 4 + [utils.DataType.FLOAT] -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=2, ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=5, ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_PRED_COL = _SCHEMA[2] -_Y_TRUE_COLS = [_SCHEMA[1], _SCHEMA[2]] -_Y_PRED_COLS = [_SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_PRED_COL = _SF_SCHEMA[2] +_Y_TRUE_COLS = [_SF_SCHEMA[1], _SF_SCHEMA[2]] +_Y_PRED_COLS = [_SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] class RecallScoreTest(parameterized.TestCase): @@ -46,8 +45,7 @@ def tearDown(self) -> None: {"params": {"labels": [None, [2, 0, 4]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_r = snowml_metrics.recall_score( @@ -69,8 +67,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_r = snowml_metrics.recall_score( @@ -92,8 +89,7 @@ def test_pos_label(self, params: Dict[str, Any]) -> None: {"params": {"average": [None, "micro", "macro", "weighted"]}}, ) def test_average_multiclass(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for average in params["average"]: actual_r = snowml_metrics.recall_score( @@ -119,8 +115,7 @@ def test_average_multiclass(self, params: Dict[str, Any]) -> None: }, ) def test_average_binary(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for idx, average in enumerate(params["average"]): y_true = params["y_true"][idx] @@ -154,8 +149,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_pred = values["y_pred"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_r = snowml_metrics.recall_score( @@ -182,8 +176,7 @@ def test_zero_division(self, params: Dict[str, Any]) -> None: [0, 0, 1, 0, 0, 0], [1, 0, 0, 0, 0, 0], ] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for zero_division in params["zero_division"]: if zero_division == "warn": diff --git a/tests/integ/snowflake/ml/modeling/metrics/roc_auc_score_test.py b/tests/integ/snowflake/ml/modeling/metrics/roc_auc_score_test.py index 508fa623..2340c9e7 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/roc_auc_score_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/roc_auc_score_test.py @@ -2,7 +2,6 @@ from unittest import mock import numpy as np -import pandas as pd from absl.testing import parameterized from absl.testing.absltest import main from sklearn import metrics as sklearn_metrics @@ -14,14 +13,14 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] + [utils.DataType.FLOAT] * 4 -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=[2, 1, 1, 1, 1], ) -_BINARY_Y_TRUE_COL = _SCHEMA[1] -_BINARY_Y_SCORE_COL = _SCHEMA[2] +_BINARY_Y_TRUE_COL = _SF_SCHEMA[1] +_BINARY_Y_SCORE_COL = _SF_SCHEMA[2] _MULTICLASS_DATA = [ [0, 2, 0.29, 0.49, 0.22, 0.18], [1, 0, 0.33, 0.16, 0.51, 0.69], @@ -30,9 +29,9 @@ [4, 1, 0.82, 0.12, 0.06, 0.91], [5, 2, 0.08, 0.46, 0.46, 0.76], ] -_MULTICLASS_Y_TRUE_COL = _SCHEMA[1] -_MULTICLASS_Y_SCORE_COLS = [_SCHEMA[2], _SCHEMA[3], _SCHEMA[4]] -_SAMPLE_WEIGHT_COL = _SCHEMA[5] +_MULTICLASS_Y_TRUE_COL = _SF_SCHEMA[1] +_MULTICLASS_Y_SCORE_COLS = [_SF_SCHEMA[2], _SF_SCHEMA[3], _SF_SCHEMA[4]] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[5] _MULTILABEL_DATA = [ [1, 0, 1, 0.8, 0.3, 0.6], [0, 1, 0, 0.2, 0.7, 0.4], @@ -55,11 +54,10 @@ def tearDown(self) -> None: self._session.close() @parameterized.parameters( # type: ignore[misc] - {"params": {"average": [None, "micro", "macro", "samples", "weighted"]}}, + {"params": {"average": ["weighted"]}}, ) def test_average_binary(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for average in params["average"]: actual_auc = snowml_metrics.roc_auc_score( @@ -84,8 +82,7 @@ def test_average_binary(self, params: Dict[str, Any]) -> None: }, ) def test_average_multiclass(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for idx, average in enumerate(params["average"]): multi_class = params["multi_class"][idx] @@ -120,8 +117,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: data = values["data"] y_true = values["y_true"] y_score = values["y_score"] - pandas_df = pd.DataFrame(data, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, data, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_auc = snowml_metrics.roc_auc_score( @@ -144,8 +140,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"max_fpr": [None, 0.1, 0.5, 1]}}, ) def test_max_fpr(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for max_fpr in params["max_fpr"]: actual_auc = snowml_metrics.roc_auc_score( @@ -165,8 +160,7 @@ def test_max_fpr(self, params: Dict[str, Any]) -> None: {"params": {"multi_class": ["ovr", "ovo"]}}, ) def test_multi_class(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for multi_class in params["multi_class"]: actual_auc = snowml_metrics.roc_auc_score( @@ -186,8 +180,7 @@ def test_multi_class(self, params: Dict[str, Any]) -> None: {"params": {"labels": [None, [0, 1, 2]]}}, ) def test_labels(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for labels in params["labels"]: actual_auc = snowml_metrics.roc_auc_score( @@ -206,8 +199,7 @@ def test_labels(self, params: Dict[str, Any]) -> None: self.assertAlmostEqual(sklearn_auc, actual_auc) def test_multilabel(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_auc = snowml_metrics.roc_auc_score( df=input_df, @@ -222,8 +214,7 @@ def test_multilabel(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.ranking.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: - pandas_df = pd.DataFrame(_MULTILABEL_DATA, columns=_MULTILABEL_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTILABEL_DATA, _MULTILABEL_SCHEMA) actual_auc = snowml_metrics.roc_auc_score( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/metrics/roc_curve_test.py b/tests/integ/snowflake/ml/modeling/metrics/roc_curve_test.py index acdd0015..1b14eba4 100644 --- a/tests/integ/snowflake/ml/modeling/metrics/roc_curve_test.py +++ b/tests/integ/snowflake/ml/modeling/metrics/roc_curve_test.py @@ -16,21 +16,21 @@ _ROWS = 100 _TYPES = [utils.DataType.INTEGER] + [utils.DataType.FLOAT] * 2 -_BINARY_DATA, _SCHEMA = utils.gen_fuzz_data( +_BINARY_DATA, _PD_SCHEMA, _SF_SCHEMA = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=[2, 1, 1], ) -_MULTICLASS_DATA, _ = utils.gen_fuzz_data( +_MULTICLASS_DATA, _, _ = utils.gen_fuzz_data( rows=_ROWS, types=_TYPES, low=0, high=[5, 1, 1], ) -_Y_TRUE_COL = _SCHEMA[1] -_Y_SCORE_COL = _SCHEMA[2] -_SAMPLE_WEIGHT_COL = _SCHEMA[3] +_Y_TRUE_COL = _SF_SCHEMA[1] +_Y_SCORE_COL = _SF_SCHEMA[2] +_SAMPLE_WEIGHT_COL = _SF_SCHEMA[3] class RocCurveTest(parameterized.TestCase): @@ -47,8 +47,7 @@ def tearDown(self) -> None: {"params": {"pos_label": [0, 2, 4]}}, ) def test_pos_label(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_MULTICLASS_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _MULTICLASS_DATA, _PD_SCHEMA) for pos_label in params["pos_label"]: actual_fpr, actual_tpr, actual_thresholds = snowml_metrics.roc_curve( @@ -71,8 +70,7 @@ def test_pos_label(self, params: Dict[str, Any]) -> None: {"params": {"sample_weight_col_name": [None, _SAMPLE_WEIGHT_COL]}}, ) def test_sample_weight(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for sample_weight_col_name in params["sample_weight_col_name"]: actual_fpr, actual_tpr, actual_thresholds = snowml_metrics.roc_curve( @@ -96,8 +94,7 @@ def test_sample_weight(self, params: Dict[str, Any]) -> None: {"params": {"drop_intermediate": [True, False]}}, ) def test_drop_intermediate(self, params: Dict[str, Any]) -> None: - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) for drop_intermediate in params["drop_intermediate"]: actual_fpr, actual_tpr, actual_thresholds = snowml_metrics.roc_curve( @@ -122,7 +119,7 @@ def test_multi_query_df(self) -> None: self._session.sql(f"create temp stage {stage}").collect() # Load data into the stage. - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) + pandas_df = pd.DataFrame(_BINARY_DATA, columns=_PD_SCHEMA) with tempfile.TemporaryDirectory() as temp_dir: filename = "data.parquet" local_path = os.path.join(temp_dir, filename) @@ -137,6 +134,7 @@ def test_multi_query_df(self) -> None: input_df = df_lhs.join(df_rhs, ["ID"]) pd_df = input_df.to_pandas() + pd_df.columns = input_df.columns actual_fpr, actual_tpr, actual_thresholds = snowml_metrics.roc_curve( df=input_df, @@ -156,8 +154,7 @@ def test_multi_query_df(self) -> None: @mock.patch("snowflake.ml.modeling.metrics.ranking.result._RESULT_SIZE_THRESHOLD", 0) def test_metric_size_threshold(self) -> None: # TODO: somehow confirm that the stage upload code path was taken. - pandas_df = pd.DataFrame(_BINARY_DATA, columns=_SCHEMA) - input_df = self._session.create_dataframe(pandas_df) + pandas_df, input_df = utils.get_df(self._session, _BINARY_DATA, _PD_SCHEMA) actual_fpr, actual_tpr, actual_thresholds = snowml_metrics.roc_curve( df=input_df, diff --git a/tests/integ/snowflake/ml/modeling/preprocessing/k_bins_discretizer_test.py b/tests/integ/snowflake/ml/modeling/preprocessing/k_bins_discretizer_test.py index fd95d5da..34548ca4 100644 --- a/tests/integ/snowflake/ml/modeling/preprocessing/k_bins_discretizer_test.py +++ b/tests/integ/snowflake/ml/modeling/preprocessing/k_bins_discretizer_test.py @@ -130,15 +130,15 @@ def test_fit_fuzz_data(self) -> None: N_BINS = [10, 7] ENCODE = "ordinal" - data, schema = utils.gen_fuzz_data( + data, pd_schema, sf_schema = utils.gen_fuzz_data( rows=1000, types=[utils.DataType.INTEGER, utils.DataType.FLOAT], ) - pandas_df, snowpark_df = utils.get_df(self._session, data, schema) + pandas_df, snowpark_df = utils.get_df(self._session, data, pd_schema) for strategy in self._strategies: sklearn_discretizer = SklearnKBinsDiscretizer(n_bins=N_BINS, encode=ENCODE, strategy=strategy) - sklearn_discretizer.fit(pandas_df[schema[1:]]) + sklearn_discretizer.fit(pandas_df[sf_schema[1:]]) target_n_bins = sklearn_discretizer.n_bins_.tolist() target_bin_edges = sklearn_discretizer.bin_edges_.tolist() @@ -147,7 +147,7 @@ def test_fit_fuzz_data(self) -> None: n_bins=N_BINS, encode=ENCODE, strategy=strategy, - input_cols=schema[1:], + input_cols=sf_schema[1:], ) discretizer.fit(df) actual_edges = discretizer.bin_edges_.tolist() @@ -197,7 +197,7 @@ def test_transform_ordinal_encoding_fuzz_data(self) -> None: ENCODE = "ordinal" OUTPUT_COLS = [f"OUT_{x}" for x in range(len(N_BINS))] - data, schema = utils.gen_fuzz_data( + data, pd_schema, sf_schema = utils.gen_fuzz_data( rows=10000, types=[ utils.DataType.INTEGER, @@ -207,30 +207,32 @@ def test_transform_ordinal_encoding_fuzz_data(self) -> None: low=-999999, high=999999, ) - pandas_df, snowpark_df = utils.get_df(self._session, data, schema) + pandas_df, snowpark_df = utils.get_df(self._session, data, pd_schema) for strategy in self._strategies: # 1. Create OSS SKLearn discretizer sklearn_discretizer = SklearnKBinsDiscretizer(n_bins=N_BINS, encode=ENCODE, strategy=strategy) - sklearn_discretizer.fit(pandas_df[schema[1:]]) - target_output = sklearn_discretizer.transform(pandas_df.sort_values(by=[schema[0]])[schema[1:]]) + sklearn_discretizer.fit(pandas_df[sf_schema[1:]]) + target_output = sklearn_discretizer.transform(pandas_df.sort_values(by=[sf_schema[0]])[sf_schema[1:]]) # 2. Create SnowML discretizer discretizer = KBinsDiscretizer( n_bins=N_BINS, encode=ENCODE, strategy=strategy, - input_cols=schema[1:], + input_cols=sf_schema[1:], output_cols=OUTPUT_COLS, ) discretizer.fit(snowpark_df) # 3. Transform with Snowpark DF and compare - actual_output = discretizer.transform(snowpark_df).sort(schema[0])[OUTPUT_COLS].to_pandas().to_numpy() + actual_output = discretizer.transform(snowpark_df).sort(sf_schema[0])[OUTPUT_COLS].to_pandas().to_numpy() np.testing.assert_allclose(target_output, actual_output) # 4. Transform with Pandas DF and compare - pd_actual_output = discretizer.transform(pandas_df.sort_values(by=[schema[0]])[schema[1:]])[OUTPUT_COLS] + pd_actual_output = discretizer.transform(pandas_df.sort_values(by=[sf_schema[0]])[sf_schema[1:]])[ + OUTPUT_COLS + ] np.testing.assert_allclose(target_output, pd_actual_output) def test_transform_onehot_encoding(self) -> None: diff --git a/tests/integ/snowflake/ml/modeling/preprocessing/one_hot_encoder_test.py b/tests/integ/snowflake/ml/modeling/preprocessing/one_hot_encoder_test.py index 89741d85..efb2871a 100644 --- a/tests/integ/snowflake/ml/modeling/preprocessing/one_hot_encoder_test.py +++ b/tests/integ/snowflake/ml/modeling/preprocessing/one_hot_encoder_test.py @@ -23,7 +23,7 @@ ) from snowflake.ml.utils import sparse as utils_sparse from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import DataFrame, Session +from snowflake.snowpark import DataFrame, Session, functions, types from tests.integ.snowflake.ml.modeling.framework import utils as framework_utils from tests.integ.snowflake.ml.modeling.framework.utils import ( BOOLEAN_COLS, @@ -120,6 +120,30 @@ def test_fit(self) -> None: """ Verify fitted categories. + Raises + ------ + AssertionError + If the fitted categories do not match those of the sklearn encoder. + """ + input_cols = NUMERIC_COLS + df_pandas, df = framework_utils.get_df(self._session, DATA, SCHEMA, np.nan) + + encoder = OneHotEncoder().set_input_cols(input_cols) + encoder.fit(df) + + actual_categories = encoder._categories_list + + # sklearn + encoder_sklearn = SklearnOneHotEncoder() + encoder_sklearn.fit(df_pandas[input_cols]) + + for actual_cats, sklearn_cats in zip(actual_categories, encoder_sklearn.categories_): + self.assertEqual(set(sklearn_cats.tolist()), set(actual_cats.tolist())) + + def test_fit_decimal(self) -> None: + """ + Verify fitted categories. + Raises ------ AssertionError @@ -128,6 +152,18 @@ def test_fit(self) -> None: input_cols = CATEGORICAL_COLS df_pandas, df = framework_utils.get_df(self._session, DATA, SCHEMA, np.nan) + # Map DoubleType to DecimalType + fields = df.schema.fields + selected_cols = [] + for field in fields: + src = field.column_identifier.quoted_name + if isinstance(field.datatype, types.DoubleType): + dest = types.DecimalType(15, 10) + selected_cols.append(functions.cast(functions.col(src), dest).alias(src)) + else: + selected_cols.append(functions.col(src)) + df = df.select(selected_cols) + encoder = OneHotEncoder().set_input_cols(input_cols) encoder.fit(df) diff --git a/tests/integ/snowflake/ml/modeling/preprocessing/standard_scaler_test.py b/tests/integ/snowflake/ml/modeling/preprocessing/standard_scaler_test.py index 0fb68e66..8d784973 100644 --- a/tests/integ/snowflake/ml/modeling/preprocessing/standard_scaler_test.py +++ b/tests/integ/snowflake/ml/modeling/preprocessing/standard_scaler_test.py @@ -16,7 +16,7 @@ StandardScaler, ) from snowflake.ml.utils.connection_params import SnowflakeLoginOptions -from snowflake.snowpark import Session +from snowflake.snowpark import Session, functions, types from tests.integ.snowflake.ml.modeling.framework import utils as framework_utils from tests.integ.snowflake.ml.modeling.framework.utils import ( DATA, @@ -69,6 +69,46 @@ def test_fit(self) -> None: np.testing.assert_allclose(actual_mean, scaler_sklearn.mean_) np.testing.assert_allclose(actual_var, scaler_sklearn.var_) + def test_fit_decimal(self) -> None: + """ + Verify fitted states with DecimalType + + Raises + ------ + AssertionError + If the fitted states do not match those of the sklearn scaler. + """ + input_cols = NUMERIC_COLS + df_pandas, df = framework_utils.get_df(self._session, DATA, SCHEMA, np.nan) + + # Map DoubleType to DecimalType + fields = df.schema.fields + selected_cols = [] + for field in fields: + src = field.column_identifier.quoted_name + if isinstance(field.datatype, types.DoubleType): + dest = types.DecimalType(38, 10) + selected_cols.append(functions.cast(functions.col(src), dest).alias(src)) + else: + selected_cols.append(functions.col(src)) + df = df.select(selected_cols) + + for _df in [df_pandas, df]: + scaler = StandardScaler().set_input_cols(input_cols) + scaler.fit(_df) + + actual_scale = scaler._convert_attribute_dict_to_ndarray(scaler.scale_) + actual_mean = scaler._convert_attribute_dict_to_ndarray(scaler.mean_) + actual_var = scaler._convert_attribute_dict_to_ndarray(scaler.var_) + + # sklearn + scaler_sklearn = SklearnStandardScaler() + scaler_sklearn.fit(df_pandas[input_cols]) + + np.testing.assert_allclose(actual_scale, scaler_sklearn.scale_) + np.testing.assert_allclose(actual_mean, scaler_sklearn.mean_) + np.testing.assert_allclose(actual_var, scaler_sklearn.var_) + def test_transform(self) -> None: """ Verify transformed results. diff --git a/tests/integ/snowflake/ml/registry/BUILD.bazel b/tests/integ/snowflake/ml/registry/BUILD.bazel index 85521799..50e2f815 100644 --- a/tests/integ/snowflake/ml/registry/BUILD.bazel +++ b/tests/integ/snowflake/ml/registry/BUILD.bazel @@ -4,7 +4,7 @@ py_test( name = "model_registry_basic_integ_test", srcs = ["model_registry_basic_integ_test.py"], deps = [ - "//snowflake/ml/registry:_ml_artifact", + "//snowflake/ml/registry:artifact_manager", "//snowflake/ml/registry:model_registry", "//snowflake/ml/utils:connection_params", "//tests/integ/snowflake/ml/test_utils:db_manager", @@ -25,6 +25,17 @@ py_test( ], ) +py_test( + name = "model_registry_compat_test", + timeout = "long", + srcs = ["model_registry_compat_test.py"], + deps = [ + "//snowflake/ml/registry:model_registry", + "//tests/integ/snowflake/ml/test_utils:common_test_base", + "//tests/integ/snowflake/ml/test_utils:db_manager", + ], +) + py_test( name = "model_registry_schema_evolution_integ_test", timeout = "long", @@ -63,5 +74,6 @@ py_test( name = "model_registry_snowservice_merge_gate_integ_test", timeout = "eternal", # 3600s srcs = ["model_registry_snowservice_merge_gate_integ_test.py"], + shard_count = 2, deps = [":model_registry_snowservice_integ_test_base"], ) diff --git a/tests/integ/snowflake/ml/registry/model_registry_basic_integ_test.py b/tests/integ/snowflake/ml/registry/model_registry_basic_integ_test.py index 7244ec29..7f9ce834 100644 --- a/tests/integ/snowflake/ml/registry/model_registry_basic_integ_test.py +++ b/tests/integ/snowflake/ml/registry/model_registry_basic_integ_test.py @@ -1,10 +1,10 @@ -import json import uuid from typing import Optional from absl.testing import absltest, parameterized -from snowflake.ml.registry import _ml_artifact, model_registry +from snowflake.ml.registry import model_registry +from snowflake.ml.registry.artifact import Artifact, ArtifactType from snowflake.ml.utils import connection_params from snowflake.snowpark import Session from tests.integ.snowflake.ml.test_utils import db_manager @@ -168,7 +168,7 @@ def test_create_and_drop_model_registry(self, database_name: str, schema_name: O self._validate_restore_db_and_schema() def test_add_and_delete_ml_artifacts(self) -> None: - """Test add_artifact() and delete_artifact() in `_ml_artifact.py` works as expected.""" + """Test add() and delete() in `_artifact_manager.py` works as expected.""" artifact_registry = db_manager.TestObjectNameGenerator.get_snowml_test_object_name( _RUN_ID, "artifact_registry" @@ -179,78 +179,51 @@ def test_add_and_delete_ml_artifacts(self) -> None: model_registry.create_model_registry( session=self._session, database_name=artifact_registry, schema_name=artifact_registry_schema ) + registry = model_registry.ModelRegistry( + session=self._session, database_name=artifact_registry, schema_name=artifact_registry_schema + ) except Exception as e: self._db_manager.drop_database(artifact_registry) raise Exception(f"Test failed with exception:{e}") - artifact_id = "123" - artifact_type = _ml_artifact.ArtifactType.TESTTYPE - artifact_name = "test_artifact" + artifact_id = "test_art_123" artifact_version = "test_artifact_version" - artifact_spec = {"test_property": "test_value"} + artifact_name = "test_artifact" + artifact = Artifact(type=ArtifactType.DATASET, spec='{"test_property": "test_value"}') try: - self.assertTrue( - _ml_artifact.if_artifact_table_exists(self._session, artifact_registry, artifact_registry_schema) - ) - - # Validate `add_artifact()` can insert entry into the artifact table - self.assertFalse( - _ml_artifact.if_artifact_exists( - self._session, - artifact_registry, - artifact_registry_schema, - artifact_id=artifact_id, - artifact_type=artifact_type, - ) - ) - _ml_artifact.add_artifact( - self._session, - artifact_registry, - artifact_registry_schema, + art_ref = registry._artifact_manager.add( + artifact=artifact, artifact_id=artifact_id, - artifact_type=artifact_type, artifact_name=artifact_name, artifact_version=artifact_version, - artifact_spec=artifact_spec, ) + self.assertTrue( - _ml_artifact.if_artifact_exists( - self._session, - artifact_registry, - artifact_registry_schema, - artifact_id=artifact_id, - artifact_type=artifact_type, + registry._artifact_manager.exists( + art_ref.name, + art_ref.version, ) ) # Validate the artifact_spec can be parsed as expected - artifact_df = _ml_artifact._get_artifact( - self._session, - artifact_registry, - artifact_registry_schema, - artifact_id=artifact_id, - artifact_type=artifact_type, + retrieved_art_df = registry._artifact_manager.get( + art_ref.name, + art_ref.version, ) - actual_artifact_spec_str = artifact_df.collect()[0]["ARTIFACT_SPEC"] - actual_artifact_spec_dict = json.loads(actual_artifact_spec_str) - self.assertDictEqual(artifact_spec, actual_artifact_spec_dict) + + actual_artifact_spec = retrieved_art_df.collect()[0]["ARTIFACT_SPEC"] + self.assertEqual(artifact._spec, actual_artifact_spec) # Validate that `delete_artifact` can remove entries from the artifact table. - _ml_artifact.delete_artifact( - self._session, - artifact_registry, - artifact_registry_schema, - artifact_id=artifact_id, - artifact_type=artifact_type, + registry._artifact_manager.delete( + art_ref.name, + art_ref.version, ) self.assertFalse( - _ml_artifact.if_artifact_exists( - self._session, - artifact_registry, - artifact_registry_schema, - artifact_id=artifact_id, - artifact_type=artifact_type, + registry._artifact_manager.exists( + art_ref.name, + art_ref.version, ) ) finally: diff --git a/tests/integ/snowflake/ml/registry/model_registry_compat_test.py b/tests/integ/snowflake/ml/registry/model_registry_compat_test.py new file mode 100644 index 00000000..5a5a21e4 --- /dev/null +++ b/tests/integ/snowflake/ml/registry/model_registry_compat_test.py @@ -0,0 +1,62 @@ +import uuid +from typing import Callable, Tuple + +from absl.testing import absltest + +from snowflake.ml.registry import model_registry +from snowflake.snowpark import session +from tests.integ.snowflake.ml.test_utils import common_test_base, db_manager + + +class ModelRegistryCompatTest(common_test_base.CommonTestBase): + def setUp(self) -> None: + """Creates Snowpark and Snowflake environments for testing.""" + super().setUp() + self.run_id = uuid.uuid4().hex + self._db_manager = db_manager.DBManager(self.session) + self.current_db = self.session.get_current_database() + self.current_schema = self.session.get_current_schema() + + def _prepare_registry_fn_factory( + self, + ) -> Tuple[Callable[[session.Session, str], None], Tuple[str]]: + self.registry_name = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(self.run_id, "registry_db") + + def prepare_registry(session: session.Session, registry_name: str) -> None: + from snowflake.connector.errors import ProgrammingError + from snowflake.ml.registry import model_registry + + try: + model_registry.create_model_registry(session=session, database_name=registry_name) + except ProgrammingError: + # Previous versions of library will call use even in the sproc env, which is not allowed. + # This is to suppress the error + pass + + return prepare_registry, (self.registry_name,) + + # Starting from 1.0.1 as we had a breaking change at that time. + # TODO: mypy is giving out error `Cannot infer type argument 1 of "compatibility_test" of "CommonTestBase" [misc]` + # Need to figure out the reason and remove ignore + @common_test_base.CommonTestBase.compatibility_test( + prepare_fn_factory=_prepare_registry_fn_factory, version_range=">=1.0.1,<=1.0.9" # type: ignore[misc] + ) + def test_open_registry_compat_v0(self) -> None: + try: + with self.assertRaisesRegex( + RuntimeError, r"Registry schema version \([0-9]+\) is ahead of deployed schema \(0\)." + ): + model_registry.ModelRegistry( + session=self.session, database_name=self.registry_name, create_if_not_exists=False + ) + model_registry.ModelRegistry( + session=self.session, database_name=self.registry_name, create_if_not_exists=True + ) + finally: + self._db_manager.drop_database(self.registry_name, if_exists=True) + self.session.use_database(self.current_db) + self.session.use_schema(self.current_schema) + + +if __name__ == "__main__": + absltest.main() diff --git a/tests/integ/snowflake/ml/registry/model_registry_integ_test.py b/tests/integ/snowflake/ml/registry/model_registry_integ_test.py index f280c2cb..83f6cd92 100644 --- a/tests/integ/snowflake/ml/registry/model_registry_integ_test.py +++ b/tests/integ/snowflake/ml/registry/model_registry_integ_test.py @@ -8,7 +8,8 @@ from snowflake import connector from snowflake.ml.dataset import dataset -from snowflake.ml.registry import _ml_artifact, model_registry +from snowflake.ml.registry import model_registry +from snowflake.ml.registry.artifact import ArtifactType from snowflake.ml.utils import connection_params from snowflake.snowpark import Session from tests.integ.snowflake.ml.test_utils import ( @@ -63,7 +64,7 @@ def test_basic_workflow(self) -> None: model=model, tags=model_tags, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python") ], sample_input_data=test_features, options={"embed_local_ml_library": True}, @@ -78,7 +79,7 @@ def test_basic_workflow(self) -> None: model=model, tags={"stage": "testing", "classifier_type": "svm.SVC"}, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python") ], sample_input_data=test_features, options={"embed_local_ml_library": True}, @@ -277,7 +278,7 @@ def test_snowml_model(self) -> None: model_version=model_version, model=model, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python") ], options={"embed_local_ml_library": True}, ) @@ -326,7 +327,7 @@ def test_snowml_pipeline(self) -> None: model_version=model_version, model=model, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python") ], options={"embed_local_ml_library": True}, ) @@ -389,63 +390,54 @@ def test_log_model_with_dataset(self) -> None: desc="a dummy dataset metadata", ) cur_user = self._session.sql("SELECT CURRENT_USER()").collect()[0]["CURRENT_USER()"] - self.assertEqual(dummy_dataset.id, dummy_dataset.id) self.assertEqual(dummy_dataset.owner, cur_user) - self.assertIsNotNone(dummy_dataset.name, dummy_snapshot_table_full_path) + self.assertIsNone(dummy_dataset.name) self.assertIsNotNone(dummy_dataset.generation_timestamp) minimal_dataset = dataset.Dataset( self._session, df=self._session.sql(spine_query), ) - self.assertEqual(minimal_dataset.id, minimal_dataset.id) self.assertEqual(minimal_dataset.owner, cur_user) - self.assertEqual(minimal_dataset.name, "") + self.assertIsNone(minimal_dataset.name) + self.assertIsNone(minimal_dataset.version) self.assertIsNotNone(minimal_dataset.generation_timestamp) - with self.assertRaisesRegex( - ValueError, - "Only one of sample_input_data and dataset should be provided.", - ): - registry.log_model( - model_name=model_name, - model_version=model_version, - model=model, - conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") - ], - sample_input_data=test_features, - dataset=dummy_dataset, - options={"embed_local_ml_library": True}, - ) - test_combinations = [ (model_version, dummy_dataset), (f"{model_version}.2", dummy_dataset), (f"{model_version}.3", minimal_dataset), ] for version, ds in test_combinations: + atf_ref = registry.log_artifact( + artifact=ds, + name=f"ds_{version}", + version=f"{version}.ds", + ) + self.assertEqual(atf_ref.name, f"ds_{version}") + self.assertEqual(atf_ref.version, f"{version}.ds") + registry.log_model( model_name=model_name, model_version=version, model=model, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self._session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self._session, "snowflake-snowpark-python") ], options={"embed_local_ml_library": True}, - dataset=ds, + artifacts=[atf_ref], ) - # test deserialized dataset from get_dataset - des_ds_0 = registry.get_dataset(model_name, version) + # test deserialized dataset from get_artifact + des_ds_0 = registry.get_artifact(atf_ref.name, atf_ref.version) self.assertIsNotNone(des_ds_0) self.assertEqual(des_ds_0, ds) # test deserialized dataset from list_artifacts rows_list = registry.list_artifacts(model_name, version).collect() self.assertEqual(len(rows_list), 1) - self.assertEqual(rows_list[0]["ID"], ds.id) - self.assertEqual(_ml_artifact.ArtifactType[rows_list[0]["TYPE"]], _ml_artifact.ArtifactType.DATASET) + self.assertEqual(rows_list[0]["ID"], des_ds_0._id) + self.assertEqual(ArtifactType[rows_list[0]["TYPE"]], ArtifactType.DATASET) des_ds_1 = dataset.Dataset.from_json(rows_list[0]["ARTIFACT_SPEC"], self._session) self.assertEqual(des_ds_1, ds) diff --git a/tests/integ/snowflake/ml/registry/model_registry_schema_evolution_integ_test.py b/tests/integ/snowflake/ml/registry/model_registry_schema_evolution_integ_test.py index 5af0f8de..c4f316c6 100644 --- a/tests/integ/snowflake/ml/registry/model_registry_schema_evolution_integ_test.py +++ b/tests/integ/snowflake/ml/registry/model_registry_schema_evolution_integ_test.py @@ -296,7 +296,7 @@ def test_api_schema_validation(self) -> None: model_version="v1", model=model, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self.session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self.session, "snowflake-snowpark-python") ], ) @@ -314,7 +314,7 @@ def test_api_schema_validation(self) -> None: model_version="v2", model=model, conda_dependencies=[ - test_env_utils.get_latest_package_versions_in_server(self.session, "snowflake-snowpark-python") + test_env_utils.get_latest_package_version_spec_in_server(self.session, "snowflake-snowpark-python") ], ) diff --git a/tests/integ/snowflake/ml/registry/model_registry_snowservice_integ_test_base.py b/tests/integ/snowflake/ml/registry/model_registry_snowservice_integ_test_base.py index 71425784..899e5d25 100644 --- a/tests/integ/snowflake/ml/registry/model_registry_snowservice_integ_test_base.py +++ b/tests/integ/snowflake/ml/registry/model_registry_snowservice_integ_test_base.py @@ -49,7 +49,6 @@ def _test_snowservice_deployment( embed_local_ml_library: Optional[bool] = True, omit_target_method_when_deploy: bool = False, ) -> None: - model, test_features, *_ = prepare_model_and_feature_fn() if omit_target_method_when_deploy: target_method = deployment_options.pop("target_method") @@ -65,7 +64,7 @@ def _test_snowservice_deployment( # Instead we rely on snowpark version on information.schema table. Note that this will not affect end user # as by the time they use it, the latest snowpark should be available in conda already. conda_dependencies = conda_dependencies or [] - conda_dependencies.append(test_env_utils.get_latest_package_versions_in_conda("snowflake-snowpark-python")) + conda_dependencies.append(test_env_utils.get_latest_package_version_spec_in_conda("snowflake-snowpark-python")) self.registry.log_model( model_name=model_name, diff --git a/tests/integ/snowflake/ml/test_utils/BUILD.bazel b/tests/integ/snowflake/ml/test_utils/BUILD.bazel index 91f7969b..2cde419f 100644 --- a/tests/integ/snowflake/ml/test_utils/BUILD.bazel +++ b/tests/integ/snowflake/ml/test_utils/BUILD.bazel @@ -38,6 +38,7 @@ py_library( ], deps = [ ":_snowml_requirements", + ":test_env_utils", "//snowflake/ml/_internal:file_utils", "//snowflake/ml/utils:connection_params", ], diff --git a/tests/integ/snowflake/ml/test_utils/common_test_base.py b/tests/integ/snowflake/ml/test_utils/common_test_base.py index 3228c590..fdbe874c 100644 --- a/tests/integ/snowflake/ml/test_utils/common_test_base.py +++ b/tests/integ/snowflake/ml/test_utils/common_test_base.py @@ -1,19 +1,40 @@ import functools import inspect +import itertools import os import tempfile -from typing import Any, Callable, Type, TypeVar +from typing import Any, Callable, List, Literal, Optional, Tuple, Type, TypeVar import cloudpickle from absl.testing import absltest, parameterized +from typing_extensions import Concatenate, ParamSpec from snowflake.ml._internal import file_utils from snowflake.ml.utils import connection_params from snowflake.snowpark import functions as F, session -from snowflake.snowpark._internal import utils as snowpark_utils -from tests.integ.snowflake.ml.test_utils import _snowml_requirements - -T = TypeVar("T") +from snowflake.snowpark._internal import udf_utils, utils as snowpark_utils +from tests.integ.snowflake.ml.test_utils import _snowml_requirements, test_env_utils + +_V = TypeVar("_V", bound="CommonTestBase") +_T_args = ParamSpec("_T_args") +_R_args = TypeVar("_R_args") + + +def get_function_body(func: Callable[..., Any]) -> str: + source_lines = inspect.getsourcelines(func)[0] + source_lines_generator = itertools.dropwhile(lambda x: x.startswith("@"), source_lines) + first_line: str = next(source_lines_generator) + indentation = len(first_line) - len(first_line.lstrip()) + first_line = first_line.strip() + if not first_line.startswith("def "): + return first_line.rsplit(":")[-1].strip() + elif not first_line.endswith(":"): + for line in source_lines_generator: + line = line.strip() + if line.endswith(":"): + break + # Find the indentation of the first line + return "".join([line[indentation:] for line in source_lines_generator]) class CommonTestBase(parameterized.TestCase): @@ -21,84 +42,170 @@ def setUp(self) -> None: """Creates Snowpark and Snowflake environments for testing.""" self.session = ( session.Session.builder.configs(connection_params.SnowflakeLoginOptions()).create() - if not snowpark_utils.is_in_stored_procedure() + if not snowpark_utils.is_in_stored_procedure() # type: ignore[no-untyped-call] # else session._get_active_session() ) def tearDown(self) -> None: - if not snowpark_utils.is_in_stored_procedure(): + if not snowpark_utils.is_in_stored_procedure(): # type: ignore[no-untyped-call] self.session.close() @classmethod def sproc_test( - kclass: Type["CommonTestBase"], local: bool = True - ) -> Callable[[Callable[["CommonTestBase", T], None]], Callable[["CommonTestBase", T], None]]: - def decorator(fn: Callable[["CommonTestBase", T], None]) -> Callable[["CommonTestBase", T], None]: + kclass: Type[_V], local: bool = True, test_callers_rights=True + ) -> Callable[[Callable[Concatenate[_V, _T_args], None]], Callable[Concatenate[_V, _T_args], None]]: + def decorator(fn: Callable[Concatenate[_V, _T_args], None]) -> Callable[Concatenate[_V, _T_args], None]: @functools.wraps(fn) - def test_wrapper(self: "CommonTestBase", *args: Any, **kwargs: Any) -> None: - if snowpark_utils.is_in_stored_procedure(): + def test_wrapper(self: _V, /, *args: _T_args.args, **kwargs: _T_args.kwargs) -> None: + if snowpark_utils.is_in_stored_procedure(): # type: ignore[no-untyped-call] fn(self, *args, **kwargs) return if local: - fn(self, *args, **kwargs) + with self.subTest("Local Test"): + fn(self, *args, **kwargs) + + def _in_sproc_test(execute_as: Literal["owner", "caller"] = "owner") -> None: + test_module = inspect.getmodule(fn) + assert test_module + cloudpickle.register_pickle_by_value(test_module) + assert test_module.__file__ + test_module_path = os.path.abspath(test_module.__file__) + ind = test_module_path.rfind(f"tests{os.sep}") + assert ind > 0 + rel_path = test_module_path[ind:] + rel_path = os.path.splitext(rel_path)[0] + test_module_name = rel_path.replace(os.sep, ".") + test_name = f"{test_module_name}.{fn.__qualname__}" + + with tempfile.TemporaryDirectory() as tmpdir: + snowml_path, snowml_start_path = file_utils.get_package_path("snowflake.ml") + + snowml_zip_module_filename = os.path.join(tmpdir, "snowflake-ml-python.zip") + with file_utils.zip_file_or_directory_to_stream(snowml_path, snowml_start_path) as input_stream: + with open(snowml_zip_module_filename, "wb") as f: + f.write(input_stream.getbuffer()) + + tests_path, tests_start_path = file_utils.get_package_path("tests") + + tests_zip_module_filename = os.path.join(tmpdir, "snowflake-ml-test.zip") + with file_utils.zip_file_or_directory_to_stream(tests_path, tests_start_path) as input_stream: + with open(tests_zip_module_filename, "wb") as f: + f.write(input_stream.getbuffer()) + + imports = [snowml_zip_module_filename, tests_zip_module_filename] + packages = [ + req for req in _snowml_requirements.REQUIREMENTS if "snowflake-connector-python" not in req + ] + + @F.sproc( # type: ignore[misc] + is_permanent=False, + packages=packages, # type: ignore[arg-type] + replace=True, + session=self.session, + anonymous=(execute_as == "caller"), + imports=imports, # type: ignore[arg-type] + execute_as=execute_as, + ) + def test_in_sproc(sess: session.Session, test_name: str) -> None: + import unittest + + loader = unittest.TestLoader() + + suite = loader.loadTestsFromName(test_name) + result = unittest.TextTestRunner(verbosity=2, failfast=False).run(suite) + if len(result.errors) > 0 or len(result.failures) > 0: + raise RuntimeError( + "Unit test failed unexpectedly with at least one error. " + f"Errors: {result.errors} Failures: {result.failures}" + ) + if result.testsRun == 0: + raise RuntimeError("Unit test does not run any test.") + + test_in_sproc(self.session, test_name) + + cloudpickle.unregister_pickle_by_value(test_module) + + with self.subTest("In-sproc Test (Owner's rights)"): + _in_sproc_test(execute_as="owner") + + if test_callers_rights: + with self.subTest("In-sproc Test (Caller's rights)"): + _in_sproc_test(execute_as="caller") - test_module = inspect.getmodule(fn) - assert test_module - cloudpickle.register_pickle_by_value(test_module) - assert test_module.__file__ - test_module_path = os.path.abspath(test_module.__file__) - ind = test_module_path.rfind(f"tests{os.sep}") - assert ind > 0 - rel_path = test_module_path[ind:] - rel_path = os.path.splitext(rel_path)[0] - test_module_name = rel_path.replace(os.sep, ".") - test_name = f"{test_module_name}.{fn.__qualname__}" - - with tempfile.TemporaryDirectory() as tmpdir: - snowml_path, snowml_start_path = file_utils.get_package_path("snowflake.ml") - - snowml_zip_module_filename = os.path.join(tmpdir, "snowflake-ml-python.zip") - with file_utils.zip_file_or_directory_to_stream(snowml_path, snowml_start_path) as input_stream: - with open(snowml_zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - tests_path, tests_start_path = file_utils.get_package_path("tests") - - tests_zip_module_filename = os.path.join(tmpdir, "snowflake-ml-test.zip") - with file_utils.zip_file_or_directory_to_stream(tests_path, tests_start_path) as input_stream: - with open(tests_zip_module_filename, "wb") as f: - f.write(input_stream.getbuffer()) - - imports = [snowml_zip_module_filename, tests_zip_module_filename] - packages = [ - req for req in _snowml_requirements.REQUIREMENTS if "snowflake-connector-python" not in req - ] - - @F.sproc( - is_permanent=False, - packages=packages, - replace=True, - session=self.session, - anonymous=True, - imports=imports, - ) - def test_in_sproc(sess: session.Session, test_name: str) -> None: - import unittest - - loader = unittest.TestLoader() - - suite = loader.loadTestsFromName(test_name) - result = unittest.TextTestRunner(verbosity=2, failfast=False).run(suite) - if len(result.errors) > 0 or len(result.failures) > 0: - raise RuntimeError( - "Unit test failed unexpectedly with at least one error. " - f"Errors: {result.errors} Failures: {result.failures}" + return test_wrapper + + return decorator + + @classmethod + def compatibility_test( + kclass: Type[_V], + prepare_fn_factory: Callable[[_V], Tuple[Callable[[session.Session, _R_args], None], _R_args]], + version_range: Optional[str] = None, + additional_packages: Optional[List[str]] = None, + ) -> Callable[[Callable[Concatenate[_V, _T_args], None]], Callable[Concatenate[_V, _T_args], None]]: + def decorator(fn: Callable[Concatenate[_V, _T_args], None]) -> Callable[Concatenate[_V, _T_args], None]: + @functools.wraps(fn) + def test_wrapper(self: _V, /, *args: _T_args.args, **kwargs: _T_args.kwargs) -> None: + prepare_fn, prepare_fn_args = prepare_fn_factory(self) + if additional_packages: + packages = additional_packages + else: + packages = [] + + _, _, return_type, input_types = udf_utils.extract_return_input_types( + prepare_fn, return_type=None, input_types=None, object_type=snowpark_utils.TempObjectType.PROCEDURE + ) + + func_body = get_function_body(prepare_fn) + func_params = inspect.signature(prepare_fn).parameters + func_name = prepare_fn.__name__ + + seen_first_arg = False + first_arg_name = None + arg_list = [] + for arg_name in func_params.keys(): + if not seen_first_arg: + seen_first_arg = True + first_arg_name = arg_name + else: + arg_list.append(arg_name) + + assert first_arg_name is not None, "The prepare function must have at least one argument" + func_source = f""" +import snowflake.snowpark + +def {func_name}({first_arg_name}: snowflake.snowpark.Session, {", ".join(arg_list)}): +{func_body} +""" + + for pkg_ver in test_env_utils.get_package_versions_in_server( + self.session, f"snowflake-ml-python{version_range}" + ): + with self.subTest(f"Testing with snowflake-ml-python version {pkg_ver}"): + final_packages = packages[:] + [f"snowflake-ml-python=={pkg_ver}"] + + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", suffix=".py", delete=False + ) as temp_file: + temp_file.write(func_source) + temp_file.flush() + + # Instead of using decorator, we register from file to prevent pickling anything from + # current env. + prepare_fn_sproc = self.session.sproc.register_from_file( + file_path=temp_file.name, + func_name=func_name, + return_type=return_type, + input_types=input_types, + is_permanent=False, + packages=final_packages, + replace=True, ) - if result.testsRun == 0: - raise RuntimeError("Unit test does not run any test.") - test_in_sproc(self.session, test_name) + prepare_fn_sproc(*prepare_fn_args, session=self.session) + + fn(self, *args, **kwargs) return test_wrapper diff --git a/tests/integ/snowflake/ml/test_utils/model_factory.py b/tests/integ/snowflake/ml/test_utils/model_factory.py index 1d36c955..7516f20d 100644 --- a/tests/integ/snowflake/ml/test_utils/model_factory.py +++ b/tests/integ/snowflake/ml/test_utils/model_factory.py @@ -18,7 +18,7 @@ OneHotEncoder, ) from snowflake.ml.modeling.xgboost import XGBClassifier # type: ignore[attr-defined] -from snowflake.snowpark import DataFrame, Session +from snowflake.snowpark import DataFrame, Session, functions, types class DEVICE(Enum): @@ -88,9 +88,24 @@ def add_simple_category(df: pd.DataFrame) -> pd.DataFrame: df["SIMPLE"] = categories return df + # Add string to the dataset df_cat = add_simple_category(df) iris_df = session.create_dataframe(df_cat) + fields = iris_df.schema.fields + # Map DoubleType to DecimalType + selected_cols = [] + count = 0 + for field in fields: + src = field.column_identifier.quoted_name + if isinstance(field.datatype, types.DoubleType) and count == 0: + dest = types.DecimalType(15, 10) + selected_cols.append(functions.cast(functions.col(src), dest).alias(src)) + count += 1 + else: + selected_cols.append(functions.col(src)) + iris_df = iris_df.select(selected_cols) + numeric_features = ["SEPALLENGTH", "SEPALWIDTH", "PETALLENGTH", "PETALWIDTH"] categorical_features = ["SIMPLE"] numeric_features_output = [x + "_O" for x in numeric_features] diff --git a/tests/integ/snowflake/ml/test_utils/spcs_integ_test_base.py b/tests/integ/snowflake/ml/test_utils/spcs_integ_test_base.py index 00e696fd..7421d355 100644 --- a/tests/integ/snowflake/ml/test_utils/spcs_integ_test_base.py +++ b/tests/integ/snowflake/ml/test_utils/spcs_integ_test_base.py @@ -15,6 +15,8 @@ class SpcsIntegTestBase(absltest.TestCase): _RUN_ID = uuid.uuid4().hex[:2] _TEST_DB = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(_RUN_ID, "db").upper() _TEST_SCHEMA = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(_RUN_ID, "schema").upper() + _TEST_STAGE = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(_RUN_ID, "stage").upper() + _TEST_IMAGE_REPO = db_manager.TestObjectNameGenerator.get_snowml_test_object_name(_RUN_ID, "repo").upper() @classmethod def setUpClass(cls) -> None: @@ -35,6 +37,7 @@ def setUpClass(cls) -> None: cls._db_manager = db_manager.DBManager(cls._session) cls._db_manager.create_database(cls._TEST_DB) cls._db_manager.create_schema(cls._TEST_SCHEMA) + cls._db_manager.create_stage(cls._TEST_STAGE, cls._TEST_SCHEMA, cls._TEST_DB, sse_encrypted=True) cls._db_manager.cleanup_databases(expire_hours=6) @classmethod diff --git a/tests/integ/snowflake/ml/test_utils/test_env_utils.py b/tests/integ/snowflake/ml/test_utils/test_env_utils.py index 7f4437a8..248e6987 100644 --- a/tests/integ/snowflake/ml/test_utils/test_env_utils.py +++ b/tests/integ/snowflake/ml/test_utils/test_env_utils.py @@ -1,8 +1,9 @@ import functools import textwrap +from typing import List import requests -from packaging import version +from packaging import requirements, version import snowflake.connector from snowflake.ml._internal import env @@ -11,15 +12,18 @@ @functools.lru_cache -def get_latest_package_versions_in_server( - session: session.Session, package_name: str, python_version: str = env.PYTHON_VERSION -) -> str: +def get_package_versions_in_server( + session: session.Session, + package_req_str: str, + python_version: str = env.PYTHON_VERSION, +) -> List[version.Version]: + package_req = requirements.Requirement(package_req_str) parsed_python_version = version.Version(python_version) sql = textwrap.dedent( f""" SELECT PACKAGE_NAME, VERSION FROM information_schema.packages - WHERE package_name = '{package_name}' + WHERE package_name = '{package_req.name}' AND language = 'python' AND runtime_version = '{parsed_python_version.major}.{parsed_python_version.minor}'; """ @@ -40,14 +44,29 @@ def get_latest_package_versions_in_server( req_ver = version.parse(row["VERSION"]) version_list.append(req_ver) except snowflake.connector.DataError: - return package_name - if len(version_list) == 0: - return package_name - return f"{package_name}=={max(version_list)}" + return [] + available_version_list = list(package_req.specifier.filter(version_list)) + return available_version_list + + +@functools.lru_cache +def get_latest_package_version_spec_in_server( + session: session.Session, + package_req_str: str, + python_version: str = env.PYTHON_VERSION, +) -> str: + package_req = requirements.Requirement(package_req_str) + available_version_list = get_package_versions_in_server(session, package_req_str, python_version) + if len(available_version_list) == 0: + return str(package_req) + return f"{package_req.name}=={max(available_version_list)}" @functools.lru_cache -def get_latest_package_versions_in_conda(package_name: str, python_version: str = env.PYTHON_VERSION) -> str: +def get_package_versions_in_conda( + package_req_str: str, python_version: str = env.PYTHON_VERSION +) -> List[version.Version]: + package_req = requirements.Requirement(package_req_str) repodata_url = "https://repo.anaconda.com/pkgs/snowflake/linux-64/repodata.json" parsed_python_version = version.Version(python_version) @@ -65,15 +84,25 @@ def get_latest_package_versions_in_conda(package_name: str, python_version: str packages_info = repodata["packages"] assert isinstance(packages_info, dict) for package_info in packages_info.values(): - if package_info["name"] == package_name and python_version_build_str in package_info["build"]: + if package_info["name"] == package_req.name and python_version_build_str in package_info["build"]: version_list.append(version.parse(package_info["version"])) - return f"{package_name}=={str(max(version_list))}" + available_version_list = list(package_req.specifier.filter(version_list)) + return available_version_list except Exception as e: max_retry -= 1 exc_list.append(e) raise RuntimeError( - f"Failed to get latest version of package {package_name} in Snowflake Anaconda Channel. " + f"Failed to get latest version of package {package_req} in Snowflake Anaconda Channel. " + "Exceptions are " + ", ".join(map(str, exc_list)) ) + + +@functools.lru_cache +def get_latest_package_version_spec_in_conda(package_req_str: str, python_version: str = env.PYTHON_VERSION) -> str: + package_req = requirements.Requirement(package_req_str) + available_version_list = get_package_versions_in_conda(package_req_str, python_version) + if len(available_version_list) == 0: + return str(package_req) + return f"{package_req.name}=={max(available_version_list)}"